Commit Graph

20 Commits

Author SHA1 Message Date
founder 431732396b 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.
2026-08-07 05:08:20 +05:00
founder eea787d6f8 chore: reap docker events, cut per-cycle work, use mkswap --file [P1/P2]
1. docker events was leaking zombies.

   docker_events_loop spawned the child, read its stdout to EOF and returned without
   calling wait(). Dropping a Child does not reap it, so every reconnect - which happens
   whenever Docker restarts or the stream ends - left a defunct process behind for the
   lifetime of the daemon. The reader loop moved into its own function so the wait happens
   on every exit path, and the exit status is logged rather than discarded.

2. sysctl status still decided ownership with contains().

   The destructive paths were fixed earlier, but inspect_sysctl_tuning kept
   first.contains(MANAGED_MARKER) in the disabled branch, so a file whose first line merely
   embedded the marker was reported as managed. Diagnostic-only, but it contradicted the
   rule the rest of the module follows. Now an exact match on the trimmed first line.

3. Swap was reconciled at the traffic interval.

   ensure_swap ran every reconcile_interval_secs, default 15, and each run spawns
   swapon --show, sysctl reads, the persistence check and the final verify. A swapfile, its
   fstab entry and vm.swappiness change when an operator changes them and at no other time,
   so this was process spawning for state that does not drift. Swap now runs on its own
   300-second schedule; traffic and firewall keep the configured interval. The startup
   reconcile is unchanged, so a fresh daemon still converges immediately.

4. The event log recorded routine success.

   Every successful cycle appended a line, and appending means reading the whole file,
   rewriting it and truncating. At 15 seconds that is roughly 17k entries a day, and the
   entries worth reading were buried among them. Only transitions are written now: a new
   failure, a failure whose message changed, and the recovery after one. set_ok and set_err
   report whether the state actually changed, which is what makes that possible. journald
   still has the full per-cycle picture through the tracing output.

5. Swapfile creation is one command.

   mkswap --file <path> --size <N>M creates and formats the file, replacing the
   fallocate → dd fallback → chmod → mkswap chain: four processes, three failure branches,
   and a window where the file existed but was not yet formatted. util-linux on Debian 13
   also handles filesystem-specific requirements - Btrfs in particular - that raw fallocate
   does not. fallocate, dd and chmod drop out of the required host tools when swap is
   enabled.

cargo test --workspace: 183 + 4 passed. clippy -D warnings clean.
2026-08-07 04:50:57 +05:00
founder c2cf194a68 fix(fs): refuse to write through symlinks, make fstab validation mandatory [P0/P1]
Three defects from the follow-up audit, all in code that runs as root.

1. The fstab backup was an arbitrary-file-overwrite primitive.

   write_file_synced used fs::File::create, which follows symlinks. A symlink planted at
   /etc/fstab.awg2-harness.bak would be followed and its target truncated and overwritten
   with the contents of /etc/fstab. Same class of bug as the sidecar write in swap.rs,
   which the next commit removes entirely.

   New module atomic.rs owns the write path for every file this package installs under
   /etc and /var/lib: stat the destination with symlink_metadata, refuse anything that is
   not a regular file or absent, create a sibling temp with create_new so a symlink at the
   temp path cannot be followed either, write, sync_all, set the mode, rename, flush the
   directory. fstab.rs and managed_file.rs both use it, which also removes the two
   near-identical write_atomic implementations they each carried.

2. Directory fsync failures were swallowed.

   Both modules document crash-durable writes, then ignored the result of syncing the
   parent directory - the step that makes the rename survive a crash. sync_parent_dir now
   returns Result and callers propagate it.

3. findmnt validation was optional in practice.

   verify_candidate had `Err(_) => Ok(())`, so a host without util-linux silently skipped
   the only structural check on a file that decides whether the machine boots. The package
   already depends on util-linux, so this is now a hard error, and findmnt joins the
   required host tools when swap.enabled is true - failing preflight rather than at rewrite
   time.

   To keep the transaction testable off Linux, commit takes the validator through an
   injected fn pointer; production always passes the real findmnt check. That seam also
   made it possible to test the rejection path directly, which was previously unreachable
   in tests.

Also in this commit, since they are the same class of defect:

- fstab rollback used fs::copy straight onto /etc/fstab, which can leave it truncated if
  it fails midway. It now writes the original - held in memory since begin, so the
  recovery path does not depend on re-reading the backup - through the same atomic
  no-follow path as a forward write.
- the pristine .original copy is checked to be a regular file when it already exists; a
  symlink or directory there means the recovery copy cannot be trusted, so the commit
  refuses instead of proceeding.
- ManagedFile decided ownership with `first.contains(marker)`, so a file whose first line
  read "# NOT Managed by awg2-harness sysctl_tuning" was treated as ours and became
  eligible for overwrite and removal. Now an exact match on the trimmed first line.
- systemctl daemon-reload after a successful fstab commit, since /etc/fstab feeds
  systemd's generators and not only mount. Best-effort with a warning: the change is
  already committed and verified, and failing the operation over a missed notification
  would be worse than a stale generator state the next boot resolves.

Thirteen tests added: symlinked destination refused with the victim asserted intact,
symlink at the temp path not followed, directory destination refused, mode applied, stale
temp cleared, rejected candidate leaves the target untouched, non-regular pristine copy
refused, symlinked backup path refused with the victim intact, exact marker matching
against three near-miss first lines, and trailing whitespace tolerated on our own marker.

cargo test --workspace: 171 + 4 passed. clippy -D warnings clean.
2026-08-07 04:04:01 +05:00
founder 63dcf7676c fix(config): tighten CIDR, interface and list validation [P1]
parse_cidr_v4 split on '/' and read the first two components without checking for a
third, so "10.8.1.0/24/garbage" parsed as a valid /24 with the tail silently dropped. The
value reaches nftables rules and tc filters, so accepting malformed input meant applying
rules derived from a config the operator did not write. Now a second separator is an
error, and the negative cases are pinned by a test: missing prefix, empty prefix, double
separator, negative and out-of-range prefixes, a malformed octet, an empty string, and a
missing address.

Interface names were only checked for being non-empty, and only for ifb_name. They are
passed straight to ip and tc, so a name that cannot exist produces a confusing runtime
failure instead of a config error. Validation now covers amnezia.interface_candidates and
traffic.ifb_name: non-empty, within the Linux IFNAMSIZ-1 limit of 15 characters, not '.'
or '..', and free of whitespace, control characters, '/' and ':'.

interface_patterns get the same rules plus one specific to the matcher: only a single
trailing '*' is supported, so a pattern like "*wg" or "a*g*" would have silently never
matched anything. A bare "*" is rejected outright - matching every interface is not a
fallback, it is a way to attach shaping to the wrong device.

container_names are matched exactly against docker ps output, so a name outside Docker's
own [a-zA-Z0-9][a-zA-Z0-9_.-]* grammar can never be found. That is now a config error
rather than a container that mysteriously never appears.

All four ordered lists - container_names, interface_candidates, interface_patterns,
wg_binaries - are checked for blank and duplicate entries through one shared helper. A
blank entry becomes an unmatchable candidate; a duplicate makes an ordered list lie about
its priority. Neither is normalized away silently.

Seven tests added, covering accepted and rejected values for each rule.

cargo test --workspace: 155 + 4 passed. clippy -D warnings clean.
2026-08-06 22:52:17 +05:00
founder a895295701 fix(swap): never touch an unowned swapfile, activate before deactivating [P0]
Two more release blockers in the swap module.

1. Foreign files could be destroyed.

   swap.path was validated against a blacklist of exact strings. /etc/../etc/fstab is not
   string-equal to /etc/fstab but resolves to it, and nothing normalized the path or
   checked the parent chain for symlinks. ensure_desired_swapfile then removed any
   existing regular file whose size did not match the configured one, so a mistyped or
   hostile path meant deleting /etc/passwd, an application config, or any other regular
   file. disable_foreign_swaps additionally deleted foreign swapfiles outright.

   Ownership is now explicit. A sidecar marker <swap.path>.awg2-harness-owned sits next to
   the managed swapfile, and inspect_swapfile_ownership classifies the path as Missing,
   ManagedValid, ManagedWrongSize or Foreign. Foreign is always a hard error - the file is
   never resized, rewritten or removed. Foreign active swaps are now only swapped off;
   deleting an administrator's swapfile is not this package's call, and swapoff plus the
   fstab rewrite already produce the exclusive runtime state.

   Path validation is split in two. The lexical half rejects relative paths, '.' and '..'
   components, whitespace, control characters and backslashes, the system path list, and
   anything under /proc, /sys or /dev; it touches no filesystem, so validate-config stays
   side-effect free on a host where the file does not exist yet. It runs after '..' is
   rejected, so the string is already normalized and the blacklist cannot be bypassed. The
   filesystem half runs just before use and refuses a symlink anywhere in the parent chain,
   not only at the final component.

   One deliberate deviation from the audit: arbitrary absolute paths stay allowed rather
   than being restricted to a fixed managed location. Pinning the path would break
   deployments that already configured their own, and the ownership marker is the guard
   that actually prevents data loss - a wrong path can now only fail, never destroy.

2. The migration order left the host swapless on failure.

   ensure_swap ran disable_foreign_swaps first, then created the swapfile, then activated
   it. Any failure in fallocate, dd, mkswap or swapon after that first step left the host
   with no swap at all - on a box provisioned with swap precisely because it is short on
   memory.

   The sequence is now a transaction: preflight, prepare the managed swapfile, swapon,
   verify it actually appears in the active list, and only then swapoff the others,
   rewrite fstab, apply swappiness and verify. Nothing destructive happens until the
   replacement is confirmed live. The one unavoidable gap is deactivating our own swap to
   resize it, which cannot be done in place; foreign swap stays up throughout.

   chmod 0600 now also happens before mkswap, so the file is never briefly world-readable
   while it already holds swap structure.

config.rs no longer carries its own copy of the path blacklist and calls the shared
validator.

Twelve tests: the four ownership states, refusal to recreate a foreign file with its
content asserted intact afterwards, '..' traversal, dangerous and virtual paths,
whitespace and control characters, relative and directory paths, a missing parent
directory, an accepted chain, and a symlinked parent (unix only).

Docs updated: IMPLEMENTATION_SPEC section 17 gained the ownership table, the ordered
transaction and the path rules; CONFIGURATION and the acceptance checklist gained the
operator-facing constraints and nine checks.

cargo test --workspace: 136 + 4 passed. clippy -D warnings clean.
2026-08-06 21:07:55 +05:00
founder 16214f3c66 AmneziaWG 3.0 readiness: centralize wg CLI contract, drop dead host-side fallback
Peer discovery is now the single owner of how wg is invoked and parsed.

wg.rs:
- resolve the binary through ordered amnezia.wg_binaries (default [awg, wg]);
  awg is the real binary in the official image and wg is a symlink to it, so awg is
  tried first and wg stays for plain WireGuard containers. No probe call: resolution
  is a property of the command being run anyway.
- drop the nsenter fallback. AmneziaWG is userspace and its UAPI socket lives under
  RUNSTATEDIR/amneziawg/ in the container mount namespace, while host wireguard-tools
  looks under /var/run/wireguard/. nsenter -n enters only the network namespace, so
  that path could never return peers.
- classify dump lines structurally instead of by column count. The AWG interface line
  is longer than a peer line (14 columns on AWG 2.0, 28 on AWG 3.0) and its
  handshake/rx/tx positions hold junk-packet sizes that parse as integers, so only the
  allowed-ips column discriminates. Column 1 of an interface line is the device private
  key, which the previous heuristic rejected only incidentally.
- fix allowed-ips parsing: `show all allowed-ips` separates a peer's addresses with
  spaces, not commas, so peers with more than one allowed IP were shaped on their first
  address only and the rest ran unlimited.
- collapse the three duplicated /32-in-CIDR filters into one tokenizer shared by the
  allowed-ips, dump and pretty-print parsers.

config.rs:
- add amnezia.wg_binaries with serde default, validated as bare command names
  (non-empty, no whitespace, no path separators) since they execute inside the
  container. Existing deployed configs keep parsing unchanged.
- remove host wg from the required-tool preflight. It gated daemon startup on
  wireguard-tools for a code path that produced nothing.

status.rs:
- discover peers once instead of twice per status call and degrade gracefully like the
  neighbouring sections instead of aborting the whole output.

Tests: real AWG 3.0 / AWG 2.0 / plain WireGuard interface-line fixtures, AWG 3.0 range
keepalive, space-separated allowed-ips, binary resolution order, and a test that parses
and validates the shipped config.example.toml.
2026-08-06 15:32:26 +05:00
founder 0d0b1c006b fix(sysctl_tuning): complete fix36 production reconcile and bbr modules-load lifecycle 2026-05-28 20:04:08 +05:00
founder 4f384bcff0 Decouple sysctl runtime validation and modprobe dependencies 2026-05-28 12:12:42 +05:00
founder 10b2d0aefb Production sysctl_tuning hardening and upgrade-safe defaults 2026-05-28 12:01:08 +05:00
founder 2c27bff800 Реализован production-модуль sysctl_tuning и полная интеграция fix33
Добавлен отдельный host-level модуль sysctl_tuning без легаси-подходов: managed sysctl файл, точечный reconcile через sysctl -w только для drifted ключей, режимы tcp_bbr off/auto/require, безопасное удаление только managed-файла и атомарная запись конфигурации.

Интегрированы новые CLI-команды apply-network-tuning/inspect-network-tuning, startup reconcile в daemon и секция Network tuning в status. Расширены config/defaults/валидация, обновлен пример конфига и документация. Добавлены unit-тесты для config и sysctl_tuning.
2026-05-28 11:31:13 +05:00
founder 44a62cf634 fix29: удалён legacy, введён Debian 13-only и обновлён rollback 2026-05-12 17:26:41 +05:00
founder ed4a571d71 fix9: безопасный host firewall apply и строгий конфиг
- host_firewall секция стала обязательной (убран silent enable)\n- host firewall применяется атомарным nft batch\n- добавлен verify-after-apply с hard-fail при drift\n- усилен tolerant parser drift (кавычки, ipv6-icmp alias)\n- расширены unit-тесты config/firewall\n- дополнен acceptance checklist блоком host firewall
2026-05-11 18:33:46 +05:00
founder 381c106f9d fix8: продакшн host firewall + dual reconcile firewall
- добавлена секция host_firewall в конфиг и defaults\n- реализован host firewall reconcile в отдельной таблице\n- apply-firewall теперь делает host firewall + anti-torrent\n- убрана JSON-инспекция из full-table drift для anti-torrent\n- расширен status вывод drift по host firewall/anti-torrent
2026-05-11 18:14:46 +05:00
founder 7a4e9764b1 fix7: продакшен-реализация полного reconcile swap и интеграций
- Полностью переписан swap-модуль в режиме exclusive ownership\n- Добавлены verify/checks, статус фактического swap-state и drift\n- Интегрирован ensure-swap в daemon и systemd ExecStartPre\n- Усилен lock API (таймауты) и применение в traffic/firewall/swap\n- Усилен parser firewall (JSON-first + устойчивый text fallback)\n- Обновлены config validation, runtime tools и default priority\n- Синхронизированы docs и build/package hygiene\n- Удалены member Cargo.lock в пользу корневого lockfile
2026-05-11 17:30:15 +05:00
founder 819cc9c308 Документация и runtime: полный production-fix по fix6 без legacy 2026-05-10 22:58:59 +05:00
founder d4cd6a4e43 traffic_core: продакшн-фиксы runtime и валидации 2026-05-10 21:29:52 +05:00
founder b8d2c4592b Полный production-рефакторинг билдера, drift-reconcile и документации по up5 2026-05-10 16:42:09 +05:00
founder d9f140b3e0 Полностью доведена production-логика AWG2 harness по up2: strict execution, tc parser, anti-torrent, status и документация 2026-05-09 23:38:53 +05:00
founder e7818054e8 Рефакторинг под прод: nsenter-first транспорт, drift-aware reconcile, hardening daemon/lock/reboot/swap, расширенный status и build-скрипт 2026-05-09 22:03:30 +05:00
founder d937f816bf feat: bootstrap awg2 harness rust workspace and docs 2026-05-09 02:17:25 +05:00