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.
AWG2 Harness
AWG2 Harness — host-side orchestrator для выделенного Debian-хоста с self-hosted AmneziaWG. Пакет управляет инфраструктурным состоянием вокруг Amnezia: per-peer traffic shaping, IFB ingress shaping, nftables host firewall, anti-torrent connection guard, managed swap, диагностикой drift и плановым reboot.
Проект разрабатывался и проверялся под AmneziaWG/AWG 2.0 и совместимый legacy-сценарий AmneziaWG/AWG 1.5. Он не заменяет AmneziaVPN, не генерирует ключи клиентов и не меняет продуктовую логику Amnezia. Amnezia сначала ставится официальным способом, после этого устанавливается AWG2 Harness.
Что ставит пакет
/usr/sbin/awg2-harness # основной orchestrator
/usr/sbin/awg2-reboot-scheduler # отдельный reboot scheduler
/lib/systemd/system/awg2-harness.service
/lib/systemd/system/awg2-reboot-scheduler.service
awg2-harness.service запускает только daemon; ensure-swap выполняется первым этапом его собственной startup-последовательности, а не отдельным ExecStartPre. Дальше daemon в фоне держит drift recovery. Daemon слушает Docker events и дополнительно делает периодический reconcile для traffic/firewall/swap; sysctl_tuning применяется только на старте daemon (если включён) или вручную отдельной командой. awg2-reboot-scheduler.service отдельно читает общий config, выбирает случайное время в заданном UTC-окне и запрашивает reboot через systemctl --no-block reboot.
Пакет не устанавливает Docker, не устанавливает AmneziaVPN, не включает сервисы автоматически и не создаёт боевой /etc/awg2-harness/config.toml. Это сделано намеренно: SSH, Amnezia и боевой config должны быть подготовлены оператором до включения firewall/reconcile.
Репозиторий
awg2_harness/
├── reboot_module/ # исходники awg2-reboot-scheduler
├── traffic_core/ # исходники awg2-harness
├── tools/ # build/package tooling
├── docs/ # эксплуатационная документация
├── config.example.toml # пример боевого конфига
├── awg2-harness.service # основной systemd unit
└── awg2-reboot-scheduler.service # reboot scheduler unit
Runtime-план production-развёртывания
Ниже целевой порядок установки. Его смысл простой: сначала безопасный SSH, потом официальная Amnezia, потом AWG2 Harness.
1. Подготовить Debian-хост
Целевая платформа AWG2 Harness — только Debian 13 amd64 на KVM/полноценной VM или dedicated host.
Минимальный baseline перед установкой Amnezia:
apt update
apt install -y openssh-server ca-certificates curl
timedatectl set-timezone Etc/UTC
2. Настроить SSH до установки Amnezia
Production-пример использует порт 2323 и вход только по ключу.
Сгенерируйте отдельную пару ключей для Amnezia на Windows (RSA PEM-формат):
ssh-keygen -t rsa -b 4096 -m PEM -f %USERPROFILE%\.ssh\amnezia_key
Дальше контракт строго такой:
- на сервер добавляется публичный ключ: содержимое
%USERPROFILE%\.ssh\amnezia_key.pubв/root/.ssh/authorized_keys; - в приложении AmneziaVPN в поле Password or SSH private key вставляется приватный ключ: содержимое
%USERPROFILE%\.ssh\amnezia_key(без.pub).
Критично: .pub никогда не вставляется в AmneziaVPN как private key.
install -d -m 0700 /root/.ssh
nano /root/.ssh/authorized_keys
chmod 0600 /root/.ssh/authorized_keys
Затем включите отдельный drop-in для порта и key-only login:
install -d -m 0755 /etc/ssh/sshd_config.d
cat >/etc/ssh/sshd_config.d/99-awg2-production.conf <<'EOF'
Port 2323
PermitRootLogin prohibit-password
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
EOF
sshd -t
systemctl reload ssh 2>/dev/null || systemctl reload sshd
ss -ltnp | grep ':2323'
Критично: не закрывайте старую SSH-сессию, пока не проверили новый вход с другого терминала:
ssh -i ~/.ssh/amnezia_key -p 2323 root@<server-ip>
Windows-проверки:
type %USERPROFILE%\.ssh\amnezia_key.pub
type %USERPROFILE%\.ssh\amnezia_key
ssh -i %USERPROFILE%\.ssh\amnezia_key -p 2323 root@<server-ip>
Поля в интерфейсе AmneziaVPN:
Server IP address [:port] <server-ip>:2323
SSH Username root
Password or SSH private key содержимое %USERPROFILE%\.ssh\amnezia_key
На сервер содержимое %USERPROFILE%\.ssh\amnezia_key.pub в /root/.ssh/authorized_keys
Если сервер уже переведён на 2323 случайно и нужно временно вернуть 22 для тестов, поменяйте Port 2323 на Port 22, выполните sshd -t, systemctl reload ssh и проверьте ss -ltnp | grep ':22'.
3. Установить AmneziaVPN официальным способом
AWG2 Harness не устанавливает Amnezia. Используйте официальный self-hosted flow AmneziaVPN:
- Установите актуальное приложение AmneziaVPN на рабочую машину.
- В приложении выберите self-hosted server.
- Укажите публичный IPv4 сервера, SSH-порт, SSH-пользователя и приватный ключ.
- Установите AmneziaWG/AWG. Для AWG 2.0 нужен актуальный AmneziaVPN, где протокол AmneziaWG 2.0 доступен в списке протоколов.
- После установки убедитесь, что контейнер поднят и UDP-порт опубликован.
Проверка на сервере:
docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'
C="$(docker ps --format '{{.Names}}' | grep -E '^(amnezia-awg2|amnezia-awg3|amnezia-awg)$' | head -n1)"
echo "container=$C"
docker port "$C"
docker exec "$C" awg show
Ожидаемые имена контейнера по умолчанию: amnezia-awg2 или amnezia-awg. amnezia-awg3 перечислен на будущее — в AmneziaVPN 5.0 такого типа контейнера ещё нет. Ожидаемый интерфейс внутри container netns: обычно awg0, fallback-кандидаты описаны в config.example.toml.
Совместимость с AmneziaWG 3.0
Проверено по исходникам на: AmneziaVPN 5.0.0.5, amneziawg-tools и amneziawg-go ветки master на момент правки. Утверждения ниже относятся к этим версиям; при более новых релизах их нужно перепроверить, а не считать бессрочными.
Обвязка не реализует AWG на уровне пакетов: tc, IFB и nftables работают с inner-IP уже после декапсуляции, поэтому header protection, content padding и рандомизация таймеров AWG 3.0 её не касаются.
Читается только peer state, всегда через docker exec. Реальный бинарь в официальном образе — /usr/bin/awg, /usr/bin/wg является symlink на него; порядок задаётся amnezia.wg_binaries (по умолчанию ["awg", "wg"]). На хосте wireguard-tools не требуется и не проверяется в preflight: AmneziaWG — userspace-реализация, её UAPI-сокет лежит в mount namespace контейнера, и хостовый wg через nsenter -n до него не достаёт.
Smoke-проверка после обновления контейнера:
C="$(docker ps --format '{{.Names}}' | grep -E '^(amnezia-awg2|amnezia-awg3|amnezia-awg)$' | head -n1)"
docker exec "$C" readlink -f /usr/bin/wg # ожидается /usr/bin/awg
docker exec "$C" awg --version
docker exec "$C" ip -o link show awg0
docker exec "$C" awg show all allowed-ips
docker port "$C"
# Структура dump без раскрытия значений:
docker exec "$C" awg show all dump | awk -F '\t' '{ print "row=" NR, "fields=" NF, "interface=" $1 }'
Ожидаемо: /usr/bin/wg указывает на /usr/bin/awg, интерфейс awg0 существует, peer-адреса отображаются, серверный порт опубликован по UDP. Peer-строки дают 9 полей.
Ширина interface-строки зависит от версии amneziawg-tools, а не от версии параметров в конфигурации: утилиты AWG 3.0 печатают расширенную строку и для туннеля, настроенного как AWG 2.0. По количеству колонок определять версию протокола нельзя, и обвязка этого не делает.
Полный вывод awg show all dump передавать нельзя: во второй колонке interface-строки находится приватный ключ устройства.
3.1 Установить AmneziaDNS (обязательный production-шаг)
Для production-профиля AWG2 установите AmneziaDNS из приложения AmneziaVPN после установки AmneziaWG/AWG:
AmneziaVPN:
Servers → <server> → Services → AmneziaDNS → Install
Затем включите использование DNS для подключения:
Settings → Connection → DNS servers → Use AmneziaDNS
Security note:
В ожидаемой production-схеме AmneziaDNS слушает 53/tcp и 53/udp внутри Docker и не публикуется на host interface. Не открывайте публично 53/tcp или 53/udp в host firewall; DNS должен быть доступен только через VPN-path.
4. Собрать пакет на build-machine
На production-хосте не нужны Rust, cargo и source tree. Сборка делается отдельно.
cd awg2_harness
./tools/build.sh --prepare-build-env --bootstrap-rust --yes
./tools/build.sh --package --version 0.3.0
(cd dist && sha256sum -c SHA256SUMS)
dpkg-deb -I dist/awg2-harness_0.3.0_amd64.deb
dpkg-deb -c dist/awg2-harness_0.3.0_amd64.deb | sort
--version можно не указывать, если версия в traffic_core/Cargo.toml уже соответствует release. Если version override указан, он меняет версию Debian-пакета, а не версию crate в Cargo.toml.
5. Перенести .deb и config на production
Есть два production-сценария установки.
Вариант A: config передаётся вместе с .deb
scp -P 2323 dist/awg2-harness_*_amd64.deb root@<server-ip>:/root/
scp -P 2323 dist/SHA256SUMS root@<server-ip>:/root/
scp -P 2323 config.example.toml root@<server-ip>:/root/awg2-harness.config.toml
Перед установкой отредактируйте /root/awg2-harness.config.toml: SSH-порт должен совпадать с реальным sshd, amnezia_port — с опубликованным UDP-портом контейнера, peer_ipv4_cidr — с подсетью клиентов Amnezia.
Вариант B (основной): config создаётся после установки пакета
scp -P 2323 dist/awg2-harness_*_amd64.deb root@<server-ip>:/root/
scp -P 2323 dist/SHA256SUMS root@<server-ip>:/root/
6. Установить пакет и config
Вариант A:
cd /root
sha256sum -c SHA256SUMS
chmod 644 /root/awg2-harness_0.3.0_amd64.deb
apt install ./awg2-harness_*_amd64.deb
install -d -m 0755 /etc/awg2-harness
install -m 0644 /root/awg2-harness.config.toml /etc/awg2-harness/config.toml
Пакет ставит пример config в /usr/share/doc/awg2-harness/repo/config.example.toml, но не копирует его в /etc автоматически.
Вариант B (основной):
cd /root
sha256sum -c SHA256SUMS
chmod 644 /root/awg2-harness_0.3.0_amd64.deb
apt install ./awg2-harness_*_amd64.deb
install -d -m 0755 /etc/awg2-harness
cp /usr/share/doc/awg2-harness/repo/config.example.toml /etc/awg2-harness/config.toml
nano /etc/awg2-harness/config.toml
В варианте B после cp обязательно проверьте перед первым apply-firewall:
host_firewall.ssh_portсовпадает с реальным sshd-портом;host_firewall.amnezia_portсовпадает с published Docker UDP port;amnezia.peer_ipv4_cidrиanti_torrent.peer_ipv4_cidrсовпадают с реальной peer-подсетью.
7. Ручная preflight-проверка
awg2-harness --config /etc/awg2-harness/config.toml validate-config
awg2-harness --config /etc/awg2-harness/config.toml validate-runtime
awg2-harness --config /etc/awg2-harness/config.toml ensure-swap
awg2-harness --config /etc/awg2-harness/config.toml apply-firewall
awg2-harness --config /etc/awg2-harness/config.toml apply-limits
awg2-harness --config /etc/awg2-harness/config.toml status
Ожидаемый healthy state:
System:
service: inactive # до enable --now нормально
Traffic actual:
drift: no
Host firewall:
drift: no
Anti-torrent:
drift: no
Swap:
drift: no
Если peers ещё нет, apply-limits корректно напишет no peers found, skip tc apply. Это не ошибка: traffic topology строится, когда есть хотя бы один peer allowed IP в целевой подсети.
8. Включить systemd services
systemctl daemon-reload
systemctl enable --now awg2-harness.service
systemctl enable --now awg2-reboot-scheduler.service
systemctl status awg2-harness.service --no-pager -l
systemctl status awg2-reboot-scheduler.service --no-pager -l
journalctl -u awg2-harness.service --since '5 minutes ago' --no-pager
journalctl -u awg2-reboot-scheduler.service --since '5 minutes ago' --no-pager
После 30–60 секунд:
awg2-harness --config /etc/awg2-harness/config.toml status
Healthy daemon показывает service: active, traffic=ok, firewall=ok, swap=ok в recent events и пустой last_error.
9. Проверить после reboot
reboot
После возврата:
systemctl status docker --no-pager -l
systemctl status awg2-harness.service --no-pager -l
systemctl status awg2-reboot-scheduler.service --no-pager -l
awg2-harness --config /etc/awg2-harness/config.toml status
Дополнительно можно проверить фактическую tc topology внутри network namespace Amnezia:
C="$(docker ps --format '{{.Names}}' | grep -E '^(amnezia-awg2|amnezia-awg3|amnezia-awg)$' | head -n1)"
PID="$(docker inspect -f '{{.State.Pid}}' "$C")"
nsenter -t "$PID" -n -- tc class show dev awg0
nsenter -t "$PID" -n -- tc class show dev ifb0
nsenter -t "$PID" -n -- nft list table inet awg2_harness
nft list table inet awg2_host_firewall
Для default config на одном peer ожидаются классы 1:10 и 2:10 с rate 50Mbit ceil 50Mbit, root classes 1:1 и 2:1 с 500Mbit, host firewall table inet awg2_host_firewall и anti-torrent table inet awg2_harness внутри Amnezia netns.
CLI команды
Все команды принимают --config. Если параметр не указан, используется /etc/awg2-harness/config.toml.
awg2-harness --config /etc/awg2-harness/config.toml <command>
validate-config проверяет TOML и смысловые ограничения: пустые списки, формат rate (50mbit, 500mbit), CIDR, reboot window, swap path, SSH/Amnezia ports, protocol list.
validate-runtime проверяет host tools, которые реально нужны выбранным модулям: tc, ip, docker, modprobe, nsenter, а также nft, systemctl, swap tooling при включённых firewall/reboot/swap. Хостовый wg в этот список не входит: peer state читается внутри контейнера через docker exec (amnezia.wg_binaries).
ensure-swap приводит swap к desired state: создаёт/проверяет swapfile, включает его с заданным priority, применяет vm.swappiness, делает swap state exclusive и переписывает swap entries в /etc/fstab под управляемый swapfile.
apply-firewall делает два reconcile: host INPUT firewall в таблице inet awg2_host_firewall и anti-torrent guard в выбранном namespace. Команда не управляет Docker NAT и host FORWARD.
apply-limits находит Amnezia container, заходит в его network namespace, читает peers через wg, строит per-peer HTB/fq_codel классы на AWG/WG интерфейсе и IFB ingress path. Если drift не найден, пишет traffic rules up-to-date, skip apply.
status печатает фактическую диагностику: сервис, контейнер, netns, интерфейс, peers, WireGuard runtime, traffic desired/actual, host firewall drift, Docker/Amnezia port path, anti-torrent counters, swap state, conntrack, recent events и planned reboot.
daemon — режим для systemd. На старте делает startup reconcile, затем слушает Docker events и периодически сверяет traffic/swap/firewall. Вручную обычно не запускается, кроме отладки.
awg2-reboot-scheduler --config /etc/awg2-harness/config.toml читает только секцию [reboot], пишет план в /run/awg2-harness/reboot.plan и ждёт назначенного UTC-времени.
Что происходит под капотом
Traffic shaping реализован через tc в network namespace контейнера Amnezia. На egress используется root qdisc htb 1: на AWG/WG интерфейсе, root class 1:1, per-peer classes 1:<id> и fq_codel под каждым peer class. На ingress ставится ingress ffff: на AWG/WG интерфейс, входящие пакеты перенаправляются через mirred в ifb0, а там строится отдельная HTB topology 2: с такими же per-peer лимитами.
Peer discovery берётся из WireGuard/AmneziaWG allowed IPs, а не из handshake state. Поэтому offline peer с allowed ips: 10.8.1.1/32 тоже получает лимит заранее.
Host firewall — отдельная AWG2-owned таблица inet awg2_host_firewall. Она открывает loopback, established/related, SSH-порт из конфига, Amnezia-порт по указанным протоколам и optionally ICMP/ICMPv6. Docker-managed FORWARD и NAT не трогаются.
Anti-torrent guard — отдельная AWG2-owned таблица inet awg2_harness обычно внутри Amnezia netns. Правило ограничивает число новых conntrack flows per source VPN IP. Это не DPI и не распознавание BitTorrent-протокола; это грубый, дешёвый guard против сотен одновременных P2P-соединений.
Swap module намеренно строгий: если swap.enabled=true, desired state — один управляемый swapfile. Старые swap entries удаляются из /etc/fstab, block-device swap выключается, но block device не удаляется.
Production-границы ответственности
AWG2 Harness владеет только собственным state:
tctopology на Amnezia interface и configuredifb_name;- nft table
inet awg2_host_firewallв host namespace; - nft table
inet awg2_harnessдля anti-torrent; /run/awg2-harness/*runtime state;- managed swapfile и swap entries, если
swap.enabled=true.
AWG2 Harness не владеет:
- установкой AmneziaVPN;
- Docker daemon lifecycle;
- Docker NAT/FORWARD chains;
- генерацией client configs/keys;
- политикой пользователя внутри Amnezia app;
- сторонними firewall-таблицами.
Документация
docs/INSTALLATION.md— установка и preflight.docs/CONFIGURATION.md— параметрыconfig.toml.docs/ACCEPTANCE_CHECKLIST.md— production checklist.docs/ANTI_TORRENT.md— connection-count guard.docs/PRODUCTION_EXAMPLE.md— эталонный production-пример.tools/README.md— сборка и упаковка.reboot_module/README.md— внешний reboot scheduler.