bf43b763cd
The previous implementation had every failure mode a home-grown PID lock has: - contention was detected by matching the substring "lock already exists" in an error message, so any change to the message would have turned a busy lock into a hard error; - stale-lock recovery removed the file and created it again as two separate operations, so two processes could each decide the lock was stale and each take it; - a reused PID made a live lock look stale, since the check was only whether /proc/<pid> exists; - Drop deleted the lock file, racing with a process that had already opened it and was sleeping on the retry loop - it would then lock a file that no longer has a directory entry, and the next arrival would create a fresh one and lock that instead. Replaced with advisory kernel locking through std::fs::File::try_lock, stabilised in Rust 1.89 and backed by flock on Linux. No new dependency. The kernel releases the lock when the holder exits by any means, including kill -9, so there is no stale state to reason about and no recovery heuristic to get wrong. The PID is still written to the file, but purely as diagnostics - nothing reads it back to make a decision. The lock file is now never deleted. Its presence carries no meaning; only the lock held on it does. That removes the race the old Drop introduced. Five tests: a second acquisition of a live lock fails, timeout reports lock busy timeout, the lock is free again once the guard drops, the file survives release, and a leftover file from a previous run does not block acquisition. Behaviour note for the four call sites (traffic, firewall, sysctl_tuning, swap): timeouts are unchanged, and a process killed mid-reconcile no longer leaves anything that needs cleaning up before the next run. cargo test --workspace: 158 + 4 passed. clippy -D warnings clean.
684 lines
53 KiB
Markdown
684 lines
53 KiB
Markdown
# Спецификация реализации AWG2 Harness
|
||
|
||
Документ описывает архитектуру и контракт reconciliation. Он отражает фактическое состояние кода, а не только замысел: расхождения между этим документом и `traffic_core/src` считаются дефектом документа.
|
||
|
||
## 1. Архитектурная роль
|
||
|
||
Production-пакет AWG2 Harness содержит два Rust-бинаря:
|
||
|
||
- `awg2-harness` — host-side оркестратор traffic/firewall/status/swap;
|
||
- `awg2-reboot-scheduler` — отдельный сервис планировщика перезагрузки.
|
||
|
||
`awg2-harness` остаётся основным host-side мульти-оркестратором для выделенного сервера Debian 13 amd64 с AmneziaWG/AWG 2.0.
|
||
|
||
Он владеет инфраструктурным состоянием, которое нужно VPN-сервису:
|
||
|
||
- обнаружение контейнера Amnezia;
|
||
- обнаружение network namespace контейнера;
|
||
- обнаружение интерфейса AWG/WG;
|
||
- обнаружение peers;
|
||
- целевое состояние traffic control;
|
||
- ingress shaping через IFB;
|
||
- nftables connection-count guard (anti-torrent);
|
||
- восстановление после Docker lifecycle events;
|
||
- периодический reconcile;
|
||
- status и диагностика;
|
||
- плановая перезагрузка;
|
||
- аварийная настройка swap.
|
||
|
||
Он не заменяет Amnezia. Он управляет окружающим host-side состоянием, которое нужно сервису Amnezia.
|
||
|
||
## 2. Модель процессов
|
||
|
||
Работают два systemd-сервиса:
|
||
|
||
```bash
|
||
/usr/sbin/awg2-harness --config /etc/awg2-harness/config.toml daemon
|
||
/usr/sbin/awg2-reboot-scheduler --config /etc/awg2-harness/config.toml
|
||
```
|
||
|
||
Демон `awg2-harness` стартует от root и работает постоянно. На старте выполняет начальный reconcile. В runtime реагирует на Docker events и параллельно крутит периодический цикл reconcile.
|
||
|
||
Право планировать перезагрузку принадлежит сервису `awg2-reboot-scheduler`. `awg2-harness` не планирует перезагрузки внутри себя.
|
||
|
||
### Конфигурация читается один раз
|
||
|
||
Оба процесса загружают `/etc/awg2-harness/config.toml` при старте и дальше работают с этой копией. Правка файла **не применяется автоматически**. Реализовывать hot reload сейчас намеренно не планируется: это потребовало бы повторной валидации, атомарной подмены текущего конфига, перепланирования reboot, смены контейнера и интерфейса на лету и отката при неверной конфигурации.
|
||
|
||
Порядок применения изменений:
|
||
|
||
```bash
|
||
awg2-harness --config /etc/awg2-harness/config.toml validate-config
|
||
awg2-harness --config /etc/awg2-harness/config.toml validate-runtime
|
||
systemctl restart awg2-harness.service
|
||
```
|
||
|
||
При изменении секции `[reboot]` дополнительно:
|
||
|
||
```bash
|
||
systemctl restart awg2-reboot-scheduler.service
|
||
```
|
||
|
||
`systemctl daemon-reload` для правки TOML не нужен — он относится к unit-файлам, а не к конфигу приложения. `ExecReload` в units намеренно отсутствует: его добавление без реальной обработки SIGHUP создало бы ложное впечатление, что перезагрузка конфига поддерживается.
|
||
|
||
### Политика systemd
|
||
|
||
Обе службы используют `Type=exec`, а не `Type=simple`: старт считается успешным только после фактического запуска бинаря, поэтому отсутствующий или неисполняемый файл роняет unit, а не рапортует об успешном старте.
|
||
|
||
`Restart=on-failure` для обеих. Для планировщика это обязательно: он штатно завершается с кодом 0 в двух случаях — при `reboot.enabled = false` и после того, как systemd принял запрос на перезагрузку. С `Restart=always` он перезапускался бы в цикле на хосте с отключёнными перезагрузками.
|
||
|
||
Задержка перезапуска ступенчатая: `RestartSec=10s`, `RestartSteps=4`, `RestartMaxDelaySec=160s`, то есть 10 → 20 → 40 → 80 → 160 секунд. Плотный цикл перезапусков против ещё поднимающейся зависимости (Docker, контейнер, сеть) только сжигает лимит стартов до того, как зависимость успеет восстановиться. Окно лимита расширено до `StartLimitIntervalSec=15min` при `StartLimitBurst=10`, иначе полная последовательность backoff не укладывалась бы в него.
|
||
|
||
`ExecStartPre` с `ensure-swap` удалён. Демон выполняет swap первым шагом собственной startup-последовательности, где действуют единый error contract, единая блокировка и единый поток логов. Дублирование давало две блокировки, два чтения `/proc/swaps` и раздвоенный путь диагностики отказа. Отдельная CLI-команда `ensure-swap` при этом остаётся доступной оператору.
|
||
|
||
Подкоманды:
|
||
|
||
```bash
|
||
awg2-harness daemon
|
||
awg2-harness apply-limits
|
||
awg2-harness apply-network-tuning
|
||
awg2-harness inspect-network-tuning
|
||
awg2-harness apply-firewall
|
||
awg2-harness status
|
||
awg2-harness ensure-swap
|
||
awg2-harness validate-config
|
||
awg2-harness validate-runtime
|
||
```
|
||
|
||
Глобальный флаг `--config`; если не задан, используется `/etc/awg2-harness/config.toml`.
|
||
|
||
Уровень валидации у подкоманд разный, и это часть контракта:
|
||
|
||
| Подкоманда | Валидация перед выполнением |
|
||
| --- | --- |
|
||
| `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`;
|
||
- не управляет `udp_wmem_min`.
|
||
|
||
### Управляемые файлы и владение
|
||
|
||
Все файлы, которые пакет пишет под `/etc`, проходят через общий модуль `managed_file.rs`. Правила одни для всех и не дублируются по модулям:
|
||
|
||
- ошибка чтения никогда не трактуется как отсутствие файла;
|
||
- файл, у которого первая строка не содержит маркер владельца, не перезаписывается и не удаляется — возвращается ошибка;
|
||
- запись атомарная: временный файл-сосед, `sync_all`, `rename`, синхронизация каталога, права `0644`;
|
||
- повторная запись того же содержимого не трогает файл.
|
||
|
||
| Файл | Владелец | Маркер |
|
||
| --- | --- | --- |
|
||
| `/etc/sysctl.d/90-awg2-harness-vpn.conf` | `sysctl_tuning` | `# Managed by awg2-harness sysctl_tuning` |
|
||
| `/etc/modules-load.d/90-awg2-harness-bbr.conf` | `sysctl_tuning` | `# Managed by awg2-harness sysctl_tuning` |
|
||
| `/etc/sysctl.d/90-awg2-harness-swap.conf` | `swap` | `# Managed by awg2-harness swap` |
|
||
|
||
Модуль `swap` раньше писал `vm.swappiness` в `/etc/sysctl.d/99-awg2-harness.conf` с другим маркером, без проверки владения перед перезаписью и без удаления при отключении. Файл переехал на `90-`, как и остальные. Старый путь удаляется автоматически, но **только** если он всё ещё несёт прежний маркер `# Managed by awg2-harness`; файл администратора по этому пути не трогается, и это не считается ошибкой миграции.
|
||
|
||
При `swap.enabled = false` управляемый файл удаляется. Runtime-значение `vm.swappiness` при этом намеренно не меняется: назначать произвольное значение означало бы выдумать политику, о которой оператор не просил. После перезагрузки действует kernel default. Активный swap при отключении модуля тоже не выключается — деструктивное действие не должно запускаться самим фактом отключения функции.
|
||
|
||
`net.ipv4.tcp_congestion_control` и `net.core.default_qdisc` попадают в целевое состояние только при активном BBR.
|
||
|
||
## 3. Структура модулей
|
||
|
||
```text
|
||
traffic_core/src/
|
||
├── main.rs точка входа и диспетчер подкоманд
|
||
├── cli.rs определение CLI (clap)
|
||
├── config.rs модель конфига, валидация, проверка host tools
|
||
├── daemon.rs цикл демона, Docker events, журнал событий
|
||
├── docker.rs обнаружение контейнера, диагностика port path
|
||
├── netns.rs транспорты выполнения команд
|
||
├── iface.rs обнаружение интерфейса
|
||
├── wg.rs обнаружение peers и разбор вывода awg/wg
|
||
├── traffic/
|
||
│ ├── mod.rs общие модели и точки входа
|
||
│ ├── parse_tc.rs чистые парсеры вывода tc/ip
|
||
│ ├── inspect.rs inspect_and_diff и контракт class id
|
||
│ └── apply.rs teardown и полная пересборка топологии
|
||
├── firewall/
|
||
│ ├── mod.rs apply_firewall, выбор namespace, общие константы
|
||
│ ├── nft.rs нормализация текста nftables и обёртки команд
|
||
│ ├── host.rs таблица inet awg2_host_firewall (host namespace)
|
||
│ └── antitorrent.rs таблица inet awg2_harness (namespace по конфигу)
|
||
├── sysctl_tuning.rs управляемый sysctl baseline
|
||
├── swap.rs управляемый swap
|
||
├── status.rs сборка и печать статуса
|
||
├── lock.rs файловые блокировки
|
||
└── error.rs типы ошибок
|
||
|
||
reboot_module/src/
|
||
└── main.rs планировщик перезагрузки
|
||
```
|
||
|
||
`traffic` разбит по ответственности: парсеры отдельно от инспекции, инспекция отдельно от apply. Чистые парсеры собраны в `parse_tc.rs` вместе со своими тестами — это тестируемая поверхность модуля, всё остальное требует живого контейнера.
|
||
|
||
`firewall` разбит по подсистемам, а не по фазам: host firewall и anti-torrent независимы, применяются в разных namespace и владеют разными таблицами. Общий у них только слой работы с `nft`.
|
||
|
||
Приватные типы объявлены в соответствующем `mod.rs`. В Rust приватные элементы видны в модуле объявления и всех дочерних модулях, поэтому подмодули работают с ними без расширения видимости до `pub(crate)`.
|
||
|
||
## 4. Модель данных
|
||
|
||
Модули работают на явных структурах, а не на строках, которые передаются по всему коду.
|
||
|
||
Фактические типы:
|
||
|
||
```rust
|
||
// docker.rs
|
||
struct RuntimeTarget {
|
||
container_name: String,
|
||
container_id: String,
|
||
container_pid: u32,
|
||
netns_path: String, // /proc/<pid>/ns/net
|
||
interface_name: String,
|
||
}
|
||
|
||
// wg.rs — состав peers для shaping
|
||
struct Peer {
|
||
ipv4: Ipv4Addr,
|
||
}
|
||
|
||
// wg.rs — runtime-состояние peers для status
|
||
struct PeerRuntime {
|
||
public_key: String,
|
||
allowed_ips: Vec<String>,
|
||
endpoint: Option<String>,
|
||
latest_handshake_epoch: Option<u64>,
|
||
transfer_rx_bytes: Option<u64>,
|
||
transfer_tx_bytes: Option<u64>,
|
||
}
|
||
|
||
// traffic.rs — построчный статус peer в выводе status
|
||
struct PeerTrafficStatus {
|
||
ip: String,
|
||
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>,
|
||
}
|
||
```
|
||
|
||
Разделение `Peer` и `PeerRuntime` намеренное: shaping зависит только от адреса, а handshake/RX/TX нужны исключительно для диагностики. Ошибка чтения runtime-состояния не должна ломать применение лимитов.
|
||
|
||
Отдельной структуры desired state нет. Целевое состояние выводится детерминированно из конфига и отсортированного списка peers прямо в `inspect_and_diff` и `apply_full_topology`.
|
||
|
||
Присвоение class id обязано быть детерминированным. Строки peer IP сортируются лексикографически, затем присваивается `class_start_id + index`. Это нельзя молча заменить на числовую сортировку IPv4: production-контракт class-id ставит `10.8.1.10` раньше `10.8.1.2`, потому что сортировка текстовая, а не числовая.
|
||
|
||
## 5. Стратегия namespace
|
||
|
||
Основной путь:
|
||
|
||
1. Найти запущенный контейнер через `docker ps` / `docker inspect`.
|
||
2. Прочитать PID контейнера.
|
||
3. Использовать `/proc/<pid>/ns/net` как целевой namespace.
|
||
4. Выполнять сетевые операции в этом namespace.
|
||
|
||
Реализация:
|
||
|
||
- текущий путь — запуск host tools со входом в namespace: `nsenter -t <pid> -n -- tc ...`, `nsenter -t <pid> -n -- ip ...`, `nsenter -t <pid> -n -- nft ...`;
|
||
- в перспективе — netlink-крейты и явное переключение namespace там, где это практично.
|
||
|
||
`docker exec` не является архитектурным умолчанием для этих операций. Он остаётся fallback-транспортом, если `nsenter` недоступен. На практике `nsenter` доступен всегда: `validate-runtime` завершается с ошибкой при его отсутствии, а `validate_all` вызывается перед `daemon`, `apply-limits`, `apply-firewall` и `ensure-swap`.
|
||
|
||
Это снижает эксплуатационный риск: если контейнер 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()`:
|
||
|
||
1. Прочитать упорядоченный `amnezia.container_names`.
|
||
2. Найти первый запущенный контейнер с точным совпадением имени.
|
||
3. Получить container ID и PID.
|
||
4. Построить `netns_path`.
|
||
5. Определить интерфейс в этом namespace.
|
||
6. Вернуть `RuntimeTarget`.
|
||
|
||
Порядок определения интерфейса:
|
||
|
||
1. Точные имена из `amnezia.interface_candidates` — проверяются через `ip link show <name>`.
|
||
2. Сканирование `ip -o link show` и сопоставление с `amnezia.interface_patterns`. Поддерживается только суффиксный `*` (`awg*`); имена вида `veth0@if8` нормализуются до `veth0`.
|
||
3. Если ничего не совпало — явная ошибка `unable to detect amnezia interface`, правила не применяются.
|
||
|
||
Эвристики «взять первый non-loopback интерфейс с IPv4» нет и быть не должно: молчаливый выбор произвольного интерфейса опаснее явной ошибки.
|
||
|
||
## 7. Обнаружение peers
|
||
|
||
Транспорт — всегда `docker exec <container>`. Обоснование в разделе 5.
|
||
|
||
Выбор бинаря идёт по упорядоченному `amnezia.wg_binaries`, default `["awg", "wg"]`. Побеждает первый, который завершился успешно. Официальный образ AmneziaWG содержит `/usr/bin/awg` и symlink `/usr/bin/wg` на него, поэтому `awg` — настоящая зависимость; `wg` остаётся для контейнеров с чистым WireGuard, где `awg` отсутствует. Отдельного probe-вызова нет: выбор бинаря является побочным результатом той команды, которая и так должна выполниться.
|
||
|
||
Значения `amnezia.wg_binaries` исполняются внутри контейнера и валидируются как голые имена команд: непустые, без пробелов, без разделителей пути.
|
||
|
||
Основная команда:
|
||
|
||
```bash
|
||
awg show all allowed-ips
|
||
```
|
||
|
||
Правила разбора:
|
||
|
||
- строки разбираются структурно на public key peer и его allowed IPs;
|
||
- в этой команде allowed ips одного peer разделены **пробелом**, в `show all dump` — запятой, в обычном `show` — `", "`; нужно потреблять все токены начиная с третьей колонки, иначе peers с несколькими allowed IP получат shaping только по первому адресу;
|
||
- оставляются только IPv4 `/32` внутри `amnezia.peer_ipv4_cidr`;
|
||
- offline peers включаются: online-состояние не является требованием для shaping;
|
||
- перед присвоением class id строки peer IP сортируются лексикографически, чтобы сохранить действующий контракт class-id.
|
||
|
||
Резервная команда:
|
||
|
||
```bash
|
||
awg show
|
||
```
|
||
|
||
Fallback-парсер разбирает текстовый вывод, извлекая строки `allowed ips:`. Он используется, если основная команда завершилась ошибкой или вернула пустой результат.
|
||
|
||
Классификация строк `show all dump`:
|
||
|
||
- строка считается peer-строкой, только если в ней не менее 9 tab-колонок, колонка 5 (allowed ips) равна `(none)` или является списком CIDR через запятую, а колонки 6–8 (handshake, rx, tx) целиком парсятся как целые;
|
||
- количество колонок само по себе не является дискриминатором: interface-строка AmneziaWG содержит AWG-параметры и *длиннее* peer-строки (14 колонок на AWG 2.0, 28 на AWG 3.0), а на позициях handshake/rx/tx у неё стоят размеры junk-пакетов, которые тоже парсятся как целые;
|
||
- это важно не только для корректности: колонка 2 interface-строки — приватный ключ устройства, и ошибочно классифицированная interface-строка выдала бы его как public key peer;
|
||
- AWG 3.0 печатает протокольные таймеры диапазонами (`20-30`). Колонка keepalive у peer не читается, и диапазон в ней не должен ломать разбор.
|
||
|
||
Политика выполнения для production:
|
||
|
||
- не использовать shell-обёртки (`sh -c`) для вызова `awg`/`wg`;
|
||
- не подавлять stderr редиректом внутри строки команды;
|
||
- вызывать бинарь напрямую и обрабатывать ненулевые коды возврата в Rust.
|
||
|
||
## 8. Целевая топология traffic
|
||
|
||
Ожидаемая топология — контракт traffic-топологии AWG2.
|
||
|
||
Egress:
|
||
|
||
```text
|
||
tc qdisc add dev <iface> root handle 1: htb
|
||
tc class add dev <iface> parent 1: classid 1:1 htb rate <root_rate> ceil <root_rate>
|
||
for each peer:
|
||
tc class add dev <iface> parent 1:1 classid 1:<id> htb rate <peer_limit> ceil <peer_limit>
|
||
tc qdisc add dev <iface> parent 1:<id> fq_codel
|
||
tc filter add dev <iface> parent 1: protocol ip u32 match ip dst <peer_ip> flowid 1:<id>
|
||
```
|
||
|
||
Ingress через IFB:
|
||
|
||
```text
|
||
modprobe ifb
|
||
ip link add name <ifb_name> type ifb
|
||
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>
|
||
tc qdisc add dev <ifb_name> root handle 2: htb
|
||
tc class add dev <ifb_name> parent 2: classid 2:1 htb rate <root_rate> ceil <root_rate>
|
||
for each peer:
|
||
tc class add dev <ifb_name> parent 2:1 classid 2:<id> htb rate <peer_limit> ceil <peer_limit>
|
||
tc qdisc add dev <ifb_name> parent 2:<id> fq_codel
|
||
tc filter add dev <ifb_name> parent 2: protocol ip u32 match ip src <peer_ip> flowid 2:<id>
|
||
```
|
||
|
||
## 9. Алгоритм reconcile
|
||
|
||
Traffic и firewall — два независимых reconcile с отдельными блокировками. Они не объединены в одну транзакцию: сбой одного не должен блокировать другой.
|
||
|
||
```text
|
||
reconcile_traffic():
|
||
если traffic.enabled = false: выйти без изменений
|
||
взять /run/awg2-harness/traffic.lock (timeout 10 c)
|
||
target = discover_target()
|
||
peers = discover_peers(config, target)
|
||
если peers пуст:
|
||
залогировать "no peers found, skip tc apply" и выйти
|
||
drift = inspect_and_diff(target, peers, config)
|
||
если drift отсутствует:
|
||
залогировать "traffic rules up-to-date, skip apply" и выйти
|
||
teardown_owned_topology(target)
|
||
apply_full_topology(target, peers, config) # внутри — verify-after-apply
|
||
```
|
||
|
||
```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
|
||
```
|
||
|
||
Каждый из firewall-путей сначала инспектирует фактическое состояние и выходит без изменений, если drift отсутствует. При наличии drift владеемая таблица удаляется и создаётся заново, после чего выполняется обязательная повторная инспекция.
|
||
|
||
Для production контролируемая полная пересборка при drift — единственный поддерживаемый путь.
|
||
|
||
Требования к устойчивости парсеров:
|
||
|
||
- `tc filter show` разбирается блоками (конечный автомат), а не наивным сканированием токенов по несвязанным строкам;
|
||
- отображение `peer_ip -> class_id` проверяется для обеих сторон: egress (`1:`) и ingress (`2:`);
|
||
- проверка обязана падать, если классы и фильтры существуют, но отображение отличается от детерминированного присвоения.
|
||
|
||
Политика apply/verify:
|
||
|
||
- все шаги apply — явные вызовы команд;
|
||
- ожидаемые конфликты идемпотентности допускаются выборочно, через отдельную обёртку, и по закрытому списку признаков: `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.
|
||
|
||
Контракт наблюдаемости anti-torrent:
|
||
|
||
- в владеемой таблице обязан присутствовать именованный counter `antitorrent_dropped_v4`;
|
||
- вывод status обязан показывать и дропнутые пакеты, и дропнутые байты.
|
||
|
||
## 10. Owned state и правила teardown
|
||
|
||
Бинарь владеет:
|
||
|
||
- 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
|
||
|
||
Это часть production-контракта AWG2. Модуль вписывается в мульти-оркестраторную модель, потому что сервер выделенный и бинарю разрешено владеть host/network правилами, нужными VPN-продукту. Правило всё равно обязано быть узко ограниченным и наблюдаемым.
|
||
|
||
Цель — ограничить количество одновременных conntrack flows на один VPN peer IP. Это не детектирование торрент-протокола, а обобщённый connection-count guard, который делает P2P-нагрузку с большим числом peers непривлекательной, не задевая обычные browsing, streaming, gaming и messaging.
|
||
|
||
Правило не должно быть глобальным лимитом на подсеть:
|
||
|
||
```nft
|
||
ip saddr 10.8.1.0/24 ct count over 200 drop
|
||
```
|
||
|
||
Такая форма может превратиться в один общий счётчик на весь совпавший трафик — в зависимости от того, как она написана и куда помещена. Для per-peer поведения stateful-объект нужно ключевать по source VPN IP. Рекомендуемая форма:
|
||
|
||
```nft
|
||
table inet awg2_harness {
|
||
counter antitorrent_dropped_v4 {}
|
||
|
||
chain forward_antitorrent {
|
||
type filter hook forward priority filter; policy accept;
|
||
|
||
ip saddr <peer_ipv4_cidr> ct state new \
|
||
meter awg2_peer_connlimit_v4 { ip saddr ct count over <max_connections_per_peer> } \
|
||
counter name antitorrent_dropped_v4 \
|
||
drop
|
||
}
|
||
}
|
||
```
|
||
|
||
Целевой namespace — тот же Amnezia network namespace, где применяется `tc` shaping. Нельзя предполагать, что начальный host namespace видит исходные source-адреса peers. Если Amnezia выполняет NAT внутри namespace контейнера, host Docker bridge может видеть только адрес контейнера, а не `10.8.1.x`.
|
||
|
||
Порог в примере конфигурации — `150` одновременных flows на peer IP. `100` — более строгий режим. `70` агрессивнее и применяется только после наблюдения реального трафика. `200`/`250` — более permissive варианты.
|
||
|
||
Размещение правила: hook `forward` с приоритетом `filter` или любым приоритетом после conntrack lookup. Оценка ограничивается `ct state new`, чтобы установленные flows продолжали работать и дропались только новые попытки сверх порога.
|
||
|
||
Владеемое состояние:
|
||
|
||
- таблица `inet awg2_harness`;
|
||
- chain `forward_antitorrent`;
|
||
- counter `antitorrent_dropped_v4`;
|
||
- meter/set state, создаваемый правилом.
|
||
|
||
Status обязан показывать наличие таблицы/chain/правила, настроенный порог и drop counter. Также он показывает давление на conntrack хоста через `net.netfilter.nf_conntrack_count` и `net.netfilter.nf_conntrack_max`, когда они доступны.
|
||
|
||
При `anti_torrent.enabled = false` демон удаляет только владеемую anti-torrent таблицу. Глобальный ruleset nftables не сбрасывается никогда. Если контейнер в этот момент отсутствует, удаление в его namespace просто пропускается — это не ошибка.
|
||
|
||
## 12. Инспекция фактического состояния
|
||
|
||
`inspect_and_diff()` собирает достаточно информации для диагностики drift:
|
||
|
||
- qdisc на `<iface>`;
|
||
- классы на `<iface>`;
|
||
- qdiscs, привязанные к per-peer классам на `<iface>`;
|
||
- фильтры под parent `1:` на `<iface>`;
|
||
- ingress qdisc на `<iface>`;
|
||
- ingress redirect filter на `<iface>`;
|
||
- существование и состояние `<ifb_name>`;
|
||
- qdisc на `<ifb_name>`;
|
||
- классы на `<ifb_name>`;
|
||
- фильтры под parent `2:` на `<ifb_name>`.
|
||
|
||
Дополнительно детектируются дубли: один и тот же peer IP в нескольких фильтрах и один и тот же class id у нескольких IP. Оба случая считаются drift.
|
||
|
||
`status` показывает компактную сводку и таблицу по peers.
|
||
|
||
## 13. Docker events и периодический reconcile
|
||
|
||
Демон использует два триггера:
|
||
|
||
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 секунд.
|
||
|
||
Таймер обязателен. События могут быть пропущены при рестарте демона, рестарте Docker или транзиентных ошибках.
|
||
|
||
Проверка готовности не должна хардкодить `awg0`. Она вызывает общий детектор интерфейса с интервалом 2 секунды до истечения `traffic.container_ready_timeout_secs`.
|
||
|
||
Порядок на старте демона и разница в критичности ошибок:
|
||
|
||
1. `sysctl_tuning`, если `apply_on_daemon_start = true` — ошибка **фатальна**, демон завершается;
|
||
2. `swap` — ошибка **фатальна**, демон завершается;
|
||
3. `traffic` — ошибка логируется, демон продолжает работу;
|
||
4. `firewall` — ошибка логируется, демон продолжает работу.
|
||
|
||
Такое разделение намеренное: некорректный baseline хоста или swap означают неверные предпосылки, тогда как отсутствие контейнера Amnezia — нормальное транзиентное состояние.
|
||
|
||
Результаты пишутся в два файла:
|
||
|
||
- `/run/awg2-harness/last_action.state` — последний успех и последняя ошибка;
|
||
- `/run/awg2-harness/events.log` — журнал событий, ограниченный 1000 строками и 256 KiB.
|
||
|
||
## 14. Блокировки
|
||
|
||
Одновременно может выполняться только один 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` |
|
||
|
||
Реализация — advisory-блокировка ядра (`flock` на Linux) через `std::fs::File`, а не PID-файл.
|
||
|
||
Поведение:
|
||
|
||
- ожидание захвата — с интервалом 100 мс до таймаута, затем ошибка `lock busy timeout`;
|
||
- PID пишется в файл **только как диагностика**; ни одно решение на его основе не принимается;
|
||
- освобождение выполняет ядро при завершении процесса — любым способом, включая `kill -9`. Понятия «устаревшая блокировка» не существует;
|
||
- файл блокировки никогда не удаляется: сам факт его наличия ничего не значит, значение имеет только удерживаемая на нём блокировка.
|
||
|
||
Прежняя реализация на PID-файлах имела типичные для такого подхода дефекты: конфликт определялся по подстроке в тексте ошибки; «устаревшая» блокировка удалялась и создавалась заново двумя отдельными операциями, поэтому два процесса могли одновременно счесть её устаревшей и оба захватить; переиспользованный PID делал живую блокировку похожей на мёртвую; удаление файла в `Drop` гонялось с процессом, который уже открыл его и ждёт.
|
||
|
||
## 15. Контракт вывода status
|
||
|
||
`awg2-harness status` печатает блоки в следующем порядке. Имена полей стабильны и пригодны для grep.
|
||
|
||
```text
|
||
System:
|
||
service: active|inactive|unknown
|
||
container: <name>
|
||
container_id: <id>
|
||
pid: <pid>
|
||
netns: /proc/<pid>/ns/net
|
||
interface: <iface>
|
||
Network tuning:
|
||
enabled, apply_on_daemon_start, tcp_bbr_mode, conf_path, modules_load_path,
|
||
значения управляемых ключей, bbr_available, bbr_active, drift[, reason][, warning]
|
||
Peers:
|
||
total: N
|
||
- <peer ip>
|
||
WireGuard clients:
|
||
IP, State, Latest handshake, RX, TX, Public key, Endpoint
|
||
Traffic desired:
|
||
peer_limit, root_rate, ifb_name, class_start_id
|
||
Traffic actual:
|
||
drift: no|yes[, reason]
|
||
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
|
||
Host firewall:
|
||
enabled, ssh_port, amnezia_port, input_policy, drift[, reason]
|
||
Docker/Amnezia port path:
|
||
docker_userland_proxy, published_udp_port, host_forward_policy_drop,
|
||
docker_forward_rules_present, note[, warning]
|
||
Anti-torrent:
|
||
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:
|
||
содержимое /run/awg2-harness/last_action.state
|
||
Recent events:
|
||
последние 10 строк /run/awg2-harness/events.log
|
||
Reboot scheduler:
|
||
enabled, window_start_utc, window_end_utc, service, planned
|
||
```
|
||
|
||
Блок `System` обязан оставаться неразрывным: строки `container`/`container_id`/`pid`/`netns`/`interface` имеют тот же отступ, что и поля любой другой секции, поэтому открытие постороннего блока между ними и заголовком `System` делает вывод неоднозначным при чтении.
|
||
|
||
Если контейнер обнаружить не удалось, `status` не завершается ошибкой, а переходит в degraded-режим. Строки `container`/`pid`/`netns`/`interface` печатаются со значениями `n/a`, секции `Peers` и `WireGuard clients` печатают `unavailable`, `Traffic actual` показывает `drift: unknown`, блок `Docker/Amnezia port path` заполняется значениями `unknown`, а блок `Peer traffic counters` не печатается вовсе. Секции host firewall, anti-torrent, swap, conntrack, событий и reboot остаются на месте.
|
||
|
||
Секции, общие для здорового и degraded путей, рендерятся одними и теми же функциями. Это обязательное требование: раньше они были продублированы, и расхождение форматов проявилось бы ровно в тот момент, когда контейнер лежит, то есть в худший для диагностики момент.
|
||
|
||
Отдельные секции деградируют независимо: ошибка чтения peer state не мешает вывести traffic, firewall и swap.
|
||
|
||
Универсальный баннер вида «system ready» недопустим. Status обязан быть фальсифицируемым: он должен явно сообщать, когда состояние частичное.
|
||
|
||
## 16. Интеграция reboot-модуля
|
||
|
||
Production-путь перезагрузки — внешний бинарь/сервис `awg2-reboot-scheduler`:
|
||
|
||
- окно берётся из конфига, а не из констант;
|
||
- случайная точка внутри окна, включая окна через полночь;
|
||
- вызов остаётся `systemctl --no-block reboot`;
|
||
- при `reboot.enabled = false` планирование отключено;
|
||
- запланированная отметка времени сохраняется в `/run/awg2-harness/reboot.plan`.
|
||
|
||
## 17. Модуль swap
|
||
|
||
Целевое состояние `ensure-swap`:
|
||
|
||
- путь из конфига, default `/swapfile`;
|
||
- размер `swap.size_mb`, в примере конфигурации — 2048 MB;
|
||
- права `0600`;
|
||
- выполнен `mkswap`;
|
||
- запись активна в `swapon --show`;
|
||
- одна запись в `/etc/fstab` с настроенным приоритетом;
|
||
- `vm.swappiness` из конфига применён в runtime и сохранён persistently.
|
||
|
||
При `swap.enabled = true` AWG2 Harness владеет состоянием swap на хосте эксклюзивно. Целевое состояние — ровно одна активная swap-запись: `swap.path`. Все прочие активные swap выключаются через `swapoff`, а их записи убираются из `/etc/fstab`. Операция обязана быть идемпотентной.
|
||
|
||
Создание файла: `fallocate`, при неудаче — fallback на `dd`.
|
||
|
||
### Владение swapfile
|
||
|
||
Пакет никогда не трогает файл, который не создавал. Рядом с управляемым swapfile лежит sidecar-маркер `<swap.path>.awg2-harness-owned` с первой строкой `# Managed by awg2-harness swap`. Без него отличить наш файл от созданного администратором невозможно, и правило «размер не совпал — пересоздать» превратилось бы в «молча уничтожить чужой файл».
|
||
|
||
| Состояние | Условие | Действие |
|
||
| --- | --- | --- |
|
||
| `Missing` | файла нет | создать |
|
||
| `ManagedValid` | маркер есть, размер совпадает | переиспользовать |
|
||
| `ManagedWrongSize` | маркер есть, размер отличается | пересоздать |
|
||
| `Foreign` | файл есть, маркера нет | **ошибка**, файл не трогается |
|
||
|
||
Чужие активные swap только выключаются через `swapoff`. Файлы не удаляются — ни обычные, ни блочные устройства. Удаление чужого swapfile не является решением этого пакета: эксклюзивное runtime-состояние достигается связкой `swapoff` плюс вычистка `/etc/fstab`, а файл оператор уберёт сам, когда сочтёт нужным.
|
||
|
||
### Порядок операций
|
||
|
||
Порядок принципиален. Управляемый swap создаётся, активируется и проверяется **до** того, как отключается любой чужой:
|
||
|
||
```text
|
||
1. взять /run/awg2-harness/swap.lock (timeout 30 c)
|
||
2. preflight: разобрать конфиг, проверить безопасность пути, снять текущее состояние
|
||
3. подготовить управляемый swapfile (с учётом владения)
|
||
4. swapon управляемого swap
|
||
5. убедиться, что он реально появился в списке активных
|
||
6. только теперь swapoff чужих swap
|
||
7. перезаписать /etc/fstab транзакцией
|
||
8. применить swappiness
|
||
9. проверить итоговое состояние
|
||
```
|
||
|
||
Шаги 2–5 не выполняют ни одного деструктивного действия над чужим состоянием. Прежний порядок отключал чужие swap первым шагом, поэтому ошибка в `fallocate`, `dd`, `mkswap` или `swapon` оставляла хост вообще без swap.
|
||
|
||
### Безопасность `swap.path`
|
||
|
||
Проверка разделена на две части. Лексическая не обращается к файловой системе и потому пригодна для `validate-config` на хосте, где файла ещё нет:
|
||
|
||
- путь абсолютный и не оканчивается на `/`;
|
||
- нет компонентов `.` и `..` — поэтому строка уже нормализована и список запрещённых путей нельзя обойти записью вида `/etc/../etc/fstab`;
|
||
- нет пробелов, управляющих символов и обратных слэшей;
|
||
- путь не входит в список запрещённых (`/`, `/etc`, `/etc/fstab`, `/etc/passwd`, `/etc/shadow`, `/dev`, `/proc`, `/sys`, `/run`, `/boot`) и не лежит под `/proc`, `/sys`, `/dev`.
|
||
|
||
Файловая часть выполняется непосредственно перед использованием пути: каждый родительский компонент обязан существовать и не быть symlink. Проверки только финального компонента недостаточно — symlink в середине цепочки перенаправляет операцию на файл, который никогда не настраивался.
|
||
|
||
### Перезапись `/etc/fstab`
|
||
|
||
Это самая разрушительная операция пакета, поэтому она транзакционная и вынесена в отдельный модуль `fstab.rs`. Контракт:
|
||
|
||
- ошибка чтения `/etc/fstab` **никогда** не интерпретируется как пустой файл. `PermissionDenied`, ошибка ввода-вывода и отсутствие файла различаются, и все три прекращают операцию. Отсутствие `/etc/fstab` на Debian-хосте считается аномалией, а не основанием создать его с нуля;
|
||
- пустой или состоящий из пробелов кандидат отклоняется;
|
||
- резервная копия обязательна. Если её не удалось записать, целевой файл не трогается вовсе — ситуация, в которой копия не пишется, и есть та, в которой она нужна;
|
||
- копий две: `/etc/fstab.awg2-harness.bak` перезаписывается перед каждой правкой и содержит состояние до неё, а `/etc/fstab.awg2-harness.original` создаётся один раз через `create_new` и хранит содержимое до первого вмешательства пакета;
|
||
- запись идёт во временный файл рядом с целевым, с `create_new`, `sync_all` и переносом прав с оригинала; устаревший временный файл от убитого процесса удаляется явно, а не переиспользуется;
|
||
- кандидат проверяется через `findmnt --verify --tab-file` до подмены. Если `findmnt` недоступен, шаг пропускается; если он отработал и отверг файл — операция прекращается;
|
||
- подмена — атомарный `rename`, после него каталог синхронизируется;
|
||
- после `rename` содержимое перечитывается и сверяется с записанным. Расхождение приводит к восстановлению из резервной копии.
|
||
|
||
Путь в записи fstab экранируется: поля разделяются пробелами, поэтому пробел или табуляция в пути сдвинули бы все последующие колонки.
|
||
|
||
Чтение swap-записей для `status` использует ту же строгую функцию чтения. Маскировка ошибки здесь показала бы «в fstab нет swap-записей», то есть здоровое состояние без drift, на хосте, где `/etc/fstab` вообще не читается.
|
||
|
||
## 18. Тестирование
|
||
|
||
Модульные тесты:
|
||
|
||
- валидация конфига, включая форму `amnezia.wg_binaries`;
|
||
- разбор `config.example.toml` в актуальную схему;
|
||
- парсер `awg|wg show all allowed-ips`, включая несколько allowed ips через пробел;
|
||
- fallback-парсер `awg|wg show`;
|
||
- классификация peer/interface строк `awg|wg show all dump` против interface-строк AWG 3.0 (28 колонок), AWG 2.0 (14 колонок) и чистого WireGuard (5 колонок);
|
||
- порядок выбора бинаря wg и ошибка при отсутствии всех кандидатов;
|
||
- фильтрация по CIDR;
|
||
- детерминированное присвоение 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.
|