docs: unify language contract to Russian and reconcile the spec with the code

The docs folder had drifted into a mixed-language contract: IMPLEMENTATION_SPEC.md was
entirely in English while the rest was Russian with English headings. Prose is now
Russian throughout; English is kept only where it is technically load-bearing (commands,
tool output, config keys, file/module/table names, tc handles) and for terms with no
settled Russian equivalent in this context (drift, reconcile, peer, namespace, handshake).
docs/README.md now states that contract explicitly.

Rewriting the spec surfaced several places where it described intent rather than the
code. Corrected against traffic_core/src:

- module layout: the spec showed traffic/ and firewall/ as directories with
  model/inspect/apply submodules. They are single files, and sysctl_tuning.rs was
  missing from the tree entirely.
- data model: Peer was documented as carrying public key, handshake and counters. It
  carries only ipv4; runtime state lives in a separate PeerRuntime, and there is no
  desired-state struct at all - it is derived inline from config plus sorted peers.
  RuntimeTarget.netns_path is a String, not a PathBuf.
- interface detection: the spec listed a third step taking the first non-loopback
  interface with an IPv4 address. No such fallback exists; detection bails with an
  explicit error, which is safer and is now documented as deliberate.
- reconcile: the pseudo-flow folded firewall into reconcile_traffic. They are two
  independent reconciles with separate locks.
- locking: only traffic.lock was documented. There are three locks, and
  sysctl_tuning.lock has a 30s timeout rather than 10s. Stale-lock recovery via
  /proc/<pid> was undocumented.
- sysctl_tuning: the module also manages /etc/modules-load.d/90-awg2-harness-bbr.conf
  when BBR is active, and refuses to touch either managed file without its marker.
  Both docs and the acceptance checklist claimed only the sysctl.d file was written.
- status contract: the documented output was outdated. All 15 printed sections are now
  listed in the order status.rs emits them, including the degraded path when no container
  is found, and the fact that the Network tuning block is printed between the System
  service line and the container lines.
- swap: size was documented as a constant 2048 MB instead of swap.size_mb, and the
  /etc/fstab.awg2-harness.bak backup was undocumented.
- apply/verify: the tolerated-error lists are now the actual closed sets from the code.

Acceptance checklist gained checks for the daemon startup failure policy (sysctl/swap
fatal, traffic/firewall not), lock contention and stale-lock recovery, degraded status,
the modules-load file, and the marker guard. The stale host wg requirement was dropped
from its host-tools item.

Every path, table, counter and log string quoted in the docs was verified to exist in
the source. cargo test --workspace: 110 + 4 passed.
This commit is contained in:
2026-08-06 16:45:00 +05:00
parent 16214f3c66
commit d98ade42ce
7 changed files with 419 additions and 366 deletions
+35 -23
View File
@@ -1,4 +1,4 @@
# Acceptance checklist перед production-выкладкой # Чек-лист приёмки перед production-выкладкой
## A. Базовая установка ## A. Базовая установка
@@ -9,10 +9,11 @@
- [ ] `docker ps` видит контейнер. - [ ] `docker ps` видит контейнер.
- [ ] Можно получить PID контейнера. - [ ] Можно получить PID контейнера.
- [ ] `/proc/<pid>/ns/net` существует. - [ ] `/proc/<pid>/ns/net` существует.
- [ ] На хосте доступны `ip`, `tc`, `nsenter`, `modprobe`. Хостовый `wg` не требуется. - [ ] На хосте доступны `ip`, `tc`, `docker`, `nsenter`, `modprobe`. Хостовый `wg` не требуется и не проверяется.
- [ ] `awg2-harness validate-config` проходит без ошибок. - [ ] `awg2-harness validate-config` проходит без ошибок.
- [ ] `awg2-harness validate-runtime` проходит без ошибок и не требует установленного `wireguard-tools`.
## B. Peer discovery ## B. Обнаружение peers
- [ ] `awg2-harness status` показывает найденный контейнер. - [ ] `awg2-harness status` показывает найденный контейнер.
- [ ] `awg2-harness status` показывает PID/netns контейнера. - [ ] `awg2-harness status` показывает PID/netns контейнера.
@@ -24,7 +25,7 @@
- [ ] В секции `WireGuard clients` вывода `status` нет строки, соответствующей самому интерфейсу (столбец `Public key` не содержит приватный ключ устройства). - [ ] В секции `WireGuard clients` вывода `status` нет строки, соответствующей самому интерфейсу (столбец `Public key` не содержит приватный ключ устройства).
- [ ] `docker exec "$C" readlink -f /usr/bin/wg` возвращает `/usr/bin/awg`. - [ ] `docker exec "$C" readlink -f /usr/bin/wg` возвращает `/usr/bin/awg`.
## C. Traffic topology contract ## C. Контракт топологии traffic
- [ ] `awg2-harness apply-limits` успешно завершается. - [ ] `awg2-harness apply-limits` успешно завершается.
- [ ] На AWG/WG-интерфейсе есть root qdisc `1: htb`. - [ ] На AWG/WG-интерфейсе есть root qdisc `1: htb`.
@@ -43,7 +44,7 @@
- [ ] Class ids начинаются с `traffic.class_start_id`, default `10`. - [ ] Class ids начинаются с `traffic.class_start_id`, default `10`.
- [ ] Peer IP → class id mapping использует стабильный лексикографический контракт: IP сортируются как строки, не numeric IPv4. - [ ] Peer IP → class id mapping использует стабильный лексикографический контракт: IP сортируются как строки, не numeric IPv4.
## D. Idempotency and drift recovery ## D. Идемпотентность и восстановление после drift
- [ ] Повторный `apply-limits` не создаёт дубли. - [ ] Повторный `apply-limits` не создаёт дубли.
- [ ] Повторный `apply-limits` не меняет healthy state без причины. - [ ] Повторный `apply-limits` не меняет healthy state без причины.
@@ -58,30 +59,36 @@
- [ ] После удаления peer устаревшие class/filter исчезают через rebuild. - [ ] После удаления peer устаревшие class/filter исчезают через rebuild.
- [ ] После добавления peer появляются новые class/filter. - [ ] После добавления peer появляются новые class/filter.
## E. Auto-healing lifecycle ## E. Самовосстановление в жизненном цикле
- [ ] `docker restart amnezia-awg2` → лимиты восстанавливаются автоматически. - [ ] `docker restart amnezia-awg2` → лимиты восстанавливаются автоматически.
- [ ] `systemctl restart docker` → после возвращения контейнера лимиты восстанавливаются автоматически. - [ ] `systemctl restart docker` → после возвращения контейнера лимиты восстанавливаются автоматически.
- [ ] `systemctl restart awg2-harness` → сервис сам находит контейнер и применяет/проверяет лимиты. - [ ] `systemctl restart awg2-harness` → сервис сам находит контейнер и применяет/проверяет лимиты.
- [ ] Reboot хоста → сервис стартует и восстанавливает лимиты. - [ ] Reboot хоста → сервис стартует и восстанавливает лимиты.
- [ ] Изменение имени интерфейса на допустимое fallback-имя не ломает detector. - [ ] Изменение имени интерфейса на допустимое fallback-имя не ломает detector.
- [ ] Если контейнер временно отсутствует, daemon логирует ожидание и продолжает работу. - [ ] Если контейнер временно отсутствует, daemon логирует ошибку reconcile и продолжает работу.
- [ ] На старте daemon сбой `sysctl_tuning` или `swap` завершает процесс, а сбой traffic/firewall только логируется — сервис остаётся живым.
- [ ] После обрыва потока `docker events` он перезапускается автоматически (проверяется, например, `systemctl restart docker`).
- [ ] Параллельный запуск `apply-limits` во время работы daemon не приводит к гонке: второй процесс ждёт блокировку до 10 секунд и затем сообщает `lock busy timeout`.
- [ ] Устаревший lock-файл после `kill -9` не блокирует работу: следующий запуск перезахватывает `/run/awg2-harness/traffic.lock` по отсутствию `/proc/<pid>`.
## F. Status and observability ## F. Статус и наблюдаемость
- [ ] `status` показывает desired peer limit из конфига, а не hardcoded 70. - [ ] `status` показывает desired peer limit из конфига, а не hardcoded 70.
- [ ] `status` показывает egress state. - [ ] `status` показывает egress state.
- [ ] `status` показывает ingress/ifb state. - [ ] `status` показывает ingress/ifb state.
- [ ] `status` показывает WireGuard clients (online/offline, latest handshake, RX/TX, endpoint). - [ ] `status` показывает WireGuard clients (online/offline, latest handshake, RX/TX, endpoint).
- [ ] `status` показывает missing/stale peer rules. - [ ] `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` явно показывает partial/drift state. - [ ] `status` явно показывает частичное состояние и drift, а не общий признак «всё хорошо».
- [ ] `status` показывает tc filter counters по peer или `filter statistics: unavailable`. - [ ] `status` показывает tc filter counters по peer или `filter statistics: unavailable`.
- [ ] Логи доступны через `journalctl -u awg2-harness`. - [ ] Логи доступны через `journalctl -u awg2-harness`.
- [ ] Ошибка отсутствия `tc`/`ip`/`wg` логируется явно. - [ ] Отсутствие обязательной host-утилиты (`tc`, `ip`, `docker`, `nsenter`) логируется явно с именем утилиты.
- [ ] Последний reconcile result виден в status или journal. - [ ] Последний reconcile result виден в status или journal.
- [ ] `status` показывает последние события reconcile/firewall из `/run/awg2-harness/events.log`. - [ ] `status` показывает последние события reconcile/firewall из `/run/awg2-harness/events.log`.
- [ ] Если контейнер не найден, `status` не падает, а переходит в degraded-режим: `Peers`/`WireGuard clients` показывают `unavailable`, `Traffic actual` — `drift: unknown`, а секции host firewall, anti-torrent, swap и conntrack продолжают выводиться.
- [ ] Секции деградируют независимо: ошибка чтения peer state не мешает вывести traffic, firewall и swap.
## G. Reboot scheduler ## G. Планировщик перезагрузки
- [ ] Если `reboot.enabled = false`, сервис не планирует reboot. - [ ] Если `reboot.enabled = false`, сервис не планирует reboot.
- [ ] Если `reboot.enabled = true`, в journal видна запланированная перезагрузка. - [ ] Если `reboot.enabled = true`, в journal видна запланированная перезагрузка.
@@ -90,28 +97,33 @@
## H. Swap ## H. Swap
- [ ] `ensure-swap` создаёт `/swapfile` размером 2048 MB. - [ ] `ensure-swap` создаёт файл по пути `swap.path` (в примере `/swapfile`) размером `swap.size_mb` (в примере 2048 MB).
- [ ] Права `/swapfile` — `0600`. - [ ] Права на swap-файл — `0600`.
- [ ] Перед перезаписью `/etc/fstab` создана резервная копия `/etc/fstab.awg2-harness.bak`.
- [ ] Если до запуска был активен другой swap, после `ensure-swap` он выключен. - [ ] Если до запуска был активен другой swap, после `ensure-swap` он выключен.
- [ ] После `ensure-swap` активен ровно один swap: `/swapfile`. - [ ] После `ensure-swap` активен ровно один swap: `/swapfile`.
- [ ] Все старые swap entries удалены из `/etc/fstab`. - [ ] Все старые swap entries удалены из `/etc/fstab`.
- [ ] Для block-device swap выполняется только `swapoff` и удаление `fstab` entry; block device не удаляется. - [ ] Для block-device swap выполняется только `swapoff` и удаление `fstab` entry; block device не удаляется.
- [ ] `vm.swappiness = 1` применён. - [ ] `vm.swappiness` из конфига применён в runtime и сохранён persistently.
- [ ] Повторный `ensure-swap` ничего не пересоздаёт и не добавляет дублей. - [ ] Повторный `ensure-swap` ничего не пересоздаёт и не добавляет дублей.
## H2. Sysctl network tuning ## H2. Сетевой baseline через sysctl
- [ ] `awg2-harness apply-network-tuning` успешно завершается. - [ ] `awg2-harness apply-network-tuning` успешно завершается.
- [ ] `awg2-harness inspect-network-tuning` показывает `enabled: true` и `drift: no`. - [ ] `awg2-harness inspect-network-tuning` показывает `enabled: true` и `drift: no`.
- [ ] Создаётся только `/etc/sysctl.d/90-awg2-harness-vpn.conf`. - [ ] Создаётся `/etc/sysctl.d/90-awg2-harness-vpn.conf`.
- [ ] В managed-файле есть marker `Managed by awg2-harness sysctl_tuning`. - [ ] `/etc/modules-load.d/90-awg2-harness-bbr.conf` создан, если BBR активен, и отсутствует, если BBR неактивен.
- [ ] `/etc/sysctl.conf` не изменён.
- [ ] В обоих managed-файлах первая строка содержит marker `Managed by awg2-harness sysctl_tuning`.
- [ ] Файл без marker по managed-пути не перезаписывается и не удаляется: команда завершается ошибкой.
- [ ] В managed-файле отсутствует `udp_wmem_min`. - [ ] В managed-файле отсутствует `udp_wmem_min`.
- [ ] `net.ipv4.tcp_congestion_control` и `net.core.default_qdisc` присутствуют в managed-файле только при активном BBR.
- [ ] Runtime apply меняет только drifted keys (`sysctl -w`), без `sysctl --system`. - [ ] Runtime apply меняет только drifted keys (`sysctl -w`), без `sysctl --system`.
- [ ] При `tcp_bbr = auto` и недоступном BBR сервис не падает, в статусе есть warning. - [ ] При `tcp_bbr = auto` и недоступном BBR сервис не падает, в статусе есть warning.
- [ ] При `tcp_bbr = require` и недоступном BBR команда/старт завершается с ошибкой. - [ ] При `tcp_bbr = require` и недоступном BBR команда/старт завершается с ошибкой.
- [ ] При `enabled=false` managed-файл удаляется только если содержит managed marker. - [ ] При `enabled=false` managed-файл удаляется только если содержит managed marker.
## I. Anti-torrent nftables guard ## I. Anti-torrent guard на nftables
- [ ] Если `anti_torrent.enabled = true`, в Amnezia netns есть `table inet awg2_harness`. - [ ] Если `anti_torrent.enabled = true`, в Amnezia netns есть `table inet awg2_harness`.
- [ ] Есть chain `forward_antitorrent` с hook `forward` и policy `accept`. - [ ] Есть chain `forward_antitorrent` с hook `forward` и policy `accept`.
@@ -125,7 +137,7 @@
- [ ] При `anti_torrent.enabled = false` удаляется/отключается только AWG2-owned nftables state; Docker/Amnezia rules не flush-ятся. - [ ] При `anti_torrent.enabled = false` удаляется/отключается только AWG2-owned nftables state; Docker/Amnezia rules не flush-ятся.
- [ ] `status` показывает `nf_conntrack_count` / `nf_conntrack_max`, если sysctl доступны. - [ ] `status` показывает `nf_conntrack_count` / `nf_conntrack_max`, если sysctl доступны.
## J. Rollback ## J. Откат
- [ ] Известно, как остановить и отключить `awg2-harness.service` и `awg2-reboot-scheduler.service`. - [ ] Известно, как остановить и отключить `awg2-harness.service` и `awg2-reboot-scheduler.service`.
- [ ] Известно, как удалить только AWG2-owned host firewall state (`inet awg2_host_firewall`). - [ ] Известно, как удалить только AWG2-owned host firewall state (`inet awg2_host_firewall`).
@@ -134,7 +146,7 @@
- [ ] При необходимости rollback включает удаление пакета `awg2-harness` после остановки сервисов. - [ ] При необходимости rollback включает удаление пакета `awg2-harness` после остановки сервисов.
- [ ] Rollback проверен на тестовом сервере. - [ ] Rollback проверен на тестовом сервере.
## K. Packaging / release ## K. Упаковка и релиз
- [ ] Package собирается на отдельной Debian 13 amd64 build-machine. - [ ] Package собирается на отдельной Debian 13 amd64 build-machine.
- [ ] Production host не требует Rust/cargo/source tree. - [ ] Production host не требует Rust/cargo/source tree.
@@ -150,7 +162,7 @@
- [ ] После ручной установки config `awg2-harness validate-config` проходит. - [ ] После ручной установки config `awg2-harness validate-config` проходит.
- [ ] Сервис стартует только после явного `systemctl enable --now awg2-harness.service`. - [ ] Сервис стартует только после явного `systemctl enable --now awg2-harness.service`.
## K2. Documentation / install UX ## K2. Документация и UX установки
- [ ] README не утверждает, что AmneziaVPN ожидает public key. - [ ] README не утверждает, что AmneziaVPN ожидает public key.
- [ ] Документация явно разделяет private key для AmneziaVPN и public key для `authorized_keys`. - [ ] Документация явно разделяет private key для AmneziaVPN и public key для `authorized_keys`.
@@ -169,7 +181,7 @@
- [ ] DNS-резолвинг у VPN-клиента работает при активном подключении. - [ ] DNS-резолвинг у VPN-клиента работает при активном подключении.
- [ ] AWG2 host firewall не открывает `53/tcp` и `53/udp`. - [ ] AWG2 host firewall не открывает `53/tcp` и `53/udp`.
## L. Host firewall (ports/SSH safety) ## L. Host firewall (порты и безопасность SSH)
- [ ] `awg2-harness validate-config` падает, если отсутствует секция `[host_firewall]`. - [ ] `awg2-harness validate-config` падает, если отсутствует секция `[host_firewall]`.
- [ ] `apply-firewall` не блокирует SSH на `host_firewall.ssh_port` (default `2323`). - [ ] `apply-firewall` не блокирует SSH на `host_firewall.ssh_port` (default `2323`).
+4 -4
View File
@@ -1,4 +1,4 @@
# Anti-torrent connection-count guard # Anti-torrent: ограничение числа соединений
Этот модуль — часть текущего production-контракта AWG2 Harness. Он вписывается в multi-orchestrator модель, потому что сервер выделенный, а бинарь уже владеет AWG2-owned network state вокруг Amnezia: `tc`, `ifb0`, lifecycle recovery и диагностика. Этот модуль — часть текущего production-контракта AWG2 Harness. Он вписывается в multi-orchestrator модель, потому что сервер выделенный, а бинарь уже владеет AWG2-owned network state вокруг Amnezia: `tc`, `ifb0`, lifecycle recovery и диагностика.
@@ -25,7 +25,7 @@ nft add rule inet filter forward ip saddr 10.8.1.0/24 ct count over 200 drop
Нужен per-source key через meter/set и отдельная таблица, которой владеет только AWG2 Harness. Нужен per-source key через meter/set и отдельная таблица, которой владеет только AWG2 Harness.
## 3. Recommended nftables shape ## 3. Рекомендуемая форма правила nftables
Правило должно применяться в том же network namespace, где живёт Amnezia AWG/WG interface и где уже применяются `tc`/`ifb0` rules. На практике это namespace контейнера Amnezia, полученный через `/proc/<container_pid>/ns/net`. Правило должно применяться в том же network namespace, где живёт Amnezia AWG/WG interface и где уже применяются `tc`/`ifb0` rules. На практике это namespace контейнера Amnezia, полученный через `/proc/<container_pid>/ns/net`.
@@ -63,7 +63,7 @@ table inet awg2_harness {
`ct state new` важен: ограничиваются новые flow attempts сверх порога, а уже установленные flows не начинают внезапно дропаться только потому, что счётчик стал высоким. `ct state new` важен: ограничиваются новые flow attempts сверх порога, а уже установленные flows не начинают внезапно дропаться только потому, что счётчик стал высоким.
## 4. Threshold ## 4. Порог
Рекомендуемый стартовый production baseline: Рекомендуемый стартовый production baseline:
@@ -104,7 +104,7 @@ nft list table inet awg2_harness
Если `nf_conntrack_count` приближается к `nf_conntrack_max`, проблема уже системная: надо разбираться с timeout, abuse или capacity. Не надо автоматически увеличивать `nf_conntrack_max` из orchestrator без отдельного решения. Если `nf_conntrack_count` приближается к `nf_conntrack_max`, проблема уже системная: надо разбираться с timeout, abuse или capacity. Не надо автоматически увеличивать `nf_conntrack_max` из orchestrator без отдельного решения.
## 7. Acceptance criteria ## 7. Критерии приёмки
- В правильном Amnezia netns существует `table inet awg2_harness`. - В правильном Amnezia netns существует `table inet awg2_harness`.
- В таблице есть `chain forward_antitorrent` с hook `forward`. - В таблице есть `chain forward_antitorrent` с hook `forward`.
+3
View File
@@ -119,6 +119,9 @@ swappiness = 1
`sysctl_tuning` — отдельный host-level модуль управляемого сетевого baseline через `sysctl`: `sysctl_tuning` — отдельный host-level модуль управляемого сетевого baseline через `sysctl`:
- persistent-файл: `/etc/sysctl.d/90-awg2-harness-vpn.conf`; - persistent-файл: `/etc/sysctl.d/90-awg2-harness-vpn.conf`;
- дополнительный managed-файл `/etc/modules-load.d/90-awg2-harness-bbr.conf` создаётся только при фактически активном BBR и удаляется, когда BBR неактивен;
- оба managed-файла несут в первой строке маркер `# Managed by awg2-harness sysctl_tuning`; файл без маркера не перезаписывается и не удаляется, вместо этого возвращается ошибка;
- `net.ipv4.tcp_congestion_control` и `net.core.default_qdisc` управляются только при активном BBR;
- runtime apply: только `sysctl -w` и только для drifted keys; - runtime apply: только `sysctl -w` и только для drifted keys;
- на установке `.deb` модуль не применяется; - на установке `.deb` модуль не применяется;
- на старте daemon модуль применяется, если `enabled=true` и `apply_on_daemon_start=true`; - на старте daemon модуль применяется, если `enabled=true` и `apply_on_daemon_start=true`;
+344 -312
View File
@@ -1,45 +1,47 @@
# AWG2 Harness implementation spec # Спецификация реализации AWG2 Harness
## 1. Architectural role Документ описывает архитектуру и контракт reconciliation. Он отражает фактическое состояние кода, а не только замысел: расхождения между этим документом и `traffic_core/src` считаются дефектом документа.
AWG2 Harness production package contains two Rust binaries: ## 1. Архитектурная роль
- `awg2-harness` — host-side traffic/firewall/status/swap orchestrator; Production-пакет AWG2 Harness содержит два Rust-бинаря:
- `awg2-reboot-scheduler` — dedicated reboot scheduler service.
`awg2-harness` remains the main host-side multi-orchestrator for a dedicated Debian 13 amd64 AmneziaWG/AWG 2.0 server. - `awg2-harness` — host-side оркестратор traffic/firewall/status/swap;
- `awg2-reboot-scheduler` — отдельный сервис планировщика перезагрузки.
It owns the infrastructure state needed by the VPN service: `awg2-harness` остаётся основным host-side мульти-оркестратором для выделенного сервера Debian 13 amd64 с AmneziaWG/AWG 2.0.
- Amnezia container discovery; Он владеет инфраструктурным состоянием, которое нужно VPN-сервису:
- container network namespace discovery;
- AWG/WG interface discovery;
- peer discovery;
- traffic-control desired state;
- IFB ingress shaping;
- nftables connection-count anti-torrent guard;
- Docker lifecycle recovery;
- periodic reconciliation;
- status/diagnostics;
- planned reboot;
- emergency swap setup.
It does not replace Amnezia. It controls the surrounding host-side infrastructure state required by the Amnezia service. - обнаружение контейнера Amnezia;
- обнаружение network namespace контейнера;
- обнаружение интерфейса AWG/WG;
- обнаружение peers;
- целевое состояние traffic control;
- ingress shaping через IFB;
- nftables connection-count guard (anti-torrent);
- восстановление после Docker lifecycle events;
- периодический reconcile;
- status и диагностика;
- плановая перезагрузка;
- аварийная настройка swap.
## 2. Process model Он не заменяет Amnezia. Он управляет окружающим host-side состоянием, которое нужно сервису Amnezia.
Two systemd services run: ## 2. Модель процессов
Работают два systemd-сервиса:
```bash ```bash
/usr/sbin/awg2-harness --config /etc/awg2-harness/config.toml daemon /usr/sbin/awg2-harness --config /etc/awg2-harness/config.toml daemon
/usr/sbin/awg2-reboot-scheduler --config /etc/awg2-harness/config.toml /usr/sbin/awg2-reboot-scheduler --config /etc/awg2-harness/config.toml
``` ```
`awg2-harness` daemon starts as root and keeps running. On startup it performs initial reconcile. During runtime it reacts to Docker events and also runs a periodic reconcile loop. Демон `awg2-harness` стартует от root и работает постоянно. На старте выполняет начальный reconcile. В runtime реагирует на Docker events и параллельно крутит периодический цикл reconcile.
Reboot scheduling authority belongs to `awg2-reboot-scheduler` service. `awg2-harness` does not schedule reboots internally. Право планировать перезагрузку принадлежит сервису `awg2-reboot-scheduler`. `awg2-harness` не планирует перезагрузки внутри себя.
Subcommands: Подкоманды:
```bash ```bash
awg2-harness daemon awg2-harness daemon
@@ -53,193 +55,195 @@ awg2-harness validate-config
awg2-harness validate-runtime awg2-harness validate-runtime
``` ```
`apply-network-tuning`/`inspect-network-tuning` относятся к отдельному host-level модулю `sysctl_tuning`. Глобальный флаг `--config`; если не задан, используется `/etc/awg2-harness/config.toml`.
Этот модуль:
- управляет только sysctl параметрами хоста; Уровень валидации у подкоманд разный, и это часть контракта:
- пишет только `/etc/sysctl.d/90-awg2-harness-vpn.conf`;
- применяет runtime-изменения точечно (`sysctl -w`) только для drifted keys; | Подкоманда | Валидация перед выполнением |
| --- | --- |
| `validate-config` | `validate_config` |
| `validate-runtime` | `validate_runtime` (поддерживаемый хост + host tools) |
| `status`, `inspect-network-tuning` | `validate_config` |
| `apply-network-tuning` | `validate_sysctl_tuning_runtime` |
| `apply-limits`, `apply-firewall`, `ensure-swap`, `daemon` | `validate_all` |
`apply-network-tuning` и `inspect-network-tuning` относятся к отдельному host-level модулю `sysctl_tuning`. Этот модуль:
- управляет только sysctl-параметрами хоста;
- пишет `/etc/sysctl.d/90-awg2-harness-vpn.conf`;
- дополнительно пишет `/etc/modules-load.d/90-awg2-harness-bbr.conf`, но только когда BBR фактически активен; если BBR неактивен, этот файл удаляется;
- применяет runtime-изменения точечно (`sysctl -w`) и только для drifted keys;
- не использует `/etc/sysctl.conf` и `sysctl --system`; - не использует `/etc/sysctl.conf` и `sysctl --system`;
- не управляет `udp_wmem_min`. - не управляет `udp_wmem_min`.
## 3. Module layout Оба управляемых файла несут в первой строке маркер `# Managed by awg2-harness sysctl_tuning`. Файл без маркера не перезаписывается и не удаляется — вместо этого возвращается ошибка. Это защищает чужие настройки, случайно оказавшиеся по тому же пути.
Recommended Rust module split: `net.ipv4.tcp_congestion_control` и `net.core.default_qdisc` попадают в целевое состояние только при активном BBR.
## 3. Структура модулей
Фактическая раскладка — плоская, без вложенных каталогов модулей:
```text ```text
src/ traffic_core/src/
├── main.rs ├── main.rs точка входа и диспетчер подкоманд
├── cli.rs ├── cli.rs определение CLI (clap)
├── config.rs ├── config.rs модель конфига, валидация, проверка host tools
├── daemon.rs ├── daemon.rs цикл демона, Docker events, журнал событий
├── lock.rs ├── docker.rs обнаружение контейнера, диагностика port path
├── docker.rs ├── netns.rs транспорты выполнения команд
├── netns.rs ├── iface.rs обнаружение интерфейса
├── iface.rs ├── wg.rs обнаружение peers и разбор вывода awg/wg
├── wg.rs ├── traffic.rs tc/IFB: инспекция, diff, teardown, apply
├── traffic/ ├── firewall.rs nftables: host firewall и anti-torrent
│ ├── mod.rs ├── sysctl_tuning.rs управляемый sysctl baseline
│ ├── model.rs ├── swap.rs управляемый swap
│ ├── inspect.rs ├── status.rs сборка и печать статуса
│ ├── apply.rs ├── lock.rs файловые блокировки
│ ├── parse_tc.rs └── error.rs типы ошибок
│ └── commands.rs
├── firewall/ reboot_module/src/
│ ├── mod.rs └── main.rs планировщик перезагрузки
│ ├── model.rs
│ ├── inspect.rs
│ ├── apply.rs
│ └── nft.rs
├── status.rs
├── swap.rs
└── error.rs
``` ```
## 4. Core data model Разбиение `traffic` и `firewall` на подкаталоги (`model.rs`, `inspect.rs`, `apply.rs`) обсуждалось, но не выполнено. Пока эти модули остаются одиночными файлами, любые изменения должны сохранять разделение на инспекцию, diff и apply внутри файла.
The traffic module should operate on explicit model structs, not raw strings passed around the codebase. ## 4. Модель данных
Conceptual model: Модули работают на явных структурах, а не на строках, которые передаются по всему коду.
Фактические типы:
```rust ```rust
// docker.rs
struct RuntimeTarget { struct RuntimeTarget {
container_name: String, container_name: String,
container_id: String, container_id: String,
container_pid: u32, container_pid: u32,
netns_path: PathBuf, netns_path: String, // /proc/<pid>/ns/net
interface_name: String, interface_name: String,
} }
// wg.rs — состав peers для shaping
struct Peer { struct Peer {
public_key: Option<String>,
ipv4: Ipv4Addr, ipv4: Ipv4Addr,
cidr: u8, // normally 32
latest_handshake: Option<HandshakeAge>,
transfer_rx: Option<ByteSize>,
transfer_tx: Option<ByteSize>,
} }
struct TrafficDesiredState { // wg.rs — runtime-состояние peers для status
iface: String, struct PeerRuntime {
ifb_name: String, public_key: String,
root_rate: Rate, allowed_ips: Vec<String>,
peer_limit: Rate, endpoint: Option<String>,
class_start_id: u16, latest_handshake_epoch: Option<u64>,
peers: Vec<PeerTrafficClass>, transfer_rx_bytes: Option<u64>,
transfer_tx_bytes: Option<u64>,
} }
struct PeerTrafficClass { // traffic.rs — построчный статус peer в выводе status
peer_ip: Ipv4Addr, struct PeerTrafficStatus {
ip: String,
class_id: u16, class_id: u16,
egress_filter_present: bool,
ingress_filter_present: bool,
egress_rate: Option<String>,
egress_ceil: Option<String>,
ingress_rate: Option<String>,
ingress_ceil: Option<String>,
drift_reason: Option<String>,
} }
``` ```
Class assignment must be deterministic. Peer IP strings are sorted lexicographically, then assigned `class_start_id + index`. Do not silently switch this to numeric IPv4 sorting: the production class-id contract maps `10.8.1.10` before `10.8.1.2` because sorting is textual, not numeric. Разделение `Peer` и `PeerRuntime` намеренное: shaping зависит только от адреса, а handshake/RX/TX нужны исключительно для диагностики. Ошибка чтения runtime-состояния не должна ломать применение лимитов.
## 5. Namespace strategy Отдельной структуры desired state нет. Целевое состояние выводится детерминированно из конфига и отсортированного списка peers прямо в `inspect_and_diff` и `apply_full_topology`.
Preferred strategy: Присвоение class id обязано быть детерминированным. Строки peer IP сортируются лексикографически, затем присваивается `class_start_id + index`. Это нельзя молча заменить на числовую сортировку IPv4: production-контракт class-id ставит `10.8.1.10` раньше `10.8.1.2`, потому что сортировка текстовая, а не числовая.
1. Detect running container through Docker API or `docker ps` / `docker inspect`. ## 5. Стратегия namespace
2. Read container PID.
3. Use `/proc/<pid>/ns/net` as the target namespace.
4. Execute network operations in that namespace.
Implementation options: Основной путь:
- current path: spawn host tools with namespace entry, for example `nsenter -t <pid> -n tc ...`, `nsenter -t <pid> -n ip ...`; 1. Найти запущенный контейнер через `docker ps` / `docker inspect`.
- later/native: use netlink crates and explicit namespace switching where practical. 2. Прочитать PID контейнера.
3. Использовать `/proc/<pid>/ns/net` как целевой namespace.
4. Выполнять сетевые операции в этом namespace.
Peer state is the one exception and must never be read with a host-side binary. Реализация:
AmneziaWG is a userspace implementation whose UAPI socket lives under
`RUNSTATEDIR/amneziawg/<iface>.sock` inside the container mount namespace, while host
`wireguard-tools` looks under `/var/run/wireguard/`. `nsenter -n` enters only the network
namespace, so a host `wg` reaches neither the socket nor a kernel device and always
returns an empty peer list. Peer discovery therefore uses `docker exec` exclusively, and
`wireguard-tools` is not a host dependency.
`docker exec` should not be the architectural default. It can remain as compatibility fallback if `nsenter`/host tools are unavailable, but the design assumption is that the Rust binary has host authority and controls the relevant namespace. - текущий путь — запуск host tools со входом в namespace: `nsenter -t <pid> -n -- tc ...`, `nsenter -t <pid> -n -- ip ...`, `nsenter -t <pid> -n -- nft ...`;
- в перспективе — netlink-крейты и явное переключение namespace там, где это практично.
This also reduces operational risk: if the Amnezia container drops `tc` or `iproute2`, host-side tools can still apply rules inside the container netns. `docker exec` не является архитектурным умолчанием для этих операций. Он остаётся fallback-транспортом, если `nsenter` недоступен. На практике `nsenter` доступен всегда: `validate-runtime` завершается с ошибкой при его отсутствии, а `validate_all` вызывается перед `daemon`, `apply-limits`, `apply-firewall` и `ensure-swap`.
## 6. Discovery flow Это снижает эксплуатационный риск: если контейнер Amnezia перестанет содержать `tc` или `iproute2`, host-side утилиты всё равно применят правила внутри его netns.
Чтение состояния peers — единственное исключение, и его нельзя выполнять host-side бинарём. AmneziaWG — userspace-реализация, её UAPI-сокет находится в `RUNSTATEDIR/amneziawg/<iface>.sock` внутри mount namespace контейнера, тогда как хостовый `wireguard-tools` ищет сокет в `/var/run/wireguard/`. `nsenter -n` входит только в network namespace, поэтому хостовый `wg` не достаёт ни до сокета, ни до kernel-устройства и всегда возвращает пустой список peers. Поэтому обнаружение peers использует исключительно `docker exec`, а `wireguard-tools` не является зависимостью хоста и не проверяется в preflight.
## 6. Порядок discovery
`discover_target()`: `discover_target()`:
1. Load ordered `amnezia.container_names`. 1. Прочитать упорядоченный `amnezia.container_names`.
2. Find the first running matching container. 2. Найти первый запущенный контейнер с точным совпадением имени.
3. Get container ID, status and PID. 3. Получить container ID и PID.
4. Build `netns_path`. 4. Построить `netns_path`.
5. Detect interface in that namespace. 5. Определить интерфейс в этом namespace.
6. Return `RuntimeTarget`. 6. Вернуть `RuntimeTarget`.
Interface detection order: Порядок определения интерфейса:
1. Exact names from `amnezia.interface_candidates`. 1. Точные имена из `amnezia.interface_candidates` — проверяются через `ip link show <name>`.
2. Pattern scan from `amnezia.interface_patterns`. 2. Сканирование `ip -o link show` и сопоставление с `amnezia.interface_patterns`. Поддерживается только суффиксный `*` (`awg*`); имена вида `veth0@if8` нормализуются до `veth0`.
3. Fallback: first non-loopback interface with IPv4 address, only if it is clearly inside the Amnezia namespace. 3. Если ничего не совпало — явная ошибка `unable to detect amnezia interface`, правила не применяются.
4. If ambiguous, return an explicit error and do not apply rules.
## 7. Peer discovery Эвристики «взять первый non-loopback интерфейс с IPv4» нет и быть не должно: молчаливый выбор произвольного интерфейса опаснее явной ошибки.
Transport is always `docker exec <container>` — see section 5 for why a host-side `wg` ## 7. Обнаружение peers
cannot work.
Binary resolution uses ordered `amnezia.wg_binaries`, default `["awg", "wg"]`. The first Транспорт — всегда `docker exec <container>`. Обоснование в разделе 5.
binary that exits successfully wins. The official AmneziaWG image ships `/usr/bin/awg` and
symlinks `/usr/bin/wg` to it, so `awg` is the real dependency; `wg` remains for plain
WireGuard containers where `awg` does not exist. There is no probe call: the resolution is
a property of the command that is being run anyway.
Primary command: Выбор бинаря идёт по упорядоченному `amnezia.wg_binaries`, default `["awg", "wg"]`. Побеждает первый, который завершился успешно. Официальный образ AmneziaWG содержит `/usr/bin/awg` и symlink `/usr/bin/wg` на него, поэтому `awg` — настоящая зависимость; `wg` остаётся для контейнеров с чистым WireGuard, где `awg` отсутствует. Отдельного probe-вызова нет: выбор бинаря является побочным результатом той команды, которая и так должна выполниться.
Значения `amnezia.wg_binaries` исполняются внутри контейнера и валидируются как голые имена команд: непустые, без пробелов, без разделителей пути.
Основная команда:
```bash ```bash
awg show all allowed-ips awg show all allowed-ips
``` ```
Parsing rules: Правила разбора:
- parse lines structurally into peer public key and allowed IPs; - строки разбираются структурно на public key peer и его allowed IPs;
- a peer's allowed ips are **space-separated** in this command, comma-separated in - в этой команде allowed ips одного peer разделены **пробелом**, в `show all dump` — запятой, в обычном `show` — `", "`; нужно потреблять все токены начиная с третьей колонки, иначе peers с несколькими allowed IP получат shaping только по первому адресу;
`show all dump` and `", "`-separated in plain `show`; all tokens from column 3 onward - оставляются только IPv4 `/32` внутри `amnezia.peer_ipv4_cidr`;
must be consumed, otherwise peers with more than one allowed IP are shaped only on their - offline peers включаются: online-состояние не является требованием для shaping;
first address; - перед присвоением class id строки peer IP сортируются лексикографически, чтобы сохранить действующий контракт class-id.
- keep IPv4 `/32` addresses within `amnezia.peer_ipv4_cidr`;
- include offline peers; online state is not a requirement for shaping;
- sort peer IP strings lexicographically before class assignment to preserve the current lexicographic class-id contract.
Fallback: Резервная команда:
```bash ```bash
awg show awg show
``` ```
The fallback parser should support standard `wg show` text output by extracting `allowed ips:` lines. Fallback-парсер разбирает текстовый вывод, извлекая строки `allowed ips:`. Он используется, если основная команда завершилась ошибкой или вернула пустой результат.
`show all dump` line classification: Классификация строк `show all dump`:
- a line is a peer line only if it has at least 9 tab-separated columns, column 5 - строка считается peer-строкой, только если в ней не менее 9 tab-колонок, колонка 5 (allowed ips) равна `(none)` или является списком CIDR через запятую, а колонки 6–8 (handshake, rx, tx) целиком парсятся как целые;
(allowed ips) is `(none)` or a comma-separated CIDR list, and columns 6-8 (handshake, - количество колонок само по себе не является дискриминатором: interface-строка AmneziaWG содержит AWG-параметры и *длиннее* peer-строки (14 колонок на AWG 2.0, 28 на AWG 3.0), а на позициях handshake/rx/tx у неё стоят размеры junk-пакетов, которые тоже парсятся как целые;
rx, tx) all parse as integers; - это важно не только для корректности: колонка 2 interface-строки — приватный ключ устройства, и ошибочно классифицированная interface-строка выдала бы его как public key peer;
- column count is not a discriminator on its own: the AmneziaWG interface line carries the - AWG 3.0 печатает протокольные таймеры диапазонами (`20-30`). Колонка keepalive у peer не читается, и диапазон в ней не должен ломать разбор.
AWG parameters and is *longer* than a peer line (14 columns for AWG 2.0, 28 for AWG 3.0),
and its handshake/rx/tx positions hold junk-packet sizes that parse as integers;
- this matters beyond correctness — column 2 of an interface line is the device private
key, so a misclassified interface line would surface it as a peer public key;
- AWG 3.0 prints protocol timers as ranges (`20-30`). The peer keepalive column is not
read, and a range value must not break parsing.
Execution policy for production: Политика выполнения для production:
- do not use shell wrappers (`sh -c`) for `wg` invocation; - не использовать shell-обёртки (`sh -c`) для вызова `awg`/`wg`;
- do not suppress stderr via redirection in command strings; - не подавлять stderr редиректом внутри строки команды;
- execute the binary directly and handle non-zero exit codes in Rust; - вызывать бинарь напрямую и обрабатывать ненулевые коды возврата в Rust.
- `amnezia.wg_binaries` values are executed inside the container and are validated as bare
command names: non-empty, no whitespace, no path separators.
## 8. Desired traffic topology ## 8. Целевая топология traffic
The expected topology is the AWG2 traffic topology contract. Ожидаемая топология — контракт traffic-топологии AWG2.
Egress: Egress:
@@ -252,7 +256,7 @@ for each peer:
tc filter add dev <iface> parent 1: protocol ip u32 match ip dst <peer_ip> flowid 1:<id> tc filter add dev <iface> parent 1: protocol ip u32 match ip dst <peer_ip> flowid 1:<id>
``` ```
Ingress through IFB: Ingress через IFB:
```text ```text
modprobe ifb modprobe ifb
@@ -268,86 +272,82 @@ for each peer:
tc filter add dev <ifb_name> parent 2: protocol ip u32 match ip src <peer_ip> flowid 2:<id> tc filter add dev <ifb_name> parent 2: protocol ip u32 match ip src <peer_ip> flowid 2:<id>
``` ```
## 9. Reconcile algorithm ## 9. Алгоритм reconcile
Pseudo-flow: Traffic и firewall — два независимых reconcile с отдельными блокировками. Они не объединены в одну транзакцию: сбой одного не должен блокировать другой.
```text ```text
reconcile_traffic(): reconcile_traffic():
acquire global lock если traffic.enabled = false: выйти без изменений
взять /run/awg2-harness/traffic.lock (timeout 10 c)
target = discover_target() target = discover_target()
peers = discover_peers(target) peers = discover_peers(config, target)
if peers empty: если peers пуст:
log and return NoPeers залогировать "no peers found, skip tc apply" и выйти
desired = build_desired_state(target, peers, config) drift = inspect_and_diff(target, peers, config)
actual = inspect_actual_state(target, desired) если drift отсутствует:
drift = diff(desired, actual) залогировать "traffic rules up-to-date, skip apply" и выйти
firewall_desired = build_firewall_desired_state(target, config) teardown_owned_topology(target)
firewall_actual = inspect_firewall_state(target, firewall_desired) apply_full_topology(target, peers, config) # внутри — verify-after-apply
firewall_drift = diff(firewall_desired, firewall_actual)
if drift.none and firewall_drift.none:
log "rules already up-to-date"
return NoChange
if drift.exists:
teardown_owned_topology(target, desired)
apply_full_topology(target, desired)
verify_after_apply(target, desired)
if firewall_drift.exists:
apply_owned_firewall_table(target, firewall_desired)
verify_firewall_after_apply(target, firewall_desired)
``` ```
For production, controlled full rebuild on drift is the only supported path. ```text
apply_firewall():
взять /run/awg2-harness/firewall.lock (timeout 10 c)
apply_host_firewall(config) # host namespace, таблица inet awg2_host_firewall
apply_antitorrent_firewall(config) # namespace по anti_torrent.apply_in_amnezia_netns
```
Parser hardening requirements: Каждый из firewall-путей сначала инспектирует фактическое состояние и выходит без изменений, если drift отсутствует. При наличии drift владеемая таблица удаляется и создаётся заново, после чего выполняется обязательная повторная инспекция.
- `tc filter show` must be parsed block-by-block (state-machine), not by naive token scanning across unrelated lines; Для production контролируемая полная пересборка при drift — единственный поддерживаемый путь.
- mapping `peer_ip -> class_id` must be validated for both egress (`1:`) and ingress (`2:`) sides;
- verification must fail when classes/filters exist but mapping differs from deterministic assignment.
Apply/verify policy: Требования к устойчивости парсеров:
- all apply steps are explicit command invocations; - `tc filter show` разбирается блоками (конечный автомат), а не наивным сканированием токенов по несвязанным строкам;
- expected idempotency conflicts (`already exists`) may be tolerated selectively; - отображение `peer_ip -> class_id` проверяется для обеих сторон: egress (`1:`) и ingress (`2:`);
- any other apply error is fatal; - проверка обязана падать, если классы и фильтры существуют, но отображение отличается от детерминированного присвоения.
- `verify_after_apply` is mandatory for both traffic and anti-torrent subsystems.
Anti-torrent observability contract: Политика apply/verify:
- named counter `antitorrent_dropped_v4` must be present in owned table; - все шаги apply — явные вызовы команд;
- status output must expose both dropped packets and dropped bytes. - ожидаемые конфликты идемпотентности допускаются выборочно, через отдельную обёртку, и по закрытому списку признаков: `file exists`, `eexist`, `object already exists`, `exclusive flag on`;
- ошибки «объект не найден» (`no such`, `not found`, `cannot find`, `does not exist`, `handle of zero`) допускаются только на шагах удаления — в teardown и при отключении владеемых таблиц;
- любая другая ошибка apply фатальна;
- `verify-after-apply` обязателен и для traffic, и для anti-torrent.
## 10. Owned state and teardown rules Контракт наблюдаемости anti-torrent:
The binary owns: - в владеемой таблице обязан присутствовать именованный counter `antitorrent_dropped_v4`;
- вывод status обязан показывать и дропнутые пакеты, и дропнутые байты.
- root qdisc `1:` on selected AWG/WG interface; ## 10. Owned state и правила teardown
- ingress qdisc `ffff:` on selected AWG/WG interface when traffic limits are enabled;
- IFB device named `traffic.ifb_name`, default `ifb0`, in the target namespace;
- root qdisc `2:` on IFB;
- classes and filters under these handles.
Teardown may delete and recreate these owned objects. Because the server is dedicated, this is acceptable and preferable to leaving stale or partial rules. Бинарь владеет:
The binary should not blindly flush unrelated host firewall rules, Docker NAT rules, or unrelated interfaces. Firewall ownership must be explicit and scoped to the AWG2 Harness table, for example `table inet awg2_harness`, inside the target network namespace. - root qdisc `1:` на выбранном интерфейсе AWG/WG;
- ingress qdisc `ffff:` на выбранном интерфейсе AWG/WG, когда лимиты включены;
- IFB-устройством с именем `traffic.ifb_name` (default `ifb0`) в целевом namespace;
- root qdisc `2:` на IFB;
- классами и фильтрами под этими handles.
Teardown может удалять и пересоздавать эти владеемые объекты. Поскольку сервер выделенный, это допустимо и предпочтительнее, чем оставлять устаревшие или частичные правила.
Бинарь не должен вслепую сбрасывать посторонние host firewall rules, Docker NAT rules или чужие интерфейсы. Владение firewall обязано быть явным и ограниченным таблицами AWG2 Harness: `inet awg2_harness` в целевом network namespace и `inet awg2_host_firewall` в host namespace.
## 11. Anti-torrent firewall guard ## 11. Anti-torrent firewall guard
This is part of the AWG2 production contract. It fits the multi-orchestrator model because the server is dedicated and the binary is allowed to own host/network rules required for the VPN product. The rule must still be scoped narrowly and observable. Это часть production-контракта AWG2. Модуль вписывается в мульти-оркестраторную модель, потому что сервер выделенный и бинарю разрешено владеть host/network правилами, нужными VPN-продукту. Правило всё равно обязано быть узко ограниченным и наблюдаемым.
Goal: limit concurrent conntrack flows per VPN peer IP. This is not torrent protocol detection. It is a generic connection-count guard that makes high-peer-count P2P workloads unattractive while leaving normal browsing, streaming, gaming and messaging traffic unaffected. Цель — ограничить количество одновременных conntrack flows на один VPN peer IP. Это не детектирование торрент-протокола, а обобщённый connection-count guard, который делает P2P-нагрузку с большим числом peers непривлекательной, не задевая обычные browsing, streaming, gaming и messaging.
The rule must not be implemented as a global subnet limit: Правило не должно быть глобальным лимитом на подсеть:
```nft ```nft
ip saddr 10.8.1.0/24 ct count over 200 drop ip saddr 10.8.1.0/24 ct count over 200 drop
``` ```
That form can become one shared counter for the whole matched traffic set, depending on how it is written and where it is placed. For per-peer behavior, key the stateful object by source VPN IP. Recommended shape: Такая форма может превратиться в один общий счётчик на весь совпавший трафик — в зависимости от того, как она написана и куда помещена. Для per-peer поведения stateful-объект нужно ключевать по source VPN IP. Рекомендуемая форма:
```nft ```nft
table inet awg2_harness { table inet awg2_harness {
@@ -364,166 +364,198 @@ table inet awg2_harness {
} }
``` ```
Target namespace: the same Amnezia network namespace where `tc` shaping is applied. Do not assume the initial host namespace sees original peer source addresses. If Amnezia performs NAT inside the container namespace, the host Docker bridge may only see the container address, not `10.8.1.x`. Целевой namespace — тот же Amnezia network namespace, где применяется `tc` shaping. Нельзя предполагать, что начальный host namespace видит исходные source-адреса peers. Если Amnezia выполняет NAT внутри namespace контейнера, host Docker bridge может видеть только адрес контейнера, а не `10.8.1.x`.
Default threshold: `100` concurrent flows per peer IP. `150` is the conservative compatibility setting if there are complaints from unusually connection-heavy WebSocket/WebRTC/trading-browser workloads. `70` is more aggressive and should only be used after real traffic observation. Порог в примере конфигурации — `150` одновременных flows на peer IP. `100` — более строгий режим. `70` агрессивнее и применяется только после наблюдения реального трафика. `200`/`250` — более permissive варианты.
Rule placement: use `forward` hook with priority `filter` or any priority after conntrack lookup. Restrict evaluation to `ct state new` so established flows continue normally and only new flow attempts beyond the threshold are dropped. Размещение правила: hook `forward` с приоритетом `filter` или любым приоритетом после conntrack lookup. Оценка ограничивается `ct state new`, чтобы установленные flows продолжали работать и дропались только новые попытки сверх порога.
Owned state: Владеемое состояние:
- table `inet awg2_harness`; - таблица `inet awg2_harness`;
- chain `forward_antitorrent`; - chain `forward_antitorrent`;
- counter `antitorrent_dropped_v4`; - counter `antitorrent_dropped_v4`;
- meter/set state created by the rule. - meter/set state, создаваемый правилом.
Status must show whether the table/chain/rule exists, the configured threshold, the peer CIDR, and the drop counter. It should also expose host conntrack pressure from `net.netfilter.nf_conntrack_count` and `net.netfilter.nf_conntrack_max` when available. Status обязан показывать наличие таблицы/chain/правила, настроенный порог и drop counter. Также он показывает давление на conntrack хоста через `net.netfilter.nf_conntrack_count` и `net.netfilter.nf_conntrack_max`, когда они доступны.
If `anti_torrent.enabled = false`, the daemon should remove or leave inactive only the AWG2-owned anti-torrent table according to config policy. It must never flush the global nftables ruleset. При `anti_torrent.enabled = false` демон удаляет только владеемую anti-torrent таблицу. Глобальный ruleset nftables не сбрасывается никогда. Если контейнер в этот момент отсутствует, удаление в его namespace просто пропускается — это не ошибка.
## 12. Actual-state inspection ## 12. Инспекция фактического состояния
`inspect_actual_state()` must collect enough information to diagnose drift: `inspect_and_diff()` собирает достаточно информации для диагностики drift:
- qdisc on `<iface>`; - qdisc на `<iface>`;
- classes on `<iface>`; - классы на `<iface>`;
- qdiscs attached to per-peer classes on `<iface>`; - qdiscs, привязанные к per-peer классам на `<iface>`;
- filters under parent `1:` on `<iface>`; - фильтры под parent `1:` на `<iface>`;
- ingress qdisc on `<iface>`; - ingress qdisc на `<iface>`;
- ingress redirect filter on `<iface>`; - ingress redirect filter на `<iface>`;
- existence and state of `<ifb_name>`; - существование и состояние `<ifb_name>`;
- qdisc on `<ifb_name>`; - qdisc на `<ifb_name>`;
- classes on `<ifb_name>`; - классы на `<ifb_name>`;
- filters under parent `2:` on `<ifb_name>`. - фильтры под parent `2:` на `<ifb_name>`.
`status` should display a compact summary and optionally a verbose mode for raw `tc` sections. Дополнительно детектируются дубли: один и тот же peer IP в нескольких фильтрах и один и тот же class id у нескольких IP. Оба случая считаются drift.
## 13. Docker events and periodic reconciliation `status` показывает компактную сводку и таблицу по peers.
The daemon should use two triggers: ## 13. Docker events и периодический reconcile
1. Docker events: container start/restart signals cause delayed reconcile after interface readiness. Демон использует два триггера:
2. Timer: periodic reconcile every `traffic.reconcile_interval_secs`.
The timer is mandatory. Events can be missed during daemon restart, Docker restart or transient errors. 1. Docker events: `docker events --filter event=start --format '{{.Actor.Attributes.name}}'`. Имя сверяется с `amnezia.container_names`, после чего выполняется ожидание готовности интерфейса и reconcile traffic + firewall. Если поток событий завершился, он перезапускается через 10 секунд.
2. Таймер: периодический reconcile каждые `traffic.reconcile_interval_secs`, но не чаще одного раза в 5 секунд.
Readiness check must not hardcode `awg0`. It must call the shared interface detector until timeout. Таймер обязателен. События могут быть пропущены при рестарте демона, рестарте Docker или транзиентных ошибках.
## 14. Locking Проверка готовности не должна хардкодить `awg0`. Она вызывает общий детектор интерфейса с интервалом 2 секунды до истечения `traffic.container_ready_timeout_secs`.
Only one traffic reconcile may run at a time across all subcommands and daemon tasks. Порядок на старте демона и разница в критичности ошибок:
Recommended lock: 1. `sysctl_tuning`, если `apply_on_daemon_start = true` — ошибка **фатальна**, демон завершается;
2. `swap` — ошибка **фатальна**, демон завершается;
3. `traffic` — ошибка логируется, демон продолжает работу;
4. `firewall` — ошибка логируется, демон продолжает работу.
```text Такое разделение намеренное: некорректный baseline хоста или swap означают неверные предпосылки, тогда как отсутствие контейнера Amnezia — нормальное транзиентное состояние.
/run/awg2-harness/traffic.lock
```
Behavior: Результаты пишутся в два файла:
- `daemon` skips overlapping reconcile and logs it; - `/run/awg2-harness/last_action.state` — последний успех и последняя ошибка;
- manual `apply-limits` either waits briefly or exits with a clear message; - `/run/awg2-harness/events.log` — журнал событий, ограниченный 1000 строками и 256 KiB.
- lock release is automatic when process exits.
## 15. Status output contract ## 14. Блокировки
`awg2-harness status` should show: Одновременно может выполняться только один reconcile каждого вида — по всем подкомандам и задачам демона.
Блокировки:
| Файл | Таймаут ожидания | Кого защищает |
| --- | --- | --- |
| `/run/awg2-harness/traffic.lock` | 10 с | `reconcile_traffic` |
| `/run/awg2-harness/firewall.lock` | 10 с | `apply_firewall` (host + anti-torrent) |
| `/run/awg2-harness/sysctl_tuning.lock` | 30 с | `ensure_sysctl_tuning` |
Поведение:
- ожидание захвата — с интервалом 100 мс до таймаута, затем ошибка `lock busy timeout`;
- в файл записывается PID владельца;
- если процесс-владелец мёртв (`/proc/<pid>` отсутствует) или содержимое файла не парсится как PID, блокировка считается устаревшей и перезахватывается;
- освобождение автоматическое при выходе из области видимости, включая пути с ошибкой.
## 15. Контракт вывода status
`awg2-harness status` печатает блоки в следующем порядке. Имена полей стабильны и пригодны для grep.
```text ```text
System: System:
service: active/inactive/unknown service: active|inactive|unknown
docker: available/unavailable Network tuning:
container: name, id, status, pid enabled, apply_on_daemon_start, tcp_bbr_mode, conf_path, modules_load_path,
значения управляемых ключей, bbr_available, bbr_active, drift[, reason][, warning]
container: <name>
container_id: <id>
pid: <pid>
netns: /proc/<pid>/ns/net netns: /proc/<pid>/ns/net
interface: awg0/wg0/... interface: <iface>
Peers: Peers:
total: N total: N
each peer: ip, optional public key prefix, handshake, rx/tx - <peer ip>
WireGuard clients:
IP, State, Latest handshake, RX, TX, Public key, Endpoint
Traffic desired: Traffic desired:
peer_limit, root_rate, ifb_name, class_start_id peer_limit, root_rate, ifb_name, class_start_id
Traffic actual: Traffic actual:
egress qdisc/classes/filters: ok/drift drift: no|yes[, reason]
ingress redirect: ok/drift topology: egress_htb_present, ingress_present, ifb_htb_present
ifb topology: ok/drift peer_table: ip, class_id, egress_filter, ingress_filter, egress, ingress, drift
stale peers: list Peer traffic counters:
missing peers: list IP, Egress hits, Ingress hits
Host firewall:
Anti-torrent actual: enabled, ssh_port, amnezia_port, input_policy, drift[, reason]
enabled: true/false Docker/Amnezia port path:
nft table/chain/rule: ok/drift docker_userland_proxy, published_udp_port, host_forward_policy_drop,
max_connections_per_peer docker_forward_rules_present, note[, warning]
dropped_new_flows counter Anti-torrent:
conntrack_count / conntrack_max if available enabled, max_connections_per_peer, drift[, reason], dropped_packets, dropped_bytes
Swap:
enabled, desired_path, desired_size_mb, desired_priority, active_entries,
desired_active, exclusive, fstab_exclusive, fstab_swap_entries,
swappiness_current, swappiness_persisted, drift[, drift_reason]
Conntrack:
nf_conntrack_count, nf_conntrack_max
Last actions: Last actions:
last reconcile result содержимое /run/awg2-harness/last_action.state
last error if any Recent events:
последние 10 строк /run/awg2-harness/events.log
Reboot scheduler:
enabled, window_start_utc, window_end_utc, service, planned
``` ```
A generic status banner like “system ready” is not enough. Rust status must be falsifiable: it should clearly say when state is partial. Блок `Network tuning` печатается между строкой `service` и строками `container`/`pid`/`netns`/`interface`, хотя последние логически относятся к `System`. Это текущее фактическое поведение; порядок зафиксирован здесь, чтобы автоматические проверки опирались на реальность.
## 16. Reboot module integration Если контейнер обнаружить не удалось, `status` не завершается ошибкой, а переходит в degraded-режим: секции `Peers` и `WireGuard clients` печатают `unavailable`, `Traffic actual` показывает `drift: unknown`, блок `Docker/Amnezia port path` заполняется значениями `unknown`, а блок `Peer traffic counters` не печатается вовсе. Секции host firewall, anti-torrent, swap, conntrack, событий и reboot остаются на месте.
Production reboot path is external binary/service `awg2-reboot-scheduler`: Отдельные секции деградируют независимо: ошибка чтения peer state не мешает вывести traffic, firewall и swap.
- window from config, not constants; Универсальный баннер вида «system ready» недопустим. Status обязан быть фальсифицируемым: он должен явно сообщать, когда состояние частичное.
- random point inside reboot window (including cross-midnight windows);
- `systemctl --no-block reboot` remains;
- disabled when `reboot.enabled = false`;
- planned reboot timestamp is persisted in `/run/awg2-harness/reboot.plan`.
## 17. Swap module ## 16. Интеграция reboot-модуля
`ensure-swap` desired state: Production-путь перезагрузки — внешний бинарь/сервис `awg2-reboot-scheduler`:
- path from config, default `/swapfile`; - окно берётся из конфига, а не из констант;
- size 2048 MB; - случайная точка внутри окна, включая окна через полночь;
- mode `0600`; - вызов остаётся `systemctl --no-block reboot`;
- `mkswap` completed; - при `reboot.enabled = false` планирование отключено;
- active in `swapon --show`; - запланированная отметка времени сохраняется в `/run/awg2-harness/reboot.plan`.
- one `/etc/fstab` entry with configured priority;
- `vm.swappiness = 1` persisted and applied.
When `swap.enabled=true`, AWG2 Harness owns host swap state exclusively. ## 17. Модуль swap
The desired state is exactly one active swap entry: `swap.path`.
All other active swap devices/files are disabled via `swapoff`.
All non-comment swap entries in `/etc/fstab` are removed and replaced by one managed AWG2 entry.
Regular-file swapfiles that are not the configured `swap.path` may be deleted only after successful `swapoff`.
Block devices are never deleted, only disabled and removed from fstab.
The operation must be idempotent.
## 18. Testing Целевое состояние `ensure-swap`:
Unit tests: - путь из конфига, default `/swapfile`;
- размер `swap.size_mb`, в примере конфигурации — 2048 MB;
- права `0600`;
- выполнен `mkswap`;
- запись активна в `swapon --show`;
- одна запись в `/etc/fstab` с настроенным приоритетом;
- `vm.swappiness` из конфига применён в runtime и сохранён persistently.
- config validation, including `amnezia.wg_binaries` shape; При `swap.enabled = true` AWG2 Harness владеет состоянием swap на хосте эксклюзивно. Целевое состояние — ровно одна активная swap-запись: `swap.path`. Все прочие активные swap-устройства и файлы выключаются через `swapoff`. Все не-комментарные swap-записи в `/etc/fstab` удаляются и заменяются одной управляемой записью AWG2; перед перезаписью создаётся резервная копия `/etc/fstab.awg2-harness.bak`. Обычные swap-файлы, не равные настроенному `swap.path`, могут быть удалены только после успешного `swapoff`. Блочные устройства не удаляются никогда — только выключаются и убираются из fstab. Операция обязана быть идемпотентной.
- `awg|wg show all allowed-ips` parser, including multiple space-separated allowed ips;
- `awg|wg show` fallback parser;
- `awg|wg show all dump` peer/interface line classification against AWG 3.0 (28 columns),
AWG 2.0 (14 columns) and plain WireGuard (5 columns) interface lines;
- wg binary resolution order and the error raised when no binary is available;
- CIDR filtering;
- deterministic class assignment with lexicographic class-id stability contract;
- nftables ruleset rendering and parsing for the AWG2-owned table;
- `tc` parser for qdisc/class/filter snapshots;
- drift diff;
- reboot window calculation;
- fstab/sysctl idempotency.
Integration tests on real host: Создание файла: `fallocate`, при неудаче — fallback на `dd`.
- initial apply; ## 18. Тестирование
- repeated apply;
- partial state recovery after deleting only ingress qdisc; Модульные тесты:
- partial state recovery after deleting `ifb0`;
- container restart; - валидация конфига, включая форму `amnezia.wg_binaries`;
- Docker restart; - разбор `config.example.toml` в актуальную схему;
- host reboot; - парсер `awg|wg show all allowed-ips`, включая несколько allowed ips через пробел;
- peer addition; - fallback-парсер `awg|wg show`;
- peer deletion; - классификация peer/interface строк `awg|wg show all dump` против interface-строк AWG 3.0 (28 колонок), AWG 2.0 (14 колонок) и чистого WireGuard (5 колонок);
- status output in healthy and broken states; - порядок выбора бинаря wg и ошибка при отсутствии всех кандидатов;
- anti-torrent rule counter increments when a peer exceeds the configured concurrent-flow threshold; - фильтрация по CIDR;
- anti-torrent disable path does not flush unrelated nftables/Docker/Amnezia rules. - детерминированное присвоение class id с контрактом лексикографической стабильности;
- рендеринг и разбор ruleset nftables для владеемых таблиц;
- парсер `tc` для снимков qdisc/class/filter;
- diff drift;
- расчёт окна перезагрузки;
- идемпотентность fstab и sysctl.
Интеграционные тесты на реальном хосте:
- первичное применение;
- повторное применение;
- восстановление частичного состояния после удаления только ingress qdisc;
- восстановление частичного состояния после удаления `ifb0`;
- рестарт контейнера;
- рестарт Docker;
- перезагрузка хоста;
- добавление peer;
- удаление peer;
- вывод status в здоровом и сломанном состояниях;
- рост counter anti-torrent при превышении peer настроенного порога одновременных flows;
- путь отключения anti-torrent не сбрасывает посторонние правила nftables/Docker/Amnezia.
+18 -18
View File
@@ -1,8 +1,8 @@
# Installation # Установка
Этот документ описывает production-порядок установки AWG2 Harness. Подробный runtime-план с SSH hardening и Amnezia preflight находится в корневом [`README.md`](../README.md). Этот документ описывает production-порядок установки AWG2 Harness. Подробный runtime-план с SSH hardening и Amnezia preflight находится в корневом [`README.md`](../README.md).
## Deployment contract ## Контракт развёртывания
- Целевой production-хост: Debian 13 amd64. - Целевой production-хост: Debian 13 amd64.
- Amnezia ставится до AWG2 Harness официальным self-hosted способом через приложение AmneziaVPN. - Amnezia ставится до AWG2 Harness официальным self-hosted способом через приложение AmneziaVPN.
@@ -13,9 +13,9 @@
- Пакет не устанавливает Docker/Amnezia и не включает services автоматически. - Пакет не устанавливает Docker/Amnezia и не включает services автоматически.
- `sysctl_tuning` не применяется в `postinst`; применение только на старте daemon или вручную через CLI. - `sysctl_tuning` не применяется в `postinst`; применение только на старте daemon или вручную через CLI.
## A. Build machine ## A. Сборочная машина
### A1. Подготовить build environment ### A1. Подготовить сборочное окружение
```bash ```bash
cd awg2_harness cd awg2_harness
@@ -207,11 +207,11 @@ nsenter -t "$AWG_PID" -n ip route
nsenter -t "$AWG_PID" -n dig @"$DNS_IP" example.com A +short nsenter -t "$AWG_PID" -n dig @"$DNS_IP" example.com A +short
``` ```
## C. Transfer artifacts ## C. Передача артефактов
Ниже два production-сценария установки. Ниже два production-сценария установки.
### C1. Вариант A: config передаётся вместе с `.deb` ### C1. Вариант A: конфиг передаётся вместе с `.deb`
```bash ```bash
scp -P 2323 dist/awg2-harness_*_amd64.deb root@<server-ip>:/root/ scp -P 2323 dist/awg2-harness_*_amd64.deb root@<server-ip>:/root/
@@ -227,14 +227,14 @@ scp -P 2323 config.example.toml root@<server-ip>:/root/awg2-harness.config.toml
- `[anti_torrent].peer_ipv4_cidr` обычно равен `[amnezia].peer_ipv4_cidr`; - `[anti_torrent].peer_ipv4_cidr` обычно равен `[amnezia].peer_ipv4_cidr`;
- `peer_limit` и `root_rate` отражают нужную traffic policy. - `peer_limit` и `root_rate` отражают нужную traffic policy.
### C2. Вариант B (основной): config создаётся на production после установки ### C2. Вариант B (основной): конфиг создаётся на production после установки
```bash ```bash
scp -P 2323 dist/awg2-harness_*_amd64.deb root@<server-ip>:/root/ scp -P 2323 dist/awg2-harness_*_amd64.deb root@<server-ip>:/root/
scp -P 2323 dist/SHA256SUMS root@<server-ip>:/root/ scp -P 2323 dist/SHA256SUMS root@<server-ip>:/root/
``` ```
## D. Production install ## D. Установка на production
### D1. Вариант A ### D1. Вариант A
@@ -266,7 +266,7 @@ systemctl daemon-reload
- `[amnezia].peer_ipv4_cidr` совпадает с peer-подсетью; - `[amnezia].peer_ipv4_cidr` совпадает с peer-подсетью;
- `[anti_torrent].peer_ipv4_cidr` обычно тот же CIDR. - `[anti_torrent].peer_ipv4_cidr` обычно тот же CIDR.
## E. Preflight before enable ## E. Preflight до включения сервисов
```bash ```bash
awg2-harness --config /etc/awg2-harness/config.toml validate-config awg2-harness --config /etc/awg2-harness/config.toml validate-config
@@ -277,7 +277,7 @@ awg2-harness --config /etc/awg2-harness/config.toml apply-limits
awg2-harness --config /etc/awg2-harness/config.toml status awg2-harness --config /etc/awg2-harness/config.toml status
``` ```
Expected healthy result: Ожидаемый здоровый результат:
```text ```text
Traffic actual: Traffic actual:
@@ -290,11 +290,11 @@ Swap:
drift: no drift: no
``` ```
If there are no peers yet, `apply-limits` may print `no peers found, skip tc apply`. That is healthy for an empty server. Если peers ещё нет, `apply-limits` напечатает `no peers found, skip tc apply`. Для пустого сервера это здоровое состояние.
When `swap.enabled=true`, AWG2 Harness intentionally makes swap exclusive: one managed swapfile, one fstab swap entry, configured swappiness. При `swap.enabled = true` AWG2 Harness намеренно делает swap эксклюзивным: один управляемый swapfile, одна swap-запись в `/etc/fstab`, заданный `swappiness`.
## F. Enable services ## F. Включение сервисов
```bash ```bash
systemctl enable --now awg2-harness.service systemctl enable --now awg2-harness.service
@@ -306,19 +306,19 @@ journalctl -u awg2-harness.service --since '5 minutes ago' --no-pager
journalctl -u awg2-reboot-scheduler.service --since '5 minutes ago' --no-pager journalctl -u awg2-reboot-scheduler.service --since '5 minutes ago' --no-pager
``` ```
After 30–60 seconds: Через 30–60 секунд:
```bash ```bash
awg2-harness --config /etc/awg2-harness/config.toml status awg2-harness --config /etc/awg2-harness/config.toml status
``` ```
## G. Reboot validation ## G. Проверка перезагрузкой
```bash ```bash
reboot reboot
``` ```
After reconnect: После восстановления подключения:
```bash ```bash
systemctl status docker --no-pager -l systemctl status docker --no-pager -l
@@ -327,6 +327,6 @@ systemctl status awg2-reboot-scheduler.service --no-pager -l
awg2-harness --config /etc/awg2-harness/config.toml status awg2-harness --config /etc/awg2-harness/config.toml status
``` ```
## H. Manual source-tree install ## H. Ручная установка из дерева исходников
`./tools/build.sh --install` and `./tools/build.sh --enable` exist for staging/debug. They are not the recommended production path. Use `.deb` on production. `./tools/build.sh --install` и `./tools/build.sh --enable` существуют для staging и отладки. Это не рекомендуемый production-путь: на production используйте `.deb`.
+2 -2
View File
@@ -1,6 +1,6 @@
# Production example (reference) # Эталонный production-пример
Этот пример фиксирует эталонный production-state после успешной установки и preflight. Значения обезличены и предназначены как reference для smoke-проверки. Документ фиксирует эталонное состояние production после успешной установки и preflight. Значения обезличены и предназначены как ориентир для smoke-проверки.
```text ```text
SSH: SSH:
+13 -7
View File
@@ -2,16 +2,22 @@
Рекомендуемый порядок чтения: Рекомендуемый порядок чтения:
1. [`../README.md`](../README.md) — общий runtime-план: SSH, официальная Amnezia, установка пакета, preflight, reboot validation и CLI. 1. [`../README.md`](../README.md) — общий runtime-план: SSH, официальная Amnezia, установка пакета, preflight, проверка перезагрузкой и CLI.
2. [`INSTALLATION.md`](INSTALLATION.md) — сжатый production install flow. 2. [`INSTALLATION.md`](INSTALLATION.md) — сжатый порядок установки на production.
3. [`CONFIGURATION.md`](CONFIGURATION.md) — параметры `/etc/awg2-harness/config.toml`. 3. [`CONFIGURATION.md`](CONFIGURATION.md) — параметры `/etc/awg2-harness/config.toml`.
4. [`ACCEPTANCE_CHECKLIST.md`](ACCEPTANCE_CHECKLIST.md) — checklist перед production-ready статусом. 4. [`ACCEPTANCE_CHECKLIST.md`](ACCEPTANCE_CHECKLIST.md) — чек-лист приёмки перед статусом production-ready.
5. [`ANTI_TORRENT.md`](ANTI_TORRENT.md) — nftables connection-count guard. 5. [`ANTI_TORRENT.md`](ANTI_TORRENT.md) — ограничение числа соединений через nftables.
6. [`IMPLEMENTATION_SPEC.md`](IMPLEMENTATION_SPEC.md) — архитектура и state reconciliation. 6. [`IMPLEMENTATION_SPEC.md`](IMPLEMENTATION_SPEC.md) — архитектура и reconciliation состояния.
Отдельно: Отдельно:
- [`../tools/README.md`](../tools/README.md) — сборка и упаковка `.deb`. - [`../tools/README.md`](../tools/README.md) — сборка и упаковка `.deb`.
- [`../reboot_module/README.md`](../reboot_module/README.md) — внешний reboot scheduler. - [`../reboot_module/README.md`](../reboot_module/README.md) — внешний планировщик перезагрузки.
Практический порядок для оператора: сначала выполнить root README, затем свериться с acceptance checklist. Остальные документы нужны для понимания design intent и отладки. Практический порядок для оператора: сначала выполнить корневой README, затем свериться с чек-листом приёмки. Остальные документы нужны для понимания замысла и отладки.
## Язык документации
Проза — на русском. Английский остаётся там, где он технически обязателен: команды, вывод утилит, ключи конфигурации, имена файлов, модулей, таблиц nftables и handles `tc`. Термины, у которых нет устоявшегося русского эквивалента в этом контексте (`drift`, `reconcile`, `peer`, `namespace`, `handshake`), не переводятся.
`IMPLEMENTATION_SPEC.md` описывает фактическое состояние кода. Расхождение между ним и `traffic_core/src` считается дефектом документа, а не допустимым упрощением.