Two modules wrote into /etc/sysctl.d and did it differently. sysctl_tuning refused to touch a file whose first line lacked its marker and cleaned up on disable. swap wrote /etc/sysctl.d/99-awg2-harness.conf with a different marker, no ownership check at all - an administrator's file at that path was silently clobbered - and never removed it when the module was turned off, so vm.swappiness stayed pinned forever. New module managed_file.rs holds the rules once: - a read that fails is never an absent file; - ownership is Absent / Managed / Foreign, decided by the marker on the first line; - write refuses Foreign, skips when the content already matches, and is otherwise atomic: temp sibling created with create_new, sync_all, mode 0644, rename, directory flush; - remove refuses Foreign and treats Absent as success. Both modules now go through it. sysctl_tuning loses its private write_atomic, ensure_managed_or_absent and remove_managed_sysctl_file_at. The swappiness file moves to /etc/sysctl.d/90-awg2-harness-swap.conf with the marker "# Managed by awg2-harness swap", matching the 90- prefix every other managed file uses. The old path is removed on first run of the new version, but only when it still carries the previous "# Managed by awg2-harness" marker; a foreign file there is left alone and does not fail the migration, since the current file lives elsewhere. Disable semantics are now explicit: swap.enabled = false removes the managed file and does nothing else. Runtime vm.swappiness is deliberately not reset - picking a value would be inventing a policy - and active swap is not deactivated, because merely turning a feature off should not trigger a destructive action. Previously ensure_swap returned early and left the persisted file behind. Also replaced a vacuous test: swappiness_conf_render_is_stable asserted that a string literal equals a format! of the same literal, touching no production code at all. It now exercises render_swappiness_conf, and a companion test proves the rendered file is recognised as managed by the same marker check that guards overwriting it - otherwise every reconcile would refuse to touch its own output. Twelve tests: the three ownership states, refusal to overwrite and refusal to remove a foreign file with content asserted intact, idempotent write, stale temp handling, an unreadable path reported as an error, plus legacy migration in all three cases - ours, foreign, absent. cargo test --workspace: 148 + 4 passed. clippy -D warnings clean.
48 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 не планирует перезагрузки внутри себя.
Подкоманды:
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 |
Поведение:
- ожидание захвата — с интервалом 100 мс до таймаута, затем ошибка
lock busy timeout; - в файл записывается PID владельца;
- если процесс-владелец мёртв (
/proc/<pid>отсутствует) или содержимое файла не парсится как PID, блокировка считается устаревшей и перезахватывается; - освобождение автоматическое при выходе из области видимости, включая пути с ошибкой.
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.