fix: harden production topology and runtime state

This commit is contained in:
2026-08-28 15:19:12 +05:00
parent 9dd4732655
commit 0c32c9abbe
26 changed files with 3276 additions and 2815 deletions
+14 -4
View File
@@ -25,9 +25,11 @@
- [ ] `awg2-harness status` показывает PID/netns контейнера.
- [ ] `awg2-harness status` показывает найденный интерфейс.
- [ ] Количество peers совпадает с `docker exec "$C" awg show all allowed-ips`.
- [ ] Peer с несколькими allowed IP даёт отдельный класс на каждый адрес (в этой команде адреса разделены пробелом).
- [ ] Каждый peer имеет ровно один server-side IPv4 `/32` внутри `amnezia.peer_ipv4_cidr`; `(none)` допустим и не создаёт класс.
- [ ] IPv4+IPv6, IPv6-only, несколько allowed IP, маска не `/32` и адрес вне configured CIDR дают явную `unsupported topology` до изменения traffic/anti-torrent state.
- [ ] Одинаковый отказ проверен для `show all allowed-ips`, fallback `show` и `show all dump`; AWG 3.1 interface-row не ошибочно принимается за peer.
- [ ] Offline peers с allowed IP тоже попадают в список shaping.
- [ ] IP вне `amnezia.peer_ipv4_cidr` не попадают в traffic shaping.
- [ ] IP вне `amnezia.peer_ipv4_cidr` не игнорируется молча: весь topology reconcile завершается ошибкой.
- [ ] В секции `WireGuard clients` вывода `status` нет строки, соответствующей самому интерфейсу (столбец `Public key` не содержит приватный ключ устройства).
- [ ] `docker exec "$C" readlink -f /usr/bin/wg` возвращает `/usr/bin/awg`.
@@ -150,7 +152,7 @@
- [ ] `awg2-harness apply-network-tuning` успешно завершается.
- [ ] `awg2-harness inspect-network-tuning` показывает `enabled: true` и `drift: no`.
- [ ] Создаётся `/etc/sysctl.d/90-awg2-harness-vpn.conf`.
- [ ] `/etc/modules-load.d/90-awg2-harness-bbr.conf` создан, если BBR активен, и отсутствует, если BBR неактивен.
- [ ] `/etc/modules-load.d/90-awg2-harness-bbr.conf` создан, если BBR желателен по configured policy и доступен, и отсутствует при `tcp_bbr = off`/недоступном `auto`.
- [ ] `/etc/sysctl.conf` не изменён.
- [ ] В обоих managed-файлах первая строка содержит marker `Managed by awg2-harness sysctl_tuning`.
- [ ] Файл без marker по managed-пути не перезаписывается и не удаляется: команда завершается ошибкой.
@@ -158,7 +160,10 @@
- [ ] В shipped/default config `netdev_backlog_enabled = false`, `netdev_max_backlog = 10000`, а `net.core.netdev_max_backlog` отсутствует в managed-файле.
- [ ] При смене `netdev_backlog_enabled = true` на `false` key удаляется из persistence; оператор учитывает, что текущее runtime-значение до reboot автоматически не сбрасывается.
- [ ] До/после ручного включения backlog сняты `/proc/net/softnet_stat`, CPU и latency под реальной нагрузкой.
- [ ] `net.ipv4.tcp_congestion_control` и `net.core.default_qdisc` присутствуют в managed-файле только при активном BBR.
- [ ] В shipped/default config `tcp_bbr = off`, `default_qdisc = fq`, `udp_rmem_min_enabled = false`; `default_qdisc = fq_codel` отвергается валидацией.
- [ ] `net.ipv4.tcp_congestion_control` и `net.core.default_qdisc` присутствуют в managed-файле только когда BBR желателен по policy.
- [ ] Status отдельно показывает `bbr_available`, `bbr_desired` и фактический `bbr_runtime_active`; при available BBR и runtime `cubic` последнее поле равно `no`.
- [ ] При `tcp_bbr = off` уже активный runtime BBR не выдаётся за desired и не маскируется как неактивный.
- [ ] `swap` пишет `vm.swappiness` в `/etc/sysctl.d/90-awg2-harness-swap.conf`, а не в `99-awg2-harness.conf`.
- [ ] При обновлении с прежней версии `/etc/sysctl.d/99-awg2-harness.conf` удаляется, если несёт маркер `# Managed by awg2-harness`.
- [ ] Файл по пути `99-awg2-harness.conf` без этого маркера остаётся нетронутым и не ломает `ensure-swap`.
@@ -181,6 +186,7 @@
- [ ] Runtime apply меняет только drifted keys (`sysctl -w`), без `sysctl --system`.
- [ ] При `tcp_bbr = auto` и недоступном BBR сервис не падает, в статусе есть warning.
- [ ] При `tcp_bbr = require` и недоступном BBR команда/старт завершается с ошибкой.
- [ ] Sustained per-peer cap проверен в двух конфигурациях, BBR-off и BBR-on; HTB `rate == ceil` даёт одинаковый лимит.
- [ ] При `enabled=false` managed-файл удаляется только если содержит managed marker.
## I. Anti-torrent guard на nftables
@@ -260,10 +266,14 @@
- [ ] Повторный `apply-firewall` идемпотентен и не вносит лишних изменений в healthy state.
- [ ] Legacy fields `host_firewall.amnezia_port`/`amnezia_protocols` отвергаются как `unknown field`.
- [ ] Новая конфигурация строится в inactive A/B chain до switch; искусственный сбой `ip6tables` после IPv4 switch возвращает IPv4 на прежнюю цепочку.
- [ ] Managed jumps переключаются только через insert-new + exact-spec delete; numeric `-R INPUT <n>`/`-D INPUT <n>` не используются.
- [ ] Чужое правило, конкурентно вставленное первым в `INPUT`, не заменяется и не удаляется; rollback удаляет только новый managed jump.
- [ ] После `docker restart` anti-torrent правила продолжают восстанавливаться корректно и не ломаются host firewall логикой.
- [ ] Проверено, где реально проходит VPN-трафик в вашей Docker-схеме (`INPUT` vs `FORWARD`) и это отражено в эксплуатационной заметке.
- [ ] Для release-кандидата зафиксирован итог проверки `INPUT` vs `FORWARD` на реальном сервере (команды/логи/вывод) и подтверждено, что текущие правила firewall покрывают реальный путь трафика.
- [ ] `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`.
- [ ] В том же блоке перечислены все UDP `host_bindings`, включая одновременно `0.0.0.0:<port>` и `[::]:<port>`; IPv6 выводится в квадратных скобках.
- [ ] Docker routed binding с пустым `HostPort` сохраняется в диагностике и не превращается в выдуманный numeric port.
- [ ] `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` автоматически.
- [ ] Сверено соответствие: `amnezia.expected_host_udp_port` ↔ опубликованный Docker UDP host port ↔ `docker exec <container> awg show <interface> listen-port`; `port_contract: ok`.
+11 -5
View File
@@ -55,13 +55,13 @@ ifb_name = "ifb0"
enabled = true
apply_on_daemon_start = true
ip_forward = true
tcp_bbr = "auto" # off | auto | require
default_qdisc = "fq" # none | fq | fq_codel
tcp_bbr = "off" # off | auto | require
default_qdisc = "fq" # none | fq
rmem_max = 67108864
wmem_max = 67108864
netdev_backlog_enabled = false
netdev_max_backlog = 10000
udp_rmem_min_enabled = true
udp_rmem_min_enabled = false
udp_rmem_min = 8192
[host_firewall]
@@ -103,6 +103,8 @@ swappiness = 1
`amnezia.peer_ipv4_cidr` — подсеть VPN peers. Production default — `10.8.1.0/24`; значение конфигурируемое.
Поддерживаемая server-side topology строго соответствует официальной self-hosted схеме Amnezia/AWG 3.1: у каждого peer либо нет маршрута (`(none)`), либо есть ровно один IPv4 host route `/32` внутри `amnezia.peer_ipv4_cidr`. IPv6, смешанные IPv4+IPv6, несколько маршрутов, более широкая маска и адрес вне подсети дают явную ошибку `unsupported topology` до изменения `tc` и anti-torrent state. Клиентские `0.0.0.0/0, ::/0` и внешний IPv6 Docker publish относятся к другим плоскостям и этим ограничением не запрещаются.
`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`.
@@ -121,15 +123,19 @@ swappiness = 1
- persistent-файл: `/etc/sysctl.d/90-awg2-harness-vpn.conf`;
- модуль `swap` отдельно управляет `/etc/sysctl.d/90-awg2-harness-swap.conf` (только `vm.swappiness`); прежний путь `/etc/sysctl.d/99-awg2-harness.conf` удаляется при первом запуске новой версии, если несёт старый маркер;
- дополнительный managed-файл `/etc/modules-load.d/90-awg2-harness-bbr.conf` создаётся только при фактически активном BBR и удаляется, когда BBR неактивен;
- дополнительный managed-файл `/etc/modules-load.d/90-awg2-harness-bbr.conf` создаётся только когда BBR выбран desired policy (`auto`/`require` и BBR доступен), и удаляется при `tcp_bbr = "off"` или недоступном `auto`;
- оба managed-файла несут в первой строке маркер `# Managed by awg2-harness sysctl_tuning`; файл без маркера не перезаписывается и не удаляется, вместо этого возвращается ошибка;
- `net.ipv4.tcp_congestion_control` и `net.core.default_qdisc` управляются только при активном BBR;
- `net.ipv4.tcp_congestion_control` и `net.core.default_qdisc` управляются только когда BBR желателен по policy; `default_qdisc` принимает только `none | fq` и не управляет per-peer `fq_codel`;
- runtime apply: только `sysctl -w` и только для drifted keys;
- на установке `.deb` модуль не применяется;
- на старте daemon модуль применяется, если `enabled=true` и `apply_on_daemon_start=true`;
- `tcp_bbr = auto` не валит запуск при недоступном BBR (warning), `require` — валит;
- для `sysctl_tuning`: `modprobe` обязателен только для `tcp_bbr = "require"`.
Production default — `tcp_bbr = "off"`: BBR влияет на локальные TCP sockets хоста, но не ускоряет UDP transport AWG и не является частью HTB peer limiter. `auto`/`require` остаются осознанным opt-in. Status разделяет три разных факта: `bbr_available`, `bbr_desired` и фактический `bbr_runtime_active`, вычисленный по текущему `net.ipv4.tcp_congestion_control`.
`udp_rmem_min_enabled = false` — production default, потому что `net.ipv4.udp_rmem_min` является per-network-namespace sysctl: запись на хосте не меняет UDP stack AWG-контейнера. Значение `udp_rmem_min = 8192` сохранено как opt-in для локальных UDP sockets хоста; отдельный container-side reconcile ради него не добавляется.
`netdev_backlog_enabled = false` — безопасный production default: harness не управляет `net.core.netdev_max_backlog`, пока оператор не включит это явно после измерений. Значение `netdev_max_backlog = 10000` используется только при `netdev_backlog_enabled = true`.
При обновлении с конфига, где backlog раньше был включён, переключение флага в `false` удаляет key из managed-файла, но не перезаписывает уже изменённое runtime-значение. До следующей перезагрузки его можно вернуть вручную к baseline дистрибутива. Перед осознанным включением backlog снимите pre/post-метрики `/proc/net/softnet_stat` (в том числе dropped/time_squeeze) и CPU/latency под реальной нагрузкой.
+28 -11
View File
@@ -127,7 +127,7 @@ awg2-harness validate-runtime
При `swap.enabled = false` управляемый файл удаляется. **Остаются на месте**: сам swapfile, его активность в ядре и запись в `/etc/fstab` — то есть после перезагрузки swap снова включится. Удаляется только persistence `vm.swappiness`. Runtime-значение `vm.swappiness` при этом намеренно не меняется: назначать произвольное значение означало бы выдумать политику, о которой оператор не просил. После перезагрузки действует kernel default. Активный swap при отключении модуля тоже не выключается — деструктивное действие не должно запускаться самим фактом отключения функции.
`net.ipv4.tcp_congestion_control` и `net.core.default_qdisc` попадают в целевое состояние только при активном BBR.
`net.ipv4.tcp_congestion_control` и `net.core.default_qdisc` попадают в целевое состояние только когда BBR желателен по policy. Фактическая активность диагностируется отдельно по runtime `net.ipv4.tcp_congestion_control`.
## 3. Структура модулей
@@ -152,7 +152,15 @@ traffic_core/src/
│ ├── host.rs owned iptables/ip6tables INPUT chains (host namespace)
│ └── antitorrent.rs таблица inet awg2_harness (namespace по конфигу)
├── sysctl_tuning.rs управляемый sysctl baseline
├── swap.rs управляемый swap
├── swap/
│ ├── mod.rs facade и orchestration ensure_swap
│ ├── model.rs модели desired/runtime/status и replacement state
│ ├── path.rs syntax/path-chain safety и state directory
│ ├── ownership.rs ownership record и legacy sidecar
│ ├── runtime.rs swapon/swapoff/mkswap и parser runtime state
│ ├── replacement.rs создание, resize и rollback транзакции
│ ├── persistence.rs /etc/fstab, swappiness и legacy migration
│ └── status.rs inspect_swap и drift aggregation
├── status.rs сборка и печать статуса
├── lock.rs файловые блокировки
└── error.rs типы ошибок
@@ -273,11 +281,13 @@ struct PeerTrafficStatus {
awg show all allowed-ips
```
Правила разбора:
Правила разбора и общий `PeerTopology` contract:
- строки разбираются структурно на public key peer и его allowed IPs;
- в этой команде allowed ips одного peer разделены **пробелом**, в `show all dump` — запятой, в обычном `show` — `", "`; нужно потреблять все токены начиная с третьей колонки, иначе peers с несколькими allowed IP получат shaping только по первому адресу;
- оставляются только IPv4 `/32` внутри `amnezia.peer_ipv4_cidr`;
- в этой команде allowed ips разделены **пробелом**, в `show all dump` — запятой, в обычном `show` — `", "`; parser обязан проверить весь список, а не молча взять первый адрес;
- поддерживается ровно один IPv4 `/32` внутри `amnezia.peer_ipv4_cidr` на peer; `(none)` допустим и не создаёт peer class;
- IPv6-only, IPv4+IPv6, несколько allowed IPs, маска не `/32`, невалидный CIDR или IPv4 вне configured peer CIDR возвращают typed `UnsupportedTopology`;
- topology проверяется один раз на startup/periodic/event cycle и передаётся traffic и anti-torrent. При ошибке ни одна из этих подсистем не начинает mutation и не сообщает успешный reconcile;
- offline peers включаются: online-состояние не является требованием для shaping;
- перед присвоением class id строки peer IP сортируются лексикографически, чтобы сохранить действующий контракт class-id.
@@ -287,7 +297,7 @@ awg show all allowed-ips
awg show
```
Fallback-парсер разбирает текстовый вывод, извлекая строки `allowed ips:`. Он используется, если основная команда завершилась ошибкой или вернула пустой результат.
Fallback-парсер разбирает текстовый вывод, извлекая строки `allowed ips:`. Он используется, если основная команда завершилась ошибкой или вернула пустой результат, и применяет тот же строгий topology contract.
Классификация строк `show all dump`:
@@ -365,7 +375,7 @@ apply_firewall():
apply_antitorrent_firewall(config) # namespace по anti_torrent.apply_in_amnezia_netns
```
Каждый из firewall-путей сначала инспектирует фактическое состояние и выходит без изменений, если drift отсутствует. Anti-torrent таблица заменяется одной атомарной nft-транзакцией. Host firewall сначала строит и проверяет inactive chain отдельно для IPv4/IPv6, затем переключает помеченный jump; сбой IPv6 после IPv4 switch откатывает IPv4. После apply выполняется обязательная повторная инспекция.
Каждый из firewall-путей сначала инспектирует фактическое состояние и выходит без изменений, если drift отсутствует. Anti-torrent таблица заменяется одной атомарной nft-транзакцией и при включённом guard требует успешно проверенный `PeerTopology`; host firewall от server-side peer topology не зависит. Host firewall сначала строит и проверяет inactive chain отдельно для IPv4/IPv6. Новый jump всегда вставляется через `-I INPUT 1`; старый остаётся вторым до успешного переключения обеих IP-family и затем удаляется по полной rule specification. Numeric `-R/-D` для managed jumps не используется. Сбой IPv6 удаляет новый IPv4 jump по точной specification, поэтому старый IPv4 jump всё время остаётся рабочим. После apply выполняется обязательная повторная инспекция.
Для production контролируемая полная пересборка при drift — единственный поддерживаемый путь.
@@ -407,6 +417,8 @@ apply_firewall():
Host firewall не открывает published Amnezia UDP port в `INPUT`: Docker bridge publish проходит через `FORWARD`/NAT. Собственная chain разрешает loopback, established/related, configured SSH и optional ICMP/ICMPv6, затем завершает обработку configured `DROP`/`ACCEPT` verdict. Поле `amnezia.expected_host_udp_port` относится только к диагностике port path.
Жизненный цикл managed jump использует только exact rule spec с comment `awg2-harness:host-firewall` и target `AWG2H_A`/`AWG2H_B`. Чужое правило, вставленное другим firewall manager между inspect и switch, не может быть заменено или удалено по изменившемуся номеру строки.
## 11. Anti-torrent firewall guard
Это часть production-контракта AWG2. Модуль вписывается в мульти-оркестраторную модель, потому что сервер выделенный и бинарю разрешено владеть host/network правилами, нужными VPN-продукту. Правило всё равно обязано быть узко ограниченным и наблюдаемым.
@@ -542,7 +554,8 @@ System:
interface: <iface>
Network tuning:
enabled, apply_on_daemon_start, tcp_bbr_mode, conf_path, modules_load_path,
значения управляемых ключей, bbr_available, bbr_active, drift[, reason][, warning]
значения управляемых ключей, bbr_available, bbr_desired,
bbr_runtime_active, drift[, reason][, warning]
Peers:
total: N
- <peer ip>
@@ -560,6 +573,7 @@ Host firewall:
enabled, ssh_port, input_policy, drift[, reason]
Docker/Amnezia port path:
expected_host_udp_port, docker_userland_proxy, published_udp_port,
host_bindings (все HostIp:HostPort, включая routed binding без HostPort),
awg_listen_udp_port, port_contract, host_forward_policy_drop,
docker_forward_rules_present, note[, warning]
Anti-torrent:
@@ -693,7 +707,7 @@ inode = 123456
### Перезапись `/etc/fstab`
Это самая разрушительная операция пакета, поэтому она транзакционная и вынесена в отдельный модуль `fstab.rs`. Контракт:
Это самая разрушительная операция пакета, поэтому она транзакционная и изолирована в `swap/persistence.rs`. Контракт:
- ошибка чтения `/etc/fstab` **никогда** не интерпретируется как пустой файл. `PermissionDenied`, ошибка ввода-вывода и отсутствие файла различаются, и все три прекращают операцию. Отсутствие `/etc/fstab` на Debian-хосте считается аномалией, а не основанием создать его с нуля;
- пустой или состоящий из пробелов кандидат отклоняется;
@@ -714,8 +728,8 @@ inode = 123456
- валидация конфига, включая форму `amnezia.wg_binaries`;
- разбор `config.example.toml` в актуальную схему с точным default-порядком контейнеров, `awg`/`wg` и anti-torrent baseline `500`;
- парсер `awg|wg show all allowed-ips`, включая несколько allowed ips через пробел;
- fallback-парсер `awg|wg show`;
- парсер `awg|wg show all allowed-ips`: один IPv4 `/32` проходит, IPv4+IPv6, IPv6-only, несколько маршрутов, маска не `/32` и адрес вне configured CIDR дают explicit `UnsupportedTopology`;
- fallback-парсер `awg|wg show` и dump-парсер применяют тот же topology contract; `(none)` остаётся допустимым;
- классификация peer/interface строк `awg|wg show all dump` против двух 30-полевых AWG 3.1 fixtures, AWG 3.0 с включённой и выключенной защитой заголовка, AWG 2.x, чистого WireGuard, а также конфигурации AWG 2.0, прочитанной tools AWG 3.0;
- разбор dump с несколькими интерфейсами, с интерфейсом без peers и с повреждёнными строками;
- порядок выбора бинаря wg и ошибка при отсутствии всех кандидатов;
@@ -727,6 +741,9 @@ inode = 123456
- packet statistics из Debian-подобного `tc -s class`;
- отказ удалять foreign IFB/root qdisc;
- typed Docker readiness retry/timeout/lower-priority event;
- Docker UDP bindings: dual-stack HostIp, routed binding с пустым HostPort и неоднозначные/divergent host ports;
- BBR decision: доступный BBR при runtime `cubic` означает `desired=yes`, `runtime_active=no`; mode `off` не маскирует уже активный runtime BBR;
- host firewall rollback удаляет новый jump по exact specification и не затрагивает чужое правило в начале `INPUT`;
- conntrack count из Amnezia namespace и global max с host;
- diff drift;
- расчёт окна перезагрузки;
+2
View File
@@ -51,6 +51,8 @@ docker exec "$C" awg show awg0 listen-port
docker inspect -f 'ports={{json .NetworkSettings.Ports}}' amnezia-dns
```
В `status` для AWG publish ожидается полный список фактических bindings, например `0.0.0.0:443` и `[::]:443`. Это внешний Docker publish; он не меняет IPv4-only server peer topology. Каждый server peer обязан иметь ровно один IPv4 `/32` внутри configured CIDR. Смешанный/IPv6/multiple `AllowedIPs` должен останавливать traffic и anti-torrent reconcile с `unsupported topology`.
Проверка host firewall выполняется через Docker-совместимый frontend. В `INPUT` ожидается первый jump с comment `awg2-harness:host-firewall`, но не allow-rule для `443/udp`:
```bash
+2
View File
@@ -55,6 +55,8 @@ Reference image содержит `/usr/bin/awg`, `/usr/bin/awg-quick` и symlink
Harness не выбирает реализацию и не требует конкретную из них. Peer state читается через `docker exec` официальным `awg` внутри контейнера. Это единая container-side точка доступа для kernel и userspace вариантов и не создаёт зависимости от версии host-side tools.
Поддерживаемый harness-контракт соответствует официальной self-hosted server topology Amnezia/AWG 3.1: один IPv4 host route `/32` на peer. Generic client config может содержать `0.0.0.0/0` и `::/0`, а Docker — публиковать AWG UDP port на внешних IPv4 и IPv6 адресах; это не server-side peer addressing. IPv6/mixed/multiple server `AllowedIPs` harness отвергает до traffic и anti-torrent mutation.
## Контракт `show all dump`
Исторические widths являются диагностикой: