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` не падает при недоступности одной из команд.
+6 -4
View File
@@ -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. Критерии приёмки
+13 -18
View File
@@ -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 на ключ `"<amnezia_port>/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 на ключ `"<expected_host_udp_port>/udp"` как container port, затем (если UDP binding ровно один) берётся он. Внутри контейнера выполняется `awg show <interface> 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` — включает плановую перезагрузку.
+28 -17
View File
@@ -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 <ifb_name> type ifb
ip link set dev <ifb_name> alias awg2-harness:traffic
ip link set dev <ifb_name> up
tc qdisc add dev <iface> handle ffff: ingress
tc filter add dev <iface> parent ffff: protocol all u32 match u32 0 0 action mirred egress redirect dev <ifb_name>
@@ -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.
+4 -2
View File
@@ -224,7 +224,7 @@ scp -P 2323 config.example.toml root@<server-ip>:/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:
+11
View File
@@ -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