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.
53 KiB
Спецификация реализации 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-сервиса:
/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, смены контейнера и интерфейса на лету и отката при неверной конфигурации.
Порядок применения изменений:
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] дополнительно:
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 при этом остаётся доступной оператору.
Подкоманды:
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. Структура модулей
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. Модель данных
Модули работают на явных структурах, а не на строках, которые передаются по всему коду.
Фактические типы:
// 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
Основной путь:
- Найти запущенный контейнер через
docker ps/docker inspect. - Прочитать PID контейнера.
- Использовать
/proc/<pid>/ns/netкак целевой namespace. - Выполнять сетевые операции в этом 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():
- Прочитать упорядоченный
amnezia.container_names. - Найти первый запущенный контейнер с точным совпадением имени.
- Получить container ID и PID.
- Построить
netns_path. - Определить интерфейс в этом namespace.
- Вернуть
RuntimeTarget.
Порядок определения интерфейса:
- Точные имена из
amnezia.interface_candidates— проверяются черезip link show <name>. - Сканирование
ip -o link showи сопоставление сamnezia.interface_patterns. Поддерживается только суффиксный*(awg*); имена видаveth0@if8нормализуются доveth0. - Если ничего не совпало — явная ошибка
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 исполняются внутри контейнера и валидируются как голые имена команд: непустые, без пробелов, без разделителей пути.
Основная команда:
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.
Резервная команда:
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:
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:
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 с отдельными блокировками. Они не объединены в одну транзакцию: сбой одного не должен блокировать другой.
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
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(defaultifb0) в целевом 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.
Правило не должно быть глобальным лимитом на подсеть:
ip saddr 10.8.1.0/24 ct count over 200 drop
Такая форма может превратиться в один общий счётчик на весь совпавший трафик — в зависимости от того, как она написана и куда помещена. Для per-peer поведения stateful-объект нужно ключевать по source VPN IP. Рекомендуемая форма:
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
Демон использует два триггера:
- Docker events:
docker events --filter event=start --format '{{.Actor.Attributes.name}}'. Имя сверяется сamnezia.container_names, после чего выполняется ожидание готовности интерфейса и reconcile traffic + firewall. Если поток событий завершился, он перезапускается через 10 секунд. - Таймер: периодический reconcile каждые
traffic.reconcile_interval_secs, но не чаще одного раза в 5 секунд.
Таймер обязателен. События могут быть пропущены при рестарте демона, рестарте Docker или транзиентных ошибках.
Проверка готовности не должна хардкодить awg0. Она вызывает общий детектор интерфейса с интервалом 2 секунды до истечения traffic.container_ready_timeout_secs.
Порядок на старте демона и разница в критичности ошибок:
sysctl_tuning, еслиapply_on_daemon_start = true— ошибка фатальна, демон завершается;swap— ошибка фатальна, демон завершается;traffic— ошибка логируется, демон продолжает работу;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.
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 создаётся, активируется и проверяется до того, как отключается любой чужой:
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.