Files
AWG2_harness/docs/IMPLEMENTATION_SPEC.md
T
founder 549fbcae31 fix(swap): make the /etc/fstab rewrite transactional [P0]
Two release blockers, both in the most destructive operation the package performs.

1. An unreadable /etc/fstab was treated as an empty one.

   let content = fs::read_to_string(fstab_path).unwrap_or_default();

   PermissionDenied, an I/O error, a damaged filesystem and "file absent" all collapsed
   into an empty string. The rewrite then emitted a fstab containing only the managed swap
   line and installed it atomically and successfully, dropping the root and boot mounts.
   Consequence: a host that does not boot, guarded by one unwrap_or_default. The same
   masking was present in read_fstab_swap_entries, so an unreadable fstab was reported by
   status as "no swap entries", which reads as healthy drift-free state.

2. The backup was best-effort.

   let _ = fs::write(bak_path, &content);

   If it failed, the rewrite proceeded anyway. IMPLEMENTATION_SPEC and the acceptance
   checklist both asserted the backup exists. The scenario where the backup fails is
   exactly the scenario where it is needed.

New module fstab.rs owns the operation:

- read_required distinguishes NotFound from every other error and fails on both. A
  missing /etc/fstab on Debian is an anomaly, not a blank slate, so it is never created;
- a blank candidate is refused outright;
- the backup is mandatory and flushed before the temp file is created, so there is no
  window where the target is about to be replaced and no recovery copy exists;
- two copies are kept: .awg2-harness.bak is the state before the current rewrite,
  .awg2-harness.original is written once with create_new and holds the host as it was
  before this package first touched it;
- the temp file is a sibling of the target, created with create_new, sync_all'd, and
  inherits the original permissions; a stale temp from a killed run is removed explicitly
  rather than reused;
- the candidate is validated with findmnt --verify --tab-file before the swap. The step is
  skipped when findmnt cannot be spawned; a rejection is fatal;
- commit is an atomic rename followed by a directory fsync;
- after the rename the target is re-read and compared to what was written. A mismatch
  restores the backup and fails.

Also fixed: render_fstab_line interpolated the path raw. fstab fields are
whitespace-separated, so a space or tab in swap.path would shift every following column.
Paths now go through octal escaping (\040, \011, \012, \134).

Ten tests, all portable - the "unreadable but present" case is exercised by pointing the
reader at a directory, which fails as a non-NotFound error on every platform. Covered:
unreadable target is an error rather than an empty file, missing target is an error,
backup failure leaves the target byte-identical, blank candidate refused, unchanged
candidate is a no-op that writes no backup, the pristine copy survives repeated rewrites,
a stale temp does not block commit, and field escaping.

Docs updated: IMPLEMENTATION_SPEC section 17 gained the full rewrite contract, the
acceptance checklist gained seven checks including the two failure modes above.

cargo test --workspace: 124 + 4 passed, ten new. clippy -D warnings clean.
2026-08-06 21:02:38 +05:00

42 KiB
Raw Blame History

Спецификация реализации 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.

Оба управляемых файла несут в первой строке маркер # Managed by awg2-harness sysctl_tuning. Файл без маркера не перезаписывается и не удаляется — вместо этого возвращается ошибка. Это защищает чужие настройки, случайно оказавшиеся по тому же пути.

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

Основной путь:

  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 исполняются внутри контейнера и валидируются как голые имена команд: непустые, без пробелов, без разделителей пути.

Основная команда:

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 (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.

Правило не должно быть глобальным лимитом на подсеть:

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

Демон использует два триггера:

  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.

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. Все не-комментарные swap-записи в /etc/fstab удаляются и заменяются одной управляемой записью AWG2. Блочные устройства не удаляются никогда — только выключаются и убираются из fstab. Операция обязана быть идемпотентной.

Создание файла: fallocate, при неудаче — fallback на dd.

Перезапись /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.