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.
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.
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.
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.
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.
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.
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.
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.
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.
Добавлен отдельный 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.
- исправлен 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
- Полностью переписан 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
Удалён Recommends: docker.io из control. Ужесточено предупреждение, что --install не является production-path. В .deb docs переведены на repo-like структуру (/usr/share/doc/awg2-harness/repo/...), чтобы не ломались относительные ссылки.
Добавлен package-режим в tools/build.sh: --package, --dist-dir, --version. Реализована сборка Debian-пакета, metadata/control, postinst/postrm и SHA256SUMS. Установочные пути унифицированы под /usr/sbin и /lib/systemd/system для production-модели.