# Спецификация реализации AWG2 Harness Документ описывает архитектуру и контракт reconciliation. Он отражает фактическое состояние кода, а не только замысел: расхождения между этим документом и `traffic_core/src` считаются дефектом документа. ## 1. Архитектурная роль Production-пакет AWG2 Harness содержит два Rust-бинаря: - `awg2-harness` — host-side оркестратор traffic/firewall/status/swap; - `awg2-reboot-scheduler` — отдельный сервис планировщика перезагрузки. `awg2-harness` остаётся основным host-side мульти-оркестратором для выделенного сервера Debian 13 amd64. Основной upstream target релиза 0.3.1 — self-hosted AmneziaWG 3.1; AWG 3.0/2.x сохраняются как regression compatibility. Версии и source contract зафиксированы в [`UPSTREAM_COMPATIBILITY.md`](UPSTREAM_COMPATIBILITY.md). Он владеет инфраструктурным состоянием, которое нужно VPN-сервису: - обнаружение контейнера Amnezia; - обнаружение network namespace контейнера; - обнаружение интерфейса AWG/WG; - обнаружение peers; - целевое состояние traffic control; - ingress shaping через IFB; - nftables connection-count guard (anti-torrent); - восстановление после Docker lifecycle events; - периодический reconcile; - status и диагностика; - плановая перезагрузка; - аварийная настройка swap. Он не заменяет Amnezia. Он управляет окружающим host-side состоянием, которое нужно сервису Amnezia. ## 2. Модель процессов Работают два systemd-сервиса: ```bash /usr/sbin/awg2-harness --config /etc/awg2-harness/config.toml daemon /usr/sbin/awg2-reboot-scheduler --config /etc/awg2-harness/config.toml ``` Демон `awg2-harness` стартует от root и работает постоянно. На старте выполняет начальный reconcile. В runtime реагирует на Docker events и параллельно крутит периодический цикл reconcile. Право планировать перезагрузку принадлежит сервису `awg2-reboot-scheduler`. `awg2-harness` не планирует перезагрузки внутри себя. ### Конфигурация читается один раз Оба процесса загружают `/etc/awg2-harness/config.toml` при старте и дальше работают с этой копией. Правка файла **не применяется автоматически**. Реализовывать hot reload сейчас намеренно не планируется: это потребовало бы повторной валидации, атомарной подмены текущего конфига, перепланирования reboot, смены контейнера и интерфейса на лету и отката при неверной конфигурации. Порядок применения изменений: ```bash awg2-harness --config /etc/awg2-harness/config.toml validate-config awg2-harness --config /etc/awg2-harness/config.toml validate-runtime systemctl restart awg2-harness.service ``` При изменении секции `[reboot]` дополнительно: ```bash systemctl restart awg2-reboot-scheduler.service ``` `systemctl daemon-reload` для правки TOML не нужен — он относится к unit-файлам, а не к конфигу приложения. `ExecReload` в units намеренно отсутствует: его добавление без реальной обработки SIGHUP создало бы ложное впечатление, что перезагрузка конфига поддерживается. ### Политика systemd Обе службы используют `Type=exec`, а не `Type=simple`: старт считается успешным только после фактического запуска бинаря, поэтому отсутствующий или неисполняемый файл роняет unit, а не рапортует об успешном старте. `Restart=on-failure` для обеих. Для планировщика это обязательно: он штатно завершается с кодом 0 в двух случаях — при `reboot.enabled = false` и после того, как systemd принял запрос на перезагрузку. С `Restart=always` он перезапускался бы в цикле на хосте с отключёнными перезагрузками. Задержка перезапуска ступенчатая: `RestartSec=10s`, `RestartSteps=4`, `RestartMaxDelaySec=160s`, то есть 10 → 20 → 40 → 80 → 160 секунд. Плотный цикл перезапусков против ещё поднимающейся зависимости (Docker, контейнер, сеть) только сжигает лимит стартов до того, как зависимость успеет восстановиться. Окно лимита расширено до `StartLimitIntervalSec=15min` при `StartLimitBurst=10`, иначе полная последовательность backoff не укладывалась бы в него. `ExecStartPre` с `ensure-swap` удалён. Демон выполняет swap первым шагом собственной startup-последовательности, где действуют единый error contract, единая блокировка и единый поток логов. Дублирование давало две блокировки, два чтения `/proc/swaps` и раздвоенный путь диагностики отказа. Отдельная CLI-команда `ensure-swap` при этом остаётся доступной оператору. Подкоманды: ```bash awg2-harness daemon awg2-harness apply-limits awg2-harness apply-network-tuning awg2-harness inspect-network-tuning awg2-harness apply-firewall awg2-harness status awg2-harness ensure-swap awg2-harness validate-config awg2-harness validate-runtime ``` Глобальный флаг `--config`; если не задан, используется `/etc/awg2-harness/config.toml`. Уровень валидации у подкоманд разный, и это часть контракта: | Подкоманда | Валидация перед выполнением | | --- | --- | | `validate-config` | `validate_config` | | `validate-runtime` | `validate_runtime` (поддерживаемый хост + host tools) | | `status`, `inspect-network-tuning` | `validate_config` | | `apply-network-tuning` | `validate_sysctl_tuning_runtime` | | `apply-limits`, `apply-firewall`, `ensure-swap`, `daemon` | `validate_all` | `apply-network-tuning` и `inspect-network-tuning` относятся к отдельному host-level модулю `sysctl_tuning`. Этот модуль: - управляет только sysctl-параметрами хоста; - пишет `/etc/sysctl.d/90-awg2-harness-vpn.conf`; - дополнительно пишет `/etc/modules-load.d/90-awg2-harness-bbr.conf`, но только когда BBR фактически активен; если BBR неактивен, этот файл удаляется; - применяет runtime-изменения точечно (`sysctl -w`) и только для drifted keys; - не использует `/etc/sysctl.conf` и `sysctl --system`; - не управляет `udp_wmem_min`. ### Управляемые файлы и владение Все файлы, которые пакет пишет под `/etc`, проходят через общий модуль `managed_file.rs`. Правила одни для всех и не дублируются по модулям: - ошибка чтения никогда не трактуется как отсутствие файла; - файл, у которого первая строка не содержит маркер владельца, не перезаписывается и не удаляется — возвращается ошибка; - запись атомарная: временный файл-сосед, `sync_all`, `rename`, синхронизация каталога, права `0644`; - повторная запись того же содержимого не трогает файл. | Файл | Владелец | Маркер | | --- | --- | --- | | `/etc/sysctl.d/90-awg2-harness-vpn.conf` | `sysctl_tuning` | `# Managed by awg2-harness sysctl_tuning` | | `/etc/modules-load.d/90-awg2-harness-bbr.conf` | `sysctl_tuning` | `# Managed by awg2-harness sysctl_tuning` | | `/etc/sysctl.d/90-awg2-harness-swap.conf` | `swap` | `# Managed by awg2-harness swap` | Модуль `swap` раньше писал `vm.swappiness` в `/etc/sysctl.d/99-awg2-harness.conf` с другим маркером, без проверки владения перед перезаписью и без удаления при отключении. Файл переехал на `90-`, как и остальные. Старый путь удаляется автоматически, но **только** если он всё ещё несёт прежний маркер `# Managed by awg2-harness`; файл администратора по этому пути не трогается, и это не считается ошибкой миграции. Если управляемый файл существует, но не читается, `status` показывает отдельную причину `managed sysctl file is unreadable`, а не «файл отсутствует». Оператору важна разница между файлом, который пакет ещё не писал, и файлом, который он не может прочитать. При `swap.enabled = false` управляемый файл удаляется. **Остаются на месте**: сам swapfile, его активность в ядре и запись в `/etc/fstab` — то есть после перезагрузки swap снова включится. Удаляется только persistence `vm.swappiness`. Runtime-значение `vm.swappiness` при этом намеренно не меняется: назначать произвольное значение означало бы выдумать политику, о которой оператор не просил. После перезагрузки действует kernel default. Активный swap при отключении модуля тоже не выключается — деструктивное действие не должно запускаться самим фактом отключения функции. `net.ipv4.tcp_congestion_control` и `net.core.default_qdisc` попадают в целевое состояние только когда BBR желателен по policy. Фактическая активность диагностируется отдельно по runtime `net.ipv4.tcp_congestion_control`. ## 3. Структура модулей ```text traffic_core/src/ ├── main.rs точка входа и диспетчер подкоманд ├── cli.rs определение CLI (clap) ├── config.rs модель конфига, валидация, проверка host tools ├── daemon.rs цикл демона, Docker events, журнал событий ├── docker.rs обнаружение контейнера, диагностика port path ├── netns.rs транспорты выполнения команд ├── iface.rs обнаружение интерфейса ├── wg.rs обнаружение peers и разбор вывода awg/wg ├── traffic/ │ ├── mod.rs общие модели и точки входа │ ├── parse_tc.rs чистые парсеры вывода tc/ip │ ├── inspect.rs inspect_and_diff и контракт class id │ └── apply.rs teardown и полная пересборка топологии ├── firewall/ │ ├── mod.rs apply_firewall, выбор namespace, общие константы │ ├── nft.rs нормализация текста nftables и обёртки команд │ ├── host.rs owned iptables/ip6tables INPUT chains (host namespace) │ └── antitorrent.rs таблица inet awg2_harness (namespace по конфигу) ├── sysctl_tuning.rs управляемый sysctl baseline ├── swap/ │ ├── mod.rs facade и orchestration ensure_swap │ ├── model.rs модели desired/runtime/status и replacement state │ ├── path.rs syntax/path-chain safety и state directory │ ├── ownership.rs ownership record и legacy sidecar │ ├── runtime.rs swapon/swapoff/mkswap и parser runtime state │ ├── replacement.rs создание, resize и rollback транзакции │ ├── persistence.rs /etc/fstab, swappiness и legacy migration │ └── status.rs inspect_swap и drift aggregation ├── status.rs сборка и печать статуса ├── lock.rs файловые блокировки └── error.rs типы ошибок reboot_module/src/ └── main.rs планировщик перезагрузки ``` `traffic` разбит по ответственности: парсеры отдельно от инспекции, инспекция отдельно от apply. Чистые парсеры собраны в `parse_tc.rs` вместе со своими тестами — это тестируемая поверхность модуля, всё остальное требует живого контейнера. `firewall` разбит по подсистемам, а не по фазам: host firewall и anti-torrent независимы. Host firewall использует совместимый с Docker frontend `iptables`/`ip6tables`; anti-torrent владеет отдельной nftables-таблицей в configured namespace. Приватные типы объявлены в соответствующем `mod.rs`. В Rust приватные элементы видны в модуле объявления и всех дочерних модулях, поэтому подмодули работают с ними без расширения видимости до `pub(crate)`. ## 4. Модель данных Модули работают на явных структурах, а не на строках, которые передаются по всему коду. Фактические типы: ```rust // docker.rs struct RuntimeTarget { container_name: String, container_id: String, container_pid: u32, netns_path: String, // /proc//ns/net interface_name: String, } // wg.rs — состав peers для shaping struct Peer { ipv4: Ipv4Addr, } // wg.rs — runtime-состояние peers для status struct PeerRuntime { public_key: String, allowed_ips: Vec, endpoint: Option, latest_handshake_epoch: Option, transfer_rx_bytes: Option, transfer_tx_bytes: Option, } // traffic.rs — построчный статус peer в выводе status struct PeerTrafficStatus { ip: String, class_id: u16, egress_filter_present: bool, ingress_filter_present: bool, egress_rate: Option, egress_ceil: Option, ingress_rate: Option, ingress_ceil: Option, drift_reason: Option, } ``` Разделение `Peer` и `PeerRuntime` намеренное: shaping зависит только от адреса, а handshake/RX/TX нужны исключительно для диагностики. Ошибка чтения runtime-состояния не должна ломать применение лимитов. Отдельной структуры desired state нет. Целевое состояние выводится детерминированно из конфига и отсортированного списка peers прямо в `inspect_and_diff` и `apply_full_topology`. Присвоение class id обязано быть детерминированным. Строки peer IP сортируются лексикографически, затем присваивается `class_start_id + index`. Это нельзя молча заменить на числовую сортировку IPv4: production-контракт class-id ставит `10.8.1.10` раньше `10.8.1.2`, потому что сортировка текстовая, а не числовая. Рендеримое textual-значение minor id ограничено `9999`. `tc` интерпретирует class id как hexadecimal; переход от строки `9999` к `10000` вышел бы за 16-bit minor. Проверяются и стартовое значение, и последний id для фактического числа peers. ## 5. Стратегия namespace Основной путь: 1. Найти запущенный контейнер через `docker ps` / `docker inspect`. 2. Прочитать PID контейнера. 3. Использовать `/proc//ns/net` как целевой namespace. 4. Выполнять сетевые операции в этом namespace. Реализация: - текущий путь — запуск host tools со входом в namespace: `nsenter -t -n -- tc ...`, `nsenter -t -n -- ip ...`, `nsenter -t -n -- nft ...`; - в перспективе — netlink-крейты и явное переключение namespace там, где это практично. `docker exec` не является архитектурным умолчанием для этих операций. Он остаётся fallback-транспортом, если `nsenter` недоступен. На практике `nsenter` доступен всегда: `validate-runtime` завершается с ошибкой при его отсутствии, а `validate_all` вызывается перед `daemon`, `apply-limits`, `apply-firewall` и `ensure-swap`. Это снижает эксплуатационный риск: если контейнер Amnezia перестанет содержать `tc` или `iproute2`, host-side утилиты всё равно применят правила внутри его netns. Чтение состояния peers — единственное исключение: harness намеренно использует официальный container-side `awg` через `docker exec`. `awg-quick` сначала пробует kernel interface типа `amneziawg`, а при его отсутствии запускает `amneziawg-go`. Container-side CLI является общей точкой доступа для обеих реализаций и изолирует harness от наличия и версии host-side `awg`/`wg`. Поэтому `wireguard-tools` не является зависимостью хоста и не проверяется в preflight. ## 6. Порядок discovery `discover_target()`: 1. Прочитать упорядоченный `amnezia.container_names`. 2. Для каждого имени по порядку выполнить один `docker inspect`, который сразу возвращает running state, container ID и PID. 3. Выбрать первый запущенный контейнер с точным совпадением имени; отсутствующий или stopped candidate не меняет порядок fallback. 4. Построить `netns_path`. 5. Определить интерфейс в этом namespace. 6. Вернуть `RuntimeTarget`. Порядок определения интерфейса: 1. Точные имена из `amnezia.interface_candidates` — проверяются через `ip link show `. 2. Сканирование `ip -o link show` и сопоставление с `amnezia.interface_patterns`. Поддерживается только суффиксный `*` (`awg*`); имена вида `veth0@if8` нормализуются до `veth0`. 3. Если ничего не совпало — явная ошибка `unable to detect amnezia interface`, правила не применяются. Эвристики «взять первый non-loopback интерфейс с IPv4» нет и быть не должно: молчаливый выбор произвольного интерфейса опаснее явной ошибки. ## 7. Обнаружение peers Транспорт — всегда `docker exec `. Обоснование в разделе 5. Выбор бинаря идёт по упорядоченному `amnezia.wg_binaries`, default `["awg", "wg"]`. Побеждает первый, который завершился успешно. Официальный образ AmneziaWG содержит `/usr/bin/awg` и symlink `/usr/bin/wg` на него, поэтому `awg` — настоящая зависимость; `wg` остаётся для контейнеров с чистым WireGuard, где `awg` отсутствует. Отдельного probe-вызова нет: выбор бинаря является побочным результатом той команды, которая и так должна выполниться. Значения `amnezia.wg_binaries` исполняются внутри контейнера и валидируются как голые имена команд: непустые, без пробелов, без разделителей пути. Основная команда: ```bash awg show all allowed-ips ``` Правила разбора и общий `PeerTopology` contract: - строки разбираются структурно на public key peer и его allowed IPs; - в этой команде allowed ips разделены **пробелом**, в `show all dump` — запятой, в обычном `show` — `", "`; parser обязан проверить весь список, а не молча взять первый адрес; - поддерживается ровно один IPv4 `/32` внутри `amnezia.peer_ipv4_cidr` на peer; `(none)` допустим и не создаёт peer class; - IPv6-only, IPv4+IPv6, несколько allowed IPs, маска не `/32`, невалидный CIDR или IPv4 вне configured peer CIDR возвращают typed `UnsupportedTopology`; - topology проверяется один раз на startup/periodic/event cycle и передаётся traffic и anti-torrent. При ошибке ни одна из этих подсистем не начинает mutation и не сообщает успешный reconcile; - offline peers включаются: online-состояние не является требованием для shaping; - перед присвоением class id строки peer IP сортируются лексикографически, чтобы сохранить действующий контракт class-id. Резервная команда: ```bash awg show ``` Fallback-парсер разбирает текстовый вывод, извлекая строки `allowed ips:`. Он используется, если основная команда завершилась ошибкой или вернула пустой результат, и применяет тот же строгий topology contract. Классификация строк `show all dump`: - строка считается peer-строкой, только если в ней не менее 9 tab-колонок, колонка 5 (allowed ips) равна `(none)` или является списком CIDR через запятую, а колонки 6–8 (handshake, rx, tx) целиком парсятся как целые; - **количество колонок не является дискриминатором и не должно им становиться.** Исторически WireGuard/AWG 2.x/AWG 3.0/AWG 3.1 tools печатают interface-row шириной 5/14/28/30 полей, а текущий peer-row содержит 9. Ширину определяет producer `amneziawg-tools`, а не настроенная версия параметров: новые tools могут печатать широкую строку для старой конфигурации с нулевыми или пустыми новыми полями. Любое правило вида «N колонок означает версию X» неверно по построению; - interface-строка к тому же *длиннее* peer-строки, а на позициях handshake/rx/tx у неё стоят размеры junk-пакетов, которые тоже парсятся как целые, поэтому и эти колонки не разделяют два случая; - это важно не только для корректности: колонка 2 interface-строки — приватный ключ устройства, и ошибочно классифицированная interface-строка выдала бы его как public key peer; - AWG 3.x печатает протокольные таймеры диапазонами (`20-30`). AWG 3.1 tools `v3.1.20260812` дополнительно печатают `RandomTrailers` и `DisableCookies` перед `fwmark`. Колонка keepalive у peer не читается, и диапазон в ней не должен ломать разбор. Политика выполнения для production: - не использовать shell-обёртки (`sh -c`) для вызова `awg`/`wg`; - не подавлять stderr редиректом внутри строки команды; - вызывать бинарь напрямую и обрабатывать ненулевые коды возврата в Rust. ## 8. Целевая топология traffic Ожидаемая топология — контракт traffic-топологии AWG2. Egress: ```text tc qdisc add dev root handle 1: htb tc class add dev parent 1: classid 1:1 htb rate ceil for each peer: tc class add dev parent 1:1 classid 1: htb rate ceil tc qdisc add dev parent 1: fq_codel tc filter add dev parent 1: protocol ip pref 10 flower dst_ip /32 classid 1: ``` Ingress через IFB: ```text modprobe ifb ip link add name type ifb ip link set dev alias awg2-harness:traffic ip link set dev up tc qdisc add dev handle ffff: ingress tc filter add dev parent ffff: protocol all u32 match u32 0 0 action mirred egress redirect dev tc qdisc add dev root handle 2: htb tc class add dev parent 2: classid 2:1 htb rate ceil for each peer: tc class add dev parent 2:1 classid 2: htb rate ceil tc qdisc add dev parent 2: fq_codel tc filter add dev parent 2: protocol ip pref 10 flower src_ip /32 classid 2: ``` Все peer-фильтры на каждом parent используют один `pref 10`: для `flower` это даёт один hash table на priority вместо множества priority structures. Маска всегда `/32`, поэтому один priority не смешивает несовместимые masks. Catch-all redirect в IFB остаётся `u32`, поскольку это одно правило, а не per-peer lookup. ## 9. Алгоритм reconcile Traffic и firewall — два независимых reconcile с отдельными блокировками. Они не объединены в одну транзакцию: сбой одного не должен блокировать другой. ```text reconcile_traffic(): если traffic.enabled = false: выйти без изменений взять /run/awg2-harness/traffic.lock (timeout 10 c) target = discover_target() peers = discover_peers(config, target) если peers пуст: проверить наличие AWG2-owned topology если она осталась от прежних peers: teardown_owned_topology(target) иначе выйти с debug-диагностикой drift = inspect_and_diff(target, peers, config) если drift отсутствует: выйти с debug-диагностикой teardown_owned_topology(target) apply_full_topology(target, peers, config) # внутри — verify-after-apply ``` ```text apply_firewall(): взять /run/awg2-harness/firewall.lock (timeout 10 c) apply_host_firewall(config) # host namespace, iptables/ip6tables A/B chains apply_antitorrent_firewall(config) # namespace по anti_torrent.apply_in_amnezia_netns ``` Каждый из firewall-путей сначала инспектирует фактическое состояние и выходит без изменений, если drift отсутствует. Anti-torrent таблица заменяется одной атомарной nft-транзакцией и при включённом guard требует успешно проверенный `PeerTopology`; host firewall от server-side peer topology не зависит. Host firewall сначала строит и проверяет inactive chain отдельно для IPv4/IPv6. Новый jump всегда вставляется через `-I INPUT 1`; старый остаётся вторым до успешного переключения обеих IP-family и затем удаляется по полной rule specification. Numeric `-R/-D` для managed jumps не используется. Сбой IPv6 удаляет новый IPv4 jump по точной specification, поэтому старый IPv4 jump всё время остаётся рабочим. После apply выполняется обязательная повторная инспекция. Для production контролируемая полная пересборка при drift — единственный поддерживаемый путь. Требования к устойчивости парсеров: - `tc filter show` разбирается блоками (конечный автомат), а не наивным сканированием токенов по несвязанным строкам; - единый parser понимает production `flower` и legacy `u32`; точный healthy-контракт требует `flower`, `pref 10`, правильное direction-field и `/32`, а legacy `u32` peer rules считаются migration drift; - нераспознанный или malformed detailed filter block не игнорируется, а делает инспекцию drifted; - отображение `peer_ip -> class_id` проверяется для обеих сторон: egress (`1:`) и ingress (`2:`); - проверка обязана падать, если классы и фильтры существуют, но отображение отличается от детерминированного присвоения. Политика apply/verify: - `modprobe ifb`, создание IFB, установка ownership alias и перевод в UP выполняются строго и до `tc` apply; - после teardown вся `tc` topology подаётся одним fail-fast сценарием в stdin команды `tc -batch -`; `-force` не используется, поэтому первая ошибка останавливает batch и сохраняет номер строки в stderr; - `tc -batch` не является транзакцией: любая ошибка после частичного применения запускает cleanup доказанно owned handles/IFB; если cleanup тоже падает, в итоговой ошибке сохраняются обе причины; - ошибки «объект не найден» (`no such`, `not found`, `cannot find`, `does not exist`, `handle of zero`) допускаются только на шагах удаления — в teardown и при отключении владеемых таблиц; - любая другая ошибка apply фатальна; - `verify-after-apply` обязателен и для traffic, и для anti-torrent. Контракт наблюдаемости anti-torrent: - в владеемой таблице обязан присутствовать именованный counter `antitorrent_dropped_v4`; - вывод status обязан показывать и дропнутые пакеты, и дропнутые байты. ## 10. Owned state и правила teardown Бинарь владеет: - root qdisc `1:` на выбранном интерфейсе AWG/WG; - ingress qdisc `ffff:` на выбранном интерфейсе AWG/WG, когда лимиты включены; - IFB-устройством с именем `traffic.ifb_name` (default `ifb0`), type `ifb` и alias `awg2-harness:traffic` в целевом namespace; - root qdisc `2:` на IFB; - классами и фильтрами под этими handles. До первой операции удаления teardown инспектирует все цели. На AWG/WG-интерфейсе owned считаются только root `htb 1:` и ingress `ffff:`; `clsact` и любой другой root — ownership conflict. IFB удаляется только при совпадении type+alias (кроме IFB, доказанно созданного текущей неуспешной попыткой). Чужие объекты не заменяются даже на выделенном сервере. Бинарь не должен вслепую сбрасывать посторонние host firewall rules, Docker NAT rules или чужие интерфейсы. Владение firewall явно ограничено таблицей `inet awg2_harness`, comment jump `awg2-harness:host-firewall` и цепочками `AWG2H_A`/`AWG2H_B`. Встроенные `INPUT`/`FORWARD`, их policy и чужие chains не очищаются. Host firewall не открывает published Amnezia UDP port в `INPUT`: Docker bridge publish проходит через `FORWARD`/NAT. Собственная chain разрешает loopback, established/related, configured SSH и optional ICMP/ICMPv6, затем завершает обработку configured `DROP`/`ACCEPT` verdict. Поле `amnezia.expected_host_udp_port` относится только к диагностике port path. Жизненный цикл managed jump использует только exact rule spec с comment `awg2-harness:host-firewall` и target `AWG2H_A`/`AWG2H_B`. Чужое правило, вставленное другим firewall manager между inspect и switch, не может быть заменено или удалено по изменившемуся номеру строки. ## 11. Anti-torrent firewall guard Это часть production-контракта AWG2. Модуль вписывается в мульти-оркестраторную модель, потому что сервер выделенный и бинарю разрешено владеть host/network правилами, нужными VPN-продукту. Правило всё равно обязано быть узко ограниченным и наблюдаемым. Цель — ограничить количество одновременных conntrack flows на один VPN peer IP. Это не детектирование торрент-протокола, а обобщённый connection-count guard, который делает P2P-нагрузку с большим числом peers непривлекательной, не задевая обычные browsing, streaming, gaming и messaging. Правило не должно быть глобальным лимитом на подсеть: ```nft ip saddr 10.8.1.0/24 ct count over 200 drop ``` Такая форма может превратиться в один общий счётчик на весь совпавший трафик — в зависимости от того, как она написана и куда помещена. Для per-peer поведения stateful-объект нужно ключевать по source VPN IP. Рекомендуемая форма: ```nft table inet awg2_harness { counter antitorrent_dropped_v4 {} chain forward_antitorrent { type filter hook forward priority filter; policy accept; ip saddr ct state new \ meter awg2_peer_connlimit_v4 { ip saddr ct count over } \ counter name antitorrent_dropped_v4 \ drop } } ``` Целевой namespace — тот же Amnezia network namespace, где применяется `tc` shaping. Нельзя предполагать, что начальный host namespace видит исходные source-адреса peers. Если Amnezia выполняет NAT внутри namespace контейнера, host Docker bridge может видеть только адрес контейнера, а не `10.8.1.x`. Порог в примере конфигурации — `500` одновременных flows на peer IP. Это production baseline по эксплуатационным измерениям проекта: guard остаётся включённым, но не должен вмешиваться в нормальный повседневный профиль. Значение ниже `500` является более агрессивной политикой и требует отдельной проверки на реальных клиентах. Порог передаётся из config и не зашит в алгоритм. Размещение правила: hook `forward` с приоритетом `filter` или любым приоритетом после conntrack lookup. Оценка ограничивается `ct state new`, чтобы установленные flows продолжали работать и дропались только новые попытки сверх порога. Владеемое состояние: - таблица `inet awg2_harness`; - chain `forward_antitorrent`; - counter `antitorrent_dropped_v4`; - meter/set state, создаваемый правилом. Status обязан показывать наличие таблицы/chain/правила, настроенный порог и drop counter. При anti-torrent в Amnezia netns `net.netfilter.nf_conntrack_count` читается в этом namespace, а глобальный `net.netfilter.nf_conntrack_max` — на host. Если container transport недоступен, host count не подставляется как ложный fallback. При `anti_torrent.enabled = false` демон удаляет только владеемую anti-torrent таблицу. Глобальный ruleset nftables не сбрасывается никогда. Если контейнер в этот момент именно не найден, удаление в его namespace пропускается — это не ошибка. Ошибка Docker API/daemon/permissions не маскируется под «контейнер не найден» и возвращается оператору. ## 12. Инспекция фактического состояния `inspect_and_diff()` собирает достаточно информации для диагностики drift: - qdisc на ``; - классы на ``; - qdiscs, привязанные к per-peer классам на ``; - фильтры под parent `1:` на ``; - ingress qdisc на ``; - ingress redirect filter на ``; - существование и состояние ``; - qdisc на ``; - классы на ``; - фильтры под parent `2:` на ``. Один вывод `tc qdisc show dev ` используется одновременно для root, ingress и per-peer `fq_codel`; один вывод `tc class show dev ` — для root и per-peer classes. Инспекция не делает отдельный child process на каждый parent. Дополнительно детектируются дубли: один и тот же peer IP в нескольких фильтрах и один и тот же class id у нескольких IP. Оба случая считаются drift. `status` показывает компактную сводку и таблицу по peers. ## 13. Docker events и периодический reconcile Демон использует два триггера: 1. Docker events: `docker events --filter event=start --format '{{.Actor.Attributes.name}}'`. Имя сверяется с `amnezia.container_names`, после чего выполняется ожидание готовности интерфейса и reconcile traffic + firewall. Если поток событий завершился, он перезапускается через 10 секунд. 2. Таймер: периодический reconcile каждые `traffic.reconcile_interval_secs`, но не чаще одного раза в 5 секунд. Он применяется к `traffic` и `firewall`; `swap` идёт по собственному расписанию — раз в 5 минут. Swapfile, запись в `/etc/fstab` и `vm.swappiness` меняются только тогда, когда их меняет оператор, а проверка на traffic-интервале означала несколько вызовов `swapon --show` и `sysctl` каждые 15 секунд ради состояния, которое само по себе не дрейфует. Таймер обязателен. События могут быть пропущены при рестарте демона, рестарте Docker или транзиентных ошибках. Внутри одного startup/periodic/event-цикла traffic и anti-torrent получают один и тот же результат discovery `RuntimeTarget`; повторные `docker inspect`, PID/interface discovery и transport construction в этом же цикле не выполняются. Наличие host `nsenter` кэшируется на время жизни процесса. Проверка готовности не должна хардкодить `awg0`. Она вызывает общий детектор интерфейса с интервалом до 2 секунд до истечения `traffic.container_ready_timeout_secs`. Retry разрешён только для typed `AppError::NotFound`; Docker/API/permission/parse errors возвращаются немедленно. Истечение timeout является ошибкой, а не silent skip. Если start event пришёл от lower-priority container, но discovery уже выбрал running container выше в `amnezia.container_names`, событие игнорируется без reconcile. Порядок на старте демона и разница в критичности ошибок: 1. `sysctl_tuning`, если `apply_on_daemon_start = true` — ошибка **фатальна**, демон завершается; 2. `swap` — ошибка **фатальна**, демон завершается; 3. `traffic` — ошибка логируется, демон продолжает работу; 4. `firewall` — ошибка логируется, демон продолжает работу. Такое разделение намеренное: некорректный baseline хоста или swap означают неверные предпосылки, тогда как отсутствие контейнера Amnezia — нормальное транзиентное состояние. Результаты пишутся в два файла: - `/run/awg2-harness/last_action.state` — последний успех и последняя ошибка; - `/run/awg2-harness/events.log` — журнал событий, ограниченный 1000 строками и 256 KiB. Ошибки отслеживаются по подсистемам (`traffic`, `firewall`, `swap`, `sysctl_tuning`, `docker_events`), а не одним общим полем. Общее поле в `last_action.state` показывает ту ошибку, которая ещё не устранена: успешный reconcile одной подсистемы не гасит незакрытую ошибку другой и не объявляет восстановление того, что не падало. Поток `docker events` при успешном переподключении тоже отмечается как восстановленный — иначе один рестарт Docker оставлял бы ошибку в `status` навсегда при исправно работающем новом потоке. В `events.log` и journald на уровне `info`/`error` пишутся только **переходы состояния**: первое появление ошибки, изменение её текста и восстановление. Рутинный healthy-цикл и повтор той же ошибки остаются на `debug`. Это исключае повторяющийся journal-шум на каждом 15-секундном reconcile, но сохраняет детали для отладки при включённом debug-уровне. ## 14. Блокировки Одновременно может выполняться только один reconcile каждого вида — по всем подкомандам и задачам демона. Блокировки: | Файл | Таймаут ожидания | Кого защищает | | --- | --- | --- | | `/run/awg2-harness/traffic.lock` | 10 с | `reconcile_traffic` | | `/run/awg2-harness/firewall.lock` | 10 с | `apply_firewall` (host + anti-torrent) | | `/run/awg2-harness/sysctl_tuning.lock` | 30 с | `ensure_sysctl_tuning` | Реализация — advisory-блокировка ядра (`flock` на Linux) через `std::fs::File`, а не PID-файл. Поведение: - ожидание захвата — с интервалом 100 мс до таймаута, затем ошибка `lock busy timeout`; - PID пишется в файл **только как диагностика**; ни одно решение на его основе не принимается; - освобождение выполняет ядро при завершении процесса — любым способом, включая `kill -9`. Понятия «устаревшая блокировка» не существует; - файл блокировки никогда не удаляется: сам факт его наличия ничего не значит, значение имеет только удерживаемая на нём блокировка. Прежняя реализация на PID-файлах имела типичные для такого подхода дефекты: конфликт определялся по подстроке в тексте ошибки; «устаревшая» блокировка удалялась и создавалась заново двумя отдельными операциями, поэтому два процесса могли одновременно счесть её устаревшей и оба захватить; переиспользованный PID делал живую блокировку похожей на мёртвую; удаление файла в `Drop` гонялось с процессом, который уже открыл его и ждёт. ## 15. Контракт вывода status `awg2-harness status` печатает блоки в следующем порядке. Имена полей стабильны и пригодны для grep. ```text System: service: active|inactive|unknown container: container_id: pid: netns: /proc//ns/net interface: Network tuning: enabled, apply_on_daemon_start, tcp_bbr_mode, conf_path, modules_load_path, значения управляемых ключей, bbr_available, bbr_desired, bbr_runtime_active, drift[, reason][, warning] Peers: total: N - WireGuard clients: IP, State, Latest handshake, RX, TX, Public key, Endpoint Traffic desired: peer_limit, root_rate, ifb_name, class_start_id Traffic actual: drift: no|yes[, reason] topology: egress_htb_present, ingress_present, ifb_htb_present peer_table: ip, class_id, egress_filter, ingress_filter, egress, ingress, drift Peer traffic counters: IP, Egress packets, Ingress packets Host firewall: enabled, ssh_port, input_policy, drift[, reason] Docker/Amnezia port path: expected_host_udp_port, docker_userland_proxy, published_udp_port, host_bindings (все HostIp:HostPort, включая routed binding без HostPort), awg_listen_udp_port, port_contract, host_forward_policy_drop, docker_forward_rules_present, note[, warning] Anti-torrent: enabled, max_connections_per_peer, drift[, reason], dropped_packets, dropped_bytes Swap: enabled, desired_path, desired_size_mb, desired_priority, active_entries, desired_active, exclusive, fstab_exclusive, fstab_swap_entries, swappiness_current, swappiness_persisted, drift[, drift_reason] Conntrack: count_namespace, nf_conntrack_count, nf_conntrack_max_global, nf_conntrack_utilization Last actions: содержимое /run/awg2-harness/last_action.state Recent events: последние 10 строк /run/awg2-harness/events.log Reboot scheduler: enabled, window_start_utc, window_end_utc, service, planned ``` Блок `System` обязан оставаться неразрывным: строки `container`/`container_id`/`pid`/`netns`/`interface` имеют тот же отступ, что и поля любой другой секции, поэтому открытие постороннего блока между ними и заголовком `System` делает вывод неоднозначным при чтении. Если контейнер обнаружить не удалось, `status` не завершается ошибкой, а переходит в degraded-режим. Строки `container`/`pid`/`netns`/`interface` печатаются со значениями `n/a`, секции `Peers` и `WireGuard clients` печатают `unavailable`, `Traffic actual` показывает `drift: unknown`, блок `Docker/Amnezia port path` заполняется значениями `unknown`, а блок `Peer traffic counters` не печатается вовсе. Секции host firewall, anti-torrent, swap, conntrack, событий и reboot остаются на месте. Секции, общие для здорового и degraded путей, рендерятся одними и теми же функциями. Это обязательное требование: раньше они были продублированы, и расхождение форматов проявилось бы ровно в тот момент, когда контейнер лежит, то есть в худший для диагностики момент. Отдельные секции деградируют независимо: ошибка чтения peer state не мешает вывести traffic, firewall и swap. Универсальный баннер вида «system ready» недопустим. Status обязан быть фальсифицируемым: он должен явно сообщать, когда состояние частичное. ## 16. Интеграция reboot-модуля Production-путь перезагрузки — внешний бинарь/сервис `awg2-reboot-scheduler`: - окно берётся из конфига, а не из констант; - случайная точка внутри окна, включая окна через полночь; - вызов остаётся `systemctl --no-block reboot`; - при `reboot.enabled = false` планирование отключено, а оставшийся от прежней конфигурации `/run/awg2-harness/reboot.plan` удаляется — иначе `status` продолжал бы показывать перезагрузку, которая никогда не наступит; - запланированная отметка времени сохраняется в `/run/awg2-harness/reboot.plan` атомарно (временный файл, `sync_all`, `rename`), и ошибка записи прекращает работу. Это единственный источник информации о том, когда хост перезагрузится; прежняя best-effort запись позволяла планировщику молча уйти в ожидание момента, о котором в системе не было никаких данных; - после принятия запроса на перезагрузку план удаляется, чтобы прошедшая отметка времени не выглядела как ожидающая. ## 17. Модуль swap Целевое состояние `ensure-swap`: - путь из конфига, default `/swapfile`; - размер `swap.size_mb`, в примере конфигурации — 2048 MB; - права `0600`; - выполнен `mkswap`; - запись активна в `swapon --show`; - одна запись в `/etc/fstab` с настроенным приоритетом; - `vm.swappiness` из конфига применён в runtime и сохранён persistently. При `swap.enabled = true` AWG2 Harness владеет состоянием swap на хосте эксклюзивно. Целевое состояние — ровно одна активная swap-запись: `swap.path`. Все прочие активные swap выключаются через `swapoff`, а их записи убираются из `/etc/fstab`. Операция обязана быть идемпотентной. Создание файла: `mkswap --file --size M` — одна команда, которая и создаёт файл, и форматирует его. Прежняя цепочка `fallocate` → fallback `dd` → `chmod` → `mkswap` давала четыре процесса, три ветки отказа и промежуток, в котором файл уже существовал, но ещё не был отформатирован. `util-linux` в Debian 13 дополнительно учитывает требования конкретных ФС (в частности Btrfs), которых голый `fallocate` не знает. Из обязательных host tools при `swap.enabled = true` соответственно ушли `fallocate`, `dd` и `chmod`. ### Владение swapfile Пакет никогда не трогает файл, который не создавал. Состояние владения хранится в `/var/lib/awg2-harness/swap-ownership.conf` — каталог доступен только root (`0700`): ```text # Managed by awg2-harness swap-ownership version = 1 path = /swapfile device = 2049 inode = 123456 ``` Ключевое здесь — `device` и `inode`, а не путь. Путь не является идентичностью: файл можно удалить, а на его месте создать другой. Прежняя схема с sidecar-маркером рядом со swapfile этого не различала, и последовательность «наш swapfile удалили вручную → маркер остался → оператор создал по тому же пути другой файл» приводила к тому, что чужой файл признавался управляемым и при несовпадении размера удалялся. Sidecar удалён; при обнаружении он только вычищается и никогда не используется как доказательство владения. | Состояние | Условие | Действие | | --- | --- | --- | | `Missing` | файла нет | создать | | `ManagedValid` | запись есть, `device`+`inode`+путь совпали, размер совпал | переиспользовать | | `ManagedWrongSize` | запись совпала, размер отличается | пересоздать | | `Foreign` | файл есть, записи нет или запись про другой путь | **ошибка**, файл не трогается | | `StaleOwnership` | запись есть, но `device`/`inode` не совпали — файл подменили | **ошибка**, файл не трогается | Запись создаётся **после** появления файла, по его фактическим metadata. До этого момента файл управляемым не считается. Чужие активные swap только выключаются через `swapoff`. Файлы не удаляются — ни обычные, ни блочные устройства. Удаление чужого swapfile не является решением этого пакета: эксклюзивное runtime-состояние достигается связкой `swapoff` плюс вычистка `/etc/fstab`, а файл оператор уберёт сам, когда сочтёт нужным. ### Порядок операций Порядок принципиален. Управляемый swap создаётся, активируется и проверяется **до** того, как отключается любой чужой: ```text 1. взять /run/awg2-harness/swap.lock (timeout 30 c) 2. preflight: разобрать конфиг, проверить безопасность пути, снять текущее состояние 3. подготовить управляемый swapfile (с учётом владения) 4. swapon управляемого swap 5. убедиться, что он реально появился в списке активных 6. удалить неактивные остатки прерванной замены (.new, .previous) 7. только теперь swapoff чужих swap 8. перезаписать /etc/fstab транзакцией 9. применить swappiness 10. проверить итоговое состояние ``` Шаги 2–5 не выполняют ни одного деструктивного действия над чужим состоянием. Прежний порядок отключал чужие swap первым шагом, поэтому ошибка в `fallocate`, `dd`, `mkswap` или `swapon` оставляла хост вообще без swap. Шаг 6 нужен потому, что вычистка остатков перед заменой (шаг 3) не покрывает процесс, убитый уже **после** активации нового файла: следующий запуск видит по основному пути валидный, активный и корректно принадлежащий нам swapfile, идёт быстрым путём и на остатки не смотрит. Размер swapfile обычно сопоставим с объёмом RAM, поэтому «когда-нибудь» здесь не годится. Удаляются только неактивные остатки; активный означает, что ядро всё ещё свопит в него, — это случай прерванного отката, и он требует оператора, а не догадки. ### Замена активного swapfile Живой swapfile нельзя изменить в размере на месте, поэтому изменение `swap.size_mb` выполняется заменой. Новый файл создаётся и активируется, **пока старый ещё работает**; старый затем не удаляется, а откладывается под именем `.awg2-harness.previous`, и до подтверждения нового файла оба лежат на диске. Инвариант отката один: откат никогда не оставляет хост без swap. Он сформулирован не как «вернуть исходное состояние любой ценой» — исходное состояние может быть недостижимо, — а как «на хосте должен работать хотя бы один подготовленный swap». Отсюда порядок восстановления: 1. активную замену сначала выключить; если `swapoff` не удался, она остаётся на месте и **продолжает обслуживать** — двигать файл, в который ядро свопит, значит только рассинхронизировать `/proc/swaps` с файловой системой; 2. неактивную замену не удалять, а отодвинуть под staging-имя: пока исходный файл не подтверждён рабочим, замена — единственная вторая swap-область на хосте; 3. вернуть исходный файл на основной путь и активировать его с **прежним** priority, а не с применяемым: применяемый мог оказаться ровно тем, что ядро отвергло; 4. если `rename` обратно не удался, исходный файл активируется прямо по пути `.awg2-harness.previous` — ядро активирует swap-область по пути, и имя файла для этого значения не имеет; 5. если исходный файл поднять не удалось вовсе, активируется отложенная замена. Соответственно у отката есть три деградированных исхода, и в каждом текст ошибки описывает **фактическое** состояние, а не намерение: замена осталась активной по основному пути; исходный swap работает из `.previous` и оба файла целы; ни один кандидат не активировался — хост без управляемого swap. Первые два сопровождаются точными командами, которыми оператор доводит состояние до нормального, когда хост это позволит. ### Безопасность `swap.path` Проверка разделена на две части. Лексическая не обращается к файловой системе и потому пригодна для `validate-config` на хосте, где файла ещё нет: - путь абсолютный и не оканчивается на `/`; - нет компонентов `.` и `..` — поэтому строка уже нормализована и список запрещённых путей нельзя обойти записью вида `/etc/../etc/fstab`; - нет пробелов, управляющих символов и обратных слэшей; - путь равен `/swapfile` **либо** лежит непосредственно в `/var/lib/awg2-harness/` (без вложенных каталогов). Это allow-list, а не deny-list, и это принципиально. Список запрещённых путей закрывает только те случаи, которые кто-то придумал; значение имеет другое — может ли родительский каталог писать кто-то кроме root. Между preflight-проверкой и запуском `mkswap --file` непривилегированный пользователь с правом записи в родительский каталог успевает подменить финальный компонент на symlink и перенаправить операцию, выполняемую от root. Это классический TOCTOU, и никакое количество `stat` перед использованием его не закрывает — окно устраняется только тем, что запись в родительский каталог доступна лишь root. Оба разрешённых корня root-owned. `/swapfile` оставлен, чтобы уже развёрнутые конфигурации не требовали правки. Файловая часть выполняется непосредственно перед использованием пути: каждый родительский компонент обязан существовать и не быть symlink. Проверки только финального компонента недостаточно — symlink в середине цепочки перенаправляет операцию на файл, который никогда не настраивался. ### Перезапись `/etc/fstab` Это самая разрушительная операция пакета, поэтому она транзакционная и изолирована в `swap/persistence.rs`. Контракт: - ошибка чтения `/etc/fstab` **никогда** не интерпретируется как пустой файл. `PermissionDenied`, ошибка ввода-вывода и отсутствие файла различаются, и все три прекращают операцию. Отсутствие `/etc/fstab` на Debian-хосте считается аномалией, а не основанием создать его с нуля; - пустой или состоящий из пробелов кандидат отклоняется; - резервная копия обязательна. Если её не удалось записать, целевой файл не трогается вовсе — ситуация, в которой копия не пишется, и есть та, в которой она нужна; - копий две: `/etc/fstab.awg2-harness.bak` перезаписывается перед каждой правкой и содержит состояние до неё, а `/etc/fstab.awg2-harness.original` создаётся один раз через `create_new` и хранит содержимое до первого вмешательства пакета; - запись идёт во временный файл рядом с целевым, с `create_new`, `sync_all` и переносом прав с оригинала; устаревший временный файл от убитого процесса удаляется явно, а не переиспользуется; - кандидат проверяется через `findmnt --verify --tab-file` до подмены. Проверка обязательна: `findmnt` входит в `util-linux`, от которого пакет и так зависит, и включён в обязательные host tools при `swap.enabled = true`. Невозможность его запустить и отказ файла одинаково прекращают операцию — молча пропускать единственную структурную проверку файла, от которого зависит загрузка хоста, недопустимо; - подмена — атомарный `rename`, после него каталог синхронизируется; - после `rename` содержимое перечитывается и сверяется с записанным. Расхождение приводит к восстановлению из резервной копии. Путь в записи fstab экранируется: поля разделяются пробелами, поэтому пробел или табуляция в пути сдвинули бы все последующие колонки. Чтение swap-записей для `status` использует ту же строгую функцию чтения. Маскировка ошибки здесь показала бы «в fstab нет swap-записей», то есть здоровое состояние без drift, на хосте, где `/etc/fstab` вообще не читается. ## 18. Тестирование Модульные тесты: - валидация конфига, включая форму `amnezia.wg_binaries`; - разбор `config.example.toml` в актуальную схему с точным default-порядком контейнеров, `awg`/`wg` и anti-torrent baseline `500`; - парсер `awg|wg show all allowed-ips`: один IPv4 `/32` проходит, IPv4+IPv6, IPv6-only, несколько маршрутов, маска не `/32` и адрес вне configured CIDR дают explicit `UnsupportedTopology`; - fallback-парсер `awg|wg show` и dump-парсер применяют тот же topology contract; `(none)` остаётся допустимым; - классификация peer/interface строк `awg|wg show all dump` против двух 30-полевых AWG 3.1 fixtures, AWG 3.0 с включённой и выключенной защитой заголовка, AWG 2.x, чистого WireGuard, а также конфигурации AWG 2.0, прочитанной tools AWG 3.0; - разбор dump с несколькими интерфейсами, с интерфейсом без peers и с повреждёнными строками; - порядок выбора бинаря wg и ошибка при отсутствии всех кандидатов; - фильтрация по CIDR; - детерминированное присвоение class id с контрактом лексикографической стабильности; - textual boundary class id `9999`/`10000`; - рендеринг и разбор ruleset nftables для anti-torrent и ownership parser host iptables chains; - парсер `tc` для снимков qdisc/class/filter; - packet statistics из Debian-подобного `tc -s class`; - отказ удалять foreign IFB/root qdisc; - typed Docker readiness retry/timeout/lower-priority event; - Docker UDP bindings: dual-stack HostIp, routed binding с пустым HostPort и неоднозначные/divergent host ports; - BBR decision: доступный BBR при runtime `cubic` означает `desired=yes`, `runtime_active=no`; mode `off` не маскирует уже активный runtime BBR; - host firewall rollback удаляет новый jump по exact specification и не затрагивает чужое правило в начале `INPUT`; - conntrack count из Amnezia namespace и global max с host; - diff drift; - расчёт окна перезагрузки; - идемпотентность fstab и sysctl. Интеграционные тесты на реальном хосте: - первичное применение; - повторное применение; - восстановление частичного состояния после удаления только ingress qdisc; - восстановление частичного состояния после удаления `ifb0`; - рестарт контейнера; - рестарт Docker; - перезагрузка хоста; - добавление peer; - удаление peer; - вывод status в здоровом и сломанном состояниях; - рост counter anti-torrent при превышении peer настроенного порога одновременных flows; - путь отключения anti-torrent не сбрасывает посторонние правила nftables/Docker/Amnezia.