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
+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.