# Спецификация реализации 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 с AmneziaWG/AWG 2.0. Он владеет инфраструктурным состоянием, которое нужно 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` не планирует перезагрузки внутри себя. Подкоманды: ```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`. Оба управляемых файла несут в первой строке маркер `# Managed by awg2-harness sysctl_tuning`. Файл без маркера не перезаписывается и не удаляется — вместо этого возвращается ошибка. Это защищает чужие настройки, случайно оказавшиеся по тому же пути. `net.ipv4.tcp_congestion_control` и `net.core.default_qdisc` попадают в целевое состояние только при активном BBR. ## 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.rs tc/IFB: инспекция, diff, teardown, apply ├── firewall.rs nftables: host firewall и anti-torrent ├── sysctl_tuning.rs управляемый sysctl baseline ├── swap.rs управляемый swap ├── status.rs сборка и печать статуса ├── lock.rs файловые блокировки └── error.rs типы ошибок reboot_module/src/ └── main.rs планировщик перезагрузки ``` Разбиение `traffic` и `firewall` на подкаталоги (`model.rs`, `inspect.rs`, `apply.rs`) обсуждалось, но не выполнено. Пока эти модули остаются одиночными файлами, любые изменения должны сохранять разделение на инспекцию, diff и apply внутри файла. ## 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`, потому что сортировка текстовая, а не числовая. ## 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 — единственное исключение, и его нельзя выполнять host-side бинарём. AmneziaWG — userspace-реализация, её UAPI-сокет находится в `RUNSTATEDIR/amneziawg/.sock` внутри mount namespace контейнера, тогда как хостовый `wireguard-tools` ищет сокет в `/var/run/wireguard/`. `nsenter -n` входит только в network namespace, поэтому хостовый `wg` не достаёт ни до сокета, ни до kernel-устройства и всегда возвращает пустой список peers. Поэтому обнаружение peers использует исключительно `docker exec`, а `wireguard-tools` не является зависимостью хоста и не проверяется в preflight. ## 6. Порядок discovery `discover_target()`: 1. Прочитать упорядоченный `amnezia.container_names`. 2. Найти первый запущенный контейнер с точным совпадением имени. 3. Получить container ID и PID. 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 ``` Правила разбора: - строки разбираются структурно на public key peer и его allowed IPs; - в этой команде allowed ips одного peer разделены **пробелом**, в `show all dump` — запятой, в обычном `show` — `", "`; нужно потреблять все токены начиная с третьей колонки, иначе peers с несколькими allowed IP получат shaping только по первому адресу; - оставляются только IPv4 `/32` внутри `amnezia.peer_ipv4_cidr`; - offline peers включаются: online-состояние не является требованием для shaping; - перед присвоением class id строки peer IP сортируются лексикографически, чтобы сохранить действующий контракт class-id. Резервная команда: ```bash awg show ``` Fallback-парсер разбирает текстовый вывод, извлекая строки `allowed ips:`. Он используется, если основная команда завершилась ошибкой или вернула пустой результат. Классификация строк `show all dump`: - строка считается peer-строкой, только если в ней не менее 9 tab-колонок, колонка 5 (allowed ips) равна `(none)` или является списком CIDR через запятую, а колонки 6–8 (handshake, rx, tx) целиком парсятся как целые; - количество колонок само по себе не является дискриминатором: interface-строка AmneziaWG содержит AWG-параметры и *длиннее* peer-строки (14 колонок на AWG 2.0, 28 на AWG 3.0), а на позициях handshake/rx/tx у неё стоят размеры junk-пакетов, которые тоже парсятся как целые; - это важно не только для корректности: колонка 2 interface-строки — приватный ключ устройства, и ошибочно классифицированная interface-строка выдала бы его как public key peer; - AWG 3.0 печатает протокольные таймеры диапазонами (`20-30`). Колонка 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 u32 match ip dst flowid 1: ``` Ingress через IFB: ```text modprobe ifb ip link add name type ifb 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 u32 match ip src flowid 2: ``` ## 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 пуст: залогировать "no peers found, skip tc apply" и выйти drift = inspect_and_diff(target, peers, config) если drift отсутствует: залогировать "traffic rules up-to-date, skip apply" и выйти 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, таблица inet awg2_host_firewall apply_antitorrent_firewall(config) # namespace по anti_torrent.apply_in_amnezia_netns ``` Каждый из firewall-путей сначала инспектирует фактическое состояние и выходит без изменений, если drift отсутствует. При наличии drift владеемая таблица удаляется и создаётся заново, после чего выполняется обязательная повторная инспекция. Для production контролируемая полная пересборка при drift — единственный поддерживаемый путь. Требования к устойчивости парсеров: - `tc filter show` разбирается блоками (конечный автомат), а не наивным сканированием токенов по несвязанным строкам; - отображение `peer_ip -> class_id` проверяется для обеих сторон: egress (`1:`) и ingress (`2:`); - проверка обязана падать, если классы и фильтры существуют, но отображение отличается от детерминированного присвоения. Политика apply/verify: - все шаги apply — явные вызовы команд; - ожидаемые конфликты идемпотентности допускаются выборочно, через отдельную обёртку, и по закрытому списку признаков: `file exists`, `eexist`, `object already exists`, `exclusive flag on`; - ошибки «объект не найден» (`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`) в целевом namespace; - root qdisc `2:` на IFB; - классами и фильтрами под этими handles. Teardown может удалять и пересоздавать эти владеемые объекты. Поскольку сервер выделенный, это допустимо и предпочтительнее, чем оставлять устаревшие или частичные правила. Бинарь не должен вслепую сбрасывать посторонние host firewall rules, Docker NAT rules или чужие интерфейсы. Владение firewall обязано быть явным и ограниченным таблицами AWG2 Harness: `inet awg2_harness` в целевом network namespace и `inet awg2_host_firewall` в host namespace. ## 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`. Порог в примере конфигурации — `150` одновременных flows на peer IP. `100` — более строгий режим. `70` агрессивнее и применяется только после наблюдения реального трафика. `200`/`250` — более permissive варианты. Размещение правила: 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. Также он показывает давление на conntrack хоста через `net.netfilter.nf_conntrack_count` и `net.netfilter.nf_conntrack_max`, когда они доступны. При `anti_torrent.enabled = false` демон удаляет только владеемую anti-torrent таблицу. Глобальный ruleset nftables не сбрасывается никогда. Если контейнер в этот момент отсутствует, удаление в его namespace просто пропускается — это не ошибка. ## 12. Инспекция фактического состояния `inspect_and_diff()` собирает достаточно информации для диагностики drift: - qdisc на ``; - классы на ``; - qdiscs, привязанные к per-peer классам на ``; - фильтры под parent `1:` на ``; - ingress qdisc на ``; - ingress redirect filter на ``; - существование и состояние ``; - qdisc на ``; - классы на ``; - фильтры под parent `2:` на ``. Дополнительно детектируются дубли: один и тот же 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 секунд. Таймер обязателен. События могут быть пропущены при рестарте демона, рестарте Docker или транзиентных ошибках. Проверка готовности не должна хардкодить `awg0`. Она вызывает общий детектор интерфейса с интервалом 2 секунды до истечения `traffic.container_ready_timeout_secs`. Порядок на старте демона и разница в критичности ошибок: 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. ## 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` | Поведение: - ожидание захвата — с интервалом 100 мс до таймаута, затем ошибка `lock busy timeout`; - в файл записывается PID владельца; - если процесс-владелец мёртв (`/proc/` отсутствует) или содержимое файла не парсится как PID, блокировка считается устаревшей и перезахватывается; - освобождение автоматическое при выходе из области видимости, включая пути с ошибкой. ## 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_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 hits, Ingress hits Host firewall: enabled, ssh_port, amnezia_port, input_policy, drift[, reason] Docker/Amnezia port path: docker_userland_proxy, published_udp_port, 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: nf_conntrack_count, nf_conntrack_max 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`. ## 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`. Все не-комментарные swap-записи в `/etc/fstab` удаляются и заменяются одной управляемой записью AWG2; перед перезаписью создаётся резервная копия `/etc/fstab.awg2-harness.bak`. Обычные swap-файлы, не равные настроенному `swap.path`, могут быть удалены только после успешного `swapoff`. Блочные устройства не удаляются никогда — только выключаются и убираются из fstab. Операция обязана быть идемпотентной. Создание файла: `fallocate`, при неудаче — fallback на `dd`. ## 18. Тестирование Модульные тесты: - валидация конфига, включая форму `amnezia.wg_binaries`; - разбор `config.example.toml` в актуальную схему; - парсер `awg|wg show all allowed-ips`, включая несколько allowed ips через пробел; - fallback-парсер `awg|wg show`; - классификация peer/interface строк `awg|wg show all dump` против interface-строк AWG 3.0 (28 колонок), AWG 2.0 (14 колонок) и чистого WireGuard (5 колонок); - порядок выбора бинаря wg и ошибка при отсутствии всех кандидатов; - фильтрация по CIDR; - детерминированное присвоение class id с контрактом лексикографической стабильности; - рендеринг и разбор ruleset nftables для владеемых таблиц; - парсер `tc` для снимков qdisc/class/filter; - diff drift; - расчёт окна перезагрузки; - идемпотентность fstab и sysctl. Интеграционные тесты на реальном хосте: - первичное применение; - повторное применение; - восстановление частичного состояния после удаления только ingress qdisc; - восстановление частичного состояния после удаления `ifb0`; - рестарт контейнера; - рестарт Docker; - перезагрузка хоста; - добавление peer; - удаление peer; - вывод status в здоровом и сломанном состояниях; - рост counter anti-torrent при превышении peer настроенного порога одновременных flows; - путь отключения anti-torrent не сбрасывает посторонние правила nftables/Docker/Amnezia.