Files
AWG2_harness/docs/ACCEPTANCE_CHECKLIST.md
T
founder 383e67605a fix(swap): activate the previous swapfile in place when it cannot be moved back [P0]
The rollback still had a path that ended with two intact swapfiles on disk and neither of
them running.

With the original already off and moved to .previous, a failed rename back returned
immediately - "the host may currently have no swap" - without trying to activate anything.
Both candidates were sitting right there: the old swapfile under its .previous name, and
the replacement that the earlier fix had preserved. That return threw away the whole point
of preserving them.

The rename back was never the requirement. The kernel activates a swap area by path and
does not care that the path ends in .previous, so the old swapfile is brought up where it
lies. The rollback now tracks both files by where they currently are rather than by a flag,
and works down the candidates: original at the managed path, original at .previous,
replacement wherever it ended up. Only when none of them activates does it report that the
host has no managed swap - and then that statement is true.

Two consequences fall out of tracking locations:

- A replacement that could not be moved aside is still on the managed path, so the restore
  no longer renames the original over it. That rename would have unlinked one of the two
  files this rollback exists to keep.
- When the original ends up serving from .previous the replacement is not released and
  nothing is deleted. The host has swap, so nothing is urgent, but the managed path does not
  hold what the ownership record describes - that is an operator's call, and the error names
  the location of both files and the commands that finish it.

Three tests: the rename back fails and the previous swapfile is activated in place; it fails
and the previous swapfile will not activate either, so the replacement takes over; a live
replacement that cannot be released is left in place and nothing else is attempted. The
last one needed a swapoff failure, which the fake could not express before.

fix(daemon): let the docker events subsystem return to healthy [P1]

The outer loop recorded an error every time the events stream ended, and nothing ever
recorded a success for it. One Docker restart therefore pinned a failure in status
permanently while the replacement stream was running normally. The reconnect now clears it
and logs the recovery once.

The per-subsystem transition logic moved onto LastActionState as record_ok/record_err, so it
can be tested without the /run writes that set_ok/set_err perform. Clearing the last error
also clears its timestamp - otherwise status showed an empty error next to the time it
stopped being true.

fix(swap): drop transition leftovers once the managed swap is confirmed live [P1]

Leftovers were only cleared on the way *into* a resize. A process killed after the
replacement went live left .previous on disk indefinitely: the next run finds a valid,
active, correctly owned swapfile at the managed path, takes the fast path, and never looks.
A swapfile is commonly sized after RAM, so that is not a rounding error. They are now
dropped right after the managed swap is confirmed active, and only when inactive - an active
leftover means the kernel is still swapping to it, which is the interrupted-rollback case
that needs an operator.

This also replaces two tests that asserted nothing about production code: both rebuilt the
active-leftover check inline instead of calling it.

Docs: the resize transition and its three degraded outcomes are now written down in
IMPLEMENTATION_SPEC rather than living only in the code, the reconcile order gained the
cleanup step, per-subsystem error tracking is described, and the acceptance checklist gained
the fault cases to exercise on Debian - each one keyed to the exact wording the error must
carry.

cargo fmt --check, clippy -D warnings, cargo test --locked --workspace --release: 199 + 4
passed.
2026-08-07 06:05:59 +05:00

28 KiB
Raw Blame History

Чек-лист приёмки перед production-выкладкой

A. Базовая установка

  • Хост — Debian 13 amd64.
  • Сервер выделенный, без соседних сервисов.
  • AmneziaWG/AWG 2.0 или AmneziaWG/AWG 1.5 установлена официальным способом.
  • Контейнер amnezia-awg2 или amnezia-awg запущен (amnezia-awg3 — резерв на будущее, в AmneziaVPN 5.0 такого типа контейнера ещё нет).
  • docker ps видит контейнер.
  • Можно получить PID контейнера.
  • /proc/<pid>/ns/net существует.
  • На хосте доступны ip, tc, docker, nsenter, modprobe. Хостовый wg не требуется и не проверяется.
  • awg2-harness validate-config проходит без ошибок.
  • awg2-harness validate-runtime проходит без ошибок и не требует установленного wireguard-tools.

B. Обнаружение peers

  • awg2-harness status показывает найденный контейнер.
  • awg2-harness status показывает PID/netns контейнера.
  • awg2-harness status показывает найденный интерфейс.
  • Количество peers совпадает с docker exec "$C" awg show all allowed-ips.
  • Peer с несколькими allowed IP даёт отдельный класс на каждый адрес (в этой команде адреса разделены пробелом).
  • Offline peers с allowed IP тоже попадают в список shaping.
  • IP вне amnezia.peer_ipv4_cidr не попадают в traffic shaping.
  • В секции WireGuard clients вывода status нет строки, соответствующей самому интерфейсу (столбец Public key не содержит приватный ключ устройства).
  • docker exec "$C" readlink -f /usr/bin/wg возвращает /usr/bin/awg.

C. Контракт топологии traffic

  • awg2-harness apply-limits успешно завершается.
  • На AWG/WG-интерфейсе есть root qdisc 1: htb.
  • На AWG/WG-интерфейсе есть root class 1:1 с traffic.root_rate.
  • Для каждого peer есть class 1:<id> с traffic.peer_limit.
  • Для каждого peer есть fq_codel под 1:<id>.
  • Для каждого peer есть egress filter dst peer_ip -> 1:<id>.
  • Есть ingress qdisc ffff: на AWG/WG-интерфейсе.
  • Есть ingress redirect через mirred ... redirect dev ifb0.
  • ifb0 существует и находится в UP state.
  • На ifb0 есть root qdisc 2: htb.
  • На ifb0 есть root class 2:1 с traffic.root_rate.
  • Для каждого peer есть class 2:<id> с traffic.peer_limit.
  • Для каждого peer есть fq_codel под 2:<id>.
  • Для каждого peer есть ingress filter src peer_ip -> 2:<id>.
  • Class ids начинаются с traffic.class_start_id, default 10.
  • Peer IP → class id mapping использует стабильный лексикографический контракт: IP сортируются как строки, не numeric IPv4.

D. Идемпотентность и восстановление после drift

  • Повторный apply-limits не создаёт дубли.
  • Повторный apply-limits не меняет healthy state без причины.
  • Если удалить root qdisc на AWG/WG-интерфейсе, daemon восстанавливает topology.
  • Если удалить ingress qdisc, daemon восстанавливает topology.
  • Если удалить ingress redirect filter (parent ffff:), drift детектируется и topology восстанавливается.
  • Если удалить ifb0, daemon восстанавливает topology.
  • Если сделать ifb0 DOWN, drift детектируется и topology восстанавливается.
  • Если удалить один peer filter, daemon восстанавливает topology.
  • Если изменить rate/ceil peer class, drift детектируется и topology восстанавливается.
  • Если добавить stale peer class/filter, drift детектируется и topology восстанавливается.
  • После удаления peer устаревшие class/filter исчезают через rebuild.
  • После добавления peer появляются новые class/filter.

E. Самовосстановление в жизненном цикле

  • docker restart amnezia-awg2 → лимиты восстанавливаются автоматически.
  • systemctl restart docker → после возвращения контейнера лимиты восстанавливаются автоматически.
  • systemctl restart awg2-harness → сервис сам находит контейнер и применяет/проверяет лимиты.
  • systemd-analyze verify проходит для обоих unit-файлов на целевом Debian 13.
  • Правка config.toml без restart не меняет поведение; после systemctl restart awg2-harness.service изменения применяются.
  • ensure-swap за один старт сервиса выполняется ровно один раз (в journal нет двух подряд swap-reconcile).
  • При устойчивом падении демона интервал перезапусков растёт (10 → 20 → 40 → 80 → 160 с), а не остаётся 10 с.
  • Reboot хоста → сервис стартует и восстанавливает лимиты.
  • Изменение имени интерфейса на допустимое fallback-имя не ломает detector.
  • Если контейнер временно отсутствует, daemon логирует ошибку reconcile и продолжает работу.
  • На старте daemon сбой sysctl_tuning или swap завершает процесс, а сбой traffic/firewall только логируется — сервис остаётся живым.
  • После обрыва потока docker events он перезапускается автоматически (проверяется, например, systemctl restart docker).
  • После восстановления потока docker events ошибка подсистемы снимается: в events.log появляется docker_events=recovered, а last_action.state не остаётся с этой ошибкой навсегда.
  • Успешный reconcile одной подсистемы не гасит незакрытую ошибку другой: при постоянно падающем traffic успешный firewall не очищает last_error и не пишет firewall=recovered.
  • Параллельный запуск apply-limits во время работы daemon не приводит к гонке: второй процесс ждёт блокировку до 10 секунд и затем сообщает lock busy timeout.
  • После kill -9 блокировка освобождается ядром автоматически; оставшийся lock-файл не мешает следующему захвату /run/awg2-harness/traffic.lock.

F. Статус и наблюдаемость

  • status показывает desired peer limit из конфига, а не hardcoded 70.
  • status показывает egress state.
  • status показывает ingress/ifb state.
  • status показывает WireGuard clients (online/offline, latest handshake, RX/TX, endpoint).
  • status показывает недостающие и устаревшие правила peers: в Traffic actual — строка reason (например, missing egress class, egress per-peer class set mismatch, duplicate egress class flowid mapping detected), в peer_table — колонка drift по каждому peer.
  • status явно показывает частичное состояние и drift, а не общий признак «всё хорошо».
  • status показывает tc filter counters по peer или filter statistics: unavailable.
  • Логи доступны через journalctl -u awg2-harness.
  • Отсутствие обязательной host-утилиты (tc, ip, docker, nsenter) логируется явно с именем утилиты.
  • Последний reconcile result виден в status или journal.
  • status показывает последние события reconcile/firewall из /run/awg2-harness/events.log.
  • Если контейнер не найден, status не падает, а переходит в degraded-режим: Peers/WireGuard clients показывают unavailable, Traffic actual — drift: unknown, а секции host firewall, anti-torrent, swap и conntrack продолжают выводиться.
  • Секции деградируют независимо: ошибка чтения peer state не мешает вывести traffic, firewall и swap.

G. Планировщик перезагрузки

  • Если reboot.enabled = false, сервис не планирует reboot и не уходит в цикл перезапусков (systemctl status показывает завершение с кодом 0, а не рестарты).
  • Если reboot.enabled = true, в journal видна запланированная перезагрузка.
  • Время попадает в окно window_start_utc–window_end_utc.
  • Вызов reboot выполняется через systemctl --no-block reboot.

H. Swap

  • ensure-swap создаёт файл по пути swap.path (в примере /swapfile) размером swap.size_mb (в примере 2048 MB).
  • Права на swap-файл — 0600.
  • Перед перезаписью /etc/fstab создана резервная копия /etc/fstab.awg2-harness.bak.
  • После первой правки существует /etc/fstab.awg2-harness.original и он не меняется при последующих запусках.
  • Нечитаемый /etc/fstab (например, chmod 000) приводит к явной ошибке, а не к перезаписи файла одной managed-строкой.
  • Отсутствующий /etc/fstab приводит к ошибке, а не к созданию нового файла.
  • Если резервную копию записать не удалось, /etc/fstab остаётся неизменным.
  • status при нечитаемом /etc/fstab показывает ошибку, а не «нет swap-записей».
  • Путь swap с пробелом в имени попадает в /etc/fstab экранированным (\040) и корректно монтируется.
  • Создан /var/lib/awg2-harness/swap-ownership.conf с device, inode и путём; каталог /var/lib/awg2-harness имеет режим 0700 и принадлежит root.
  • Старый sidecar <swap.path>.awg2-harness-owned после обновления удалён и больше не создаётся.
  • Если swapfile удалить вручную и создать по тому же пути другой файл, ensure-swap завершается ошибкой StaleOwnership и не удаляет новый файл.
  • swap.path вне /swapfile и /var/lib/awg2-harness/ отвергается на validate-config (проверить /tmp/awg.swap).
  • Если до запуска был активен другой swap, после ensure-swap он выключен.
  • После ensure-swap активен ровно один swap: /swapfile.
  • Все старые swap entries удалены из /etc/fstab.
  • Чужой swap не удаляется как файл — ни обычный файл, ни block device; выполняется только swapoff и удаление записи из /etc/fstab.
  • Существующий по swap.path файл без маркера владения приводит к явной ошибке, а не к пересозданию; его содержимое остаётся нетронутым.
  • Если создание или активация нового swap провалились, ранее активный swap остаётся включённым (проверяется, например, недостижимым размером swap.size_mb).
  • swap.path с .. (/etc/../etc/fstab), с пробелом, с управляющим символом или под /proc, /sys, /dev отвергается на validate-config.
  • swap.path, у которого один из родительских каталогов — symlink, отвергается на ensure-swap.
  • vm.swappiness из конфига применён в runtime и сохранён persistently.
  • Повторный ensure-swap ничего не пересоздаёт и не добавляет дублей.

H2. Сетевой baseline через sysctl

  • awg2-harness apply-network-tuning успешно завершается.
  • awg2-harness inspect-network-tuning показывает enabled: true и drift: no.
  • Создаётся /etc/sysctl.d/90-awg2-harness-vpn.conf.
  • /etc/modules-load.d/90-awg2-harness-bbr.conf создан, если BBR активен, и отсутствует, если BBR неактивен.
  • /etc/sysctl.conf не изменён.
  • В обоих managed-файлах первая строка содержит marker Managed by awg2-harness sysctl_tuning.
  • Файл без marker по managed-пути не перезаписывается и не удаляется: команда завершается ошибкой.
  • В managed-файле отсутствует udp_wmem_min.
  • net.ipv4.tcp_congestion_control и net.core.default_qdisc присутствуют в managed-файле только при активном BBR.
  • swap пишет vm.swappiness в /etc/sysctl.d/90-awg2-harness-swap.conf, а не в 99-awg2-harness.conf.
  • При обновлении с прежней версии /etc/sysctl.d/99-awg2-harness.conf удаляется, если несёт маркер # Managed by awg2-harness.
  • Файл по пути 99-awg2-harness.conf без этого маркера остаётся нетронутым и не ломает ensure-swap.
  • При swap.enabled = false файл 90-awg2-harness-swap.conf удаляется, а swapfile, его активность и запись в /etc/fstab остаются — после перезагрузки swap снова включится; удаляется только persistence vm.swappiness.
  • Изменение только swap.priority не пересоздаёт swapfile (проверяется по неизменившемуся mtime файла).
  • При изменении swap.size_mb на активном swap хост не остаётся без swap во время mkswap --file: в /proc/swaps всё время присутствует хотя бы одна запись.
  • Если swapoff второго чужого swap падает, ранее отключённые чужие swap возвращаются в активное состояние.
  • При откате resize swap возвращается с прежним priority, а не с новым (проверяется заведомо неприменимым swap.priority).
  • Kill процесса в середине resize: следующий запуск с активным .new или .previous даёт понятную ошибку с командой swapoff, а не бесконечно падает на удалении активного файла.
  • Kill процесса после активации нового swapfile: следующий ensure-swap проходит быстрым путём и удаляет неактивный .previous; активный .previous при этом не трогается.
  • После отката в /proc/swaps присутствует ровно исходный swap; .new отключён и удалён.
  • Откат при недоступном rename обратно (например, основной путь занят каталогом): исходный swapfile активируется прямо по пути .previous, в /proc/swaps есть запись, ошибка содержит is serving from и путь обоих файлов.
  • Откат при неподнимающемся исходном swapfile: активируется отложенная замена, ошибка содержит is left active so the host is not without swap; ни один из файлов не удалён.
  • Откат при активной замене, которую не удаётся отключить: файлы не перемещаются, ошибка содержит is left in place and serving, замена продолжает обслуживать.
  • Формулировка «хост без управляемого swap» появляется только тогда, когда в /proc/swaps действительно нет ни одной managed-записи.
  • Нечитаемый managed sysctl-файл даёт в status причину managed sysctl file is unreadable, а не «файл отсутствует».
  • При reboot.enabled = false файл /run/awg2-harness/reboot.plan удалён, status не показывает устаревший план.
  • После принятия запроса на перезагрузку reboot.plan удалён.
  • Любой managed-файл под /etc, у которого первая строка не содержит маркер владельца, не перезаписывается и не удаляется.
  • Runtime apply меняет только drifted keys (sysctl -w), без sysctl --system.
  • При tcp_bbr = auto и недоступном BBR сервис не падает, в статусе есть warning.
  • При tcp_bbr = require и недоступном BBR команда/старт завершается с ошибкой.
  • При enabled=false managed-файл удаляется только если содержит managed marker.

I. Anti-torrent guard на nftables

  • Если anti_torrent.enabled = true, в Amnezia netns есть table inet awg2_harness.
  • На хосте таблицы inet awg2_harness при apply_in_amnezia_netns = true нет: nft list table inet awg2_harness в host namespace возвращает ошибку «No such file or directory».
  • При недоступном Docker apply-firewall с anti_torrent.enabled = false завершается ошибкой, а не молчаливым успехом.
  • systemctl stop docker не останавливает awg2-harness.service (проверка отсутствия Requires=docker.service).
  • Есть chain forward_antitorrent с hook forward и policy accept.
  • Правило ограничивает ip saddr <peer_ipv4_cidr>, default 10.8.1.0/24.
  • Лимит считается per source VPN IP через meter/set, а не глобально по всей подсети.
  • Порог совпадает с anti_torrent.max_connections_per_peer; текущий пример config использует 150, более строгий вариант — 100.
  • Drop counter виден в status.
  • На first-run без table awg2-harness apply-firewall создаёт table inet awg2_harness, а не падает.
  • При превышении порога новые flows дропаются, established flows ниже порога не ломаются.
  • После restart контейнера nftables rule пересоздаётся в новом netns.
  • При anti_torrent.enabled = false удаляется/отключается только AWG2-owned nftables state; Docker/Amnezia rules не flush-ятся.
  • status показывает nf_conntrack_count / nf_conntrack_max, если sysctl доступны.

J. Откат

  • Известно, как остановить и отключить awg2-harness.service и awg2-reboot-scheduler.service.
  • Известно, как удалить только AWG2-owned host firewall state (inet awg2_host_firewall).
  • Известно, как удалить только AWG2-owned anti-torrent state (inet awg2_harness) в выбранном namespace.
  • Известно, как удалить AWG2-owned tc topology (root, ingress, ifb0) в netns Amnezia.
  • При необходимости rollback включает удаление пакета awg2-harness после остановки сервисов.
  • Rollback проверен на тестовом сервере.

K. Упаковка и релиз

  • Package собирается на отдельной Debian 13 amd64 build-machine.
  • Production host не требует Rust/cargo/source tree.
  • ./tools/build.sh --package создаёт dist/awg2-harness_<version>_amd64.deb.
  • dist/SHA256SUMS валидирует checksum пакета.
  • Пакет ставит бинарь в /usr/sbin/awg2-harness.
  • Пакет ставит unit в /lib/systemd/system/awg2-harness.service.
  • Пакет не устанавливает /etc/awg2-harness/config.toml автоматически.
  • Пакет не включает/не стартует сервис автоматически.
  • Пакет не имеет Depends/Recommends на docker.io; установка Amnezia/Docker внешняя.
  • Пакет не имеет Depends на wireguard-tools: peer state читается внутри контейнера, host-side wg не используется.
  • apt install ./awg2-harness_<version>_amd64.deb не устанавливает и не заменяет Docker.
  • apt install ./awg2-harness_<version>_amd64.deb проходит на clean Debian 13 Amnezia host.
  • После ручной установки config awg2-harness validate-config проходит.
  • Сервис стартует только после явного systemctl enable --now awg2-harness.service.

K2. Документация и UX установки

  • README не утверждает, что AmneziaVPN ожидает public key.
  • Документация явно разделяет private key для AmneziaVPN и public key для authorized_keys.
  • Есть пример заполнения AmneziaVPN fields: Server IP address [:port], SSH Username, Password or SSH private key.
  • Описаны Windows пути %USERPROFILE%\.ssh\amnezia_key и %USERPROFILE%\.ssh\amnezia_key.pub.
  • Описан runtime config bootstrap из /usr/share/doc/awg2-harness/repo/config.example.toml.
  • Clean install подтверждает, что /etc/awg2-harness/config.toml не создаётся автоматически.

K3. AmneziaDNS

  • AmneziaDNS установлен из приложения AmneziaVPN.
  • Контейнер amnezia-dns в состоянии healthy.
  • docker inspect amnezia-dns показывает "53/tcp":null и "53/udp":null.
  • amnezia-dns и активный AWG-контейнер (amnezia-awg2, amnezia-awg3 или amnezia-awg) подключены к одной Amnezia DNS Docker-сети.
  • Публичный DNS-запрос к <server-public-ip>:53 не резолвит домены; допустимы connection refused, timeout или no servers could be reached.
  • DNS-резолвинг у VPN-клиента работает при активном подключении.
  • AWG2 host firewall не открывает 53/tcp и 53/udp.

L. Host firewall (порты и безопасность SSH)

  • awg2-harness validate-config падает, если отсутствует секция [host_firewall].
  • apply-firewall не блокирует SSH на host_firewall.ssh_port (default 2323).
  • После apply-firewall порт host_firewall.amnezia_port (default 443) доступен по всем протоколам из host_firewall.amnezia_protocols.
  • Если эксплуатационно требуется закрыть legacy SSH 22, это явно подтверждено проверкой доступа на ssh_port перед закрытием 22.
  • При host_firewall.enabled = false удаляется только таблица inet awg2_host_firewall.
  • Повторный apply-firewall идемпотентен и не вносит лишних изменений в healthy state.
  • При невалидном host_firewall.amnezia_protocols (icmp, foo, дубли) validate-config завершается ошибкой.
  • После docker restart anti-torrent правила продолжают восстанавливаться корректно и не ломаются host firewall логикой.
  • Проверено, где реально проходит VPN-трафик в вашей Docker-схеме (INPUT vs FORWARD) и это отражено в эксплуатационной заметке.
  • Для release-кандидата зафиксирован итог проверки INPUT vs FORWARD на реальном сервере (команды/логи/вывод) и подтверждено, что текущие правила firewall покрывают реальный путь трафика.
  • status показывает блок Docker/Amnezia port path с полями docker_userland_proxy, published_udp_port и note про Docker-managed FORWARD.
  • status показывает поля host_forward_policy_drop и docker_forward_rules_present в блоке Docker/Amnezia port path.
  • Если docker_userland_proxy=false и FORWARD policy DROP, при отсутствии явных Docker accept-признаков status выводит warning, но awg2-harness не изменяет FORWARD автоматически.
  • Сверено соответствие: config host_firewall.amnezia_port ↔ опубликованный Docker UDP host port ↔ docker exec amnezia-awg2 awg show (listening port).
  • При нескольких UDP bindings без однозначного соответствия host_firewall.amnezia_port диагностика published_udp_port показывает unknown (ambiguous), а не произвольный первый порт.
  • Для диагностики FORWARD используется best-effort проверка: iptables -S FORWARD и fallback через nft list ruleset; status не падает при недоступности одной из команд.