From 9dd4732655e646f567c37b5b520a9352c4a27984 Mon Sep 17 00:00:00 2001 From: Crimson Date: Fri, 28 Aug 2026 05:56:40 +0500 Subject: [PATCH] fix: harden firewall and traffic reconciliation --- README.md | 35 +- config.example.toml | 16 +- docs/ACCEPTANCE_CHECKLIST.md | 29 +- docs/ANTI_TORRENT.md | 10 +- docs/CONFIGURATION.md | 31 +- docs/IMPLEMENTATION_SPEC.md | 45 +- docs/INSTALLATION.md | 6 +- docs/PRODUCTION_EXAMPLE.md | 11 + tools/README.md | 2 +- tools/build.sh | 10 +- traffic_core/src/config.rs | 80 +-- traffic_core/src/daemon.rs | 181 ++++- traffic_core/src/docker.rs | 59 +- traffic_core/src/firewall/host.rs | 975 +++++++++++++++++++++------ traffic_core/src/firewall/mod.rs | 7 +- traffic_core/src/iface.rs | 3 +- traffic_core/src/status.rs | 266 +++++--- traffic_core/src/sysctl_tuning.rs | 1 + traffic_core/src/traffic/apply.rs | 248 +++++-- traffic_core/src/traffic/inspect.rs | 51 +- traffic_core/src/traffic/mod.rs | 23 +- traffic_core/src/traffic/parse_tc.rs | 57 +- traffic_core/src/wg.rs | 59 ++ 23 files changed, 1681 insertions(+), 524 deletions(-) diff --git a/README.md b/README.md index efe63a0..b8f1d75 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # AWG2 Harness -`AWG2 Harness` — host-side orchestrator для выделенного Debian-хоста с self-hosted AmneziaWG. Основной target релиза 0.3.1 — AmneziaWG 3.1; AWG 3.0/2.x сохраняются как regression compatibility. Пакет управляет инфраструктурным состоянием вокруг Amnezia: per-peer traffic shaping, IFB ingress shaping, nftables host firewall, anti-torrent connection guard, managed swap, диагностикой drift и плановым reboot. +`AWG2 Harness` — host-side orchestrator для выделенного Debian-хоста с self-hosted AmneziaWG. Основной target релиза 0.3.1 — AmneziaWG 3.1; AWG 3.0/2.x сохраняются как regression compatibility. Пакет управляет инфраструктурным состоянием вокруг Amnezia: per-peer traffic shaping, IFB ingress shaping, Docker-совместимый host firewall через `iptables`/`ip6tables`, nftables anti-torrent guard, managed swap, диагностикой drift и плановым reboot. Релиз source-validated по официальной документации и исходникам AWG 3.1; фактическая live-приёмка выполняется после production deployment. Harness не заменяет AmneziaVPN, не генерирует ключи клиентов и не мигрирует AWG 2.0 на 3.1. Amnezia сначала ставится официальным способом, после этого устанавливается AWG2 Harness. Точная upstream-база зафиксирована в [`docs/UPSTREAM_COMPATIBILITY.md`](docs/UPSTREAM_COMPATIBILITY.md). @@ -209,7 +209,7 @@ scp -P 2323 dist/SHA256SUMS root@:/root/ scp -P 2323 config.example.toml root@:/root/awg2-harness.config.toml ``` -Перед установкой отредактируйте `/root/awg2-harness.config.toml`: SSH-порт должен совпадать с реальным sshd, `amnezia_port` — с опубликованным UDP-портом контейнера, `peer_ipv4_cidr` — с подсетью клиентов Amnezia. +Перед установкой отредактируйте `/root/awg2-harness.config.toml`: SSH-порт должен совпадать с реальным sshd, `amnezia.expected_host_udp_port` — с опубликованным Docker UDP-портом, `peer_ipv4_cidr` — с подсетью клиентов Amnezia. Это поле используется для диагностики и не создаёт host INPUT allow-rule: published bridge traffic проходит через Docker `FORWARD`/NAT path. #### Вариант B (основной): config создаётся после установки пакета @@ -248,7 +248,7 @@ nano /etc/awg2-harness/config.toml В варианте B после `cp` обязательно проверьте перед первым `apply-firewall`: - `host_firewall.ssh_port` совпадает с реальным sshd-портом; -- `host_firewall.amnezia_port` совпадает с published Docker UDP port; +- `amnezia.expected_host_udp_port` совпадает с published Docker UDP port; - `amnezia.peer_ipv4_cidr` и `anti_torrent.peer_ipv4_cidr` совпадают с реальной peer-подсетью. ### 7. Ручная preflight-проверка @@ -271,6 +271,8 @@ Traffic actual: drift: no Host firewall: drift: no +Docker/Amnezia port path: + port_contract: ok Anti-torrent: drift: no Swap: @@ -321,13 +323,16 @@ awg2-harness --config /etc/awg2-harness/config.toml status C="$(docker ps --format '{{.Names}}' | grep -E '^(amnezia-awg2|amnezia-awg)$' | head -n1)" PID="$(docker inspect -f '{{.State.Pid}}' "$C")" -nsenter -t "$PID" -n -- tc class show dev awg0 -nsenter -t "$PID" -n -- tc class show dev ifb0 +nsenter -t "$PID" -n -- tc -s class show dev awg0 +nsenter -t "$PID" -n -- tc -s class show dev ifb0 nsenter -t "$PID" -n -- nft list table inet awg2_harness -nft list table inet awg2_host_firewall +iptables -S INPUT +iptables -S AWG2H_A 2>/dev/null || iptables -S AWG2H_B +ip6tables -S INPUT +ip6tables -S AWG2H_A 2>/dev/null || ip6tables -S AWG2H_B ``` -Для default config на одном peer ожидаются классы `1:10` и `2:10` с `rate 50Mbit ceil 50Mbit`, root classes `1:1` и `2:1` с `500Mbit`, host firewall table `inet awg2_host_firewall` и anti-torrent table `inet awg2_harness` внутри Amnezia netns. +Для default config на одном peer ожидаются классы `1:10` и `2:10` с `rate 50Mbit ceil 50Mbit`, root classes `1:1` и `2:1` с `500Mbit`, помеченный переход из host `INPUT` в одну из цепочек `AWG2H_A`/`AWG2H_B` и anti-torrent table `inet awg2_harness` внутри Amnezia netns. ## CLI команды @@ -337,15 +342,15 @@ nft list table inet awg2_host_firewall awg2-harness --config /etc/awg2-harness/config.toml ``` -`validate-config` проверяет TOML и смысловые ограничения: пустые списки, формат rate (`50mbit`, `500mbit`), CIDR, reboot window, swap path, SSH/Amnezia ports, protocol list. +`validate-config` проверяет TOML и смысловые ограничения: пустые списки, формат rate (`50mbit`, `500mbit`), CIDR, reboot window, swap path, SSH/ожидаемый Amnezia UDP port и диапазон textual class id `10..=9999`. Удалённые поля `host_firewall.amnezia_port`/`amnezia_protocols` считаются ошибкой, а не молча игнорируются. -`validate-runtime` проверяет host tools, которые реально нужны выбранным модулям: `tc`, `ip`, `docker`, `modprobe`, `nsenter`, а также `nft`, `systemctl`, swap tooling при включённых firewall/reboot/swap. Хостовый `wg` в этот список не входит: peer state читается внутри контейнера через `docker exec` (`amnezia.wg_binaries`). +`validate-runtime` проверяет host tools, которые реально нужны выбранным модулям: `tc`, `ip`, `docker`, `modprobe`, `nsenter`, а также `iptables`, `ip6tables`, `nft`, `systemctl` и swap tooling при включённых firewall/reboot/swap. Хостовый `wg` в этот список не входит: peer state читается внутри контейнера через `docker exec` (`amnezia.wg_binaries`). `ensure-swap` приводит swap к desired state: создаёт/проверяет swapfile, включает его с заданным priority, применяет `vm.swappiness`, делает swap state exclusive и переписывает swap entries в `/etc/fstab` под управляемый swapfile. -`apply-firewall` делает два reconcile: host INPUT firewall в таблице `inet awg2_host_firewall` и anti-torrent guard в выбранном namespace. Команда не управляет Docker NAT и host `FORWARD`. +`apply-firewall` делает два reconcile: host INPUT firewall в изолированных `iptables`/`ip6tables` A/B-цепочках и nftables anti-torrent guard в выбранном namespace. Команда не меняет policy встроенного `INPUT`, Docker NAT или host `FORWARD`. -`apply-limits` находит Amnezia container, заходит в его network namespace, читает peers через container-side `awg`/`wg`, строит per-peer HTB/fq_codel классы на AWG/WG интерфейсе и IFB ingress path. Peer filters создаются classifier-ом `flower` с единым `pref 10`; legacy peer-`фильтры `u32` считаются drift и мигрируют через controlled rebuild. Healthy state не печатает сообщение на каждом цикле daemon. +`apply-limits` находит Amnezia container, заходит в его network namespace, читает peers через container-side `awg`/`wg`, строит per-peer HTB/fq_codel классы на AWG/WG интерфейсе и IFB ingress path. Peer filters создаются classifier-ом `flower` с единым `pref 10`; legacy peer-фильтры `u32` считаются drift и мигрируют через controlled rebuild. Перед удалением проверяются qdisc handles и alias IFB `awg2-harness:traffic`; чужой объект с тем же именем является ошибкой. Healthy state не печатает сообщение на каждом цикле daemon. `status` печатает фактическую диагностику: сервис, контейнер, netns, интерфейс, peers, WireGuard runtime, traffic desired/actual, host firewall drift, Docker/Amnezia port path, anti-torrent counters, swap state, conntrack, recent events и planned reboot. @@ -355,11 +360,11 @@ awg2-harness --config /etc/awg2-harness/config.toml ## Что происходит под капотом -Traffic shaping реализован через `tc` в network namespace контейнера Amnezia. На egress используется root qdisc `htb 1:` на AWG/WG интерфейсе, root class `1:1`, per-peer classes `1:` и `fq_codel` под каждым peer class. Egress peer filter сопоставляет `dst_ip /32`, ingress peer filter — `src_ip /32`; оба используют `flower pref 10`. На ingress ставится `ingress ffff:` на AWG/WG интерфейс, входящие пакеты перенаправляются через `mirred` в `ifb0`, а там строится отдельная HTB topology `2:` с такими же per-peer лимитами. Только catch-all redirect в IFB остаётся `u32`. Вся `tc` topology после teardown передаётся одним fail-fast batch через `tc -batch -`, затем обязательно проверяется. +Traffic shaping реализован через `tc` в network namespace контейнера Amnezia. На egress используется root qdisc `htb 1:` на AWG/WG интерфейсе, root class `1:1`, per-peer classes `1:` и `fq_codel` под каждым peer class. Egress peer filter сопоставляет `dst_ip /32`, ingress peer filter — `src_ip /32`; оба используют `flower pref 10`. На ingress ставится `ingress ffff:` на AWG/WG интерфейс, входящие пакеты перенаправляются через `mirred` в configured IFB, а там строится отдельная HTB topology `2:` с такими же per-peer лимитами. Только catch-all redirect в IFB остаётся `u32`. `tc -batch -` является fail-fast, но не транзакционным: при любой ошибке Harness удаляет только доказанно свои частичные объекты, сохраняет первичную и cleanup-ошибку и затем обязательно выполняет verify. Счётчики peers берутся из `tc -s class`, потому что class-only `flower` не гарантирует action statistics. Peer discovery берётся из WireGuard/AmneziaWG allowed IPs, а не из handshake state. Поэтому offline peer с `allowed ips: 10.8.1.1/32` тоже получает лимит заранее. -Host firewall — отдельная AWG2-owned таблица `inet awg2_host_firewall`. Она открывает loopback, established/related, SSH-порт из конфига, Amnezia-порт по указанным протоколам и optionally ICMP/ICMPv6. Docker-managed `FORWARD` и NAT не трогаются. +Host firewall владеет только помеченным переходом из встроенного `INPUT` и двумя A/B-цепочками `AWG2H_A`/`AWG2H_B` отдельно для IPv4 и IPv6. Цепочка разрешает loopback, established/related, SSH-порт из конфига и optionally ICMP/ICMPv6, затем применяет configured verdict. Новая цепочка полностью проверяется до переключения; ошибка IPv6 после переключения IPv4 запускает rollback. Published Amnezia UDP port здесь намеренно не открывается: Docker bridge publish обслуживается через Docker-managed `FORWARD`/NAT path. Anti-torrent guard — отдельная AWG2-owned таблица `inet awg2_harness` обычно внутри Amnezia netns. Правило ограничивает число новых conntrack flows per source VPN IP. Это не DPI и не распознавание BitTorrent-протокола; это грубый, дешёвый guard против сотен одновременных P2P-соединений. @@ -369,8 +374,8 @@ Swap module намеренно строгий: если `swap.enabled=true`, des AWG2 Harness владеет только собственным state: -- `tc` topology на Amnezia interface и configured `ifb_name`; -- nft table `inet awg2_host_firewall` в host namespace; +- `tc` topology с handles `1:`/`ffff:` на Amnezia interface и IFB с alias `awg2-harness:traffic`; +- помеченный host `INPUT` jump и цепочки `AWG2H_A`/`AWG2H_B` в `iptables`/`ip6tables`; - nft table `inet awg2_harness` для anti-torrent; - `/run/awg2-harness/*` runtime state; - managed swapfile и swap entries, если `swap.enabled=true`. diff --git a/config.example.toml b/config.example.toml index eae7438..e135cf1 100644 --- a/config.example.toml +++ b/config.example.toml @@ -18,13 +18,21 @@ wg_binaries = ["awg", "wg"] # docker exec "$C" awg show peer_ipv4_cidr = "10.8.1.0/24" +# Ожидаемый внешний UDP-порт, опубликованный Docker для AWG. +# Это diagnostic contract, а не правило host INPUT firewall. +expected_host_udp_port = 443 + [traffic] enabled = true peer_limit = "50mbit" root_rate = "500mbit" reconcile_interval_secs = 15 container_ready_timeout_secs = 120 +# Textual minor id для tc: допустимо только 10..=9999. Число peers также +# не должно выводить последний class id за 9999. class_start_id = 10 +# Созданный Harness IFB получает alias awg2-harness:traffic. Существующий +# интерфейс с тем же именем без этого alias считается чужим и не удаляется. ifb_name = "ifb0" [sysctl_tuning] @@ -63,13 +71,9 @@ udp_rmem_min = 8192 enabled = true # Должен совпадать с реально слушающим портом sshd до apply-firewall. ssh_port = 2323 -# Должен совпадать с опубликованным Docker/Amnezia UDP-портом. -# Проверка: docker port amnezia-awg2 2>/dev/null || docker port amnezia-awg -# Транспортный порт AmneziaWG/AWG, опубликованный Docker, обычно 443/udp. -# Это не AmneziaDNS. Не указывайте здесь 53. -amnezia_port = 443 -amnezia_protocols = ["udp"] allow_icmp = true +# Конечный verdict собственной AWG2H_A/AWG2H_B chain; policy встроенного +# INPUT Harness не меняет. default_input_policy = "drop" [anti_torrent] diff --git a/docs/ACCEPTANCE_CHECKLIST.md b/docs/ACCEPTANCE_CHECKLIST.md index 5b97108..e87dde6 100644 --- a/docs/ACCEPTANCE_CHECKLIST.md +++ b/docs/ACCEPTANCE_CHECKLIST.md @@ -42,6 +42,7 @@ - [ ] Есть ingress qdisc `ffff:` на AWG/WG-интерфейсе. - [ ] Есть ingress redirect через `mirred ... redirect dev ifb0`. - [ ] `ifb0` существует и находится в UP state. +- [ ] `ip -details link show ifb0` показывает alias `awg2-harness:traffic`; чужой IFB без этого alias не удаляется и не заменяется. - [ ] На `ifb0` есть root qdisc `2: htb`. - [ ] На `ifb0` есть root class `2:1` с `traffic.root_rate`. - [ ] Для каждого peer есть class `2:` с `traffic.peer_limit`. @@ -49,6 +50,7 @@ - [ ] Для каждого peer есть ingress filter `flower pref 10 src_ip peer_ip/32 -> 2:`. - [ ] Все peer-фильтры на каждом parent используют один `pref 10`; `u32` остаётся только у catch-all ingress redirect. - [ ] Class ids начинаются с `traffic.class_start_id`, default `10`. +- [ ] `class_start_id` и последний фактический peer class остаются в textual-диапазоне `10..=9999`; конфигурация/число peers, требующие `10000`, отвергаются до apply. - [ ] Peer IP → class id mapping использует стабильный лексикографический контракт: IP сортируются как строки, не numeric IPv4. ## D. Идемпотентность и восстановление после drift @@ -68,6 +70,8 @@ - [ ] После добавления peer появляются новые class/filter. - [ ] Legacy peer-фильтры `u32` детектируются как migration drift и заменяются на `flower` через rebuild. - [ ] Ошибка в любой строке `tc -batch -` останавливает apply; после успеха проходит verify-after-apply. +- [ ] Ошибка после частичного `tc -batch -` запускает cleanup только qdisc handles `1:`/`ffff:` и IFB с доказанным владением; в сообщении сохранены и primary, и cleanup error. +- [ ] Чужой root qdisc, `clsact` или IFB с тем же именем вызывает явный ownership conflict и остаётся нетронутым. ## E. Самовосстановление в жизненном цикле @@ -84,6 +88,8 @@ - [ ] На старте daemon сбой `sysctl_tuning` или `swap` завершает процесс, а сбой traffic/firewall только логируется — сервис остаётся живым. - [ ] После обрыва потока `docker events` он перезапускается автоматически (проверяется, например, `systemctl restart docker`). - [ ] После восстановления потока `docker events` ошибка подсистемы снимается: в `events.log` появляется `docker_events=recovered`, а `last_action.state` не остаётся с этой ошибкой навсегда. +- [ ] Readiness retry после start event повторяется только для typed `NotFound`; Docker permission/API/parse error немедленно отражается как ошибка, timeout не превращается в silent skip. +- [ ] Start event lower-priority контейнера игнорируется, если уже выбран running container с более высоким приоритетом в `amnezia.container_names`. - [ ] Один startup/periodic/event-цикл использует один discovery `RuntimeTarget` для traffic и anti-torrent, а не повторяет `docker inspect` и interface discovery. - [ ] Успешный reconcile одной подсистемы не гасит незакрытую ошибку другой: при постоянно падающем `traffic` успешный `firewall` не очищает `last_error` и не пишет `firewall=recovered`. - [ ] Параллельный запуск `apply-limits` во время работы daemon не приводит к гонке: второй процесс ждёт блокировку до 10 секунд и затем сообщает `lock busy timeout`. @@ -97,8 +103,7 @@ - [ ] `status` показывает WireGuard clients (online/offline, latest handshake, RX/TX, endpoint). - [ ] `status` показывает недостающие и устаревшие правила peers: в `Traffic actual` — строка `reason` (например, `missing egress class`, `egress per-peer class set mismatch`, `duplicate egress class flowid mapping detected`), в `peer_table` — колонка `drift` по каждому peer. - [ ] `status` явно показывает частичное состояние и drift, а не общий признак «всё хорошо». -- [ ] `status` показывает tc filter counters по peer или `filter statistics: unavailable`. -- [ ] Flower counters и legacy u32 counters в `status` разбираются одним parser-ом и привязываются к правильному peer IP. +- [ ] `status` показывает egress/ingress packet counters из `tc -s class show` по peer class или `n/a`; class-only flower action statistics не используются. - [ ] Логи доступны через `journalctl -u awg2-harness`. - [ ] Повтор одной и той же reconcile-ошибки не создаёт `error`-запись на каждом цикле; первая ошибка, изменение её текста и recovery видны как отдельные переходы. - [ ] Отсутствие обязательной host-утилиты (`tc`, `ip`, `docker`, `nsenter`) логируется явно с именем утилиты. @@ -194,12 +199,12 @@ - [ ] При превышении порога новые flows дропаются, established flows ниже порога не ломаются. - [ ] После restart контейнера nftables rule пересоздаётся в новом netns. - [ ] При `anti_torrent.enabled = false` удаляется/отключается только AWG2-owned nftables state; Docker/Amnezia rules не flush-ятся. -- [ ] `status` показывает `nf_conntrack_count`, `nf_conntrack_max` и `nf_conntrack_utilization`, если sysctl доступны. +- [ ] `status` показывает `count_namespace`, `nf_conntrack_count`, глобальный `nf_conntrack_max_global` и utilization; при netns anti-torrent count читается внутри Amnezia, а max — на host. ## J. Откат - [ ] Известно, как остановить и отключить `awg2-harness.service` и `awg2-reboot-scheduler.service`. -- [ ] Известно, как удалить только AWG2-owned host firewall state (`inet awg2_host_firewall`). +- [ ] Известно, как удалить только AWG2-owned host firewall state (comment jump `awg2-harness:host-firewall`, chains `AWG2H_A`/`AWG2H_B` в обеих IP-family). - [ ] Известно, как удалить только AWG2-owned anti-torrent state (`inet awg2_harness`) в выбранном namespace. - [ ] Известно, как удалить AWG2-owned `tc` topology (`root`, `ingress`, `ifb0`) в netns Amnezia. - [ ] При необходимости rollback включает удаление пакета `awg2-harness` после остановки сервисов. @@ -218,6 +223,7 @@ - [ ] Пакет не включает/не стартует сервис автоматически. - [ ] Пакет не имеет `Depends`/`Recommends` на `docker.io`; установка Amnezia/Docker внешняя. - [ ] Пакет не имеет `Depends` на `wireguard-tools`: peer state читается внутри контейнера, host-side `wg` не используется. +- [ ] Пакет имеет runtime `Depends` на `iptables` и `nftables`. - [ ] `apt install ./awg2-harness__amd64.deb` не устанавливает и не заменяет Docker. - [ ] `apt install ./awg2-harness__amd64.deb` проходит на clean Debian 13 Amnezia host. - [ ] После ручной установки config `awg2-harness validate-config` проходит. @@ -246,17 +252,20 @@ - [ ] `awg2-harness validate-config` падает, если отсутствует секция `[host_firewall]`. - [ ] `apply-firewall` не блокирует SSH на `host_firewall.ssh_port` (default `2323`). -- [ ] После `apply-firewall` порт `host_firewall.amnezia_port` (default `443`) доступен по всем протоколам из `host_firewall.amnezia_protocols`. +- [ ] Встроенная policy `INPUT` не меняется; managed jump с comment `awg2-harness:host-firewall` стоит первым и ведёт в одну из цепочек `AWG2H_A`/`AWG2H_B` для IPv4 и IPv6. +- [ ] Managed chain разрешает loopback, established/related, configured SSH и optional ICMP/ICMPv6, затем завершает обработку configured `DROP`/`ACCEPT` verdict. +- [ ] Host firewall не добавляет allow-rule для published Amnezia UDP port: bridge publish проходит через Docker-managed `FORWARD`/NAT. - [ ] Если эксплуатационно требуется закрыть legacy SSH `22`, это явно подтверждено проверкой доступа на `ssh_port` перед закрытием `22`. -- [ ] При `host_firewall.enabled = false` удаляется только таблица `inet awg2_host_firewall`. +- [ ] При `host_firewall.enabled = false` удаляются только помеченные jumps и доказанно owned `AWG2H_A`/`AWG2H_B`; чужие rules/chains остаются нетронутыми. - [ ] Повторный `apply-firewall` идемпотентен и не вносит лишних изменений в healthy state. -- [ ] При невалидном `host_firewall.amnezia_protocols` (`icmp`, `foo`, дубли) `validate-config` завершается ошибкой. +- [ ] Legacy fields `host_firewall.amnezia_port`/`amnezia_protocols` отвергаются как `unknown field`. +- [ ] Новая конфигурация строится в inactive A/B chain до switch; искусственный сбой `ip6tables` после IPv4 switch возвращает IPv4 на прежнюю цепочку. - [ ] После `docker restart` anti-torrent правила продолжают восстанавливаться корректно и не ломаются host firewall логикой. - [ ] Проверено, где реально проходит VPN-трафик в вашей Docker-схеме (`INPUT` vs `FORWARD`) и это отражено в эксплуатационной заметке. - [ ] Для release-кандидата зафиксирован итог проверки `INPUT` vs `FORWARD` на реальном сервере (команды/логи/вывод) и подтверждено, что текущие правила firewall покрывают реальный путь трафика. -- [ ] `status` показывает блок `Docker/Amnezia port path` с полями `docker_userland_proxy`, `published_udp_port` и note про Docker-managed `FORWARD`. +- [ ] `status` показывает блок `Docker/Amnezia port path` с полями `expected_host_udp_port`, `docker_userland_proxy`, `published_udp_port`, `awg_listen_udp_port`, `port_contract` и note про Docker-managed `FORWARD`. - [ ] `status` показывает поля `host_forward_policy_drop` и `docker_forward_rules_present` в блоке `Docker/Amnezia port path`. - [ ] Если `docker_userland_proxy=false` и `FORWARD policy DROP`, при отсутствии явных Docker accept-признаков `status` выводит warning, но `awg2-harness` не изменяет `FORWARD` автоматически. -- [ ] Сверено соответствие: `config host_firewall.amnezia_port` ↔ опубликованный Docker UDP host port ↔ `docker exec amnezia-awg2 awg show` (`listening port`). -- [ ] При нескольких UDP bindings без однозначного соответствия `host_firewall.amnezia_port` диагностика `published_udp_port` показывает `unknown` (ambiguous), а не произвольный первый порт. +- [ ] Сверено соответствие: `amnezia.expected_host_udp_port` ↔ опубликованный Docker UDP host port ↔ `docker exec awg show listen-port`; `port_contract: ok`. +- [ ] При нескольких UDP bindings без однозначного соответствия `amnezia.expected_host_udp_port` диагностика `published_udp_port` показывает `unknown` (ambiguous), а не произвольный первый порт. - [ ] Для диагностики FORWARD используется best-effort проверка: `iptables -S FORWARD` и fallback через `nft list ruleset`; `status` не падает при недоступности одной из команд. diff --git a/docs/ANTI_TORRENT.md b/docs/ANTI_TORRENT.md index cacdb70..080eba3 100644 --- a/docs/ANTI_TORRENT.md +++ b/docs/ANTI_TORRENT.md @@ -89,15 +89,17 @@ max_connections_per_peer = 500 `ct count` использует conntrack. Для примера 23 peers × 500 flows дают до 11 500 потенциальных flow slots перед ограничением каждого peer. Само по себе это не является доказательством достаточной ёмкости конкретного хоста, поэтому фактическое состояние conntrack обязательно наблюдается. -Что надо мониторить: +Что надо мониторить (count — в namespace, где работает guard; max — глобально на host): ```bash -sysctl net.netfilter.nf_conntrack_count +C="$(docker ps --format '{{.Names}}' | grep -E '^(amnezia-awg2|amnezia-awg)$' | head -n1)" +AWG_PID="$(docker inspect -f '{{.State.Pid}}' "$C")" +nsenter -t "$AWG_PID" -n -- sysctl net.netfilter.nf_conntrack_count sysctl net.netfilter.nf_conntrack_max -nft list table inet awg2_harness +nsenter -t "$AWG_PID" -n -- nft list table inet awg2_harness ``` -Если `nf_conntrack_count` приближается к `nf_conntrack_max`, проблема уже системная: надо разбираться с timeout, abuse или capacity. `awg2-harness status` показывает оба значения и вычисленный `nf_conntrack_utilization`, чтобы давление было видно без ручного деления. Не надо автоматически увеличивать `nf_conntrack_max` из orchestrator без отдельного решения. +Если `nf_conntrack_count` приближается к `nf_conntrack_max`, проблема уже системная: надо разбираться с timeout, abuse или capacity. `awg2-harness status` показывает `count_namespace`, count, глобальный max и вычисленный utilization. При недоступном container namespace host count намеренно не подставляется. Не надо автоматически увеличивать `nf_conntrack_max` из orchestrator без отдельного решения. ## 7. Критерии приёмки diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 8164ce9..2d1ac12 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -4,7 +4,7 @@ ## Создание runtime config -Пакет намеренно **не** создаёт `/etc/awg2-harness/config.toml` автоматически. Это снижает риск случайно применить firewall с неверным SSH-портом или неверным Amnezia-портом. +Пакет намеренно **не** создаёт `/etc/awg2-harness/config.toml` автоматически. Это снижает риск случайно применить firewall с неверным SSH-портом или проверять не тот Amnezia UDP-порт. После установки `.deb` создайте runtime config вручную: @@ -17,7 +17,7 @@ nano /etc/awg2-harness/config.toml Перед первым `apply-firewall`/`apply-limits` оператор обязан сверить: - `host_firewall.ssh_port` — должен совпадать с реально слушающим `sshd` портом; -- `host_firewall.amnezia_port` — должен совпадать с published Docker/Amnezia UDP port; +- `amnezia.expected_host_udp_port` — должен совпадать с published Docker/Amnezia UDP port; - `amnezia.peer_ipv4_cidr` — должен совпадать с реальной peer-подсетью; - `anti_torrent.peer_ipv4_cidr` — обычно тот же CIDR. @@ -40,6 +40,7 @@ interface_candidates = ["awg0", "wg0", "amnezia0", "tun0"] interface_patterns = ["awg*", "wg*", "amnezia*", "tun*"] wg_binaries = ["awg", "wg"] peer_ipv4_cidr = "10.8.1.0/24" +expected_host_udp_port = 443 [traffic] enabled = true @@ -66,8 +67,6 @@ udp_rmem_min = 8192 [host_firewall] enabled = true ssh_port = 2323 -amnezia_port = 443 -amnezia_protocols = ["udp"] allow_icmp = true default_input_policy = "drop" @@ -104,6 +103,8 @@ swappiness = 1 `amnezia.peer_ipv4_cidr` — подсеть VPN peers. Production default — `10.8.1.0/24`; значение конфигурируемое. +`amnezia.expected_host_udp_port` — ожидаемый published UDP host port Docker для AmneziaWG. Поле обязательное и используется диагностикой `status` для контракта `expected host port ↔ Docker binding ↔ awg/wg listen-port`. Оно не создаёт host INPUT allow-rule: трафик к published bridge container проходит через Docker `FORWARD`/NAT path, а не через host `INPUT`. + `traffic.peer_limit` — per-peer лимит в обе стороны. Текущий production default — `50mbit`. `traffic.root_rate` — верхний HTB-класс, в текущем production default — `500mbit`. @@ -112,9 +113,9 @@ swappiness = 1 `traffic.container_ready_timeout_secs` — сколько ждать появления интерфейса после старта контейнера. -`traffic.class_start_id` — первый class id. Production default — `10`, что даёт классы `1:10`, `2:10` и дальше. +`traffic.class_start_id` — первый class id. Production default — `10`, что даёт классы `1:10`, `2:10` и дальше. Допустимый диапазон `10..=9999`; каждый фактический peer class также обязан остаться не выше `9999`. Это граница текстового представления: `tc` разбирает class minor как hexadecimal, поэтому следующая десятичная строка `10000` уже вышла бы за 16-bit minor id. -`traffic.ifb_name` — имя IFB-интерфейса для ingress shaping. Production default — `ifb0`. Этот параметр можно оставить в конфиге, потому что бинарь реально создаёт/удаляет этот интерфейс. +`traffic.ifb_name` — имя IFB-интерфейса для ingress shaping. Production default — `ifb0`. Harness ставит созданному IFB alias `awg2-harness:traffic` и удаляет его только при совпадении type+alias. Существующий чужой интерфейс с тем же именем вызывает ошибку и не заменяется. `sysctl_tuning` — отдельный host-level модуль управляемого сетевого baseline через `sysctl`: @@ -153,31 +154,25 @@ swappiness = 1 `anti_torrent.enabled` — включает nftables connection-count guard против high-peer-count P2P-нагрузки. Это отдельный AWG2 Harness module и часть текущего production-контракта. -`host_firewall.enabled` — включает host-level INPUT firewall в отдельной AWG2-owned таблице `inet awg2_host_firewall`. +`host_firewall.enabled` — включает host-level INPUT firewall. Harness владеет только помеченным comment переходом из встроенного `INPUT` и изолированными цепочками `AWG2H_A`/`AWG2H_B` в `iptables` и `ip6tables`. Встроенная policy, Docker `FORWARD`/NAT и чужие правила не очищаются и не меняются. `host_firewall.ssh_port` — SSH порт хоста, который обязательно должен быть открыт. Production default/example: `2323`. Значение обязано совпадать с реально слушающим sshd портом до применения `apply-firewall`. -`host_firewall.amnezia_port` — внешний UDP/TCP порт Amnezia, который AWG2 открывает в host `INPUT` (в зависимости от `host_firewall.amnezia_protocols`). В текущей целевой конфигурации default — `443`; значение должно совпадать с реально опубликованным Amnezia UDP/TCP port. +Поля `host_firewall.amnezia_port` и `host_firewall.amnezia_protocols` удалены. Если они остались в старом runtime config, `validate-config` завершится ошибкой `unknown field`, чтобы оператор явно мигрировал значение в `amnezia.expected_host_udp_port`. Host firewall не должен открывать published Docker port в `INPUT`. -Важно: `host_firewall.amnezia_port` — это transport-порт AmneziaWG/AWG (например, `443/udp`). Это не порт AmneziaDNS. - -- Не задавайте `amnezia_port = 53`. -- Не добавляйте DNS/53 в `amnezia_protocols`. -- Не открывайте `53/tcp` или `53/udp` в AWG2 host firewall для ожидаемого production-профиля. +Не открывайте `53/tcp` или `53/udp` в AWG2 host firewall для ожидаемого production-профиля. В expected production layout AmneziaDNS работает как внутренний Docker/VPN сервис и не публикуется на public host interface. -Для диагностического блока `Docker/Amnezia port path` в `status` приоритет сопоставления такой: сначала ищется published UDP binding с `HostPort == host_firewall.amnezia_port`, затем fallback на ключ `"/udp"` как container port, затем (если UDP binding ровно один) берётся он. Если UDP bindings несколько и однозначного совпадения нет, статус показывает `published_udp_port: unknown`. +Для диагностического блока `Docker/Amnezia port path` в `status` приоритет сопоставления такой: сначала ищется published UDP binding с `HostPort == amnezia.expected_host_udp_port`, затем fallback на ключ `"/udp"` как container port, затем (если UDP binding ровно один) берётся он. Внутри контейнера выполняется `awg show listen-port` с fallback на `wg`. `port_contract: ok` означает, что host port совпал с config, а container port Docker binding — с фактическим listen-port. При нескольких UDP bindings без однозначного соответствия или ошибке probe выводится `unknown`, а не произвольный первый порт. Диагностика `FORWARD` в этом блоке является best-effort: сначала используется `iptables -S FORWARD`, затем fallback через `nft list ruleset`; результаты только отображаются в `status` и не изменяют host `FORWARD`/Docker NAT. -`host_firewall.amnezia_protocols` — протоколы для порта Amnezia (`udp`, `tcp` или оба). - Важно: `awg2-harness` не управляет host `FORWARD`/Docker NAT-цепочками. Проект сознательно не добавляет универсальные правила в `FORWARD` и не меняет Docker-managed forwarding path. `host_firewall.allow_icmp` — разрешать ICMP/ICMPv6 на входе. -`host_firewall.default_input_policy` — политика INPUT chain (`drop` или `accept`). Для production рекомендуется `drop`. +`host_firewall.default_input_policy` — конечный verdict собственной цепочки (`drop` или `accept`). Для production рекомендуется `drop`. Это поле не меняет policy встроенной цепочки `INPUT`. `anti_torrent.max_connections_per_peer` — максимум одновременных conntrack flows на один VPN peer IP. Штатный production baseline — `500`, выбранный по эксплуатационным измерениям проекта так, чтобы connection guard оставался включённым и не вмешивался в нормальный повседневный профиль. Значение ниже `500` является сознательно более агрессивной политикой и требует проверки на реальных клиентах. Поле обязательное и не скрыто в Rust default. @@ -190,7 +185,7 @@ swappiness = 1 Для production рекомендуется `true`, если peer source IP (`10.8.1.x`) видны именно внутри netns контейнера. -`apply-firewall` в текущей модели делает **оба** reconcile: host firewall (host namespace) и anti-torrent firewall (configured namespace). +`apply-firewall` в текущей модели делает **оба** reconcile: iptables/ip6tables host firewall (host namespace) и nftables anti-torrent firewall (configured namespace). Host-цепочки обновляются через A/B staging: обе IP-family сначала строятся и проверяются, после чего jump переключается; при ошибке IPv6 выполненный IPv4 switch откатывается. `reboot.enabled` — включает плановую перезагрузку. diff --git a/docs/IMPLEMENTATION_SPEC.md b/docs/IMPLEMENTATION_SPEC.md index 082fa97..c06b87b 100644 --- a/docs/IMPLEMENTATION_SPEC.md +++ b/docs/IMPLEMENTATION_SPEC.md @@ -149,7 +149,7 @@ traffic_core/src/ ├── firewall/ │ ├── mod.rs apply_firewall, выбор namespace, общие константы │ ├── nft.rs нормализация текста nftables и обёртки команд -│ ├── host.rs таблица inet awg2_host_firewall (host namespace) +│ ├── host.rs owned iptables/ip6tables INPUT chains (host namespace) │ └── antitorrent.rs таблица inet awg2_harness (namespace по конфигу) ├── sysctl_tuning.rs управляемый sysctl baseline ├── swap.rs управляемый swap @@ -163,7 +163,7 @@ reboot_module/src/ `traffic` разбит по ответственности: парсеры отдельно от инспекции, инспекция отдельно от apply. Чистые парсеры собраны в `parse_tc.rs` вместе со своими тестами — это тестируемая поверхность модуля, всё остальное требует живого контейнера. -`firewall` разбит по подсистемам, а не по фазам: host firewall и anti-torrent независимы, применяются в разных namespace и владеют разными таблицами. Общий у них только слой работы с `nft`. +`firewall` разбит по подсистемам, а не по фазам: host firewall и anti-torrent независимы. Host firewall использует совместимый с Docker frontend `iptables`/`ip6tables`; anti-torrent владеет отдельной nftables-таблицей в configured namespace. Приватные типы объявлены в соответствующем `mod.rs`. В Rust приватные элементы видны в модуле объявления и всех дочерних модулях, поэтому подмодули работают с ними без расширения видимости до `pub(crate)`. @@ -218,6 +218,8 @@ struct PeerTrafficStatus { Присвоение 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 Основной путь: @@ -321,6 +323,7 @@ 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 @@ -358,27 +361,27 @@ reconcile_traffic(): ```text apply_firewall(): взять /run/awg2-harness/firewall.lock (timeout 10 c) - apply_host_firewall(config) # host namespace, таблица inet awg2_host_firewall + apply_host_firewall(config) # host namespace, iptables/ip6tables A/B chains apply_antitorrent_firewall(config) # namespace по anti_torrent.apply_in_amnezia_netns ``` -Каждый из firewall-путей сначала инспектирует фактическое состояние и выходит без изменений, если drift отсутствует. При наличии drift владеемая таблица удаляется и создаётся заново, после чего выполняется обязательная повторная инспекция. +Каждый из firewall-путей сначала инспектирует фактическое состояние и выходит без изменений, если drift отсутствует. Anti-torrent таблица заменяется одной атомарной nft-транзакцией. Host firewall сначала строит и проверяет inactive chain отдельно для IPv4/IPv6, затем переключает помеченный jump; сбой IPv6 после IPv4 switch откатывает IPv4. После apply выполняется обязательная повторная инспекция. Для production контролируемая полная пересборка при drift — единственный поддерживаемый путь. Требования к устойчивости парсеров: - `tc filter show` разбирается блоками (конечный автомат), а не наивным сканированием токенов по несвязанным строкам; -- единый parser понимает production `flower` и legacy `u32`, включая counters; точный healthy-контракт требует `flower`, `pref 10`, правильное direction-field и `/32`, а legacy `u32` peer rules считаются migration drift; +- единый 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 и его перевод в UP выполняются строго и до `tc` apply; +- `modprobe ifb`, создание IFB, установка ownership alias и перевод в UP выполняются строго и до `tc` apply; - после teardown вся `tc` topology подаётся одним fail-fast сценарием в stdin команды `tc -batch -`; `-force` не используется, поэтому первая ошибка останавливает batch и сохраняет номер строки в stderr; -- ожидаемые конфликты идемпотентности допускаются выборочно, через отдельную обёртку, и по закрытому списку признаков: `file exists`, `eexist`, `object already exists`, `exclusive flag on`; +- `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. @@ -394,13 +397,15 @@ apply_firewall(): - root qdisc `1:` на выбранном интерфейсе AWG/WG; - ingress qdisc `ffff:` на выбранном интерфейсе AWG/WG, когда лимиты включены; -- IFB-устройством с именем `traffic.ifb_name` (default `ifb0`) в целевом namespace; +- IFB-устройством с именем `traffic.ifb_name` (default `ifb0`), type `ifb` и alias `awg2-harness:traffic` в целевом namespace; - root qdisc `2:` на IFB; - классами и фильтрами под этими handles. -Teardown может удалять и пересоздавать эти владеемые объекты. Поскольку сервер выделенный, это допустимо и предпочтительнее, чем оставлять устаревшие или частичные правила. +До первой операции удаления teardown инспектирует все цели. На AWG/WG-интерфейсе owned считаются только root `htb 1:` и ingress `ffff:`; `clsact` и любой другой root — ownership conflict. IFB удаляется только при совпадении type+alias (кроме IFB, доказанно созданного текущей неуспешной попыткой). Чужие объекты не заменяются даже на выделенном сервере. -Бинарь не должен вслепую сбрасывать посторонние host firewall rules, Docker NAT rules или чужие интерфейсы. Владение firewall обязано быть явным и ограниченным таблицами AWG2 Harness: `inet awg2_harness` в целевом network namespace и `inet awg2_host_firewall` в host namespace. +Бинарь не должен вслепую сбрасывать посторонние 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. ## 11. Anti-torrent firewall guard @@ -444,7 +449,7 @@ table inet awg2_harness { - counter `antitorrent_dropped_v4`; - meter/set state, создаваемый правилом. -Status обязан показывать наличие таблицы/chain/правила, настроенный порог и drop counter. Также он один раз читает `net.netfilter.nf_conntrack_count` и `net.netfilter.nf_conntrack_max` и, когда оба значения доступны, печатает `nf_conntrack_utilization` в процентах. +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 не маскируется под «контейнер не найден» и возвращается оператору. @@ -480,7 +485,7 @@ Status обязан показывать наличие таблицы/chain/п Внутри одного startup/periodic/event-цикла traffic и anti-torrent получают один и тот же результат discovery `RuntimeTarget`; повторные `docker inspect`, PID/interface discovery и transport construction в этом же цикле не выполняются. Наличие host `nsenter` кэшируется на время жизни процесса. -Проверка готовности не должна хардкодить `awg0`. Она вызывает общий детектор интерфейса с интервалом 2 секунды до истечения `traffic.container_ready_timeout_secs`. +Проверка готовности не должна хардкодить `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. Порядок на старте демона и разница в критичности ошибок: @@ -550,11 +555,12 @@ Traffic actual: 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 + IP, Egress packets, Ingress packets Host firewall: - enabled, ssh_port, amnezia_port, input_policy, drift[, reason] + enabled, ssh_port, input_policy, drift[, reason] Docker/Amnezia port path: - docker_userland_proxy, published_udp_port, host_forward_policy_drop, + expected_host_udp_port, docker_userland_proxy, published_udp_port, + 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 @@ -563,7 +569,7 @@ Swap: desired_active, exclusive, fstab_exclusive, fstab_swap_entries, swappiness_current, swappiness_persisted, drift[, drift_reason] Conntrack: - nf_conntrack_count, nf_conntrack_max, nf_conntrack_utilization + count_namespace, nf_conntrack_count, nf_conntrack_max_global, nf_conntrack_utilization Last actions: содержимое /run/awg2-harness/last_action.state Recent events: @@ -715,8 +721,13 @@ inode = 123456 - порядок выбора бинаря wg и ошибка при отсутствии всех кандидатов; - фильтрация по CIDR; - детерминированное присвоение class id с контрактом лексикографической стабильности; -- рендеринг и разбор ruleset nftables для владеемых таблиц; +- 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; +- conntrack count из Amnezia namespace и global max с host; - diff drift; - расчёт окна перезагрузки; - идемпотентность fstab и sysctl. diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 1684931..e3de6e8 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -224,7 +224,7 @@ scp -P 2323 config.example.toml root@:/root/awg2-harness.config.toml Перед установкой отредактируйте `/root/awg2-harness.config.toml`. Минимально проверьте: - `[host_firewall].ssh_port` совпадает с реальным SSH-портом (`2323` в production-примере); -- `[host_firewall].amnezia_port` совпадает с published UDP port Amnezia; +- `[amnezia].expected_host_udp_port` совпадает с published UDP port Amnezia; - `[amnezia].peer_ipv4_cidr` совпадает с подсетью clients; - `[anti_torrent].peer_ipv4_cidr` обычно равен `[amnezia].peer_ipv4_cidr`; - `peer_limit` и `root_rate` отражают нужную traffic policy. @@ -264,7 +264,7 @@ systemctl daemon-reload Перед первым `apply-firewall` обязательно перепроверьте: - `[host_firewall].ssh_port` совпадает с реально слушающим `sshd` портом; -- `[host_firewall].amnezia_port` совпадает с published Docker UDP port; +- `[amnezia].expected_host_udp_port` совпадает с published Docker UDP port; - `[amnezia].peer_ipv4_cidr` совпадает с peer-подсетью; - `[anti_torrent].peer_ipv4_cidr` обычно тот же CIDR. @@ -286,6 +286,8 @@ Traffic actual: drift: no Host firewall: drift: no +Docker/Amnezia port path: + port_contract: ok Anti-torrent: drift: no Swap: diff --git a/docs/PRODUCTION_EXAMPLE.md b/docs/PRODUCTION_EXAMPLE.md index 7af9a03..435e162 100644 --- a/docs/PRODUCTION_EXAMPLE.md +++ b/docs/PRODUCTION_EXAMPLE.md @@ -27,6 +27,7 @@ Expected status: network tuning drift: no traffic drift: no host firewall drift: no + Docker/Amnezia port_contract: ok anti-torrent drift: no swap drift: no ``` @@ -46,9 +47,19 @@ docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' C="$(docker ps --format '{{.Names}}' | grep -E '^(amnezia-awg2|amnezia-awg)$' | head -n1)" docker port "$C" docker exec "$C" awg show +docker exec "$C" awg show awg0 listen-port docker inspect -f 'ports={{json .NetworkSettings.Ports}}' amnezia-dns ``` +Проверка host firewall выполняется через Docker-совместимый frontend. В `INPUT` ожидается первый jump с comment `awg2-harness:host-firewall`, но не allow-rule для `443/udp`: + +```bash +iptables -S INPUT +iptables -S AWG2H_A 2>/dev/null || iptables -S AWG2H_B +ip6tables -S INPUT +ip6tables -S AWG2H_A 2>/dev/null || ip6tables -S AWG2H_B +``` + Проверка AWG-контракта внутри контейнера (без раскрытия секретов): ```bash diff --git a/tools/README.md b/tools/README.md index 9a4407c..6a1538e 100644 --- a/tools/README.md +++ b/tools/README.md @@ -72,7 +72,7 @@ cp /usr/share/doc/awg2-harness/repo/config.example.toml /etc/awg2-harness/config nano /etc/awg2-harness/config.toml ``` -Перед первым `apply-firewall` обязательно сверяйте `ssh_port`, `amnezia_port` и `peer_ipv4_cidr` с реальным состоянием хоста и Amnezia. +Перед первым `apply-firewall` обязательно сверяйте `host_firewall.ssh_port`, `amnezia.expected_host_udp_port` и `peer_ipv4_cidr` с реальным состоянием хоста и Amnezia. Ожидаемый UDP port является диагностическим контрактом Docker publish и не открывается отдельным правилом в host `INPUT`. ## Что скрипт намеренно не делает diff --git a/tools/build.sh b/tools/build.sh index 4b91156..51fedbb 100644 --- a/tools/build.sh +++ b/tools/build.sh @@ -37,6 +37,7 @@ BUILD_PACKAGES=( RUNTIME_PACKAGES=( iproute2 + iptables nftables kmod util-linux @@ -381,11 +382,12 @@ Section: net Priority: optional Architecture: amd64 Maintainer: AWG2 Harness -Depends: libc6, iproute2, nftables, kmod, util-linux, procps, systemd +Depends: libc6, iproute2, iptables, nftables, kmod, util-linux, procps, systemd Description: Host-side orchestrator for AmneziaWG traffic shaping and lifecycle recovery AWG2 Harness manages AmneziaWG traffic shaping, IFB ingress shaping, - nftables anti-torrent guard, reboot scheduling, swap setup and daemonized - drift recovery on dedicated Debian hosts. Includes external reboot scheduler. + iptables host firewall, nftables anti-torrent guard, reboot scheduling, + swap setup and daemonized drift recovery on dedicated Debian hosts. + Includes external reboot scheduler. EOF cat >"$staging/DEBIAN/postinst" <<'EOF' @@ -406,7 +408,7 @@ echo "Then run preflight before enabling services:" echo " awg2-harness --config /etc/awg2-harness/config.toml validate-config" echo " awg2-harness --config /etc/awg2-harness/config.toml validate-runtime" echo " awg2-harness --config /etc/awg2-harness/config.toml status" -echo "Enable services only after SSH, Amnezia port and peer CIDR are verified." +echo "Enable services only after SSH, expected Amnezia UDP port and peer CIDR are verified." echo " systemctl enable --now awg2-harness.service awg2-reboot-scheduler.service" exit 0 diff --git a/traffic_core/src/config.rs b/traffic_core/src/config.rs index 6053684..6aa8004 100644 --- a/traffic_core/src/config.rs +++ b/traffic_core/src/config.rs @@ -2,6 +2,10 @@ use std::{fs, net::Ipv4Addr, path::Path, process::Command}; use serde::Deserialize; +/// The allocator renders minor IDs with decimal digits while `tc` parses them as hex. +/// Keeping the rendered value at four digits guarantees it fits into a 16-bit minor ID. +pub const MAX_TEXTUAL_CLASS_ID: u16 = 9999; + fn default_host_firewall_enabled() -> bool { true } @@ -10,14 +14,6 @@ fn default_ssh_port() -> u16 { 2323 } -fn default_amnezia_port() -> u16 { - 443 -} - -fn default_amnezia_protocols() -> Vec { - vec!["udp".to_string()] -} - fn default_allow_icmp() -> bool { true } @@ -91,6 +87,7 @@ pub struct AmneziaConfig { pub interface_candidates: Vec, pub interface_patterns: Vec, pub peer_ipv4_cidr: String, + pub expected_host_udp_port: u16, #[serde(default = "default_wg_binaries")] pub wg_binaries: Vec, } @@ -115,15 +112,12 @@ pub struct AntiTorrentConfig { } #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct HostFirewallConfig { #[serde(default = "default_host_firewall_enabled")] pub enabled: bool, #[serde(default = "default_ssh_port")] pub ssh_port: u16, - #[serde(default = "default_amnezia_port")] - pub amnezia_port: u16, - #[serde(default = "default_amnezia_protocols")] - pub amnezia_protocols: Vec, #[serde(default = "default_allow_icmp")] pub allow_icmp: bool, #[serde(default = "default_input_policy")] @@ -135,8 +129,6 @@ impl Default for HostFirewallConfig { Self { enabled: default_host_firewall_enabled(), ssh_port: default_ssh_port(), - amnezia_port: default_amnezia_port(), - amnezia_protocols: default_amnezia_protocols(), allow_icmp: default_allow_icmp(), default_input_policy: default_input_policy(), } @@ -200,8 +192,10 @@ impl Config { ); } } - if self.traffic.class_start_id < 10 { - anyhow::bail!("traffic.class_start_id must be >= 10"); + if !(10..=MAX_TEXTUAL_CLASS_ID).contains(&self.traffic.class_start_id) { + anyhow::bail!( + "traffic.class_start_id must be in 10..={MAX_TEXTUAL_CLASS_ID}; tc parses textual class IDs as hexadecimal" + ); } validate_interface_name(&self.traffic.ifb_name, "traffic.ifb_name")?; if self.traffic.peer_limit.trim().is_empty() { @@ -267,25 +261,8 @@ impl Config { if self.host_firewall.ssh_port == 0 { anyhow::bail!("host_firewall.ssh_port must be in 1..=65535"); } - if self.host_firewall.amnezia_port == 0 { - anyhow::bail!("host_firewall.amnezia_port must be in 1..=65535"); - } - if self.host_firewall.amnezia_protocols.is_empty() { - anyhow::bail!( - "host_firewall.amnezia_protocols must not be empty; allowed values: tcp, udp" - ); - } - let mut normalized = std::collections::BTreeSet::new(); - for p in &self.host_firewall.amnezia_protocols { - let pp = p.trim().to_ascii_lowercase(); - if pp != "tcp" && pp != "udp" { - anyhow::bail!( - "host_firewall.amnezia_protocols contains invalid value: {p}; allowed: tcp, udp" - ); - } - if !normalized.insert(pp) { - anyhow::bail!("host_firewall.amnezia_protocols contains duplicate protocol: {p}"); - } + if self.amnezia.expected_host_udp_port == 0 { + anyhow::bail!("amnezia.expected_host_udp_port must be in 1..=65535"); } let policy = self .host_firewall @@ -361,7 +338,7 @@ impl Config { required.push("nft"); } if self.host_firewall.enabled { - required.push("nft"); + required.extend(["iptables", "ip6tables", "nft"]); } if self.reboot.enabled { required.push("systemctl"); @@ -492,6 +469,7 @@ fn tool_exists(tool: &str) -> bool { "modprobe" => &[&["--version"]], "nsenter" => &[&["--help"]], "nft" => &[&["--version"]], + "iptables" | "ip6tables" => &[&["--version"]], "systemctl" => &[&["--version"], &["--help"]], "mkswap" => &[&["--help"]], "swapon" => &[&["--help"]], @@ -634,6 +612,7 @@ mod tests { interface_candidates: vec!["awg0".into()], interface_patterns: vec!["awg*".into()], peer_ipv4_cidr: "10.8.1.0/24".into(), + expected_host_udp_port: 443, wg_binaries: vec!["awg".into(), "wg".into()], }, traffic: TrafficConfig { @@ -820,12 +799,32 @@ mod tests { } #[test] - fn host_firewall_rejects_empty_protocols() { + fn class_start_id_respects_textual_hex_boundary() { let mut cfg = base_config(); - cfg.host_firewall.amnezia_protocols = vec![]; + cfg.traffic.class_start_id = 9999; + assert!(cfg.validate_config().is_ok()); + cfg.traffic.class_start_id = 10_000; assert!(cfg.validate_config().is_err()); } + #[test] + fn expected_host_udp_port_must_be_nonzero() { + let mut cfg = base_config(); + cfg.amnezia.expected_host_udp_port = 0; + assert!(cfg.validate_config().is_err()); + } + + #[test] + fn removed_host_firewall_port_fields_are_rejected_instead_of_ignored() { + let raw = include_str!("../../config.example.toml").replace( + "default_input_policy = \"drop\"", + "default_input_policy = \"drop\"\namnezia_port = 443\namnezia_protocols = [\"udp\"]", + ); + let error = toml::from_str::(&raw).expect_err("legacy fields must fail"); + assert!(error.to_string().contains("unknown field")); + assert!(error.to_string().contains("amnezia_port")); + } + #[test] fn sysctl_tuning_default_is_valid() { let cfg = base_config(); @@ -888,6 +887,7 @@ mod tests { vec!["amnezia-awg2", "amnezia-awg"] ); assert_eq!(cfg.amnezia.wg_binaries, vec!["awg", "wg"]); + assert_eq!(cfg.amnezia.expected_host_udp_port, 443); assert_eq!(cfg.anti_torrent.max_connections_per_peer, 500); } @@ -932,6 +932,7 @@ container_names = ["amnezia-awg2"] interface_candidates = ["awg0"] interface_patterns = ["awg*"] peer_ipv4_cidr = "10.8.1.0/24" +expected_host_udp_port = 443 [traffic] enabled = true @@ -972,6 +973,7 @@ container_names = ["amnezia-awg2"] interface_candidates = ["awg0"] interface_patterns = ["awg*"] peer_ipv4_cidr = "10.8.1.0/24" +expected_host_udp_port = 443 [traffic] enabled = true @@ -985,8 +987,6 @@ ifb_name = "ifb0" [host_firewall] enabled = true ssh_port = 2323 -amnezia_port = 443 -amnezia_protocols = ["udp"] allow_icmp = true default_input_policy = "drop" diff --git a/traffic_core/src/daemon.rs b/traffic_core/src/daemon.rs index be1a68e..c8df3ae 100644 --- a/traffic_core/src/daemon.rs +++ b/traffic_core/src/daemon.rs @@ -7,7 +7,7 @@ use std::{ time::{Duration, Instant}, }; -use crate::{config::Config, docker, firewall, swap, sysctl_tuning, traffic}; +use crate::{config::Config, docker, error::AppError, firewall, swap, sysctl_tuning, traffic}; use tracing::{error, info}; #[derive(Debug, Clone, Default)] @@ -257,10 +257,25 @@ fn consume_docker_events( ) -> anyhow::Result<()> { for line in reader.lines() { let name = line?.trim().to_string(); - if cfg.amnezia.container_names.iter().any(|n| n == &name) - && let Some(target) = - wait_for_target(cfg, &name, cfg.traffic.container_ready_timeout_secs) - { + if cfg.amnezia.container_names.iter().any(|n| n == &name) { + let target = match wait_for_target(cfg, &name, cfg.traffic.container_ready_timeout_secs) + { + Ok(Some(target)) => target, + Ok(None) => continue, + Err(e) => { + let message = format!("event runtime target discovery failed: {e:#}"); + if cfg.traffic.enabled { + set_err(state, "traffic", message.clone()); + } + if cfg.anti_torrent.apply_in_amnezia_netns { + set_err(state, "firewall", message.clone()); + } + append_event(&format!( + "docker_event container={name} target=error error=\"{e}\"" + )); + continue; + } + }; append_event(&format!("docker_event container={name} action=start")); if let Err(e) = traffic::reconcile_traffic_with_target(cfg, &target) { if set_err(state, "traffic", format!("event traffic reconcile: {e}")) { @@ -294,22 +309,63 @@ fn wait_for_target( cfg: &Config, container_name: &str, timeout_secs: u64, -) -> Option { - let start = Instant::now(); - while start.elapsed() < Duration::from_secs(timeout_secs.max(1)) { - if let Ok(target) = docker::discover_target(cfg) { - if target.container_name == container_name { - return Some(target); - } - // A higher-priority configured container is already the active target. A start - // event for a lower-priority candidate cannot change desired state, so waiting - // for the full readiness timeout would only stall the event consumer. - return None; - } +) -> anyhow::Result> { + wait_for_target_with( + cfg, + container_name, + timeout_secs, + docker::discover_target, + thread::sleep, + ) +} - thread::sleep(Duration::from_secs(2)); +fn wait_for_target_with( + cfg: &Config, + container_name: &str, + timeout_secs: u64, + mut discover: F, + mut sleep: S, +) -> anyhow::Result> +where + F: FnMut(&Config) -> anyhow::Result, + S: FnMut(Duration), +{ + let timeout_secs = timeout_secs.max(1); + let attempts = timeout_secs.div_ceil(2); + for attempt in 0..attempts { + match discover(cfg) { + Ok(target) => { + if target.container_name == container_name { + return Ok(Some(target)); + } + // A higher-priority configured container is already the active target. A + // lower-priority start event cannot change desired state. + return Ok(None); + } + Err(e) if error_is_not_ready(&e) => {} + Err(e) => { + return Err(e.context(format!( + "hard discovery error while waiting for container {container_name}" + ))); + } + } + let elapsed_before_sleep = attempt * 2; + let remaining = timeout_secs.saturating_sub(elapsed_before_sleep); + sleep(Duration::from_secs(remaining.min(2))); } - None + anyhow::bail!( + "container {container_name} did not become ready within {} seconds", + timeout_secs + ) +} + +fn error_is_not_ready(error: &anyhow::Error) -> bool { + error.chain().any(|cause| { + matches!( + cause.downcast_ref::(), + Some(AppError::NotFound(_)) + ) + }) } impl LastActionState { @@ -424,6 +480,7 @@ fn now_rfc3339() -> String { #[cfg(test)] mod tests { use super::*; + use std::{cell::RefCell, collections::VecDeque}; /// One shared error slot meant a successful firewall reconcile erased a traffic failure /// that was still current, and logged `firewall=recovered` for a subsystem that had @@ -475,4 +532,90 @@ mod tests { "a cleared error must not leave its timestamp behind" ); } + + fn target(name: &str) -> docker::RuntimeTarget { + docker::RuntimeTarget { + container_name: name.into(), + container_id: "id".into(), + container_pid: 42, + netns_path: "/proc/42/ns/net".into(), + interface_name: "awg0".into(), + } + } + + #[test] + fn readiness_wait_retries_only_not_found_and_then_succeeds() { + let cfg = crate::firewall::tests::test_config(); + let replies = RefCell::new(VecDeque::from([ + Err(AppError::NotFound("not ready".into()).into()), + Ok(target("amnezia-awg2")), + ])); + let sleeps = RefCell::new(Vec::new()); + let got = wait_for_target_with( + &cfg, + "amnezia-awg2", + 4, + |_| replies.borrow_mut().pop_front().expect("reply"), + |duration| sleeps.borrow_mut().push(duration), + ) + .expect("ready") + .expect("target"); + assert_eq!(got.container_name, "amnezia-awg2"); + assert_eq!(*sleeps.borrow(), [Duration::from_secs(2)]); + } + + #[test] + fn readiness_wait_surfaces_hard_discovery_errors_without_retry() { + let cfg = crate::firewall::tests::test_config(); + let calls = RefCell::new(0usize); + let err = wait_for_target_with( + &cfg, + "amnezia-awg2", + 120, + |_| { + *calls.borrow_mut() += 1; + anyhow::bail!("docker permission denied") + }, + |_| panic!("hard errors must not sleep"), + ) + .expect_err("hard error"); + assert!(err.to_string().contains("hard discovery error")); + assert_eq!(*calls.borrow(), 1); + } + + #[test] + fn lower_priority_start_event_is_ignored_when_primary_target_is_running() { + let cfg = crate::firewall::tests::test_config(); + let calls = RefCell::new(0usize); + let result = wait_for_target_with( + &cfg, + "amnezia-awg", + 30, + |_| { + *calls.borrow_mut() += 1; + Ok(target("amnezia-awg2")) + }, + |_| panic!("an already selected primary target must not wait"), + ) + .expect("discovery succeeds"); + assert!(result.is_none()); + assert_eq!(*calls.borrow(), 1); + } + + #[test] + fn readiness_timeout_is_an_error_instead_of_a_silent_none() { + let cfg = crate::firewall::tests::test_config(); + let err = wait_for_target_with( + &cfg, + "amnezia-awg2", + 3, + |_| Err(AppError::NotFound("not ready".into()).into()), + |_| {}, + ) + .expect_err("timeout"); + assert!( + err.to_string() + .contains("did not become ready within 3 seconds") + ); + } } diff --git a/traffic_core/src/docker.rs b/traffic_core/src/docker.rs index 856fff9..25bb2e4 100644 --- a/traffic_core/src/docker.rs +++ b/traffic_core/src/docker.rs @@ -39,8 +39,12 @@ pub struct PublishedUdpPortPath { #[derive(Debug, Clone)] pub struct PortPathDiagnostics { + pub expected_host_udp_port: u16, pub docker_userland_proxy: TriState, pub published_udp_port: Option, + pub published_udp_port_error: Option, + pub awg_listen_udp_port: Option, + pub awg_listen_udp_port_error: Option, pub host_forward_policy_drop: TriState, pub docker_forward_rules_present: TriState, } @@ -57,6 +61,19 @@ impl PortPathDiagnostics { } None } + + pub fn port_contract(&self) -> &'static str { + match (&self.published_udp_port, self.awg_listen_udp_port) { + (Some(published), Some(listen_port)) + if published.host_port == self.expected_host_udp_port + && published.container_port == listen_port => + { + "ok" + } + (Some(_), Some(_)) => "mismatch", + _ => "unknown", + } + } } pub fn discover_target(cfg: &Config) -> anyhow::Result { @@ -159,15 +176,28 @@ pub fn run_capture(args: &[&str]) -> anyhow::Result { pub fn collect_port_path_diagnostics( container_name: &str, expected_udp_port: u16, + awg_listen_port: anyhow::Result, ) -> PortPathDiagnostics { let docker_userland_proxy = detect_userland_proxy().unwrap_or(TriState::Unknown); - let published_udp_port = discover_published_udp_port(container_name, expected_udp_port).ok(); + let published = discover_published_udp_port(container_name, expected_udp_port); + let (published_udp_port, published_udp_port_error) = match published { + Ok(port) => (Some(port), None), + Err(e) => (None, Some(e.to_string())), + }; + let (awg_listen_udp_port, awg_listen_udp_port_error) = match awg_listen_port { + Ok(port) => (Some(port), None), + Err(e) => (None, Some(e.to_string())), + }; let (host_forward_policy_drop, docker_forward_rules_present) = inspect_forward_state().unwrap_or((TriState::Unknown, TriState::Unknown)); PortPathDiagnostics { + expected_host_udp_port: expected_udp_port, docker_userland_proxy, published_udp_port, + published_udp_port_error, + awg_listen_udp_port, + awg_listen_udp_port_error, host_forward_policy_drop, docker_forward_rules_present, } @@ -654,11 +684,38 @@ table inet filter { #[test] fn warning_is_emitted_for_risky_forward_state() { let diag = PortPathDiagnostics { + expected_host_udp_port: 443, docker_userland_proxy: TriState::False, published_udp_port: None, + published_udp_port_error: Some("not found".into()), + awg_listen_udp_port: None, + awg_listen_udp_port_error: Some("not available".into()), host_forward_policy_drop: TriState::True, docker_forward_rules_present: TriState::Unknown, }; assert!(diag.warning().is_some()); } + + #[test] + fn port_contract_checks_both_sides_of_docker_mapping() { + let mut diag = PortPathDiagnostics { + expected_host_udp_port: 443, + docker_userland_proxy: TriState::Unknown, + published_udp_port: Some(PublishedUdpPortPath { + host_port: 443, + container_ip: "172.17.0.2".into(), + container_port: 443, + }), + published_udp_port_error: None, + awg_listen_udp_port: Some(443), + awg_listen_udp_port_error: None, + host_forward_policy_drop: TriState::Unknown, + docker_forward_rules_present: TriState::Unknown, + }; + assert_eq!(diag.port_contract(), "ok"); + diag.awg_listen_udp_port = Some(51820); + assert_eq!(diag.port_contract(), "mismatch"); + diag.awg_listen_udp_port = None; + assert_eq!(diag.port_contract(), "unknown"); + } } diff --git a/traffic_core/src/firewall/host.rs b/traffic_core/src/firewall/host.rs index 3e310b7..111ac33 100644 --- a/traffic_core/src/firewall/host.rs +++ b/traffic_core/src/firewall/host.rs @@ -1,91 +1,523 @@ -//! Host INPUT firewall, owned table `inet awg2_host_firewall`. +//! Host INPUT firewall, реализованный через совместимый с Docker слой iptables. //! -//! Applied in the host namespace only. The table is scoped so that disabling the module -//! removes exactly what it created and nothing that Docker, Amnezia or the operator owns. +//! Модуль не меняет policy встроенной цепочки INPUT и не очищает чужие правила. +//! Он владеет только двумя цепочками `AWG2H_A`/`AWG2H_B` и помеченными comment +//! переходами из INPUT. Новая конфигурация сначала полностью собирается в неактивной +//! цепочке, затем переход в INPUT переключается одной командой. -use crate::{config::Config, netns::build_host_transport}; +use crate::{config::Config, netns::NsTransport, netns::build_host_transport}; use super::{ - HOST_FW_TABLE, - nft::{is_nft_not_found, normalize_nft_text, run_allow_notfound, run_nft_batch, table_exists}, + LEGACY_HOST_FW_TABLE, + nft::{run_allow_notfound, table_exists}, }; +const CHAIN_A: &str = "AWG2H_A"; +const CHAIN_B: &str = "AWG2H_B"; +const OWNER_MARK: &str = "awg2-harness:host-firewall"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Family { + V4, + V6, +} + +impl Family { + fn program(self) -> &'static str { + match self { + Self::V4 => "iptables", + Self::V6 => "ip6tables", + } + } + + fn icmp_protocol(self) -> &'static str { + match self { + Self::V4 => "icmp", + Self::V6 => "ipv6-icmp", + } + } +} + +#[derive(Debug, Eq, PartialEq)] +enum ChainOwnership { + Absent, + Owned(Vec), + Foreign, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct ManagedJump { + position: usize, + chain: String, +} + pub(super) fn apply_host_firewall(cfg: &Config) -> anyhow::Result<()> { let transport = build_host_transport(); + apply_host_firewall_with_transport(cfg, transport.as_ref()) +} + +fn apply_host_firewall_with_transport( + cfg: &Config, + transport: &dyn NsTransport, +) -> anyhow::Result<()> { if !cfg.host_firewall.enabled { - run_allow_notfound( - transport.as_ref(), - &["nft", "delete", "table", "inet", HOST_FW_TABLE], - )?; + return remove_owned_firewall(transport); + } + + if host_firewall_drift_reason_with_transport(cfg, transport)?.is_none() { return Ok(()); } - if host_firewall_drift_reason(cfg)?.is_none() { - return Ok(()); + let old_v4 = top_managed_chain(transport, Family::V4)?; + let next_v4 = choose_staging_chain(transport, Family::V4)?; + let next_v6 = choose_staging_chain(transport, Family::V6)?; + + // Обе семейства должны быть полностью готовы до первого изменения INPUT. + prepare_chain(transport, Family::V4, next_v4, cfg)?; + if let Err(primary) = prepare_chain(transport, Family::V6, next_v6, cfg) { + let cleanup = delete_known_owned_chain(transport, Family::V4, next_v4); + return Err(with_cleanup(primary, cleanup)); } - let policy = cfg - .host_firewall - .default_input_policy - .trim() - .to_ascii_lowercase(); + if let Err(primary) = switch_to_chain(transport, Family::V4, next_v4) { + let cleanup_v4 = delete_known_owned_chain(transport, Family::V4, next_v4); + let cleanup_v6 = delete_known_owned_chain(transport, Family::V6, next_v6); + return Err(with_two_cleanups(primary, cleanup_v4, cleanup_v6)); + } - let present = table_exists(transport.as_ref(), HOST_FW_TABLE)?; - let batch = build_host_firewall_batch_script(cfg, &policy, present); - run_nft_batch(transport.as_ref(), &batch)?; + if let Err(primary) = switch_to_chain(transport, Family::V6, next_v6) { + let rollback = rollback_switch(transport, Family::V4, old_v4.as_deref(), next_v4); + let cleanup_v4 = delete_known_owned_chain(transport, Family::V4, next_v4); + let cleanup_v6 = delete_known_owned_chain(transport, Family::V6, next_v6); + let error = with_cleanup(primary, rollback); + return Err(with_two_cleanups(error, cleanup_v4, cleanup_v6)); + } - if let Some(reason) = host_firewall_drift_reason(cfg)? { + remove_duplicate_jumps(transport, Family::V4, next_v4)?; + remove_duplicate_jumps(transport, Family::V6, next_v6)?; + + // Удаляем таблицу прежней реализации только после успешного включения обеих семей. + run_allow_notfound( + transport, + &["nft", "delete", "table", "inet", LEGACY_HOST_FW_TABLE], + )?; + + cleanup_inactive_chain(transport, Family::V4, next_v4)?; + cleanup_inactive_chain(transport, Family::V6, next_v6)?; + + if let Some(reason) = host_firewall_drift_reason_with_transport(cfg, transport)? { anyhow::bail!("host firewall verify-after-apply failed: {reason}"); } Ok(()) } -/// Renders the replacement as a single nft transaction. -/// -/// The delete belongs *inside* the batch. `nft -f` applies a script atomically: if any -/// line fails to parse or the kernel rejects the ruleset, nothing is committed. Deleting -/// the table as a separate command first, as this used to, meant a failure in the -/// following batch left the host with no INPUT policy and no allow rules at all - a -/// fail-open window on the firewall, opened by a syntax error or a transient netlink -/// failure. -/// -/// The delete is only emitted when the table actually exists, because `delete table` on a -/// missing table aborts the whole transaction. -fn build_host_firewall_batch_script(cfg: &Config, policy: &str, table_present: bool) -> String { - let mut batch = String::new(); - if table_present { - batch.push_str(&format!("delete table inet {HOST_FW_TABLE}\n")); +fn choose_staging_chain( + transport: &dyn NsTransport, + family: Family, +) -> anyhow::Result<&'static str> { + let jumps = managed_jumps(transport, family)?; + if let Some(unexpected) = jumps + .iter() + .find(|jump| jump.chain != CHAIN_A && jump.chain != CHAIN_B) + { + anyhow::bail!( + "{} INPUT contains an AWG2 marker pointing to unexpected chain {}", + family.program(), + unexpected.chain + ); } - batch.push_str(&format!("add table inet {HOST_FW_TABLE}\n")); - batch.push_str(&format!( - "add chain inet {HOST_FW_TABLE} input {{ type filter hook input priority filter; policy {policy}; }}\n" - )); - batch.push_str(&format!( - "add rule inet {HOST_FW_TABLE} input iif \"lo\" accept\n" - )); - batch.push_str(&format!( - "add rule inet {HOST_FW_TABLE} input ct state established,related accept\n" - )); - batch.push_str(&format!( - "add rule inet {HOST_FW_TABLE} input tcp dport {} accept\n", - cfg.host_firewall.ssh_port - )); - for proto in &cfg.host_firewall.amnezia_protocols { - let p = proto.trim().to_ascii_lowercase(); - batch.push_str(&format!( - "add rule inet {HOST_FW_TABLE} input {p} dport {} accept\n", - cfg.host_firewall.amnezia_port - )); + if jumps.first().is_some_and(|jump| jump.chain == CHAIN_A) { + Ok(CHAIN_B) + } else { + Ok(CHAIN_A) } +} + +fn prepare_chain( + transport: &dyn NsTransport, + family: Family, + chain: &str, + cfg: &Config, +) -> anyhow::Result<()> { + match inspect_chain(transport, family, chain)? { + ChainOwnership::Absent => { + run_family(transport, family, vec!["-N".into(), chain.into()])?; + } + ChainOwnership::Owned(_) => { + // Неактивная цепочка могла остаться после прерванного предыдущего запуска. + remove_jumps_to_chain(transport, family, chain)?; + run_family(transport, family, vec!["-F".into(), chain.into()])?; + } + ChainOwnership::Foreign => { + anyhow::bail!( + "{} chain {chain} exists but is not owned by AWG2 Harness", + family.program() + ) + } + } + + for rule in configured_rules(family, chain, cfg) { + if let Err(primary) = run_family(transport, family, rule) { + let cleanup = delete_known_owned_chain(transport, family, chain); + return Err(with_cleanup(primary, cleanup)); + } + } + + verify_chain(transport, family, chain, cfg) +} + +fn configured_rules(family: Family, chain: &str, cfg: &Config) -> Vec> { + let mut rules = vec![ + append_rule(chain, &["-i", "lo", "-j", "ACCEPT"]), + append_rule( + chain, + &[ + "-m", + "conntrack", + "--ctstate", + "ESTABLISHED,RELATED", + "-j", + "ACCEPT", + ], + ), + append_rule( + chain, + &[ + "-p", + "tcp", + "--dport", + &cfg.host_firewall.ssh_port.to_string(), + "-j", + "ACCEPT", + ], + ), + ]; if cfg.host_firewall.allow_icmp { - batch.push_str(&format!( - "add rule inet {HOST_FW_TABLE} input ip protocol icmp accept\n" - )); - batch.push_str(&format!( - "add rule inet {HOST_FW_TABLE} input ip6 nexthdr icmpv6 accept\n" + rules.push(append_rule( + chain, + &["-p", family.icmp_protocol(), "-j", "ACCEPT"], )); } - batch + let verdict = cfg + .host_firewall + .default_input_policy + .trim() + .to_ascii_uppercase(); + rules.push(append_rule(chain, &["-j", &verdict])); + rules +} + +fn append_rule(chain: &str, body: &[&str]) -> Vec { + let mut rule = vec!["-A".into(), chain.into()]; + rule.extend(body.iter().map(|part| (*part).to_string())); + // Комментарий на каждой строке позволяет доказать владение до flush/delete. + rule.splice( + rule.len() - 2..rule.len() - 2, + [ + "-m".to_string(), + "comment".to_string(), + "--comment".to_string(), + OWNER_MARK.to_string(), + ], + ); + rule +} + +fn jump_rule(operation: &str, position: Option, chain: &str) -> Vec { + let mut args = vec![operation.into(), "INPUT".into()]; + if let Some(position) = position { + args.push(position.to_string()); + } + args.extend([ + "-m".into(), + "comment".into(), + "--comment".into(), + OWNER_MARK.into(), + "-j".into(), + chain.into(), + ]); + args +} + +fn switch_to_chain(transport: &dyn NsTransport, family: Family, chain: &str) -> anyhow::Result<()> { + let first_is_managed = managed_jumps(transport, family)? + .first() + .is_some_and(|jump| jump.position == 1); + let args = if first_is_managed { + jump_rule("-R", Some(1), chain) + } else { + jump_rule("-I", Some(1), chain) + }; + run_family(transport, family, args).map(|_| ()) +} + +fn rollback_switch( + transport: &dyn NsTransport, + family: Family, + old_chain: Option<&str>, + new_chain: &str, +) -> anyhow::Result<()> { + match old_chain { + Some(chain) => run_family(transport, family, jump_rule("-R", Some(1), chain)).map(|_| ()), + None => { + let jumps = managed_jumps(transport, family)?; + if jumps + .first() + .is_some_and(|jump| jump.position == 1 && jump.chain == new_chain) + { + run_family( + transport, + family, + vec!["-D".into(), "INPUT".into(), "1".into()], + )?; + } + Ok(()) + } + } +} + +fn top_managed_chain( + transport: &dyn NsTransport, + family: Family, +) -> anyhow::Result> { + let jumps = managed_jumps(transport, family)?; + let Some(first) = jumps.first() else { + return Ok(None); + }; + if first.position != 1 { + return Ok(None); + } + if first.chain != CHAIN_A && first.chain != CHAIN_B { + anyhow::bail!( + "{} INPUT contains an AWG2 marker pointing to unexpected chain {}", + family.program(), + first.chain + ); + } + Ok(Some(first.chain.clone())) +} + +fn managed_jumps(transport: &dyn NsTransport, family: Family) -> anyhow::Result> { + let output = run_family(transport, family, vec!["-S".into(), "INPUT".into()])?; + Ok(parse_managed_jumps(&output)) +} + +fn parse_managed_jumps(output: &str) -> Vec { + output + .lines() + .filter(|line| line.trim_start().starts_with("-A INPUT ")) + .enumerate() + .filter_map(|(index, line)| { + let tokens = line + .replace(['\"', '\''], "") + .split_whitespace() + .map(str::to_string) + .collect::>(); + if !tokens + .windows(2) + .any(|pair| pair[0] == "--comment" && pair[1] == OWNER_MARK) + { + return None; + } + let chain = tokens + .windows(2) + .find(|pair| pair[0] == "-j") + .map(|pair| pair[1].clone())?; + Some(ManagedJump { + position: index + 1, + chain, + }) + }) + .collect() +} + +fn remove_duplicate_jumps( + transport: &dyn NsTransport, + family: Family, + active: &str, +) -> anyhow::Result<()> { + let jumps = managed_jumps(transport, family)?; + if !jumps + .first() + .is_some_and(|jump| jump.position == 1 && jump.chain == active) + { + anyhow::bail!( + "{} INPUT does not start with the expected managed jump", + family.program() + ); + } + for jump in jumps.iter().skip(1).rev() { + run_family( + transport, + family, + vec!["-D".into(), "INPUT".into(), jump.position.to_string()], + )?; + } + Ok(()) +} + +fn remove_jumps_to_chain( + transport: &dyn NsTransport, + family: Family, + chain: &str, +) -> anyhow::Result<()> { + for jump in managed_jumps(transport, family)? + .into_iter() + .filter(|jump| jump.chain == chain) + .collect::>() + .into_iter() + .rev() + { + run_family( + transport, + family, + vec!["-D".into(), "INPUT".into(), jump.position.to_string()], + )?; + } + Ok(()) +} + +fn remove_all_managed_jumps(transport: &dyn NsTransport, family: Family) -> anyhow::Result<()> { + for jump in managed_jumps(transport, family)?.into_iter().rev() { + run_family( + transport, + family, + vec!["-D".into(), "INPUT".into(), jump.position.to_string()], + )?; + } + Ok(()) +} + +fn inspect_chain( + transport: &dyn NsTransport, + family: Family, + chain: &str, +) -> anyhow::Result { + let output = match run_family(transport, family, vec!["-S".into(), chain.into()]) { + Ok(output) => output, + Err(error) if is_chain_missing(&error.to_string()) => return Ok(ChainOwnership::Absent), + Err(error) => return Err(error), + }; + Ok(classify_chain_text(&output, chain)) +} + +fn classify_chain_text(output: &str, chain: &str) -> ChainOwnership { + let rules = output + .lines() + .filter(|line| line.trim_start().starts_with(&format!("-A {chain} "))) + .map(str::trim) + .map(str::to_string) + .collect::>(); + if !rules.is_empty() && rules.iter().all(|line| line_has_owner_mark(line)) { + ChainOwnership::Owned(rules) + } else { + ChainOwnership::Foreign + } +} + +fn line_has_owner_mark(line: &str) -> bool { + let normalized = line.replace(['"', '\''], ""); + normalized + .split_whitespace() + .collect::>() + .windows(2) + .any(|pair| pair[0] == "--comment" && pair[1] == OWNER_MARK) +} + +fn verify_chain( + transport: &dyn NsTransport, + family: Family, + chain: &str, + cfg: &Config, +) -> anyhow::Result<()> { + let actual = match inspect_chain(transport, family, chain)? { + ChainOwnership::Owned(rules) => rules, + ChainOwnership::Absent => anyhow::bail!("{} chain {chain} disappeared", family.program()), + ChainOwnership::Foreign => { + anyhow::bail!("{} chain {chain} contains foreign rules", family.program()) + } + }; + let expected = configured_rules(family, chain, cfg) + .into_iter() + .map(|rule| canonical_rule(&rule.join(" "))) + .collect::>(); + let actual = actual + .iter() + .map(|rule| canonical_rule(rule)) + .collect::>(); + if actual != expected { + anyhow::bail!( + "{} chain {chain} rules differ from config", + family.program() + ); + } + Ok(()) +} + +fn canonical_rule(rule: &str) -> String { + let mut tokens = rule + .replace(['\"', '\''], "") + .split_whitespace() + .map(|token| token.to_ascii_lowercase()) + .collect::>(); + let mut index = 0; + while index + 1 < tokens.len() { + if tokens[index] == "-m" && (tokens[index + 1] == "tcp" || tokens[index + 1] == "udp") { + tokens.drain(index..=index + 1); + continue; + } + if tokens[index] == "--ctstate" { + let mut states = tokens[index + 1].split(',').collect::>(); + states.sort_unstable(); + tokens[index + 1] = states.join(","); + } + index += 1; + } + tokens.join(" ") +} + +fn cleanup_inactive_chain( + transport: &dyn NsTransport, + family: Family, + active: &str, +) -> anyhow::Result<()> { + let inactive = if active == CHAIN_A { CHAIN_B } else { CHAIN_A }; + delete_owned_chain(transport, family, inactive) +} + +fn delete_owned_chain( + transport: &dyn NsTransport, + family: Family, + chain: &str, +) -> anyhow::Result<()> { + match inspect_chain(transport, family, chain)? { + ChainOwnership::Absent => Ok(()), + ChainOwnership::Foreign => Ok(()), + ChainOwnership::Owned(_) => delete_known_owned_chain(transport, family, chain), + } +} + +fn delete_known_owned_chain( + transport: &dyn NsTransport, + family: Family, + chain: &str, +) -> anyhow::Result<()> { + remove_jumps_to_chain(transport, family, chain)?; + run_family(transport, family, vec!["-F".into(), chain.into()])?; + run_family(transport, family, vec!["-X".into(), chain.into()])?; + Ok(()) +} + +fn remove_owned_firewall(transport: &dyn NsTransport) -> anyhow::Result<()> { + for family in [Family::V4, Family::V6] { + remove_all_managed_jumps(transport, family)?; + delete_owned_chain(transport, family, CHAIN_A)?; + delete_owned_chain(transport, family, CHAIN_B)?; + } + run_allow_notfound( + transport, + &["nft", "delete", "table", "inet", LEGACY_HOST_FW_TABLE], + ) } pub fn host_firewall_drift_reason(cfg: &Config) -> anyhow::Result> { @@ -93,171 +525,316 @@ pub fn host_firewall_drift_reason(cfg: &Config) -> anyhow::Result return Ok(None); } let transport = build_host_transport(); - let table = match transport.run(&["nft", "list", "table", "inet", HOST_FW_TABLE]) { - Ok(out) => out, - Err(e) if is_nft_not_found(&e.to_string()) => return Ok(Some("missing nft table".into())), - Err(e) => return Err(anyhow::anyhow!("host firewall inspect failed: {e}")), - }; - let nt = normalize_nft_text(&table); - Ok(host_firewall_drift_reason_from_text(&nt, cfg)) + host_firewall_drift_reason_with_transport(cfg, transport.as_ref()) } -/// Checks the normalized ruleset against config. Returns the first missing piece. -/// -/// SSH is checked before anything optional: locking the operator out is the one failure -/// mode of this module that cannot be repaired remotely. -fn host_firewall_drift_reason_from_text(nt: &str, cfg: &Config) -> Option { - let ssh = format!("tcp dport {} accept", cfg.host_firewall.ssh_port); - if !nt.contains("chain input") { - return Some("missing input chain".into()); - } - if !nt.contains("type filter hook input") { - return Some("missing input hook".into()); - } - let pol = format!( - "policy {}", - cfg.host_firewall - .default_input_policy - .trim() - .to_ascii_lowercase() - ); - if !nt.contains(&pol) { - return Some("wrong input policy".into()); - } - if !nt.contains("iif lo accept") { - return Some("missing loopback allow".into()); - } - if !has_established_related_rule(nt) { - return Some("missing established/related allow".into()); - } - if !nt.contains(&ssh) { - return Some("missing ssh allow rule".into()); - } - for proto in &cfg.host_firewall.amnezia_protocols { - let p = proto.trim().to_ascii_lowercase(); - let need = format!("{p} dport {} accept", cfg.host_firewall.amnezia_port); - if !nt.contains(&need) { - return Some(format!("missing amnezia allow rule for {p}")); +fn host_firewall_drift_reason_with_transport( + cfg: &Config, + transport: &dyn NsTransport, +) -> anyhow::Result> { + for family in [Family::V4, Family::V6] { + let jumps = managed_jumps(transport, family)?; + let Some(first) = jumps.first() else { + return Ok(Some(format!("missing {} INPUT jump", family.program()))); + }; + if first.position != 1 || (first.chain != CHAIN_A && first.chain != CHAIN_B) { + return Ok(Some(format!( + "{} managed jump is not first or targets an unexpected chain", + family.program() + ))); + } + if jumps.len() != 1 { + return Ok(Some(format!( + "{} INPUT contains duplicate managed jumps", + family.program() + ))); + } + if let Err(error) = verify_chain(transport, family, &first.chain, cfg) { + return Ok(Some(error.to_string())); } } - if cfg.host_firewall.allow_icmp { - if !nt.contains("ip protocol icmp accept") { - return Some("missing icmp allow rule".into()); - } - if !(nt.contains("ip6 nexthdr icmpv6 accept") - || nt.contains("ip6 nexthdr ipv6-icmp accept")) - { - return Some("missing icmpv6 allow rule".into()); - } + if table_exists(transport, LEGACY_HOST_FW_TABLE)? { + return Ok(Some( + "legacy nft host firewall table is still present".into(), + )); } - None + Ok(None) } -/// `nft` renders the state list in either order depending on version. -fn has_established_related_rule(nt: &str) -> bool { - nt.contains("ct state established related accept") - || nt.contains("ct state related established accept") +fn run_family( + transport: &dyn NsTransport, + family: Family, + args: Vec, +) -> anyhow::Result { + let mut command = vec![family.program().to_string(), "-w".to_string()]; + command.extend(args); + let refs = command.iter().map(String::as_str).collect::>(); + transport + .run(&refs) + .map_err(|error| anyhow::anyhow!("{} command failed: {error}", family.program())) +} + +fn is_chain_missing(message: &str) -> bool { + let message = message.to_ascii_lowercase(); + message.contains("no chain") + || message.contains("does a matching rule exist") + || message.contains("not found") + || message.contains("no such") +} + +fn with_cleanup(primary: anyhow::Error, cleanup: anyhow::Result<()>) -> anyhow::Error { + match cleanup { + Ok(()) => primary, + Err(cleanup) => anyhow::anyhow!("{primary}; cleanup also failed: {cleanup}"), + } +} + +fn with_two_cleanups( + primary: anyhow::Error, + first: anyhow::Result<()>, + second: anyhow::Result<()>, +) -> anyhow::Error { + with_cleanup(with_cleanup(primary, first), second) } #[cfg(test)] mod tests { use super::*; use crate::firewall::tests::test_config; + use std::{collections::BTreeMap, sync::Mutex}; - #[test] - fn host_firewall_parser_tolerates_quoted_lo_and_ipv6_icmp_alias() { - let table = r#" -table inet awg2_host_firewall { - chain input { - type filter hook input priority filter; policy drop; - iif "lo" accept - ct state established,related accept - tcp dport 2323 accept - udp dport 443 accept - ip protocol icmp accept - ip6 nexthdr ipv6-icmp accept - } -} -"#; - let cfg = test_config(); - let nt = normalize_nft_text(table); - assert_eq!(host_firewall_drift_reason_from_text(&nt, &cfg), None); + struct StatefulFirewallTransport { + state: Mutex, + } + + struct StatefulFirewall { + inputs: BTreeMap>, + chains: BTreeMap>, + calls: Vec, + fail_v6_switch_to_b: bool, + } + + impl StatefulFirewallTransport { + fn with_active_a(cfg: &Config) -> Self { + let mut inputs = BTreeMap::new(); + let mut chains = BTreeMap::new(); + for family in [Family::V4, Family::V6] { + let program = family.program().to_string(); + inputs.insert( + program.clone(), + vec![format!( + "-A INPUT -m comment --comment {OWNER_MARK} -j {CHAIN_A}" + )], + ); + chains.insert( + format!("{program}:{CHAIN_A}"), + configured_rules(family, CHAIN_A, cfg) + .into_iter() + .map(|rule| rule.join(" ")) + .collect(), + ); + } + Self { + state: Mutex::new(StatefulFirewall { + inputs, + chains, + calls: Vec::new(), + fail_v6_switch_to_b: true, + }), + } + } + } + + impl NsTransport for StatefulFirewallTransport { + fn run(&self, args: &[&str]) -> anyhow::Result { + let command = args.join(" "); + let mut state = self.state.lock().expect("state"); + state.calls.push(command); + if args.first() == Some(&"nft") { + anyhow::bail!("No such file or directory"); + } + let program = args.first().copied().expect("program"); + assert_eq!(args.get(1), Some(&"-w")); + let operation = args.get(2).copied().expect("operation"); + match operation { + "-S" if args.get(3) == Some(&"INPUT") => { + let rules = state.inputs.get(program).cloned().unwrap_or_default(); + Ok(format!("-P INPUT ACCEPT\n{}\n", rules.join("\n"))) + } + "-S" => { + let chain = args.get(3).copied().expect("chain"); + let key = format!("{program}:{chain}"); + let Some(rules) = state.chains.get(&key) else { + anyhow::bail!("No chain/target/match by that name") + }; + Ok(format!("-N {chain}\n{}\n", rules.join("\n"))) + } + "-N" => { + let chain = args.get(3).copied().expect("chain"); + state + .chains + .insert(format!("{program}:{chain}"), Vec::new()); + Ok(String::new()) + } + "-A" => { + let chain = args.get(3).copied().expect("chain"); + state + .chains + .get_mut(&format!("{program}:{chain}")) + .expect("existing chain") + .push(args[2..].join(" ")); + Ok(String::new()) + } + "-F" => { + let chain = args.get(3).copied().expect("chain"); + state + .chains + .get_mut(&format!("{program}:{chain}")) + .expect("existing chain") + .clear(); + Ok(String::new()) + } + "-X" => { + let chain = args.get(3).copied().expect("chain"); + state.chains.remove(&format!("{program}:{chain}")); + Ok(String::new()) + } + "-I" | "-R" => { + let target = args + .windows(2) + .find(|pair| pair[0] == "-j") + .map(|pair| pair[1]) + .expect("jump target"); + if program == "ip6tables" && target == CHAIN_B && state.fail_v6_switch_to_b { + anyhow::bail!("injected ip6tables switch failure"); + } + let rule = format!("-A INPUT {}", args[5..].join(" ")); + let input = state.inputs.entry(program.to_string()).or_default(); + if operation == "-R" { + input[0] = rule; + } else { + input.insert(0, rule); + } + Ok(String::new()) + } + "-D" => { + let position = args + .get(4) + .expect("position") + .parse::() + .expect("numeric position"); + state + .inputs + .get_mut(program) + .expect("input") + .remove(position - 1); + Ok(String::new()) + } + _ => anyhow::bail!("unexpected command: {args:?}"), + } + } + + fn run_with_stdin(&self, _args: &[&str], _input: &str) -> anyhow::Result { + anyhow::bail!("unexpected stdin") + } } #[test] - fn normalized_established_related_rule_matches() { - let nt = normalize_nft_text("ct state established,related accept"); - assert!(nt.contains("ct state established related accept")); - assert!(has_established_related_rule(&nt)); + fn rendered_rules_do_not_open_the_docker_published_awg_port() { + let cfg = test_config(); + let text = configured_rules(Family::V4, CHAIN_A, &cfg) + .into_iter() + .map(|rule| rule.join(" ")) + .collect::>() + .join("\n"); + assert!(text.contains("--dport 2323")); + assert!(!text.contains("--dport 443")); + assert!(text.contains("-j DROP")); } #[test] - fn host_firewall_drift_detects_missing_established_related_rule() { - let table = r#" -table inet awg2_host_firewall { - chain input { - type filter hook input priority filter; policy drop; - iif "lo" accept - tcp dport 2323 accept - udp dport 443 accept - ip protocol icmp accept - ip6 nexthdr icmpv6 accept - } -} -"#; + fn every_managed_chain_rule_has_an_ownership_marker() { let cfg = test_config(); - let nt = normalize_nft_text(table); - assert_eq!( - host_firewall_drift_reason_from_text(&nt, &cfg), - Some("missing established/related allow".into()) + assert!( + configured_rules(Family::V6, CHAIN_B, &cfg) + .iter() + .all(|rule| rule.iter().any(|token| token == OWNER_MARK)) ); } - /// On a host with no managed table there is nothing to delete, and emitting the delete - /// anyway would abort the whole transaction. #[test] - fn batch_script_omits_delete_when_the_table_is_absent() { - let cfg = test_config(); - let script = build_host_firewall_batch_script(&cfg, "drop", false); - assert!(script.contains("add table inet awg2_host_firewall")); - assert!(!script.contains("delete table inet awg2_host_firewall")); - } - - /// Replacement is one transaction: the delete of the old table is part of the same - /// script, so a rejected ruleset leaves the previous one in place instead of having - /// already removed it and then failing. - #[test] - fn batch_script_deletes_and_recreates_in_one_transaction() { - let cfg = test_config(); - let script = build_host_firewall_batch_script(&cfg, "drop", true); - let delete_at = script - .find("delete table inet awg2_host_firewall") - .expect("delete present"); - let add_at = script - .find("add table inet awg2_host_firewall") - .expect("add present"); - assert!(delete_at < add_at, "delete must precede the rebuild"); - assert!(script.contains("tcp dport 2323 accept")); + fn foreign_or_empty_chain_is_never_classified_as_owned() { + assert_eq!( + classify_chain_text("-N AWG2H_A\n", CHAIN_A), + ChainOwnership::Foreign + ); + assert_eq!( + classify_chain_text("-A AWG2H_A -j ACCEPT\n", CHAIN_A), + ChainOwnership::Foreign + ); } #[test] - fn host_firewall_policy_drift_check_trims_policy_value() { - let table = r#" -table inet awg2_host_firewall { - chain input { - type filter hook input priority filter; policy drop; - iif "lo" accept - ct state established,related accept - tcp dport 2323 accept - udp dport 443 accept - ip protocol icmp accept - ip6 nexthdr icmpv6 accept - } -} + fn parser_tracks_real_input_positions_and_only_our_marker() { + let input = r#" +-P INPUT ACCEPT +-A INPUT -p tcp --dport 22 -j ACCEPT +-A INPUT -m comment --comment "awg2-harness:host-firewall" -j AWG2H_A +-A INPUT -m comment --comment somebody-else -j AWG2H_A "#; - let mut cfg = test_config(); - cfg.host_firewall.default_input_policy = " drop ".into(); - let nt = normalize_nft_text(table); - assert_eq!(host_firewall_drift_reason_from_text(&nt, &cfg), None); + assert_eq!( + parse_managed_jumps(input), + vec![ManagedJump { + position: 2, + chain: CHAIN_A.into(), + }] + ); + } + + #[test] + fn canonical_rules_tolerate_iptables_tcp_module_and_ctstate_order() { + let left = "-A AWG2H_A -p tcp -m tcp --dport 2323 -m comment --comment awg2-harness:host-firewall -j ACCEPT"; + let right = "-A AWG2H_A -p tcp --dport 2323 -m comment --comment awg2-harness:host-firewall -j ACCEPT"; + assert_eq!(canonical_rule(left), canonical_rule(right)); + assert_eq!( + canonical_rule("--ctstate ESTABLISHED,RELATED"), + canonical_rule("--ctstate RELATED,ESTABLISHED") + ); + } + + #[test] + fn ipv6_switch_failure_rolls_ipv4_back_and_removes_staging_chains() { + let mut previous = test_config(); + previous.host_firewall.ssh_port = 22; + let transport = StatefulFirewallTransport::with_active_a(&previous); + let desired = test_config(); + + let error = apply_host_firewall_with_transport(&desired, &transport) + .expect_err("injected v6 switch must fail"); + assert!( + error + .to_string() + .contains("injected ip6tables switch failure") + ); + + let state = transport.state.lock().expect("state"); + for program in ["iptables", "ip6tables"] { + let input = state.inputs.get(program).expect("input rules"); + assert_eq!(input.len(), 1); + assert!(input[0].ends_with(&format!("-j {CHAIN_A}"))); + assert!(!state.chains.contains_key(&format!("{program}:{CHAIN_B}"))); + } + let v4_switches = state + .calls + .iter() + .filter(|call| call.starts_with("iptables -w -R INPUT 1")) + .collect::>(); + assert_eq!( + v4_switches.len(), + 2, + "switch to B followed by rollback to A" + ); + assert!(v4_switches[0].ends_with(&format!("-j {CHAIN_B}"))); + assert!(v4_switches[1].ends_with(&format!("-j {CHAIN_A}"))); } } diff --git a/traffic_core/src/firewall/mod.rs b/traffic_core/src/firewall/mod.rs index 14980c4..6f779de 100644 --- a/traffic_core/src/firewall/mod.rs +++ b/traffic_core/src/firewall/mod.rs @@ -1,8 +1,8 @@ -//! nftables state owned by AWG2 Harness. +//! Firewall state owned by AWG2 Harness. //! //! Two independent subsystems live here and are deliberately kept apart: //! -//! - [`host`] owns `inet awg2_host_firewall` in the host namespace (INPUT policy); +//! - [`host`] owns isolated iptables/ip6tables chains in host INPUT; //! - [`antitorrent`] owns `inet awg2_harness` in the namespace selected by config. //! //! Neither ever touches the global ruleset, host `FORWARD`, or Docker NAT chains. @@ -38,7 +38,7 @@ pub(crate) fn antitorrent_drop_counter_with_transport( } const ANTITORRENT_TABLE: &str = "awg2_harness"; -const HOST_FW_TABLE: &str = "awg2_host_firewall"; +const LEGACY_HOST_FW_TABLE: &str = "awg2_host_firewall"; pub fn apply_firewall(cfg: &Config) -> anyhow::Result<()> { let _lock = @@ -99,6 +99,7 @@ pub(crate) mod tests { interface_candidates: vec!["awg0".into()], interface_patterns: vec!["awg*".into()], peer_ipv4_cidr: "10.8.1.0/24".into(), + expected_host_udp_port: 443, wg_binaries: vec!["awg".into(), "wg".into()], }, traffic: TrafficConfig { diff --git a/traffic_core/src/iface.rs b/traffic_core/src/iface.rs index 23ea1ab..6cce51a 100644 --- a/traffic_core/src/iface.rs +++ b/traffic_core/src/iface.rs @@ -1,5 +1,6 @@ use crate::{ config::Config, + error::AppError, netns::{NsTransport, build_transport}, }; @@ -32,7 +33,7 @@ pub fn detect_interface_with_transport( } } - anyhow::bail!("unable to detect amnezia interface") + Err(AppError::NotFound("amnezia interface is not ready".to_string()).into()) } fn glob_like_match(pattern: &str, value: &str) -> bool { diff --git a/traffic_core/src/status.rs b/traffic_core/src/status.rs index 8338a52..5be8140 100644 --- a/traffic_core/src/status.rs +++ b/traffic_core/src/status.rs @@ -60,7 +60,8 @@ pub fn print_status(cfg: &Config) -> anyhow::Result<()> { let docker_path_diag = docker::collect_port_path_diagnostics( &target.container_name, - cfg.host_firewall.amnezia_port, + cfg.amnezia.expected_host_udp_port, + wg::discover_listen_port(cfg, &target), ); // Peer discovery is one container round-trip, shared by both sections below. @@ -139,16 +140,20 @@ pub fn print_status(cfg: &Config) -> anyhow::Result<()> { } println!(" topology:"); - let egress_qdisc = transport - .run(&["tc", "qdisc", "show", "dev", &target.interface_name]) - .unwrap_or_default(); - let ingress_qdisc = egress_qdisc.clone(); - let ifb_qdisc = transport - .run(&["tc", "qdisc", "show", "dev", &cfg.traffic.ifb_name]) - .unwrap_or_default(); - println!(" egress_htb_present: {}", egress_qdisc.contains("htb")); - println!(" ingress_present: {}", ingress_qdisc.contains("ingress")); - println!(" ifb_htb_present: {}", ifb_qdisc.contains("htb")); + match transport.run(&["tc", "qdisc", "show", "dev", &target.interface_name]) { + Ok(qdiscs) => { + println!(" egress_htb_present: {}", qdiscs.contains("htb")); + println!(" ingress_present: {}", qdiscs.contains("ingress")); + } + Err(e) => { + println!(" egress_htb_present: unknown ({e})"); + println!(" ingress_present: unknown ({e})"); + } + } + match transport.run(&["tc", "qdisc", "show", "dev", &cfg.traffic.ifb_name]) { + Ok(qdiscs) => println!(" ifb_htb_present: {}", qdiscs.contains("htb")), + Err(e) => println!(" ifb_htb_present: unknown ({e})"), + } println!(" peer_table:"); let peer_table = if cfg.traffic.enabled { @@ -161,7 +166,7 @@ pub fn print_status(cfg: &Config) -> anyhow::Result<()> { } else { Ok(None) }; - match peer_table { + match &peer_table { Ok(Some(rows)) if rows.is_empty() => println!(" no peers discovered"), Ok(Some(rows)) => { for r in rows { @@ -171,11 +176,11 @@ pub fn print_status(cfg: &Config) -> anyhow::Result<()> { r.class_id, r.egress_filter_present, r.ingress_filter_present, - r.egress_rate.unwrap_or_else(|| "n/a".to_string()), - r.egress_ceil.unwrap_or_else(|| "n/a".to_string()), - r.ingress_rate.unwrap_or_else(|| "n/a".to_string()), - r.ingress_ceil.unwrap_or_else(|| "n/a".to_string()), - r.drift_reason.unwrap_or_else(|| "no".to_string()) + r.egress_rate.as_deref().unwrap_or("n/a"), + r.egress_ceil.as_deref().unwrap_or("n/a"), + r.ingress_rate.as_deref().unwrap_or("n/a"), + r.ingress_ceil.as_deref().unwrap_or("n/a"), + r.drift_reason.as_deref().unwrap_or("no") ); } } @@ -184,26 +189,31 @@ pub fn print_status(cfg: &Config) -> anyhow::Result<()> { } println!("Peer traffic counters:"); - println!(" IP Egress hits Ingress hits"); - match collect_peer_filter_hits( - &target.interface_name, - &cfg.traffic.ifb_name, - transport.as_ref(), - ) { - Ok(map) if map.is_empty() => println!(" filter statistics: unavailable"), - Ok(map) => { - for (ip, (eg, ing)) in map { - println!(" {:<11} {:<13} {}", ip, eg, ing); + println!(" IP Egress packets Ingress packets"); + match &peer_table { + Ok(Some(rows)) if rows.is_empty() => println!(" no peers discovered"), + Ok(Some(rows)) => { + for row in rows { + let egress = row + .egress_packets + .map(|value| value.to_string()) + .unwrap_or_else(|| "n/a".to_string()); + let ingress = row + .ingress_packets + .map(|value| value.to_string()) + .unwrap_or_else(|| "n/a".to_string()); + println!(" {:<11} {:<14} {}", row.ip, egress, ingress); } } - Err(_) => println!(" filter statistics: unavailable"), + Ok(None) => println!(" traffic disabled"), + Err(e) => println!(" class statistics: unavailable ({e})"), } print_host_firewall_section(cfg); print_docker_amnezia_port_path(&docker_path_diag); print_antitorrent_section_with_transport(cfg, Some(transport.as_ref())); print_swap_status(cfg); - print_conntrack_section(); + print_conntrack_section(cfg, Some(transport.as_ref())); print_last_actions_section(); print_recent_events_section(); print_reboot_section(cfg); @@ -240,10 +250,10 @@ fn print_degraded_sections(cfg: &Config) { println!(" drift: unknown (container/netns not discovered)"); print_host_firewall_section(cfg); - print_unknown_docker_amnezia_port_path(); + print_unknown_docker_amnezia_port_path(cfg.amnezia.expected_host_udp_port); print_antitorrent_section(cfg); print_swap_status(cfg); - print_conntrack_section(); + print_conntrack_section(cfg, None); print_last_actions_section(); print_recent_events_section(); print_reboot_section(cfg); @@ -261,11 +271,6 @@ fn print_host_firewall_section(cfg: &Config) { println!("Host firewall:"); println!(" enabled: {}", cfg.host_firewall.enabled); println!(" ssh_port: {}/tcp", cfg.host_firewall.ssh_port); - println!( - " amnezia_port: {}/{}", - cfg.host_firewall.amnezia_port, - cfg.host_firewall.amnezia_protocols.join(",") - ); println!( " input_policy: {}", cfg.host_firewall @@ -336,18 +341,51 @@ fn print_antitorrent_section_with_transport( } } -fn print_conntrack_section() { +#[derive(Debug, Clone, PartialEq, Eq)] +struct ConntrackStatus { + count_namespace: &'static str, + count: String, + max_global: String, +} + +fn print_conntrack_section( + cfg: &Config, + container_transport: Option<&dyn crate::netns::NsTransport>, +) { println!("Conntrack:"); - let count = read_sysctl_or_na("net.netfilter.nf_conntrack_count"); - let max = read_sysctl_or_na("net.netfilter.nf_conntrack_max"); - println!(" nf_conntrack_count: {count}"); - println!(" nf_conntrack_max: {max}"); + let host_transport = crate::netns::build_host_transport(); + let status = collect_conntrack_status(cfg, container_transport, host_transport.as_ref()); + println!(" count_namespace: {}", status.count_namespace); + println!(" nf_conntrack_count: {}", status.count); + println!(" nf_conntrack_max_global: {}", status.max_global); println!( " nf_conntrack_utilization: {}", - conntrack_utilization(&count, &max).unwrap_or_else(|| "n/a".to_string()) + conntrack_utilization(&status.count, &status.max_global) + .unwrap_or_else(|| "n/a".to_string()) ); } +fn collect_conntrack_status( + cfg: &Config, + container_transport: Option<&dyn crate::netns::NsTransport>, + host_transport: &dyn crate::netns::NsTransport, +) -> ConntrackStatus { + let in_amnezia = cfg.anti_torrent.enabled && cfg.anti_torrent.apply_in_amnezia_netns; + let count = if in_amnezia { + container_transport + .map(|transport| read_sysctl_or_na(transport, "net.netfilter.nf_conntrack_count")) + .unwrap_or_else(|| "n/a".to_string()) + } else { + read_sysctl_or_na(host_transport, "net.netfilter.nf_conntrack_count") + }; + ConntrackStatus { + count_namespace: if in_amnezia { "amnezia" } else { "host" }, + count, + // The kernel limit is global even though non-init netns expose it read-only. + max_global: read_sysctl_or_na(host_transport, "net.netfilter.nf_conntrack_max"), + } +} + fn conntrack_utilization(count: &str, max: &str) -> Option { let count = count.trim().parse::().ok()?; let max = max.trim().parse::().ok()?; @@ -357,13 +395,11 @@ fn conntrack_utilization(count: &str, max: &str) -> Option { Some(format!("{:.1}%", count as f64 * 100.0 / max as f64)) } -fn read_sysctl_or_na(key: &str) -> String { - Command::new("sysctl") - .args(["-n", key]) - .output() +fn read_sysctl_or_na(transport: &dyn crate::netns::NsTransport, key: &str) -> String { + transport + .run(&["sysctl", "-n", key]) .ok() - .filter(|o| o.status.success()) - .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .map(|value| value.trim().to_string()) .filter(|v| !v.is_empty()) .unwrap_or_else(|| "n/a".to_string()) } @@ -465,6 +501,7 @@ fn print_swap_status(cfg: &Config) { fn print_docker_amnezia_port_path(diag: &docker::PortPathDiagnostics) { println!("Docker/Amnezia port path:"); + println!(" expected_host_udp_port: {}", diag.expected_host_udp_port); println!( " docker_userland_proxy: {}", diag.docker_userland_proxy.as_str() @@ -474,8 +511,23 @@ fn print_docker_amnezia_port_path(diag: &docker::PortPathDiagnostics) { " published_udp_port: {} -> {}:{}", p.host_port, p.container_ip, p.container_port ), - None => println!(" published_udp_port: unknown"), + None => println!( + " published_udp_port: unknown ({})", + diag.published_udp_port_error + .as_deref() + .unwrap_or("no detail") + ), } + match diag.awg_listen_udp_port { + Some(port) => println!(" awg_listen_udp_port: {port}"), + None => println!( + " awg_listen_udp_port: unknown ({})", + diag.awg_listen_udp_port_error + .as_deref() + .unwrap_or("no detail") + ), + } + println!(" port_contract: {}", diag.port_contract()); println!( " host_forward_policy_drop: {}", diag.host_forward_policy_drop.as_str() @@ -490,12 +542,15 @@ fn print_docker_amnezia_port_path(diag: &docker::PortPathDiagnostics) { } } -/// Same block as [`print_docker_amnezia_port_path`], but every probe needs the container, -/// so all four fields are reported as unknown rather than omitted. -fn print_unknown_docker_amnezia_port_path() { +/// Same block as [`print_docker_amnezia_port_path`], but container-dependent probes are +/// reported as unknown rather than omitted. +fn print_unknown_docker_amnezia_port_path(expected_host_udp_port: u16) { println!("Docker/Amnezia port path:"); + println!(" expected_host_udp_port: {expected_host_udp_port}"); println!(" docker_userland_proxy: unknown"); println!(" published_udp_port: unknown"); + println!(" awg_listen_udp_port: unknown"); + println!(" port_contract: unknown"); println!(" host_forward_policy_drop: unknown"); println!(" docker_forward_rules_present: unknown"); println!("{FORWARD_OWNERSHIP_NOTE}"); @@ -542,30 +597,32 @@ fn format_bytes(v: u64) -> String { } } -fn collect_peer_filter_hits( - iface: &str, - ifb: &str, - transport: &dyn crate::netns::NsTransport, -) -> anyhow::Result> { - let egress = transport.run(&["tc", "-s", "filter", "show", "dev", iface, "parent", "1:"])?; - let ingress = transport.run(&["tc", "-s", "filter", "show", "dev", ifb, "parent", "2:"])?; - let egress_hits = traffic::parse_peer_filter_hits(&egress); - let ingress_hits = traffic::parse_peer_filter_hits(&ingress); - - let mut out: BTreeMap = BTreeMap::new(); - for (ip, c) in egress_hits { - out.entry(ip).or_insert((0, 0)).0 = c; - } - for (ip, c) in ingress_hits { - out.entry(ip).or_insert((0, 0)).1 = c; - } - Ok(out) -} - #[cfg(test)] mod tests { - use super::conntrack_utilization; - use crate::traffic; + use super::{collect_conntrack_status, conntrack_utilization}; + use crate::netns::NsTransport; + use std::sync::Mutex; + + struct SysctlTransport { + count: &'static str, + max: &'static str, + calls: Mutex>, + } + + impl NsTransport for SysctlTransport { + fn run(&self, args: &[&str]) -> anyhow::Result { + self.calls.lock().expect("calls").push(args.join(" ")); + match args.last().copied() { + Some("net.netfilter.nf_conntrack_count") => Ok(self.count.into()), + Some("net.netfilter.nf_conntrack_max") => Ok(self.max.into()), + _ => anyhow::bail!("unexpected command: {args:?}"), + } + } + + fn run_with_stdin(&self, _args: &[&str], _input: &str) -> anyhow::Result { + anyhow::bail!("unexpected stdin") + } + } #[test] fn conntrack_utilization_handles_valid_and_unavailable_values() { @@ -578,31 +635,48 @@ mod tests { } #[test] - fn parse_tc_filter_hits_supports_sent_and_success() { - let raw = r#" -filter parent 1: protocol ip pref 1 u32 chain 0 -filter parent 1: protocol ip pref 1 u32 chain 0 fh 800: ht divisor 1 -filter parent 1: protocol ip pref 1 u32 chain 0 fh 800::800 order 2048 key ht 800 bkt 0 flowid 1:10 not_in_hw - match 0a080102/ffffffff at 16 - Sent 100 bytes 7 pkt (dropped 0, overlimits 0 requeues 0) -filter parent 1: protocol ip pref 1 u32 chain 0 fh 800::801 order 2049 key ht 800 bkt 0 flowid 1:11 not_in_hw - match 0a080103/ffffffff at 16 success 9 -"#; + fn conntrack_count_comes_from_amnezia_namespace_but_max_from_host() { + let mut cfg = crate::firewall::tests::test_config(); + cfg.anti_torrent.enabled = true; + cfg.anti_torrent.apply_in_amnezia_netns = true; + let container = SysctlTransport { + count: "41\n", + max: "must-not-read", + calls: Mutex::new(Vec::new()), + }; + let host = SysctlTransport { + count: "must-not-read", + max: "262144\n", + calls: Mutex::new(Vec::new()), + }; - let map = traffic::parse_peer_filter_hits(raw); - assert_eq!(map.get("10.8.1.2"), Some(&7)); - assert_eq!(map.get("10.8.1.3"), Some(&9)); + let status = collect_conntrack_status(&cfg, Some(&container), &host); + assert_eq!(status.count_namespace, "amnezia"); + assert_eq!(status.count, "41"); + assert_eq!(status.max_global, "262144"); + assert_eq!( + container.calls.lock().expect("calls").as_slice(), + ["sysctl -n net.netfilter.nf_conntrack_count"] + ); + assert_eq!( + host.calls.lock().expect("calls").as_slice(), + ["sysctl -n net.netfilter.nf_conntrack_max"] + ); } #[test] - fn parse_tc_filter_hits_supports_flower() { - let raw = r#" -filter protocol ip pref 10 flower chain 0 -filter protocol ip pref 10 flower chain 0 handle 0x1 classid 1:10 - dst_ip 10.8.1.2/32 - Sent 2048 bytes 17 pkt (dropped 0, overlimits 0 requeues 0) -"#; - let map = traffic::parse_peer_filter_hits(raw); - assert_eq!(map.get("10.8.1.2"), Some(&17)); + fn missing_container_does_not_fall_back_to_the_wrong_host_count() { + let mut cfg = crate::firewall::tests::test_config(); + cfg.anti_torrent.enabled = true; + cfg.anti_torrent.apply_in_amnezia_netns = true; + let host = SysctlTransport { + count: "999", + max: "1000", + calls: Mutex::new(Vec::new()), + }; + let status = collect_conntrack_status(&cfg, None, &host); + assert_eq!(status.count, "n/a"); + assert_eq!(status.max_global, "1000"); + assert_eq!(host.calls.lock().expect("calls").len(), 1); } } diff --git a/traffic_core/src/sysctl_tuning.rs b/traffic_core/src/sysctl_tuning.rs index ec27d6e..95d48c7 100644 --- a/traffic_core/src/sysctl_tuning.rs +++ b/traffic_core/src/sysctl_tuning.rs @@ -502,6 +502,7 @@ mod tests { interface_candidates: vec!["awg0".into()], interface_patterns: vec!["awg*".into()], peer_ipv4_cidr: "10.8.1.0/24".into(), + expected_host_udp_port: 443, wg_binaries: vec!["awg".into(), "wg".into()], }, traffic: TrafficConfig { diff --git a/traffic_core/src/traffic/apply.rs b/traffic_core/src/traffic/apply.rs index 55eabb7..b1bf503 100644 --- a/traffic_core/src/traffic/apply.rs +++ b/traffic_core/src/traffic/apply.rs @@ -5,6 +5,7 @@ //! applied topology surfaces as an error instead of silently becoming the new state. use crate::{config::Config, netns::NsTransport, wg}; +use serde_json::Value; use super::{ PEER_FILTER_PREF, @@ -17,10 +18,64 @@ pub(super) fn teardown_owned_topology( iface: &str, ifb: &str, ) -> anyhow::Result<()> { - run_allow_notfound(transport, &["tc", "qdisc", "del", "dev", iface, "root"])?; - run_allow_notfound(transport, &["tc", "qdisc", "del", "dev", iface, "ingress"])?; - run_allow_notfound(transport, &["ip", "link", "del", ifb])?; - Ok(()) + teardown_owned_topology_inner(transport, iface, ifb, false) +} + +pub(super) const IFB_OWNERSHIP_ALIAS: &str = "awg2-harness:traffic"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum IfbOwnership { + Absent, + Owned, + Foreign(String), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct QdiscOwnership { + root_owned: bool, + ingress_owned: bool, +} + +fn teardown_owned_topology_inner( + transport: &dyn NsTransport, + iface: &str, + ifb: &str, + allow_unmarked_ifb_created_now: bool, +) -> anyhow::Result<()> { + // Inspect every target before deleting the first one. A foreign IFB/root is a hard + // conflict, not drift that may be repaired by destroying somebody else's state. + let qdiscs = inspect_qdisc_ownership(transport, iface)?; + let ifb_ownership = inspect_ifb_ownership(transport, ifb)?; + if let IfbOwnership::Foreign(reason) = &ifb_ownership + && !allow_unmarked_ifb_created_now + { + anyhow::bail!("refusing to delete unowned IFB {ifb}: {reason}"); + } + + let mut errors = Vec::new(); + if qdiscs.root_owned + && let Err(e) = run_allow_notfound(transport, &["tc", "qdisc", "del", "dev", iface, "root"]) + { + errors.push(format!("egress qdisc cleanup failed: {e}")); + } + if qdiscs.ingress_owned + && let Err(e) = + run_allow_notfound(transport, &["tc", "qdisc", "del", "dev", iface, "ingress"]) + { + errors.push(format!("ingress qdisc cleanup failed: {e}")); + } + if (matches!(ifb_ownership, IfbOwnership::Owned) + || (allow_unmarked_ifb_created_now && matches!(ifb_ownership, IfbOwnership::Foreign(_)))) + && let Err(e) = run_allow_notfound(transport, &["ip", "link", "del", "dev", ifb]) + { + errors.push(format!("IFB cleanup failed: {e}")); + } + + if errors.is_empty() { + Ok(()) + } else { + anyhow::bail!(errors.join("; ")) + } } pub(super) fn apply_full_topology( @@ -31,30 +86,125 @@ pub(super) fn apply_full_topology( peers: &[wg::Peer], ) -> anyhow::Result<()> { run_step(transport, &["modprobe", "ifb"])?; - run_step( - transport, - &["ip", "link", "add", "name", ifb, "type", "ifb"], - )?; - run_step(transport, &["ip", "link", "set", "dev", ifb, "up"])?; - let batch = build_tc_batch_script(cfg, iface, ifb, peers)?; - run_tc_batch(transport, &batch)?; - - let verify = inspect_and_diff( - transport, - iface, - ifb, - cfg.traffic.class_start_id, - &cfg.traffic.root_rate, - &cfg.traffic.peer_limit, - peers, - )?; - if verify.has_drift { - anyhow::bail!("verify-after-apply failed: {}", verify.reason); + match inspect_ifb_ownership(transport, ifb)? { + IfbOwnership::Absent => {} + IfbOwnership::Owned => { + anyhow::bail!("owned IFB {ifb} still exists before apply; teardown did not complete") + } + IfbOwnership::Foreign(reason) => { + anyhow::bail!("refusing to replace unowned IFB {ifb}: {reason}") + } } + let mut ifb_created = false; + let result = (|| -> anyhow::Result<()> { + run_step( + transport, + &["ip", "link", "add", "name", ifb, "type", "ifb"], + )?; + ifb_created = true; + run_step( + transport, + &[ + "ip", + "link", + "set", + "dev", + ifb, + "alias", + IFB_OWNERSHIP_ALIAS, + ], + )?; + run_step(transport, &["ip", "link", "set", "dev", ifb, "up"])?; + let batch = build_tc_batch_script(cfg, iface, ifb, peers)?; + run_tc_batch(transport, &batch)?; + + let verify = inspect_and_diff( + transport, + iface, + ifb, + cfg.traffic.class_start_id, + &cfg.traffic.root_rate, + &cfg.traffic.peer_limit, + peers, + )?; + if verify.has_drift { + anyhow::bail!("verify-after-apply failed: {}", verify.reason); + } + Ok(()) + })(); + + if let Err(primary) = result { + return match teardown_owned_topology_inner(transport, iface, ifb, ifb_created) { + Ok(()) => Err(primary), + Err(cleanup) => Err(anyhow::anyhow!( + "{primary}; cleanup after failed traffic apply also failed: {cleanup}" + )), + }; + } Ok(()) } +pub(super) fn inspect_ifb_ownership( + transport: &dyn NsTransport, + ifb: &str, +) -> anyhow::Result { + let raw = match transport.run(&["ip", "-details", "-json", "link", "show", "dev", ifb]) { + Ok(raw) => raw, + Err(e) if is_not_found_error(&e.to_string()) => return Ok(IfbOwnership::Absent), + Err(e) => return Err(anyhow::anyhow!("IFB ownership inspect failed: {e}")), + }; + let value: Value = serde_json::from_str(&raw) + .map_err(|e| anyhow::anyhow!("IFB ownership JSON is malformed: {e}"))?; + let link = value + .as_array() + .and_then(|v| v.first()) + .ok_or_else(|| anyhow::anyhow!("IFB ownership JSON contains no link"))?; + let kind = link + .get("linkinfo") + .and_then(|v| v.get("info_kind")) + .and_then(Value::as_str); + let alias = link.get("ifalias").and_then(Value::as_str); + if kind == Some("ifb") && alias == Some(IFB_OWNERSHIP_ALIAS) { + return Ok(IfbOwnership::Owned); + } + Ok(IfbOwnership::Foreign(format!( + "expected type=ifb alias={IFB_OWNERSHIP_ALIAS:?}, got type={kind:?} alias={alias:?}" + ))) +} + +fn inspect_qdisc_ownership( + transport: &dyn NsTransport, + iface: &str, +) -> anyhow::Result { + let raw = transport + .run(&["tc", "qdisc", "show", "dev", iface]) + .map_err(|e| anyhow::anyhow!("qdisc ownership inspect failed: {e}"))?; + let mut ownership = QdiscOwnership { + root_owned: false, + ingress_owned: false, + }; + for line in raw.lines().map(str::trim) { + if line.contains(" root") { + if line.contains("qdisc htb 1:") { + ownership.root_owned = true; + } else if !line.split_whitespace().any(|token| token == "0:") { + anyhow::bail!("refusing to replace foreign root qdisc on {iface}: {line}"); + } + } + if line.contains("qdisc ingress") { + if line.contains("qdisc ingress ffff:") { + ownership.ingress_owned = true; + } else { + anyhow::bail!("refusing to replace foreign ingress qdisc on {iface}: {line}"); + } + } else if line.contains("qdisc clsact") { + anyhow::bail!("refusing to replace foreign clsact qdisc on {iface}: {line}"); + } + } + Ok(ownership) +} + fn run_tc_batch(transport: &dyn NsTransport, batch: &str) -> anyhow::Result<()> { transport .run_with_stdin(&["tc", "-batch", "-"], batch) @@ -67,24 +217,18 @@ pub(super) fn empty_topology_drift_reason( iface: &str, ifb: &str, ) -> anyhow::Result> { - let qdiscs = transport - .run(&["tc", "qdisc", "show", "dev", iface]) + let qdiscs = inspect_qdisc_ownership(transport, iface) .map_err(|e| anyhow::anyhow!("empty topology qdisc inspect failed: {e}"))?; - if qdiscs.lines().any(|line| { - let line = line.trim(); - (line.contains("qdisc htb 1:") && line.contains(" root")) - || line.contains("qdisc ingress ffff:") - }) { + if qdiscs.root_owned || qdiscs.ingress_owned { return Ok(Some("owned tc topology exists without peers".to_string())); } - match transport.run(&["ip", "link", "show", ifb]) { - Ok(out) if !out.trim().is_empty() => { - Ok(Some(format!("owned IFB {ifb} exists without peers"))) - } - Ok(_) => Ok(None), - Err(e) if is_not_found_error(&e.to_string()) => Ok(None), - Err(e) => Err(anyhow::anyhow!("empty topology IFB inspect failed: {e}")), + match inspect_ifb_ownership(transport, ifb)? { + IfbOwnership::Absent => Ok(None), + IfbOwnership::Owned => Ok(Some(format!("owned IFB {ifb} exists without peers"))), + IfbOwnership::Foreign(reason) => Err(anyhow::anyhow!( + "unowned IFB {ifb} blocks traffic apply: {reason}" + )), } } @@ -236,7 +380,7 @@ mod tests { fn run(&self, args: &[&str]) -> anyhow::Result { match args { ["tc", "qdisc", "show", "dev", "awg0"] => Ok(self.qdiscs.to_string()), - ["ip", "link", "show", "ifb0"] => self + ["ip", "-details", "-json", "link", "show", "dev", "ifb0"] => self .ifb .as_ref() .map(|out| (*out).to_string()) @@ -339,7 +483,9 @@ mod tests { fn empty_peer_state_detects_leftover_ifb_for_cleanup() { let transport = EmptyTopologyTransport { qdiscs: "qdisc noqueue 0: root refcnt 2", - ifb: Ok("7: ifb0: mtu 1500"), + ifb: Ok( + r#"[{"ifname":"ifb0","ifalias":"awg2-harness:traffic","linkinfo":{"info_kind":"ifb"}}]"#, + ), }; let reason = @@ -349,4 +495,28 @@ mod tests { Some("owned IFB ifb0 exists without peers") ); } + + #[test] + fn empty_peer_state_refuses_an_unmarked_ifb() { + let transport = EmptyTopologyTransport { + qdiscs: "qdisc noqueue 0: root refcnt 2", + ifb: Ok(r#"[{"ifname":"ifb0","linkinfo":{"info_kind":"ifb"}}]"#), + }; + + let error = empty_topology_drift_reason(&transport, "awg0", "ifb0") + .expect_err("foreign IFB must be protected"); + assert!(error.to_string().contains("unowned IFB ifb0")); + } + + #[test] + fn empty_peer_state_refuses_a_foreign_root_qdisc() { + let transport = EmptyTopologyTransport { + qdiscs: "qdisc cake 8001: root refcnt 2 bandwidth 1Gbit", + ifb: Err(anyhow::anyhow!("IFB must not be inspected")), + }; + + let error = empty_topology_drift_reason(&transport, "awg0", "ifb0") + .expect_err("foreign qdisc must be protected"); + assert!(error.to_string().contains("foreign root qdisc")); + } } diff --git a/traffic_core/src/traffic/inspect.rs b/traffic_core/src/traffic/inspect.rs index 79e8de9..464f784 100644 --- a/traffic_core/src/traffic/inspect.rs +++ b/traffic_core/src/traffic/inspect.rs @@ -6,14 +6,15 @@ use std::collections::{BTreeMap, BTreeSet}; -use crate::{netns::NsTransport, wg}; +use crate::{config::MAX_TEXTUAL_CLASS_ID, netns::NsTransport, wg}; use super::{ FilterClassifier, FilterDirection, FilterInspect, PEER_FILTER_PREF, TrafficDrift, + apply::{IfbOwnership, inspect_ifb_ownership}, parse_tc::{ collect_leaf_class_ids, extract_filter_ip_class_map, has_fq_codel_for_parent, - has_ingress_redirect_to_ifb, is_not_found_error, link_is_up, normalize_tc_rate, - parse_class_rate_ceil_map, parse_root_class_rate_ceil, rate_ceil_matches_limit, + has_ingress_redirect_to_ifb, link_is_up, normalize_tc_rate, parse_class_rate_ceil_map, + parse_root_class_rate_ceil, rate_ceil_matches_limit, }, }; @@ -58,22 +59,22 @@ pub(super) fn inspect_and_diff( }); } - let ifb_link = match transport.run(&["ip", "link", "show", ifb]) { - Ok(out) => out, - Err(e) if is_not_found_error(&e.to_string()) => { + match inspect_ifb_ownership(transport, ifb)? { + IfbOwnership::Absent => { return Ok(TrafficDrift { has_drift: true, reason: "missing ifb interface".to_string(), }); } - Err(e) => return Err(anyhow::anyhow!("ifb link inspect failed: {e}")), - }; - if ifb_link.trim().is_empty() { - return Ok(TrafficDrift { - has_drift: true, - reason: "missing ifb interface".to_string(), - }); + IfbOwnership::Foreign(reason) => { + return Ok(TrafficDrift { + has_drift: true, + reason: format!("unowned ifb interface: {reason}"), + }); + } + IfbOwnership::Owned => {} } + let ifb_link = inspect_cmd(transport, &["ip", "link", "show", ifb], "ifb link state")?; if !link_is_up(&ifb_link) { return Ok(TrafficDrift { has_drift: true, @@ -349,6 +350,11 @@ pub(super) fn expected_peer_class_map( let class_id = class_start_id .checked_add(idx_u16) .ok_or_else(|| anyhow::anyhow!("class id overflow: too many peers"))?; + if class_id > MAX_TEXTUAL_CLASS_ID { + anyhow::bail!( + "class id {class_id} exceeds textual tc limit {MAX_TEXTUAL_CLASS_ID}; reduce traffic.class_start_id or peer count" + ); + } map.insert(p.ipv4.to_string(), class_id); } Ok(map) @@ -381,6 +387,9 @@ mod tests { "tc filter show dev awg0 parent ffff:" => { "filter parent ffff: protocol all pref 49152 u32\n action order 1: mirred egress redirect dev ifb0\n" } + "ip -details -json link show dev ifb0" => { + r#"[{"ifname":"ifb0","ifalias":"awg2-harness:traffic","linkinfo":{"info_kind":"ifb"}}]"# + } "ip link show ifb0" => "2: ifb0: mtu 1500 state UP\n", "tc filter show dev awg0 parent 1:" => { "filter protocol ip pref 10 flower chain 0\nfilter protocol ip pref 10 flower chain 0 handle 0x1 classid 1:10\n dst_ip 10.8.1.2/32\n" @@ -430,10 +439,18 @@ mod tests { } #[test] - fn class_id_overflow_is_reported() { + fn class_id_outside_textual_range_is_reported() { let err = expected_peer_class_map(&peers(&["10.8.1.2", "10.8.1.3"]), u16::MAX) - .expect_err("overflow"); - assert!(err.to_string().contains("class id overflow")); + .expect_err("out of range"); + assert!(err.to_string().contains("textual tc limit")); + } + + #[test] + fn textual_class_id_boundary_is_enforced_for_actual_peers() { + assert!(expected_peer_class_map(&peers(&["10.8.1.2"]), 9999).is_ok()); + let err = expected_peer_class_map(&peers(&["10.8.1.2", "10.8.1.3"]), 9999) + .expect_err("second textual ID must exceed four digits"); + assert!(err.to_string().contains("textual tc limit 9999")); } #[test] @@ -457,7 +474,7 @@ mod tests { assert!(!drift.has_drift, "{}", drift.reason); let calls = transport.calls.lock().expect("calls lock"); - assert_eq!(calls.len(), 8); + assert_eq!(calls.len(), 9); assert_eq!( calls .iter() diff --git a/traffic_core/src/traffic/mod.rs b/traffic_core/src/traffic/mod.rs index f1e4f35..bc04e63 100644 --- a/traffic_core/src/traffic/mod.rs +++ b/traffic_core/src/traffic/mod.rs @@ -63,7 +63,6 @@ struct ParsedPeerFilter { classifier: FilterClassifier, direction: FilterDirection, pref: Option, - packets: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -82,6 +81,8 @@ pub struct PeerTrafficStatus { pub egress_ceil: Option, pub ingress_rate: Option, pub ingress_ceil: Option, + pub egress_packets: Option, + pub ingress_packets: Option, pub drift_reason: Option, } @@ -221,7 +222,7 @@ pub(crate) fn peer_status_table_with_state( "parent", "1:", ]) - .unwrap_or_default(); + .map_err(|e| anyhow::anyhow!("egress filter status failed: {e}"))?; let filters_ingress = transport .run(&[ "tc", @@ -232,13 +233,13 @@ pub(crate) fn peer_status_table_with_state( "parent", "2:", ]) - .unwrap_or_default(); + .map_err(|e| anyhow::anyhow!("ingress filter status failed: {e}"))?; let classes_egress_full = transport - .run(&["tc", "class", "show", "dev", &target.interface_name]) - .unwrap_or_default(); + .run(&["tc", "-s", "class", "show", "dev", &target.interface_name]) + .map_err(|e| anyhow::anyhow!("egress class status failed: {e}"))?; let classes_ingress_full = transport - .run(&["tc", "class", "show", "dev", &cfg.traffic.ifb_name]) - .unwrap_or_default(); + .run(&["tc", "-s", "class", "show", "dev", &cfg.traffic.ifb_name]) + .map_err(|e| anyhow::anyhow!("ingress class status failed: {e}"))?; let inspect_egress = extract_filter_ip_class_map(&filters_egress); let inspect_ingress = extract_filter_ip_class_map(&filters_ingress); @@ -246,6 +247,8 @@ pub(crate) fn peer_status_table_with_state( let actual_map_ingress = inspect_ingress.map; let egress_rates = parse_class_rate_ceil_map(&classes_egress_full, "1:"); let ingress_rates = parse_class_rate_ceil_map(&classes_ingress_full, "2:"); + let egress_packets = parse_tc::parse_class_packet_map(&classes_egress_full, "1:"); + let ingress_packets = parse_tc::parse_class_packet_map(&classes_ingress_full, "2:"); let expected_map = expected_peer_class_map(peers, cfg.traffic.class_start_id)?; let expected_limit = normalize_tc_rate(&cfg.traffic.peer_limit); @@ -316,6 +319,8 @@ pub(crate) fn peer_status_table_with_state( egress_ceil: e.map(|v| v.ceil.clone()), ingress_rate: i.map(|v| v.rate.clone()), ingress_ceil: i.map(|v| v.ceil.clone()), + egress_packets: egress_packets.get(&class_egress).copied(), + ingress_packets: ingress_packets.get(&class_ingress).copied(), drift_reason: if reasons.is_empty() { None } else { @@ -333,7 +338,3 @@ fn peer_filter_contract_matches(filter: &ParsedPeerFilter, direction: FilterDire && filter.direction == direction && filter.prefix == 32 } - -pub(crate) fn parse_peer_filter_hits(raw: &str) -> BTreeMap { - parse_tc::parse_peer_filter_hits(raw) -} diff --git a/traffic_core/src/traffic/parse_tc.rs b/traffic_core/src/traffic/parse_tc.rs index bb16206..1df835c 100644 --- a/traffic_core/src/traffic/parse_tc.rs +++ b/traffic_core/src/traffic/parse_tc.rs @@ -50,14 +50,6 @@ pub(super) fn extract_filter_ip_class_map(filters: &str) -> FilterInspect { } } -pub(super) fn parse_peer_filter_hits(filters: &str) -> BTreeMap { - extract_filter_ip_class_map(filters) - .filters - .into_iter() - .filter_map(|filter| filter.packets.map(|packets| (filter.ip, packets))) - .collect() -} - fn parse_peer_filter_block(block: &str) -> Option { let lower = block.to_ascii_lowercase(); let classifier = if lower.split_whitespace().any(|token| token == "flower") { @@ -69,7 +61,6 @@ fn parse_peer_filter_block(block: &str) -> Option { }; let pref = extract_token_u16(block, "pref"); let class_id = extract_class_id(block)?; - let packets = extract_packet_count(block); let (ip, prefix, direction) = match classifier { FilterClassifier::Flower => extract_flower_ip(block)?, @@ -83,7 +74,6 @@ fn parse_peer_filter_block(block: &str) -> Option { classifier, direction, pref, - packets, }) } @@ -190,20 +180,30 @@ fn extract_token_u16(block: &str, key: &str) -> Option { None } -fn extract_packet_count(block: &str) -> Option { - for line in block.lines() { +pub(super) fn parse_class_packet_map(raw: &str, prefix: &str) -> BTreeMap { + let mut out = BTreeMap::new(); + let mut current_class: Option = None; + for line in raw.lines() { let tokens: Vec<&str> = line.split_whitespace().collect(); + if tokens.first() == Some(&"class") && tokens.get(1) == Some(&"htb") { + current_class = tokens + .get(2) + .filter(|class_id| class_id.starts_with(prefix)) + .map(|class_id| (*class_id).to_string()); + continue; + } if tokens.first() == Some(&"Sent") && tokens.get(2) == Some(&"bytes") && tokens.get(4) == Some(&"pkt") + && let (Some(class_id), Some(packets)) = ( + current_class.as_ref(), + tokens.get(3).and_then(|value| value.parse::().ok()), + ) { - return tokens.get(3)?.parse::().ok(); - } - if let Some(pos) = tokens.iter().position(|token| *token == "success") { - return tokens.get(pos + 1)?.parse::().ok(); + out.insert(class_id.clone(), packets); } } - None + out } pub(super) fn has_fq_codel_for_parent(raw_qdisc: &str, parent_class: &str) -> bool { @@ -432,17 +432,15 @@ mod tests { } #[test] - fn parse_debian13_flower_filters_and_statistics() { + fn parse_debian13_flower_filters_without_assuming_action_statistics() { let raw = r#" filter protocol ip pref 10 flower chain 0 filter protocol ip pref 10 flower chain 0 handle 0x1 classid 1:10 dst_ip 10.8.1.2/32 not_in_hw - Sent 1200 bytes 12 pkt (dropped 0, overlimits 0 requeues 0) filter protocol ip pref 10 flower chain 0 handle 0x2 classid 1:11 dst_ip 10.8.1.3 not_in_hw - Sent 900 bytes 9 pkt (dropped 0, overlimits 0 requeues 0) "#; let inspect = extract_filter_ip_class_map(raw); assert_eq!(inspect.unrecognized_rules, 0); @@ -454,7 +452,24 @@ filter protocol ip pref 10 flower chain 0 handle 0x2 classid 1:11 && filter.pref == Some(10) && filter.prefix == 32 })); - assert_eq!(parse_peer_filter_hits(raw).get("10.8.1.2"), Some(&12)); + } + + #[test] + fn parse_debian13_htb_class_packet_statistics() { + let raw = r#" +class htb 1:1 root rate 500Mbit ceil 500Mbit burst 1600b cburst 1600b + Sent 9999 bytes 99 pkt (dropped 0, overlimits 0 requeues 0) +class htb 1:10 parent 1:1 leaf 8001: prio 0 rate 50Mbit ceil 50Mbit + Sent 2048 bytes 17 pkt (dropped 0, overlimits 0 requeues 0) + rate 0bit 0pps backlog 0b 0p requeues 0 +class htb 1:11 parent 1:1 leaf 8002: prio 0 rate 50Mbit ceil 50Mbit + Sent 4096 bytes 23 pkt (dropped 0, overlimits 0 requeues 0) +"#; + let packets = parse_class_packet_map(raw, "1:"); + assert_eq!(packets.get("1:1"), Some(&99)); + assert_eq!(packets.get("1:10"), Some(&17)); + assert_eq!(packets.get("1:11"), Some(&23)); + assert!(parse_class_packet_map(raw, "2:").is_empty()); } #[test] diff --git a/traffic_core/src/wg.rs b/traffic_core/src/wg.rs index 5529796..14a34c9 100644 --- a/traffic_core/src/wg.rs +++ b/traffic_core/src/wg.rs @@ -77,6 +77,30 @@ pub fn discover_peer_runtime( ) } +pub fn discover_listen_port(cfg: &Config, target: &RuntimeTarget) -> anyhow::Result { + discover_listen_port_with_transport( + &container_transport(target), + &cfg.amnezia.wg_binaries, + &target.interface_name, + ) +} + +fn discover_listen_port_with_transport( + transport: &dyn NsTransport, + binaries: &[String], + interface: &str, +) -> anyhow::Result { + let raw = run_wg(transport, binaries, &["show", interface, "listen-port"])?; + let port = raw + .trim() + .parse::() + .map_err(|_| anyhow::anyhow!("awg/wg returned an invalid listen port: {raw:?}"))?; + if port == 0 { + anyhow::bail!("awg/wg returned listen port 0"); + } + Ok(port) +} + pub fn discover_peer_runtime_with_transport( transport: &dyn NsTransport, binaries: &[String], @@ -663,4 +687,39 @@ mod tests { discover_peer_runtime_with_transport(&transport, &[], "10.8.1.0/24").expect_err("err"); assert!(err.to_string().contains("amnezia.wg_binaries is empty")); } + + #[test] + fn listen_port_uses_the_selected_interface_and_primary_binary() { + let transport = TestTransport::new(vec![("awg show awg0 listen-port", Ok("443\n"))]); + assert_eq!( + discover_listen_port_with_transport(&transport, &binaries(), "awg0") + .expect("listen port"), + 443 + ); + } + + #[test] + fn listen_port_falls_back_to_wg_and_rejects_zero() { + let transport = TestTransport::new(vec![ + ( + "awg show awg0 listen-port", + Err(anyhow::anyhow!("awg missing")), + ), + ("wg show awg0 listen-port", Ok("0")), + ]); + let error = discover_listen_port_with_transport(&transport, &binaries(), "awg0") + .expect_err("zero must fail"); + assert!(error.to_string().contains("listen port 0")); + } + + #[test] + fn listen_port_rejects_non_numeric_output() { + let transport = TestTransport::new(vec![( + "awg show awg0 listen-port", + Ok("listening port: 443"), + )]); + let error = discover_listen_port_with_transport(&transport, &binaries(), "awg0") + .expect_err("invalid output"); + assert!(error.to_string().contains("invalid listen port")); + } }