fix: harden firewall and traffic reconciliation

This commit is contained in:
2026-08-28 05:56:40 +05:00
parent 9787cb2d23
commit 9dd4732655
23 changed files with 1681 additions and 524 deletions
+19 -10
View File
@@ -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:<id>` с `traffic.peer_limit`.
@@ -49,6 +50,7 @@
- [ ] Для каждого peer есть ingress filter `flower pref 10 src_ip peer_ip/32 -> 2:<id>`.
- [ ] Все 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_<version>_amd64.deb` не устанавливает и не заменяет Docker.
- [ ] `apt install ./awg2-harness_<version>_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 <container> awg show <interface> 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` не падает при недоступности одной из команд.