Commit Graph

50 Commits

Author SHA1 Message Date
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
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
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 61ce1beb19 fix: make the reboot plan trustworthy and report unreadable sysctl files [P2]
Three diagnostic defects from the follow-up audit.

1. The reboot plan was entirely best-effort.

   persist_plan discarded the directory creation, the write and every error. The plan file
   is the only thing on the host that says when it is going to reboot, so a failed write
   produced a scheduler sitting on a timer that nothing could report. It now creates the
   directory, writes through a temp file with sync_all and rename, and returns Result.

   The plan also outlived its meaning in two places. With reboot.enabled = false the
   scheduler exited without touching a plan written when it was still enabled, so status
   kept advertising a reboot that would never happen. And after systemd accepted the reboot
   request the plan stayed behind, leaving a past timestamp looking pending. Both paths now
   clear it; an absent plan is the desired end state, not an error.

2. sysctl status turned a read failure into "file absent".

   inspect_sysctl_tuning used fs::read_to_string(..).ok() for both managed files, so an
   unreadable file was reported as missing - a diagnostic stating the opposite of the
   truth, in the module whose whole job is to report drift accurately. Reads now go through
   ManagedFile::read, which distinguishes absent from unreadable, and an unreadable file
   produces its own drift reason naming the path and the error.

3. README described the removed ExecStartPre.

   It still said the service runs ensure-swap before starting the daemon. The daemon
   performs it as the first step of its own startup sequence.

Docs also state explicitly what survives swap.enabled = false, which was implied but never
written: the swapfile, its activation and the /etc/fstab entry all remain, so swap comes
back after a reboot. Only the vm.swappiness persistence is removed.

Acceptance checklist gained seven checks covering the priority-only path not recreating the
file, swap remaining active throughout a resize, foreign-swap rollback, the unreadable
status, and both reboot-plan lifecycle cases.

cargo test --workspace: 179 + 4 passed. clippy -D warnings clean.
2026-08-07 04:18:35 +05:00
founder 322f67f69a fix(swap): bind ownership to device+inode, restrict swap.path to root-owned roots [P0]
Two defects that together made the previous ownership model unsafe, and reverse an earlier
deviation that the follow-up audit correctly rejected.

1. The sidecar marker identified a path, not a file.

   Ownership was `<swap.path>.awg2-harness-owned` sitting next to the swapfile, and the
   check was whether that sidecar existed and started with our marker. Nothing tied it to
   the file it was supposed to describe. The sequence that loses data: our swapfile is
   removed by hand, the sidecar survives, an operator puts an unrelated file at the same
   path - and the next reconcile calls it managed and, if the size differs, deletes it.
   The claim in the previous commit that a wrong path "can now only fail, never destroy"
   was wrong.

   Ownership now lives in /var/lib/awg2-harness/swap-ownership.conf, a root-only directory
   created 0700, and records version, path, device and inode. A path is not an identity: a
   file can be deleted and another created in its place, but (device, inode) cannot be
   reused while our file exists. Classification gained StaleOwnership for "right path,
   different file", which refuses exactly like Foreign but says which situation the
   operator is actually in. Only ManagedValid and ManagedWrongSize ever authorise a
   delete, and that is asserted directly.

   The record is written after the file exists, from its real metadata, so until it lands
   the swapfile is not considered managed. The old sidecar is now only ever removed, never
   read.

   classify_ownership is a pure function taking the recorded state and the observed
   identity, so the cases that matter - stale record, record for another path, no record -
   are unit-testable rather than requiring a Linux filesystem.

2. An arbitrary swap.path was still exploitable.

   The previous commit kept arbitrary absolute paths and argued the ownership marker was
   sufficient protection. It is not, and the audit is right to reject it. Between
   assert_swap_path_is_safe and fallocate/dd/mkswap running, a user who can write the
   parent directory replaces the final component with a symlink and redirects a root-owned
   operation. That is a TOCTOU; stat-ing before use cannot close it, because the check and
   the use are separate syscalls. Paths like /tmp/awg.swap or /home/user/awg.swap made this
   reachable by any local user.

   swap.path is now restricted to /swapfile or a file directly under
   /var/lib/awg2-harness/. Both are root-owned, so the window does not exist rather than
   being detected. /swapfile is kept so existing deployments need no config change. The
   deny-list it replaces only ruled out the paths someone had thought of; what actually
   matters is who can write the parent.

Eleven tests: the five classification states, a stale record, a record for another path,
the invariant that no refusal state is managed, record round-trip, malformed and
unsupported-version records, the newly allowed and newly rejected path shapes.

cargo test --workspace: 176 + 4 passed. clippy -D warnings clean.
2026-08-07 04:08:52 +05:00
founder 6643328567 docs+test(wg): column count never identifies an AWG version [P2]
Both the code comment and the docs said the interface row is "14 columns for AWG 2.0, 28
for AWG 3.0". That reads as a version signal and it is not one. The width of the row is
decided by the version of amneziawg-tools doing the printing, not by which protocol
parameters the tunnel has configured: AWG 3.0 tools emit the wide row for an AWG 2.0
configuration too, with the newer fields simply zero, empty or (none). Any rule of the
form "N columns means version X" is wrong by construction.

The classifier never relied on column count for that purpose - it discriminates on the
allowed-ips column - so this is a correction to what the code claims about itself and to
what the docs would lead a future change to assume.

Five fixtures added from the audit's list, all previously uncovered:

- AWG 3.0 interface row with header protection disabled: the key column is (none) and the
  range fields collapse to single values;
- an AWG 2.0 configuration read through AWG 3.0 tools, asserted to have the same width as
  a native AWG 3.0 row and to still be rejected while its peer parses;
- a dump containing several interfaces, each with its own peers;
- an interface with no peers at all;
- malformed rows - truncated, non-numeric handshake, non-CIDR allowed-ips - which must be
  skipped rather than partially interpreted.

Docs also pin what was verified and against which versions, rather than stating it as
timeless: AmneziaVPN 5.0.0.5 and the master branches of amneziawg-tools and amneziawg-go
at the time of the change.

amnezia-awg3 is now described as what it is - not a confirmed official name. AmneziaVPN
5.0.0.5 added AWG 3.0 support but still exposes only Awg and Awg2 container variants. It
stays in the example list on purpose, and the reasoning is written down: a name that does
not match costs nothing because the search continues down the list, while a name missing
from the list produces a silent "container not found". This is a deliberate deviation from
the audit's suggestion to build defaults only from names in use today.

cargo fmt --check, clippy -D warnings, and cargo test --locked --workspace --release all
clean: 163 + 4 passed.
2026-08-06 23:03:10 +05:00
founder c6fa1a0af9 chore: drop dead code and the stale wireguard-tools dependency [P2]
wireguard-tools was still declared in Depends and in the runtime package list installed by
tools/build.sh --prepare-runtime-env. Since peer discovery moved to docker exec, no code
path invokes a host-side wg, and check_host_tools stopped requiring it. The declaration
was therefore installing a package the harness never calls and misrepresenting the actual
architecture to anyone reading the control file. Removed from both places.

Three items were carrying #[allow(dead_code)], which is a way of silencing the compiler
about a real question rather than answering it:

- SysctlTuningStatus.desired duplicated data that is already in .current - every
  SysctlValue holds both the desired and the current value, so the second Vec was the same
  information cloned on every inspect call and read by nobody;
- SysctlValue.managed was always set to true and never read. Ownership is decided per file
  by the marker, not per key, so the field encoded nothing;
- unix_now() in the reboot scheduler, along with the SystemTime and UNIX_EPOCH imports it
  needed. The module works in chrono NaiveDateTime throughout.

All three are removed rather than annotated. The workspace now builds clean under
-D warnings with no dead_code exceptions anywhere.

cargo test --workspace: 158 + 4 passed. clippy -D warnings clean.
2026-08-06 22:59:18 +05:00
founder bf43b763cd refactor(lock): use kernel file locking instead of a hand-rolled PID lock [P2]
The previous implementation had every failure mode a home-grown PID lock has:

- contention was detected by matching the substring "lock already exists" in an error
  message, so any change to the message would have turned a busy lock into a hard error;
- stale-lock recovery removed the file and created it again as two separate operations,
  so two processes could each decide the lock was stale and each take it;
- a reused PID made a live lock look stale, since the check was only whether /proc/<pid>
  exists;
- Drop deleted the lock file, racing with a process that had already opened it and was
  sleeping on the retry loop - it would then lock a file that no longer has a directory
  entry, and the next arrival would create a fresh one and lock that instead.

Replaced with advisory kernel locking through std::fs::File::try_lock, stabilised in Rust
1.89 and backed by flock on Linux. No new dependency. The kernel releases the lock when
the holder exits by any means, including kill -9, so there is no stale state to reason
about and no recovery heuristic to get wrong. The PID is still written to the file, but
purely as diagnostics - nothing reads it back to make a decision.

The lock file is now never deleted. Its presence carries no meaning; only the lock held
on it does. That removes the race the old Drop introduced.

Five tests: a second acquisition of a live lock fails, timeout reports lock busy timeout,
the lock is free again once the guard drops, the file survives release, and a leftover
file from a previous run does not block acquisition.

Behaviour note for the four call sites (traffic, firewall, sysctl_tuning, swap): timeouts
are unchanged, and a process killed mid-reconcile no longer leaves anything that needs
cleaning up before the next run.

cargo test --workspace: 158 + 4 passed. clippy -D warnings clean.
2026-08-06 22:56:59 +05:00
founder a9b6e3c15d fix(systemd): drop the duplicate ensure-swap, use Type=exec and stepped restart backoff [P1]
awg2-harness.service ran ensure-swap from ExecStartPre while the daemon runs it again as
the first step of its own startup. Idempotency kept it harmless, but it cost two locks,
two reads of /proc/swaps, two persistence checks, and split the startup failure path
across two units so an operator reading systemctl status could not tell which invocation
failed. The daemon owns the startup sequence, its error contract and its log stream, so
the ExecStartPre is removed. The standalone ensure-swap subcommand is unaffected.

Type=simple reports a unit as started the moment systemd forks, before the binary is
known to be executable. Type=exec waits for the exec to succeed, so a missing or
non-executable /usr/sbin/awg2-harness fails the unit instead of appearing to start.

Restart policy: the daemon moves from Restart=always to on-failure, matching the
scheduler. A clean exit is deliberate and should not be respawned. For the scheduler this
was already load-bearing and is now documented - it exits 0 both when reboot.enabled is
false and after systemd accepts the reboot request, so Restart=always would have spun it
in a loop on any host with scheduled reboots disabled.

RestartSec=10s with StartLimitBurst=5 over StartLimitIntervalSec=60 could exhaust the
limit in about 50 seconds, which is easy to hit when the daemon is failing because Docker
or the container is still coming up - the restarts burn the budget before the dependency
can recover. Replaced with a stepped backoff, 10 -> 20 -> 40 -> 80 -> 160s via
RestartSteps=4 and RestartMaxDelaySec=160s, and the limit window widened to 15 minutes
with a burst of 10 so a full backoff sequence fits inside it. Both directives require
systemd 254+; Debian 13 ships 257.

Documented alongside, because it is the same lifecycle contract and had no home in the
docs: configuration is read once at process start and a config edit needs a restart of the
affected service. daemon-reload is not involved - it applies to unit files, not to the
application config. ExecReload is deliberately absent, since adding it without real SIGHUP
handling would imply a reload path that does not exist.

Unit files are installed from the repo root by tools/build.sh, which already runs
systemd-analyze verify on the build host when it is available, so these changes are
verified there. Not verifiable in this environment: no Debian, no systemd.

Acceptance checklist gained five checks covering unit verification, the restart-required
contract, single ensure-swap per start, growing backoff, and the scheduler not looping
when reboots are disabled.
2026-08-06 22:54:11 +05:00
founder e9f0e29f75 refactor: one ownership model for every file written under /etc [P1]
Two modules wrote into /etc/sysctl.d and did it differently. sysctl_tuning refused to
touch a file whose first line lacked its marker and cleaned up on disable. swap wrote
/etc/sysctl.d/99-awg2-harness.conf with a different marker, no ownership check at all -
an administrator's file at that path was silently clobbered - and never removed it when
the module was turned off, so vm.swappiness stayed pinned forever.

New module managed_file.rs holds the rules once:

- a read that fails is never an absent file;
- ownership is Absent / Managed / Foreign, decided by the marker on the first line;
- write refuses Foreign, skips when the content already matches, and is otherwise atomic:
  temp sibling created with create_new, sync_all, mode 0644, rename, directory flush;
- remove refuses Foreign and treats Absent as success.

Both modules now go through it. sysctl_tuning loses its private write_atomic,
ensure_managed_or_absent and remove_managed_sysctl_file_at.

The swappiness file moves to /etc/sysctl.d/90-awg2-harness-swap.conf with the marker
"# Managed by awg2-harness swap", matching the 90- prefix every other managed file uses.
The old path is removed on first run of the new version, but only when it still carries
the previous "# Managed by awg2-harness" marker; a foreign file there is left alone and
does not fail the migration, since the current file lives elsewhere.

Disable semantics are now explicit: swap.enabled = false removes the managed file and
does nothing else. Runtime vm.swappiness is deliberately not reset - picking a value
would be inventing a policy - and active swap is not deactivated, because merely turning
a feature off should not trigger a destructive action. Previously ensure_swap returned
early and left the persisted file behind.

Also replaced a vacuous test: swappiness_conf_render_is_stable asserted that a string
literal equals a format! of the same literal, touching no production code at all. It now
exercises render_swappiness_conf, and a companion test proves the rendered file is
recognised as managed by the same marker check that guards overwriting it - otherwise
every reconcile would refuse to touch its own output.

Twelve tests: the three ownership states, refusal to overwrite and refusal to remove a
foreign file with content asserted intact, idempotent write, stale temp handling, an
unreadable path reported as an error, plus legacy migration in all three cases - ours,
foreign, absent.

cargo test --workspace: 148 + 4 passed. clippy -D warnings clean.
2026-08-06 22:50:19 +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 549fbcae31 fix(swap): make the /etc/fstab rewrite transactional [P0]
Two release blockers, both in the most destructive operation the package performs.

1. An unreadable /etc/fstab was treated as an empty one.

   let content = fs::read_to_string(fstab_path).unwrap_or_default();

   PermissionDenied, an I/O error, a damaged filesystem and "file absent" all collapsed
   into an empty string. The rewrite then emitted a fstab containing only the managed swap
   line and installed it atomically and successfully, dropping the root and boot mounts.
   Consequence: a host that does not boot, guarded by one unwrap_or_default. The same
   masking was present in read_fstab_swap_entries, so an unreadable fstab was reported by
   status as "no swap entries", which reads as healthy drift-free state.

2. The backup was best-effort.

   let _ = fs::write(bak_path, &content);

   If it failed, the rewrite proceeded anyway. IMPLEMENTATION_SPEC and the acceptance
   checklist both asserted the backup exists. The scenario where the backup fails is
   exactly the scenario where it is needed.

New module fstab.rs owns the operation:

- read_required distinguishes NotFound from every other error and fails on both. A
  missing /etc/fstab on Debian is an anomaly, not a blank slate, so it is never created;
- a blank candidate is refused outright;
- the backup is mandatory and flushed before the temp file is created, so there is no
  window where the target is about to be replaced and no recovery copy exists;
- two copies are kept: .awg2-harness.bak is the state before the current rewrite,
  .awg2-harness.original is written once with create_new and holds the host as it was
  before this package first touched it;
- the temp file is a sibling of the target, created with create_new, sync_all'd, and
  inherits the original permissions; a stale temp from a killed run is removed explicitly
  rather than reused;
- the candidate is validated with findmnt --verify --tab-file before the swap. The step is
  skipped when findmnt cannot be spawned; a rejection is fatal;
- commit is an atomic rename followed by a directory fsync;
- after the rename the target is re-read and compared to what was written. A mismatch
  restores the backup and fails.

Also fixed: render_fstab_line interpolated the path raw. fstab fields are
whitespace-separated, so a space or tab in swap.path would shift every following column.
Paths now go through octal escaping (\040, \011, \012, \134).

Ten tests, all portable - the "unreadable but present" case is exercised by pointing the
reader at a directory, which fails as a non-NotFound error on every platform. Covered:
unreadable target is an error rather than an empty file, missing target is an error,
backup failure leaves the target byte-identical, blank candidate refused, unchanged
candidate is a no-op that writes no backup, the pristine copy survives repeated rewrites,
a stale temp does not block commit, and field escaping.

Docs updated: IMPLEMENTATION_SPEC section 17 gained the full rewrite contract, the
acceptance checklist gained seven checks including the two failure modes above.

cargo test --workspace: 124 + 4 passed, ten new. clippy -D warnings clean.
2026-08-06 21:02:38 +05:00
founder dbceda5c13 refactor(firewall): split firewall.rs by subsystem, collapse duplicated test fixtures
firewall.rs held two subsystems that share nothing but the nft command layer: the host
INPUT firewall, applied in the host namespace and owning inet awg2_host_firewall, and the
anti-torrent guard, applied in the namespace chosen by config and owning inet
awg2_harness. The split follows that boundary rather than an inspect/apply one:

  firewall/mod.rs          apply_firewall, namespace selection, shared table constants
  firewall/nft.rs          text normalization and the nft command wrappers
  firewall/host.rs         host INPUT firewall and its 5 tests
  firewall/antitorrent.rs  connection-count guard and its 6 tests

Production logic is unchanged. Function sets before and after are identical: 25 against
25, verified by extracting names from the old blob and the four new files and diffing.

Not a pure move in one respect, stated here so it is not a surprise in review: the test
module contained four near-identical 45-line Config literals, about 180 lines of
copy-paste, differing only in the single field each test actually cared about. Carrying
four copies into the new files would have knowingly propagated the duplication, so they
collapse into one test_config() helper in firewall/mod.rs that tests mutate. This is why
the four files total 875 lines against the old 890 despite gaining doc comments.

Doc comments now record the reasoning that was only implicit before: why nft output is
compared after normalization (rendering differs across versions - quoting, comma-separated
ct state lists, brace layout), why the host firewall batch never contains a delete (a
partially applied INPUT chain could lock out SSH), why ct state new is load-bearing in the
anti-torrent rule, why connlimit_state_present accepts two renderings, and why the
anti-torrent rule defaults to the container namespace.

IMPLEMENTATION_SPEC section 3 is updated: it described the old flat layout and carried the
split as an unfulfilled intention. Both traffic and firewall now match what it documents.

cargo test --workspace: 110 + 4 passed, unchanged count. clippy -D warnings clean.
2026-08-06 18:51:31 +05:00
founder 84275d0fe4 status: deduplicate the healthy and degraded paths, keep the System block contiguous
print_status and print_degraded_sections carried four byte-identical blocks - host
firewall (25 lines), anti-torrent (24), last actions (9) and recent events (10) - plus a
Conntrack block that was the same logic assembled differently and a Traffic desired block
of four printlns. Roughly 80 lines of copy-paste with no test covering either copy, so a
format change applied to one path would have silently diverged from the other. The
divergence would only ever be visible while the container is down, which is the worst
moment to discover the diagnostic output is wrong.

Every shared section is now one function called from both paths:
print_traffic_desired_section, print_host_firewall_section, print_antitorrent_section,
print_conntrack_section, print_last_actions_section, print_recent_events_section. The file
already had this shape for print_swap_status and print_reboot_section; the pattern is now
followed consistently. The Docker/Amnezia port path block keeps two variants because they
genuinely differ - real values against unknown - but the shared FORWARD ownership note is
a single constant.

The runtime state paths were also duplicated across modules: status.rs re-declared the
literals that daemon.rs already owns. daemon::LAST_STATE_FILE and daemon::EVENTS_FILE are
now pub and reused, and the recent-events window is a named constant instead of a bare 10.

Ordering fix in the same function: print_network_tuning_status ran between the service
line and the container lines, so container/container_id/pid/netns/interface appeared under
the "Network tuning:" header at the same two-space indent as its fields. Container
identity is now printed inside the discovery match, before any other section opens, in
both the healthy and degraded paths. Field names are unchanged; only the order of two
blocks moved. IMPLEMENTATION_SPEC section 15 documented the old order and is updated.

print_degraded_sections returned anyhow::Result<()> while never failing; it returns unit
now.

status.rs: 645 -> 602 lines. cargo test --workspace: 110 + 4 passed. clippy -D warnings
clean.
2026-08-06 18:41:39 +05:00
founder d98ade42ce docs: unify language contract to Russian and reconcile the spec with the code
The docs folder had drifted into a mixed-language contract: IMPLEMENTATION_SPEC.md was
entirely in English while the rest was Russian with English headings. Prose is now
Russian throughout; English is kept only where it is technically load-bearing (commands,
tool output, config keys, file/module/table names, tc handles) and for terms with no
settled Russian equivalent in this context (drift, reconcile, peer, namespace, handshake).
docs/README.md now states that contract explicitly.

Rewriting the spec surfaced several places where it described intent rather than the
code. Corrected against traffic_core/src:

- module layout: the spec showed traffic/ and firewall/ as directories with
  model/inspect/apply submodules. They are single files, and sysctl_tuning.rs was
  missing from the tree entirely.
- data model: Peer was documented as carrying public key, handshake and counters. It
  carries only ipv4; runtime state lives in a separate PeerRuntime, and there is no
  desired-state struct at all - it is derived inline from config plus sorted peers.
  RuntimeTarget.netns_path is a String, not a PathBuf.
- interface detection: the spec listed a third step taking the first non-loopback
  interface with an IPv4 address. No such fallback exists; detection bails with an
  explicit error, which is safer and is now documented as deliberate.
- reconcile: the pseudo-flow folded firewall into reconcile_traffic. They are two
  independent reconciles with separate locks.
- locking: only traffic.lock was documented. There are three locks, and
  sysctl_tuning.lock has a 30s timeout rather than 10s. Stale-lock recovery via
  /proc/<pid> was undocumented.
- sysctl_tuning: the module also manages /etc/modules-load.d/90-awg2-harness-bbr.conf
  when BBR is active, and refuses to touch either managed file without its marker.
  Both docs and the acceptance checklist claimed only the sysctl.d file was written.
- status contract: the documented output was outdated. All 15 printed sections are now
  listed in the order status.rs emits them, including the degraded path when no container
  is found, and the fact that the Network tuning block is printed between the System
  service line and the container lines.
- swap: size was documented as a constant 2048 MB instead of swap.size_mb, and the
  /etc/fstab.awg2-harness.bak backup was undocumented.
- apply/verify: the tolerated-error lists are now the actual closed sets from the code.

Acceptance checklist gained checks for the daemon startup failure policy (sysctl/swap
fatal, traffic/firewall not), lock contention and stale-lock recovery, degraded status,
the modules-load file, and the marker guard. The stale host wg requirement was dropped
from its host-tools item.

Every path, table, counter and log string quoted in the docs was verified to exist in
the source. cargo test --workspace: 110 + 4 passed.
2026-08-06 16:45:00 +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 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 4ff2bb2c30 Документация: унификация проверок для amnezia-awg2 и amnezia-awg 2026-05-12 21:19:08 +05:00
founder c7b5da98f4 Документация DNS: динамический deep-check и уточнение критериев 2026-05-12 21:12:15 +05:00
founder 8e3e924952 Документация и postinst UX: безопасная проверка checksum и preflight-first 2026-05-12 20:46:57 +05:00
founder 4757799f04 Документация: обязательный production-профиль AmneziaDNS и правила DNS/53 2026-05-12 20:36:17 +05:00
founder b3d274f89d Документация и UX установки: полный production-fix по fix32 2026-05-12 20:16:56 +05:00
founder d1d919a615 fix31: финальный polish формулировок без исторических отсылок 2026-05-12 18:05:27 +05:00
founder bb37da72c0 fix30: очищены old-bash формулировки и уточнён class-id контракт 2026-05-12 17:50:15 +05:00
founder 44a62cf634 fix29: удалён legacy, введён Debian 13-only и обновлён rollback 2026-05-12 17:26:41 +05:00
founder 69c737bcf4 fix28: обновлена production-документация и упаковка docs в deb 2026-05-12 16:56:36 +05:00
founder c10fb775e9 Fix23: вернуть production ssh_port 2323 в конфиге и документации
- config.example: host_firewall.ssh_port=2323

- docs: синхронизированы CONFIGURATION/MIGRATION/ACCEPTANCE_CHECKLIST
2026-05-12 14:02:12 +05:00
founder 6e586c7af9 Fix23: финализация production-сборки 0.1.4
- Swap: сузил tolerance usable size до 64 KiB

- Cargo.lock: синхронизирована версия awg2-harness = 0.1.4

- Example/docs: ssh_port default приведен к 22

- Обновлен acceptance checklist по SSH default
2026-05-12 14:00:54 +05:00
founder 44d3593944 Fix22: подготовка production 0.1.4 и стабилизация сервиса
- Поднята версия awg2-harness до 0.1.4

- Обновлены production defaults: peer_limit=50mbit, root_rate=500mbit

- Добавлен systemd anti-flapping rate-limit и увеличен RestartSec

- Синхронизирована документация CONFIGURATION/MIGRATION
2026-05-12 13:47:37 +05:00
founder 1e2a3b5b67 Добавлен fallback диагностики FORWARD через nft и расширен вывод status 2026-05-12 11:48:18 +05:00
founder f8c6f2e92d Уточнена диагностика UDP port mapping для Docker и обновлены checklist/docs 2026-05-11 19:30:25 +05:00
founder adc244daa7 Добавлена диагностика Docker/Amnezia port path в status и уточнена документация host firewall 2026-05-11 19:22:39 +05:00
founder 36600c0a5a fix10: стабилизация host firewall first-run и no-op reconcile
- исправлен first-run дефект: delete table вынесен из nft batch\n- добавлен no-op путь при отсутствии host firewall drift\n- исправлен trim для default_input_policy в drift-check\n- добавлены тесты для batch script и policy trim\n- уточнен release-gate в acceptance checklist по INPUT/FORWARD
2026-05-11 18:45:17 +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 83c4e3ba05 workspace и reboot-модуль: второй продакшн бинарь и упаковка 2026-05-10 21:30:06 +05:00
founder a4bb7adb4f Traffic: исправить детект ingress redirect для реального вывода tc
has_ingress_redirect_to_ifb переведён на case-insensitive разбор и поддержку обоих вариантов: dev <ifb> и device <ifb>. Добавлены тесты на lowercase dev-формат и отрицательный кейс с другим ifb.

Также синхронизирован docs/ANTI_TORRENT.md: единая шкала threshold (100 default, 150 мягче, 200/250 permissive, 70 агрессивный), и антипример over 200 явно помечен как non-default/non-production.
2026-05-10 18:07:05 +05:00
founder b83f2ee3a9 Пакет: убрать docker.io из metadata и улучшить help/docs layout
Удалён Recommends: docker.io из control. Ужесточено предупреждение, что --install не является production-path. В .deb docs переведены на repo-like структуру (/usr/share/doc/awg2-harness/repo/...), чтобы не ломались относительные ссылки.
2026-05-10 17:36:31 +05:00
founder 9d2d2bc0c2 Сборка: добавить production packaging flow через .deb
Добавлен package-режим в tools/build.sh: --package, --dist-dir, --version. Реализована сборка Debian-пакета, metadata/control, postinst/postrm и SHA256SUMS. Установочные пути унифицированы под /usr/sbin и /lib/systemd/system для production-модели.
2026-05-10 17:26:11 +05:00
founder 76c6b52f4e Полный продакшн-фикс: parity с old bash, drift-recovery, status/runtime и builder separation 2026-05-10 17:09:39 +05:00
founder b8d2c4592b Полный production-рефакторинг билдера, drift-reconcile и документации по up5 2026-05-10 16:42:09 +05:00
founder b3dad5974c Полный продакшн-фикс: hardened build/install, drift/firewall/wg проверки и актуализация документации 2026-05-10 15:41:44 +05:00
founder 535c1dbc33 Полная реализация production-фиксов из up3: anti-torrent rebuild, peer-status, drift rate/ceil, reboot window 2026-05-10 00:15:17 +05:00
founder d9f140b3e0 Полностью доведена production-логика AWG2 harness по up2: strict execution, tc parser, anti-torrent, status и документация 2026-05-09 23:38:53 +05:00
founder d937f816bf feat: bootstrap awg2 harness rust workspace and docs 2026-05-09 02:17:25 +05:00