fix: apply nft batches in the selected namespace, complete the swap rollback [P0]

Two release blockers, the first a regression introduced by dd5be47.

1. The anti-torrent batch was applied on the host.

   Making the rebuild atomic moved it from run_step(transport, ..) to run_nft_batch(script),
   and run_nft_batch spawned `nft` directly with Command::new. An nft transaction belongs to
   the network namespace of the process that runs nft, so with the production default
   apply_in_amnezia_netns = true the sequence became: inspect inside the container, install
   the table on the host, verify inside the container, fail. The guard was silently
   ineffective, a stray table accumulated on the host, and the next reconcile could fail on
   `add table` because the host copy already existed.

   NsTransport gains run_with_stdin, implemented for all three transports - host, nsenter,
   and docker exec with -i, which is required or the child sees EOF immediately.
   run_nft_batch now takes the transport, and both callers pass the one they inspected
   with. A recording fake transport asserts the script goes through the transport rather
   than to a host process, which is the regression this could not previously catch.

2. Swap replacement rolled back with the wrong priority and not from every branch.

   The rollback reactivated with desired.priority - the value that may be exactly what the
   kernel had just rejected - because the previously active priority was dropped at the
   match arm and never passed in. The active entry's priority is now carried into
   replace_active_swapfile and used by the rollback.

   Three early failure branches also returned without undoing anything: a failed swapoff of
   the original, a failed rename to .previous, and a failed swapoff of the staging file all
   left the staging swap active and the transition half-applied, after which the next
   reconcile tried to unlink a file that was still active and could not proceed.

   The transition now records what it completed - staging_active, original_active,
   original_moved, new_installed - and one rollback undoes exactly that much, in an order
   that never leaves the host without swap: restore the original file, reactivate it at its
   previous priority, confirm it, and only then release and remove the staging swap. The
   returned error carries the original cause plus what the recovery achieved.

   Leftovers from a killed process are no longer removed blindly. A .new or .previous that
   is still active cannot be unlinked, so it is reported with the swapoff command that
   clears it instead of failing the same way on every reconcile.

Also in this commit, from the same audit:

- Requires=docker.service dropped in favour of Wants=. The daemon is built to outlive
  Docker being down, but Requires= had systemd stop the harness whenever Docker stopped,
  and Restart=on-failure does not cover a dependency-ordered stop - so the reconnect loop
  never got to run.
- apply_antitorrent_disabled swallowed every discover_target error, not just "no container
  running". Docker being unreachable or a permission error on the socket reported success
  for a cleanup that never happened. Only AppError::NotFound is tolerated now.
- FstabTransaction::begin asserts /etc/fstab is a regular non-symlink file. read_to_string
  and metadata both follow links, so a symlinked fstab would be read through and then
  replaced by the rename, detaching the real file from the path the system boots from.
- append_event serialises its read-modify-write behind a mutex; the reconcile loop and the
  Docker events thread both append and could drop an entry.
- tool_exists loses the fallocate, chmod and dd arms, dead since mkswap --file replaced the
  allocation chain.

Docs corrected where they no longer matched the code: findmnt is described as mandatory
rather than skippable, the swap creation wording is mkswap --file, and the README package
examples use 0.1.6 to match Cargo.toml. Remaining fallocate mentions are explicitly
historical ("the previous chain"), which is accurate.

Seven tests added: batch routed through the given transport, batch failure reported as
such, rollback state defaults and previous-priority carry, active leftovers reported rather
than deleted, inactive leftovers cleared.

cargo fmt --check, clippy -D warnings, cargo test --locked --workspace --release: 188 + 4
passed.
This commit is contained in:
2026-08-07 05:08:20 +05:00
parent eea787d6f8
commit 431732396b
14 changed files with 423 additions and 123 deletions
+1 -1
View File
@@ -200,7 +200,7 @@ swappiness = 1
Ограничения `swap.path`: путь должен быть либо `/swapfile`, либо файлом непосредственно в `/var/lib/awg2-harness/`. Дополнительно: абсолютный, без компонентов `.` и `..`, без пробелов, управляющих символов и обратных слэшей; на этапе применения проверяется, что ни один родительский каталог не является symlink.
Ограничение корней обязательно, а не косметика. Если родительский каталог доступен на запись кому-то кроме root, между проверкой пути и запуском `fallocate`/`mkswap` этот пользователь успевает подменить файл на symlink и перенаправить операцию, выполняемую от root. Оба разрешённых корня принадлежат root, поэтому окна не возникает. `/swapfile` оставлен для совместимости с уже развёрнутыми конфигурациями.
Ограничение корней обязательно, а не косметика. Если родительский каталог доступен на запись кому-то кроме root, между проверкой пути и запуском `mkswap --file` этот пользователь успевает подменить файл на symlink и перенаправить операцию, выполняемую от root. Оба разрешённых корня принадлежат root, поэтому окна не возникает. `/swapfile` оставлен для совместимости с уже развёрнутыми конфигурациями.
Владение фиксируется в `/var/lib/awg2-harness/swap-ownership.conf` по `device` + `inode` + пути, а не по маркеру рядом с файлом. Файл, которого нет в этой записи, или файл с тем же путём, но другими `device`/`inode` (то есть подменённый), считается чужим: он никогда не пересоздаётся и не удаляется, вместо этого возвращается ошибка. Чужие активные swap только выключаются — их файлы пакет не удаляет.