Skip to content

feat(nebula): advertise a nebula relay β€” a read-only check, and the sudo apply beside it - #1272

Open
ZacxDev wants to merge 7 commits into
mainfrom
feat/workbench-nebula-relay
Open

feat(nebula): advertise a nebula relay β€” a read-only check, and the sudo apply beside it#1272
ZacxDev wants to merge 7 commits into
mainfrom
feat/workbench-nebula-relay

Conversation

@ZacxDev

@ZacxDev ZacxDev commented Sep 4, 2026

Copy link
Copy Markdown
Member

What

relay.relays in a nebula config is the list of mesh IPs peers may use to relay packets TO this host β€” not the list of relays this host uses to reach others. That is why use_relays: true on its own changes nothing. Workbench renders relays: [], so no peer can relay to it and a peer that cannot hole-punch (CGNAT, symmetric NAT, off-LAN) simply never connects. That is the blocker behind clawgate #497.

This PR ships two scripts:

file privilege what it does
nix/system/check-nebula-relays.sh none read-only verifier; no writes, no rebuild, no sudo
nix/system/apply-nebula-relay.sh root patches /etc/nixos/configuration.nix, then nixos-rebuild test β†’ verify β†’ switch β†’ verify

πŸ”΄ The second one is a root-privileged, system-mutating script. An earlier version of this body said the PR "ships only the check … does NOT edit that file and does NOT rebuild … no sudo". That was true at 405ecfd4 and false from e894fe10 onward β€” the apply script has been on the branch since. Corrected here; read the apply script as what it is.

Why the check exists separately

  • Parses relay.relays using the stdlib only, so it has no PyYAML dependency (import yaml is not satisfiable from the python3 a sudo bash … actually gets here).
  • Never greps the rendered YAML for the relay address. lighthouse.hosts carries the same mesh addresses, so a pattern like ^\s+- <relay>$ matches a config whose relays: is empty. Measured: it reported a match on the pre-change file and would have certified a change that had not happened. The trap is in the header and asserted in the self-test.
  • Gates its own verdict on --self-test, which must read a relays: [] fixture and a populated one correctly before any PASS is printed.
  • Exit codes: 0 advertised Β· 1 not advertised Β· 2 cannot determine. Every rc-2 path also prints one machine-readable REASON: <token> line, so a caller can branch on the condition instead of pattern-matching prose. A --self-test failure is rc 2, not rc 1 β€” 1 means "the relay is absent", a different claim. (An earlier version of this body said rc 1. Corrected.)

Round 3 β€” the eight findings from the audit of e894fe10..e7f2e45a

finding fix
F-A the rollback was not a rollback switch registers the profile and installs the bootloader before activation, so a failed activation still moved the machine β€” measured: generation 387 held relays: - 10.42.0.2 after the script reported a clean rollback. The order is now nixos-rebuild test β†’ verify β†’ nixos-rebuild switch β†’ verify. test activates without registering a generation or touching the bootloader, so a failure there really does persist nothing. The trap tracks five states and its message distinguishes never activated Β· test failed Β· activated-not-persisted Β· switch attempted (profile may have moved, with readlink /nix/var/nix/profiles/system to check) Β· persisted.
F-B the anchor was not scoped to NEBULA_NET reproduced: NEBULA_NET=travel reported DONE … advertised by nebula@travel.service while the four lines landed in the mesh block. The services.nebula.networks.<NET> = { block is now located first (brace depth, comments and string bodies skipped) and the anchor pair is only accepted inside it. No unique declaration, a block that never closes, or an anchor count β‰  1 inside the range each abort.
F-C root wrote to a predictable path in world-writable /tmp mktemp -d (0700) for the verifier capture, removed by the trap; the patch temp is a mktemp sibling of $CFG so the final mv stays atomic.
F-D a missing backup skipped the rollback silently the else exists, cp's status is checked, and failure prints πŸ”΄ ROLLBACK FAILED β€” your config is still patched at <path> with the exact four lines to delete by hand.
F-E [ -f "$CFG" ] accepted a symlink and mv destroyed it refused, not followed β€” the deliberate half of the choice, because following a caller-chosen path as root is the same hazard as the /tmp one. Compared via readlink -f, not [ -L ], so a symlinked directory component is caught too; the error names the resolved target and the one-flag fix.
F-F a fixture comment claimed coverage it did not provide fixture 5's comment said it killed a mutant that scans past the end of the relay block. Measured: replacing the terminator with if False: left --self-test green. Fixture 5 keeps its (real) shape with an honest comment, and a new fixture 5b β€” a later top-level am_relay: β€” kills the mutant with its own message. πŸ”΄ The comment states plainly that pkgs.formats.yaml cannot emit that shape, so 5b pins the parser's structural correctness, not a reachable production config.
F-G the egress-cost warning never reached the operator on rc 1 the verifier's full output is now printed before proceeding, so the πŸ”΄ paragraph about relayed traffic egressing a billed host is read at the moment of choosing.
F-I the rc-2 retry was wider than its comment narrowed: the single systemctl restart fires only on REASON: unit-process-disagree. Six other rc-2 conditions (inactive unit, unreadable -config, parser failure, …) no longer drop every mesh session for nothing.

Nits also fixed: unused indent and local spellings removed; the preflight tool list now names every binary the script execs (diff, mktemp, tr, cut, wc, cp, mv, date, readlink, grep, …); both scripts now warn that applying a relay restarts the mesh the operator may be connected over.

F-H β€” the scripts now have repo-gate coverage

They had none: --self-test was never invoked by scripts/gate.sh and the apply script had no automated exercise at all.

  • scripts/tests/test_nebula_relay_apply.py β€” 30 tests, in the existing hermetic scripts/tests target, so scripts/gate.sh runs them. Nothing in it can reach a real nixos-rebuild, /nix/var/nix/profiles/system or /etc/nixos: the config is a fixture under tmp_path reached via NEBULA_CFG, and id/ip/systemctl/nixos-rebuild/nix-instantiate are shims on PATH.
  • The verifier under test is the real one, and it reads the running process β€” so the rig spawns a real child whose argv carries -config <rendered.yml> and the nixos-rebuild shim re-renders that yml from the .nix the script just patched. Every fake process is stopped by its own PID; no pattern ever reaches pkill.
  • scripts/tests/mutants-nebula-relay.sh β€” the mutation battery, in the tree so "mutation-verified" can be re-derived rather than believed. Same shape as the existing mutants-*.sh: CONTROL-CLEAN / CONTROL-KILL / CONTROL-DETECTOR, each mutation applied-verified by occurrence count, and each mutant naming the test that must kill it and the message it must die with β€” a mutant that dies to a different test's error reports WRONG-KILLER rather than counting as covered.

Coverage matrix (all red before the fix, green at HEAD)

case rc resulting config state
happy path 0 patched, exactly 4 lines, inside the mesh block; 1 backup
re-run when already satisfied 0 unchanged, no backup, nixos-rebuild never invoked
wrong host 1 unchanged, no rebuild
config already carries a relays list 1 unchanged, merge-by-hand message naming the line
nixos-rebuild test fails (F-A) β‰ 0 restored; "NOT PERSISTED"; switch never reached
test succeeds, verifier fails β‰ 0 restored; "ACTIVATED, NOT PERSISTED"
switch fails after a good test β‰ 0 restored; "THE PROFILE MAY HAVE MOVED" + readlink …; never claims nothing was persisted
backup missing at rollback (F-D) β‰ 0 still patched, and the message says so
CFG is a symlink (F-E) 1 symlink intact, target untouched
CFG under a symlinked directory 1 target untouched
NEBULA_NET whose block lacks the anchor (F-B) 1 unchanged β€” mesh block byte-identical
two anchors, the named net is the second (F-B) 0 lands in mesh, not the first anchor
unknown NEBULA_NET 1 unchanged
no predictable /tmp path live while the verifier runs (F-C) 0 observed from inside the run
verifier FAIL text reaches stdout (F-G) 0 EGRESSES THE RELAY present
deferred restart β†’ retried once (F-I) 0 1 restart per verify pass
a different rc 2 β†’ no restart (F-I) β‰ 0 0 restarts
inactive unit β‰ 0 rejected in the preflight, before any write
invalid Nix after the patch 1 restored, no backup taken, no temp sibling left
check --self-test (F-H) 0 β€”

Mutation results β€” 22/22, one declared survivor

20 mutants killed by their named test, with that test's own message, plus CONTROL-CLEAN / CONTROL-KILL / CONTROL-DETECTOR green (22/22 including the controls). Two entries are recorded honestly rather than rounded up:

  • M-X-1-line-count-guard-off ([ "$added" = "4" ]) is a declared survivor: the awk pass already exits 3 unless it made exactly one insertion, and one insertion is always four lines, so no input reachable through the script's own guards can move $added. Kept as defence in depth, not counted as coverage. The battery asserts it survives, so if the justification ever stops holding the battery says so.
  • M-FB-2-patch-pass-unscoped is killed, but by the awk n != 1 guard (two anchors β‡’ two insertions β‡’ abort), not by the assertion naming the wrong block. So the range guard inside the patch pass is defence in depth behind the scoped anchor count; the battery scores it on the message it really dies with.

Both scripts also exercised end-to-end against a copy of the live /etc/nixos/configuration.nix (not the file itself): block located at lines 702–733, anchor unique inside it, +4 lines landing in the mesh block, rebuild log == ["test", "switch"]. shellcheck -S warning is clean on both.

One thing the gate turned up that is NOT this PR's

A full scripts/gate.sh --tier pytest on the branch reports two failures. One was mine and is fixed (test_no_test_writes_a_usr_bin_env_shebang_at_runtime β€” the harness wrote its own #!/usr/bin/env bash; it now goes through testlib.mockbin.write_exec, and the shim bodies are POSIX sh, controlled with dash -n and a full behavioural re-run under dash, because this host's /bin/sh is bash and would hide a bashism until the sandbox).

The other β€” test_clawgate_task_interview_guard.py::test_a_body_file_written_by_a_heredoc_on_the_same_line_is_read β€” is not from this work. scripts/claude-hooks is byte-identical between this branch's tip before my commits and after (ls-tree OID 2a363038 both sides), and main has since deleted that test along with 85 lines of the guard. So it is a branch-is-behind-main artifact, and the merge resolves it.

Gating was therefore done on an integration tree (origin/main + this branch), not on the branch alone: scripts/gate.sh --tier pytest there is PASS, 30/30 targets, TOTAL collected=21508 passed=21505 skipped=3 failed=0, with scripts/tests at 12477/12477.

Not verified

  • Nothing was applied for real. /etc/nixos/configuration.nix is unmodified, nixos-rebuild was never run in any form, and /nix/var/nix/profiles/system was not touched or rolled back. The pending generation from the earlier run is still in place, by the operator's choice.
  • nixos-rebuild test's no-profile-no-bootloader property is taken from its documented behaviour and from the F-A measurement of switch; it was not re-measured on this host, because doing so means running a rebuild.
  • The /tmp symlink attack is shown closed by observing that no predictable path is live while the verifier runs (plus a source-level check). A literal symlink-planting reproduction against the fixed script was not performed β€” it needs the script's $$ in advance.
  • Nothing off-LAN, and no real relayed traffic, has been exercised.

πŸ€– Generated with Claude Code

https://claude.ai/code/session_01RMYjPVhL74So5kqc6XTzCW

ZacxDev and others added 2 commits September 3, 2026 19:40
/etc/nixos/configuration.nix on workbench renders
`relay: {am_relay: false, relays: [], use_relays: true}` β€” an empty `relays:`
means no peer can relay TO workbench, so laptop->workbench is direct
hole-punch only. That is the blocker behind clawgate #497.

Adds an idempotent apply script in the existing nix/system/apply-*.sh style:
backs up the config, asserts exactly one match of the target block before
patching, rebuilds, then verifies.

The verification parses the rendered YAML's relay.relays rather than grepping
for the relay IP. The obvious grep is a false positive: lighthouse.hosts lists
the same two mesh addresses, so `grep -E '^\s+- 10\.42\.0\.2$'` MATCHES the
pre-change config and would certify the fix while relays: was still empty.
The script runs the pre-change file through the check on every run as a
negative control, so a PASS is only printed beside a fired control.

Both relay targets were confirmed `am_relay: true` in the LIVE
nebula-lighthouse-config on the homelab and production clusters β€” nebula
requires that of anything listed in a peer's relays:.

Not yet applied: sudo on workbench is password-gated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RMYjPVhL74So5kqc6XTzCW
Claude-Session-Id: 8ad54f57-207e-4fe2-9cec-b9d2fcf014f3
Audit of #1272 found three blockers in the apply script, all downstream of
it being a stateful text-patcher that edits /etc/nixos and rebuilds:

- It committed the production lighthouse's public IP into a PUBLIC repo,
  turning scripts/tests/test_no_public_ips.py red and adding a fourth site
  to a ratchet built only to shrink. apply-nebula-443.sh:4 already states
  the convention this violated.
- Its verdict could not tell "already correct" from "broken": on a second
  run RENDERED_BEFORE is the patched config, so the negative control read
  as not-firing and a correctly-configured host got a hard FAIL.
- Its `"relays = [" in s` idempotency guard is a whole-file substring, so
  on a host where apply-travel-prep.sh already inserted a relay list it
  silently skipped and reported success. That is the laptop's live state.

Also: `import yaml` is not satisfiable from the python3 on the PATH that
`sudo bash …` actually gets here, so the script could not run as documented.

Rather than fix six findings on that design, this drops the auto-patching.
The config edit is four lines the operator applies and can read in full;
what is worth shipping is the check, which is the part that was subtle.

check-nebula-relays.sh is read-only, needs no sudo, and:
- parses relay.relays with the STDLIB only (no PyYAML dependency);
- never greps the rendered YAML for the relay address -- lighthouse.hosts
  carries the same mesh addresses, so that pattern matches a config whose
  relays: is empty. The trap is documented in the header and asserted in
  the self-test;
- gates its own verdict on a --self-test that must read BOTH a relays: []
  and a populated fixture correctly, so a PASS is never printed by a
  parser that has not been shown able to say either answer;
- prints the exact nix snippet to add when the check fails.

Verified on the workbench node: live run FAIL rc1 (relays is empty today),
shimmed PASS rc0, unit-missing rc2, unresolvable -config rc2, drop-in
override picks the last ExecStart rc0, wrong-expected-relay rc1, self-test
rc0. Two parser mutants (drop block-style items; report a relay for [])
each fail the self-test rc1 and take the live run to rc2. The public-IP
test was negative-controlled: green here, and red at the old file:line
once that file is staged again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RMYjPVhL74So5kqc6XTzCW
Claude-Session-Id: 8ad54f57-207e-4fe2-9cec-b9d2fcf014f3
@ZacxDev ZacxDev changed the title feat(nebula): apply script for the workbench relay block feat(nebula): read-only check for whether this host advertises a nebula relay Sep 4, 2026
@ZacxDev

ZacxDev commented Sep 4, 2026

Copy link
Copy Markdown
Member Author

Audit round 1 β€” findings and what the fix round did

Full adversarial audit at 4ca29d7a. Verdict was needs rework (3 πŸ”΄, 5 🟑, 2 🟒). Rather than fix six findings on the apply-script design, the fix round replaced it with a read-only verifier β€” see 405ecfd4.

Findings F2, F3, F4, F6 and F7 are closed by deletion: the apply script no longer exists, so there is no second-run verdict, no substring idempotency guard, no timestamped backup, no host assumption and no half-patched-config failure mode.

1. F1 (public IP literal in a PUBLIC repo, gate red) β€” fixed: the file carrying it is deleted; the replacement has no public-IP literal, and the fixtures use RFC 5737 documentation addresses, which the scan explicitly treats as non-reportable. Negative-controlled: test_no_public_ips is green on the branch and red at the old file:line once that file is staged again.
2. F2 (verdict cannot distinguish "already correct" from "broken"; second run FAILs a correct host) β€” closed by deletion: no apply script, no RENDERED_BEFORE/RENDERED_AFTER pair. The verifier is stateless and idempotent by construction; running it twice gives the same answer.
3. F3 ("relays = [" whole-file substring silently half-applies where apply-travel-prep.sh already ran) β€” closed by deletion: nothing patches the config now, and the verifier asserts the DESIRED END STATE (is the expected relay in relay.relays) rather than the presence of a token.
4. F4 (same-second re-run overwrites the only pristine backup, so the printed rollback restores the patched file) β€” closed by deletion: no backup is taken because no write is made.
5. F5 (PyYAML unavailable on the PATH sudo actually gets, so the script could not run as documented) β€” fixed: the replacement parses relay.relays with the stdlib only. Run and verified under the default-PATH python3, which cannot import yaml.
6. F6 (no host assertion, and hostname does not discriminate β€” both hosts are "nixos") β€” closed by deletion: the verifier only reads, so running it on the wrong host misreports nothing and changes nothing.
7. F7 (no failure atomicity around nixos-rebuild; config left patched with no rollback instruction) β€” closed by deletion: there is no rebuild and no patch.
8. F9 (the comment's justification for the second relay was factually wrong β€” 10.42.0.1 is publicly reachable via the home uplink, not a LAN-only fallback) β€” fixed: the second relay is dropped entirely; the snippet the verifier prints lists the production lighthouse only. The incorrect claim is corrected in the PR body.
9. F10 (relays_ok asserted only one of the two relays; is-active ran after PASS was printed) β€” closed: only one relay is expected now, and there is no unit-state check printed after the verdict.
10. F8 (unpriced Hetzner egress for relayed traffic) β€” NOT addressed in code, and deliberately: it is a property of using a relay at all, not of this script. Recorded here so it is open rather than absent.

Not fixed, recorded as open: F8 (relayed workbench traffic bills Hetzner egress). This is inherent to advertising the production lighthouse as a relay and is a decision for the operator, not a defect in the check.

The verifier's own evidence β€” seven exit-code cases and two parser mutants β€” is in the PR body and the commit message. The relay is still not applied; that needs the operator's password.

… findings designed out

check-nebula-relays.sh tells you the relay is missing; this applies it. It is a
second attempt at the script the audit rejected, built so each of that round's
findings is structurally absent rather than patched over:

- F1 (public IP in a PUBLIC repo): no public-IP literal. Both defaults are mesh
  addresses; the relay's public underlay appears nowhere.
- F2 (could not tell "already correct" from "broken"): the pre-state comes from
  the VERIFIER, not from the file, so a stale edit that was never switched does
  not read as satisfied. Already-satisfied prints SATISFIED and exits 0 without
  touching the config or taking a backup.
- F3 (whole-file substring guard silently half-applying): the anchor is the pair
  `    settings = {` + `      punchy = {` and must match EXACTLY once β€” the bare
  `settings = {` line occurs twice in the live file, the pair once. A config that
  already carries `relays = [` fails that check and aborts naming the line and
  telling the operator to merge by hand; it never merges lists itself.
- F4 (same-second re-run clobbering the pristine backup): backup name carries
  timestamp AND pid, an existing path is refused rather than overwritten, and no
  backup is taken on any path that does not write.
- F5 (PyYAML/python3 unavailable on the sudo PATH): the patch is awk; python3 is
  needed only by the verifier and is checked in preflight with the exact
  `sudo env "PATH=$PATH"` remedy. python3 is NOT in /run/current-system/sw/bin
  here, so this is a real failure mode, not a hypothetical.
- F6 (no host assertion; `hostname` is `nixos` on more than one machine): the
  guard is this host's own nebula mesh address, which is unique by construction.
- F7 (no failure atomicity): the temp copy is syntax-checked with
  `nix-instantiate --parse` and line-counted BEFORE the backup is taken and
  before anything is moved into place; from the move onward an EXIT trap restores
  the backup, and it distinguishes "never switched" from "already switched, the
  running system still needs a rebuild to revert".

Verification is delegated to check-nebula-relays.sh rather than reimplemented.

Exercised end-to-end against a copy of the live configuration.nix with shimmed
id/ip/systemctl/nixos-rebuild (no sudo, nothing real touched): happy path rc0 and
the 4 lines land in the right block; re-run when satisfied rc0, config unchanged,
0 new backups; wrong host rc1 before any write; config already carrying a relays
list rc1 with the merge-by-hand message; rebuild failure rc1 with the config
restored and "never switched"; verifier failure after a successful switch rc1
with the config restored and the "already switched" warning. Two mutants β€” insert
invalid Nix, insert 5 lines β€” each abort with THAT gate's own error, before the
backup, config untouched, and the unmutated script goes green on the same fixture.

The awk `n != 1` guard is defence in depth behind the preflight anchor count and
is not independently reachable; it is not claimed as tested.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RMYjPVhL74So5kqc6XTzCW
Claude-Session-Id: 8ad54f57-207e-4fe2-9cec-b9d2fcf014f3
@ZacxDev ZacxDev changed the title feat(nebula): read-only check for whether this host advertises a nebula relay feat(nebula): advertise a nebula relay β€” a read-only check, and the sudo apply beside it Sep 4, 2026
…rse as "no relay"

Round 2 of the audit found three defects, all in the verifier, all verified
before fixing:

- 🟑-1 the file CLAIMED "a PASS means the running service loaded the new value"
  while the code read only `systemctl cat` -- the unit file. Measured: that
  exits 0 for an INACTIVE unit, so a config that built but never took effect
  read as applied. A comment is a claim, and this one was false. Now the config
  comes from /proc/<MainPID>/cmdline, the unit is required to be active, and a
  disagreement between the unit file and the running process is its own reported
  outcome ("a rebuild has landed but nebula has not restarted onto it").
- 🟑-2 a one-line flow mapping parsed to NOTHING and was reported as
  "relays is EMPTY" -- the exact opposite of the truth -- and a python traceback
  inside the command substitution did the same, because `set -e` does not abort
  there and `read` succeeds on the empty here-string. Both are now exit 2,
  "cannot determine", which is a different claim from "the list is empty".
- 🟑-3 the -config extraction matched only `-config X`. Measured against the
  other spellings Go's flag package accepts: with a drop-in using `--config` or
  `-config=`, the old sed silently fell back to the BASE unit's config and
  reported on the wrong file. One extractor now feeds from both the process
  cmdline and the unit's last ExecStart, so the two cannot drift.
- 🟒-1 the header claimed the generator never emits flow style. It does:
  `relays: []` -- the exact state this script exists to detect -- IS flow style.
  A maintainer trusting that comment could have deleted the branch that sees it.
- 🟒-2 --self-test failure now exits 2, not 1; 1 means "relay absent", a
  different claim, and a CI job could not tell them apart.
- 🟒-4 the remediation block now says relayed traffic egresses the relay in both
  directions, so a billed host puts every relayed byte on that bill (F8, which
  was declared open rather than fixed).

Exit codes are now 0 advertised / 1 not advertised / 2 cannot determine.

Self-test grows from 3 controls to 8: the empty flow list, a block sequence, a
populated flow list, a one-line flow mapping, a terminator case, an absent
relay key that must be rc 2 rather than a false "empty", all four -config
spellings plus absent, and the naive-grep trap. Round 2's surviving mutants M1
and M3 are killed, plus a new one for the flow-mapping branch; positive control
green. M5 (terminator `break` -> `in_block = False`) is NOT claimed as killed:
measured, it differs only on a config with a duplicate top-level `relay:` key,
which a JSON-derived generator cannot emit. It is an equivalent mutant and
adding a fixture for an impossible input would be manufacturing coverage.

apply-nebula-relay.sh: since the verifier now returns 2 when the running process
has not picked up a new config, the post-switch check retries ONCE after
restarting the unit before rolling back -- `nixos-rebuild switch` normally
restarts a changed unit, and when it has not, restarting it is the completion of
the change we just made rather than an escalation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RMYjPVhL74So5kqc6XTzCW
Claude-Session-Id: 8ad54f57-207e-4fe2-9cec-b9d2fcf014f3
@ZacxDev

ZacxDev commented Sep 4, 2026

Copy link
Copy Markdown
Member Author

Audit round 2 β€” findings and what the fix round did

Delta audit of 4ca29d7a..405ecfd4. Verdict was merge after fixing 🟑-1 and 🟑-2; all three 🟑 and three of the four 🟒 are now fixed in e7f2e45a. Every finding was reproduced before being fixed β€” none was taken on the auditor's word.

1. 🟑-1 (the file claimed a PASS meant the RUNNING service had loaded the value, while the code read only `systemctl cat`) β€” fixed. Confirmed first that `systemctl cat` exits 0 for an inactive unit, so the claim was false, not merely weak. The config now comes from /proc/<MainPID>/cmdline, the unit must be active, and a unit-file/process disagreement is its own reported outcome rather than a PASS.
2. 🟑-2a (a one-line flow mapping parsed to nothing and was reported as "relays is EMPTY") β€” fixed. Reproduced: the block-capture loop returned [] on that shape. A flow-mapping branch was added and is covered by a self-test fixture.
3. 🟑-2b (a python traceback inside the command substitution did not abort under set -e, so a crashed parser read as a definitive answer) β€” fixed. Reproduced with a non-UTF-8 config. The parser now exits 2 on unreadable/undecodable input and on an absent relay key, and the caller checks that status.
4. 🟑-3 (the -config extraction matched only `-config X`, so a drop-in using another spelling silently fell back to the BASE unit's config) β€” fixed. Measured old vs new on the same input: old returned /OLD.yml for `--config` and `-config=`, new returns /NEW.yml for all four spellings Go's flag package accepts. One extractor now feeds from both the process cmdline and the unit's last ExecStart.
5. 🟒-1 (the header claimed the generator never emits flow style) β€” fixed. The claim was false in the one case that matters: `relays: []`, the state the script exists to detect, IS flow style. Both branches are now marked load-bearing.
6. 🟒-2 (--self-test exit code collided with "relay absent") β€” fixed: self-test failure exits 2, relay-absent exits 1.
7. 🟒-3 (self-test fixtures too narrow; mutants M1 and M3 survived) β€” fixed. Fixtures went from 3 to 8. M1 and M3 now killed, plus a new mutant for the flow-mapping branch; positive control green.
8. 🟒-4 / F8 (relayed traffic egresses a billed host, previously declared open) β€” now stated in the remediation text the script prints, so the operator sees it at the moment they choose a relay.
9. M5 (terminator `break` -> `in_block = False`) is NOT claimed as killed. Measured: it differs only on a config with a DUPLICATE top-level `relay:` key, which a JSON-derived generator cannot emit. It is an equivalent mutant; a fixture for an impossible input would be manufacturing coverage, not gaining it.
10. apply-nebula-relay.sh adjusted: because the verifier now returns 2 when the running process has not picked up a new config, the post-switch check retries ONCE after restarting the unit before rolling back.

Field report from the first real run. The operator ran apply-nebula-relay.sh under sudo while this was in review. The patch applied correctly, the run failed at or after nixos-rebuild switch, and the rollback worked β€” /etc/nixos/configuration.nix came back byte-identical to its backup, which was preserved.

πŸ”΄ It also exposed a design defect not in either audit round: this machine has a pending 26.05 β†’ 26.11 release jump, so nixos-rebuild switch rebuilt the world (2h16m, largely wine-wow-11.0) and attempted a full release activation. A script advertised as "add four lines to the nebula config" triggered an OS upgrade. It needs a preflight comparing the pending closure against the running system, refusing or warning when they differ by more than the delta. Not fixed here β€” recorded so it is open rather than absent.

Separately and pre-existing: nothing has activated on that host since it booted 30 days ago (/run/current-system is 26.05 while the profile points at a 26.11 generation), which is why nothing done to configuration.nix has been taking effect.

ZacxDev and others added 3 commits September 4, 2026 19:08
… and cover both scripts

Round 3 of the audit found eight issues in the root-privileged half. All eight
are fixed, and both scripts now have repo-gate coverage they had none of.

F-A β€” the rollback was not a rollback. `nixos-rebuild switch` registers the
built system into /nix/var/nix/profiles/system and installs the bootloader
BEFORE activation, so a failed activation still moved the machine while the
trap printed "the system was never switched, so nothing is running the change".
Measured on this host: generation 387 held `relays: - 10.42.0.2` after a
reported-clean rollback. The order is now test -> verify -> switch -> verify;
`nixos-rebuild test` activates without registering a generation or touching the
bootloader, so a failure there really does persist nothing. The trap tracks five
states and distinguishes never-activated, test-failed, activated-not-persisted,
switch-attempted (profile MAY have moved, with the command to check) and
persisted. It never claims more than it knows.

F-B β€” the anchor was tied to nothing. Reproduced: NEBULA_NET=travel reported
success naming nebula@travel.service while the four lines landed in the mesh
block. The services.nebula.networks.<NET> block is now located by brace depth
first and the anchor is only accepted inside it; every ambiguity aborts.

F-C β€” root wrote to /tmp/nebula-relay-pre.$$. /tmp is 1777 and `>` follows
symlinks. Now mktemp -d (0700), cleaned by the trap; the patch temp is a mktemp
sibling of $CFG so the mv stays atomic.

F-D β€” a missing backup skipped the rollback silently. There is an else now, cp's
status is checked, and failure prints ROLLBACK FAILED with the manual fix.

F-E β€” `[ -f "$CFG" ]` accepted a symlink and mv destroyed it. Symlinks are
REFUSED, not followed: writing through a caller-chosen path as root is the same
hazard as the /tmp one. Compared via readlink -f, so a symlinked directory
component is caught too.

F-F β€” a fixture comment claimed coverage it did not provide. Replacing the relay
block's terminator with `if False:` left --self-test green. Fixture 5 keeps an
honest comment; new fixture 5b kills the mutant with its own message, and says
plainly that pkgs.formats.yaml cannot emit that shape, so it pins the parser's
structure and not a reachable production config.

F-G β€” the egress-cost warning never reached the operator. The verifier's rc-1
output is now printed in full before proceeding.

F-I β€” the rc-2 retry was wider than its comment. check-nebula-relays.sh now
emits a machine-readable `REASON: <token>` on every rc-2 path and the single
restart fires only on unit-process-disagree.

Also: unused `indent` and `local spellings` dropped; the preflight tool list
names every binary the script execs; both scripts warn that applying a relay
restarts the mesh the operator may be connected over.

F-H β€” coverage. scripts/tests/test_nebula_relay_apply.py (29 tests, in the
existing hermetic scripts/tests target) drives the real scripts against a
fixture configuration.nix via NEBULA_CFG with id/ip/systemctl/nixos-rebuild/
nix-instantiate shimmed on PATH. The verifier under test is the real one and it
reads the running process, so the rig spawns a real child carrying
`-config <rendered.yml>` and the rebuild shim re-renders that yml from the .nix
just patched. Nothing can reach a real rebuild, the profile, or /etc/nixos.
scripts/tests/mutants-nebula-relay.sh is the battery, in the same shape as the
other mutants-*.sh: three controls, applied-verified mutations, and each mutant
naming the test that must kill it AND the message it must die with.

20/20 in the battery. Two results are recorded rather than rounded up:
M-X-1 (the +4-lines guard) is a DECLARED SURVIVOR -- unreachable behind the awk
`n != 1` guard, kept as defence in depth, not counted as coverage; and
M-FB-2 is killed by that awk guard rather than by the assertion naming the wrong
block, so the patch pass's range guard is defence in depth behind the scoped
anchor count.

Nothing was applied for real: /etc/nixos/configuration.nix is unmodified,
nixos-rebuild was never run in any form, and the profile was not touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RMYjPVhL74So5kqc6XTzCW
Claude-Session-Id: 8ad54f57-207e-4fe2-9cec-b9d2fcf014f3
…d-rolled shebang

The repo's own gate caught this before the sandbox did:
`test_runtime_shebangs.py::test_no_test_writes_a_usr_bin_env_shebang_at_runtime`
went red on two sites in the new harness. A test that writes a stub at RUNTIME
and then execs it cannot use `#!/usr/bin/env bash` β€” /usr/bin/env exists on this
NixOS host and does NOT exist in the nix build sandbox, which is the
authoritative tier, so the defect is structurally invisible here.

`testlib.mockbin.write_exec` owns that decision for the whole repo, so the shims
go through it. Consequence: the bodies are now POSIX sh, which required two real
changes rather than a shebang swap:

  * the `ip` shim used `${@: -1}` (bash-only) to take the last argument; it now
    walks "$@".
  * the `nixos-rebuild` shim had a dead `cat …/rebuild_{test,switch}_rc` line
    using brace expansion; removed (the case below already sets rc).
  * the `id` shim no longer execs the real `id` through /usr/bin/env for
    unexpected args β€” it fails loudly instead, which is the right answer for a
    shim anyway.

Controlled two ways, because "it parses" and "it behaves" are different claims:
`dash -n` on all five generated shims, and the rig re-run end to end with the
shims written under dash (happy / deferred-restart / unreadable-config /
wrong-host all identical to the bash-backed /bin/sh run). This host's /bin/sh is
bash-interactive, so without that second control a bashism would have stayed
invisible until the sandbox.

Mutation battery re-run after the change: 20/20, same one declared survivor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RMYjPVhL74So5kqc6XTzCW
Claude-Session-Id: 8ad54f57-207e-4fe2-9cec-b9d2fcf014f3
… exists

The patch temp is a mktemp SIBLING of $CFG rather than a file in the scratch dir,
because the last step is `mv "$TMP" "$CFG"` and that is only atomic within one
filesystem. Nothing asserted that it gets cleaned up, and only one path fails
while it still exists: `nix-instantiate --parse` rejecting the patched file,
which happens after the temp is written and before the backup is taken.

test_an_invalid_nix_result_aborts_before_the_backup_and_leaves_no_temp pins all
four claims on that path β€” the abort, its own message, no temp sibling left, and
NO backup taken (the backup must not precede the parse check). The existing
scratch-on-failure test now also asserts the sibling is gone.

Two mutants added, both killed with this test's own message:
  M-X-3 drops `rm -f "$TMP"` from the cleanup       -> "temp sibling leaked"
  M-X-4 turns the parse check into `|| true`        -> "not valid Nix"

Battery: 22/22, one declared survivor (unchanged).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RMYjPVhL74So5kqc6XTzCW
Claude-Session-Id: 8ad54f57-207e-4fe2-9cec-b9d2fcf014f3
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant