Files
AWG2_harness/docs/ACCEPTANCE_CHECKLIST.md
T
founder 6b3e5d1b2f fix(swap): never release the fallback swap during a failed rollback; release 0.3.0 [P0]
The rollback claimed an invariant in its own comment - restore the original, confirm it,
and only then release staging - and did not enforce it. original_back was computed and then
ignored: the staging swapoff ran unconditionally.

The failure path that mattered: staging is active, the original is already off, reactivating
the original fails (swapon can fail under the memory pressure that made the resize
necessary), and the rollback then switched off the staging swap - the last thing carrying
load. Recovering from a failed resize left the host with no swap at all, which is worse
than the failure being recovered from.

original_back now gates the release. If the original cannot be brought back the rollback
returns early with the staging swap still active, and the error carries the two commands
that finish the job by hand. An extra temporary swap is always the better end state than
none. The staging file is also only unlinked once swapoff has actually succeeded - an
active swapfile cannot be removed, and attempting it produced an error that explained
nothing.

Asserting this needs a seam, because the invariant is a sequence of calls rather than a
value: a small SwapCommands trait covers swapon/swapoff, with the real implementation
behind it. Three tests: the original cannot be restored and swapoff is never called with
the message naming the preserved fallback; the original comes back and staging is released
in that order; nothing was taken down so nothing is touched.

Also from the same audit:

- StaleOwnership advice was wrong. It suggested deleting the ownership record, which turns
  the file Foreign - refused just as firmly. The message now distinguishes the two cases: a
  leftover .previous next to the swapfile is the signature of a replacement killed between
  installing the new file and recording its identity, so the error says nothing is lost,
  points at the intact previous swapfile, and gives the exact commands for going back or
  keeping the new file. Full automatic crash recovery is deliberately not added here - it
  needs a transition journal, and that belongs with the Debian acceptance work.
- The acceptance checklist still described the PID-file lock ("re-acquires by the absence
  of /proc/<pid>"), which stopped being true when locking moved to flock. The kernel
  releases it on process exit, and the leftover file means nothing.
- shellcheck was installed by --prepare-build-env but never run. It now runs in
  prepare_build_gate: the script installs systemd units and writes to /usr/sbin, so it is
  part of the release surface.
- The skip message for systemd-analyze verify now says what the check actually is - a
  post-install gate, expected to be skipped on a clean builder - rather than reading like a
  build gate that silently did not run.

One audit item did not reproduce: there is no duplicated -j in antitorrent_drop_counter,
the flag appears once.

Version raised to 0.3.0 in traffic_core/Cargo.toml, with the packaging examples in README
and INSTALLATION updated to match. The reboot scheduler keeps its own 0.2.0.

cargo fmt --check, clippy -D warnings, cargo test --locked --workspace --release: 191 + 4
passed. build.sh not syntax-checked here - no bash on this host; the shellcheck gate now
covers it on the builder.
2026-08-07 05:23:42 +05:00

238 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Чек-лист приёмки перед 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`).
- [ ] Параллельный запуск `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`, а не бесконечно падает на удалении активного файла.
- [ ] После отката в `/proc/swaps` присутствует ровно исходный swap; `.new` отключён и удалён.
- [ ] Нечитаемый 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` не падает при недоступности одной из команд.