a895295701
Two more release blockers in the swap module. 1. Foreign files could be destroyed. swap.path was validated against a blacklist of exact strings. /etc/../etc/fstab is not string-equal to /etc/fstab but resolves to it, and nothing normalized the path or checked the parent chain for symlinks. ensure_desired_swapfile then removed any existing regular file whose size did not match the configured one, so a mistyped or hostile path meant deleting /etc/passwd, an application config, or any other regular file. disable_foreign_swaps additionally deleted foreign swapfiles outright. Ownership is now explicit. A sidecar marker <swap.path>.awg2-harness-owned sits next to the managed swapfile, and inspect_swapfile_ownership classifies the path as Missing, ManagedValid, ManagedWrongSize or Foreign. Foreign is always a hard error - the file is never resized, rewritten or removed. Foreign active swaps are now only swapped off; deleting an administrator's swapfile is not this package's call, and swapoff plus the fstab rewrite already produce the exclusive runtime state. Path validation is split in two. The lexical half rejects relative paths, '.' and '..' components, whitespace, control characters and backslashes, the system path list, and anything under /proc, /sys or /dev; it touches no filesystem, so validate-config stays side-effect free on a host where the file does not exist yet. It runs after '..' is rejected, so the string is already normalized and the blacklist cannot be bypassed. The filesystem half runs just before use and refuses a symlink anywhere in the parent chain, not only at the final component. One deliberate deviation from the audit: arbitrary absolute paths stay allowed rather than being restricted to a fixed managed location. Pinning the path would break deployments that already configured their own, and the ownership marker is the guard that actually prevents data loss - a wrong path can now only fail, never destroy. 2. The migration order left the host swapless on failure. ensure_swap ran disable_foreign_swaps first, then created the swapfile, then activated it. Any failure in fallocate, dd, mkswap or swapon after that first step left the host with no swap at all - on a box provisioned with swap precisely because it is short on memory. The sequence is now a transaction: preflight, prepare the managed swapfile, swapon, verify it actually appears in the active list, and only then swapoff the others, rewrite fstab, apply swappiness and verify. Nothing destructive happens until the replacement is confirmed live. The one unavoidable gap is deactivating our own swap to resize it, which cannot be done in place; foreign swap stays up throughout. chmod 0600 now also happens before mkswap, so the file is never briefly world-readable while it already holds swap structure. config.rs no longer carries its own copy of the path blacklist and calls the shared validator. Twelve tests: the four ownership states, refusal to recreate a foreign file with its content asserted intact afterwards, '..' traversal, dangerous and virtual paths, whitespace and control characters, relative and directory paths, a missing parent directory, an accepted chain, and a symlinked parent (unix only). Docs updated: IMPLEMENTATION_SPEC section 17 gained the ownership table, the ordered transaction and the path rules; CONFIGURATION and the acceptance checklist gained the operator-facing constraints and nine checks. cargo test --workspace: 136 + 4 passed. clippy -D warnings clean.
633 lines
46 KiB
Markdown
633 lines
46 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` не планирует перезагрузки внутри себя.
|
||
|
||
Подкоманды:
|
||
|
||
```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`.
|
||
|
||
Оба управляемых файла несут в первой строке маркер `# Managed by awg2-harness sysctl_tuning`. Файл без маркера не перезаписывается и не удаляется — вместо этого возвращается ошибка. Это защищает чужие настройки, случайно оказавшиеся по тому же пути.
|
||
|
||
`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` |
|
||
|
||
Поведение:
|
||
|
||
- ожидание захвата — с интервалом 100 мс до таймаута, затем ошибка `lock busy timeout`;
|
||
- в файл записывается PID владельца;
|
||
- если процесс-владелец мёртв (`/proc/<pid>` отсутствует) или содержимое файла не парсится как PID, блокировка считается устаревшей и перезахватывается;
|
||
- освобождение автоматическое при выходе из области видимости, включая пути с ошибкой.
|
||
|
||
## 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.
|