Skip to content

feat(tailscale): the independent second path — and the closure preflight #1272 left open - #1378

Merged
ZacxDev merged 10 commits into
mainfrom
feat/tailscale-second-path
Sep 8, 2026
Merged

feat(tailscale): the independent second path — and the closure preflight #1272 left open#1378
ZacxDev merged 10 commits into
mainfrom
feat/tailscale-second-path

Conversation

@ZacxDev

@ZacxDev ZacxDev commented Sep 8, 2026

Copy link
Copy Markdown
Member

Rank 4 of the pre-departure hardening. Nebula is currently the only remote path to the workbench, and the operator leaves the LAN for months in ~3 days. Tailscale is the second door: different control plane, different relays, no shared component with nebula.

Two files, both in the idiom of apply-nebula-relay.sh / check-nebula-relays.sh.

nix/system/apply-tailscale.sh

Adds services.tailscale to /etc/nixos/configuration.nix, then switches.

Role discrimination. 🔴 hostname CANNOT tell these machines apart — both answer nixos. The guard is the nebula mesh address, unique by construction: 10.42.0.30 → subnet router (useRoutingFeatures = "server", IP forwarding, advertises 192.168.50.0/24); 10.42.0.100 → plain client (useRoutingFeatures = "client", which is what sets checkReversePath = "loose" so accepted subnet routes actually work). Anything else aborts before any write.

🔴 The closure preflight — the actual point of this PR

nixos-rebuild switch applies everything pending, not just your delta. A 4-line nebula edit once triggered a 26.05 → 26.11 jump that took 2h16m and rebuilt the world (largely wine-wow-11.0). That was recorded as an open defect on #1272 and never fixed. This is the fix.

Three independent measurements before switching, all from a temp file — /etc/nixos is untouched until they pass, so a refusal has nothing to roll back:

  1. dry-build run TWICE, against the current config and the patched one. Pending work is reported separately from the tailscale delta, so a channel bump can neither be blamed on tailscale nor hidden behind it.
  2. The nixpkgs release string, extracted by one implementation from both store-path basenames and cross-checked against /run/current-system/nixos-version — without that control an extractor that is wrong on both sides agrees with itself and waves a release jump straight through. A release change is refused unconditionally.
  3. The closure as SETS (nix-store -qR | comm -3), not a parse of diff-closures — that command prints nothing when closures match, so an empty result is indistinguishable from a command that never ran.

Measured on the workbench, 2026-09-07:

build fetch download
PENDING, with no change at all 40 24 159.4 MiB
TOTAL with tailscale 47 25 177.0 MiB
DELTA (tailscale only) +7 +1 +17.6 MiB

The +1 is tailscale-1.102.3; the +7 are regenerated unit/etc/activation derivations. The thresholds are calibrated from those numbers, so the gate refuses on this host today — correctly: 40 queued derivations are not what anyone asked for. It names the clean fix (run nixos-rebuild switch as its own deliberate operation first, then re-run) and prints --allow-world-rebuild.

🔴 readlink -f on both sides, always. /nix/var/nix/profiles/system is a symlink to another symlink — measured, it reads system-389-link, a bare name — while /run/current-system points straight at the store. Single-level readlink returns a name on one side and a store path on the other, so they can never compare equal and the mismatch is reported forever. _store_path resolves fully and refuses anything outside /nix/store, so an empty string cannot masquerade as agreement.

nix/system/check-tailscale.sh

Read-only, no sudo, runnable before and after.

🔴 It reads live runtime state, not the nix config and not the unit file — tailscale status --json, tailscale debug prefs, /proc/sys, ip route. systemctl cat exits 0 for a dead unit, so a config that built but never activated reads as applied; that is exactly why check-nebula-relays.sh was rewritten, and this does not repeat it. systemctl is-active is printed as context with no verdict keyed on it.

🔴 ADVERTISED and APPROVED are two different claims and both are printed. A route the node advertises but which has not been approved in the admin console carries no traffic, and from the node's side that is indistinguishable from success. Approval is read from Self.PrimaryRoutes, which only the control plane populates.

🔴 Node key expiry is reported with its date and days remaining, and being enabled at all counts as outstanding — the 180-day default is shorter than the trip and the lapse is silent.

Four distinct exit codes: 0 all good · 3 node correct, an admin-console step outstanding · 1 node-side failure · 2 cannot determine. 3 exists so apply-tailscale.sh does not roll back a correct switch because a browser tab has not been clicked yet.

Role auto-detection degrades honestly: if nebula.mesh has no address — the expected case when tailscale matters most — it refuses and prints the --role remedy rather than guessing.

What was tested

  • Both --self-tests green. 8/8 mutants killed on the verifier (substring route matching, PrimaryRoutes falling back to the node's own advertisement, both spellings of disabled key expiry, days arithmetic, three refuse-vs-answer paths) and 12/12 on the apply script (dry-build singular/plural/zero/GiB, .drv stripping, dashed hostnames, readlink vs readlink -f, the store-path check, and both branches of the gate) — each by its own named case, with an unmutated positive control, and two SURVIVED results traced to a broken mutation harness and re-run rather than reported.
  • The verifier driven end-to-end against a mock tailscale: rc 0, 1, 2 and 3 all watched, including the forwarding and lanroute FAIL branches (TS_PROC_ROOT exists so the forwarding check's failing branch can be driven — neither host has IPv6 forwarding on today, so the passing branch alone would be all anyone ever saw).
  • The public-IP ratchet was watched go RED naming check-tailscale.sh with a planted realistic IP, then green again byte-identical. Both content scans confirmed to enumerate the new files. Fixtures use CGNAT/RFC1918/.example.test only.
  • shellcheck 0.11.0 clean. 290 tests green across the four content ratchets and the adjacent structural gates.
  • Validated against the real tailscale 1.102.3 binary (the exact version the closure installs): tailscale debug prefs exists ("Print prefs") and tailscale status --json exists, and every JSON field the parser reads — AdvertiseRoutes, RouteAll, WantRunning, BackendState, TailscaleIPs, PrimaryRoutes, KeyExpiry, HostName — is present in the binary's string table. That probe was itself controlled: the first attempt reported 0 for everything because it was pointed at a 1378-byte bash wrapper, and the positive control (tailscaled, expected many, got 1) is what caught it; re-pointed at the 44 MB Go binary the control reads 106 and a fabricated field name still reads 0.
  • The verifier run end-to-end against that real binary with no daemon running: rc 2 CANNOT DETERMINE, surfacing tailscaled's own "it doesn't appear to be running" message — not a false FAIL and not a false PASS.
  • The --include nixos-config= plumbing validated for real: a faithful copy of the live config produced the identical system derivation hash, and the patched one a different hash that evaluates cleanly — confirming the generated block causes no attribute conflict with the module's own mkOverride 97 forwarding sysctls.

🔴 NOT verified

Nothing has been applied to a live host. sudo here is password-gated, so nixos-rebuild build, nixos-rebuild switch, the post-switch verify path, and and the response of a live, authenticated tailscaled are all unexercised. The CLI surface and field names are confirmed against the real 1.102.3 binary (above), but the exact JSON nesting is not — the fixtures are built from the documented ipn.Prefs / ipnstate.Status shapes, not captured from a running daemon. Treat the first real run as the verification.

Prerequisites and manual steps

An operator needs a Tailscale account/tailnet first. Then, after the switch, three steps that cannot be scripted — the script prints all three and the verifier keeps reporting rc 3 until they are done:

  1. sudo tailscale up --advertise-routes=192.168.50.0/24 --accept-dns=false (workbench) / sudo tailscale up --accept-routes (laptop) — needs a browser.
  2. Approve the subnet route in the admin console. Until then it carries nothing.
  3. Disable node key expiry. The 180-day default crosses the trip.

Related: #1272 (where this defect was recorded), #1287 (multi-path host reachability).


Audit round 2 — 11 findings fixed (65c391f6)

Two were blockers that made the script unusable as written. Every fix below was
driven old-vs-new against the real scripts, with nixos-rebuild/tailscale/ip/
systemctl shimmed, so each control shows the OLD behaviour reproducing and the NEW one
correcting it.

🔴 F1 — the first apply run was GUARANTEED to roll back

This script deliberately does not run tailscale up (it needs a browser), so straight
after its switch the node has never authenticated: NeedsLogin, Online false, no
TailscaleIPs, nothing advertised. The verifier turned that into four entries in
fails[] and exited 1 — not 3 — while the post-switch verify accepted only rc 0/2/3
and died on anything else, so the EXIT trap restored the backup. The comment claiming
"rc 3 is the EXPECTED state straight after a switch" was false, and the section above
that says 3 is one of four exit codes was describing a state the script could not
reach.

OLD (fec530c1) NEW (65c391f6)
same fixture, successful switch ABORT … ROLLED BACK, grep -c services.tailscale = 0 === SWITCHED ===, = 1
verifier rc on that state 1 (node-side FAILURE) 4 (INCOMPLETE, no defect)

Not a wider rc allow-list. "Never authenticated" is now a first-class state in the
verifier (_authenticated, two signals that must agree — an address in 100.64.0.0/10
and a BackendState that is not NeedsLogin/NoState) with its own exit code 4 =
INCOMPLETE
, and the apply script's case enumerates all five codes with what each
means. rc 1 still rolls back — driven with forwarding off, and pinned by a test,
because a guard that can no longer go red was removed rather than fixed.

🔴 F8 — a partial cp left /etc/nixos/configuration.nix truncated, silently

PATCHED=1 was set after cp -p "$TMP" "$CFG". cp truncates then writes, so a
failure part way through (ENOSPC — / on this host is at 77%) aborted under set -e
with PATCHED=0, the trap's rollback branch was skipped, and the config was left
half-written with a good backup unused beside it and nothing printed. Measured with a
cp that writes 40 bytes and exits 1:

  • OLD → configuration.nix is a 40-byte fragment ending mid-word, no rollback, no message.
  • NEW → rollback fires, file restored, and the message correctly says the switch was never started.

The auditor judged the original sequencing correct; it is not. Moving the flag first is
safe in the other direction — a cp that fails before writing restores a byte-identical
file.

The rest

# Was Now
F7 absent Self.KeyExpiryPASS keyexpiry : disabled, printed on a node that had never logged in absence is ambiguous; "disabled" is claimed only when the node IS authenticated, otherwise UNKNOWN
F3 rollback asserted "The system was never switched" from a flag that only records whether the switch returned zero three states; only the establishable one is asserted, with the commands to check the other
F4 "Largest pending items", alphabetical, head -15 — on the real 38-drv queue every steam-* was hidden and wine-wow-11.0 sorted last all of them, labelled a name list and not a size ranking; a cap says how many it omitted
F5 fetch counts and MiB were printed and read by no gate — 0 to build / 4 GiB to fetch passed untouched (measured: OLD proceeded to switch) download volume gated in its own right (_over_mib, floating point); header's "three ways" is now four, each with what it CANNOT see; the release gate's major.minor blind spot is stated where it reports
F6 grep -q 'services\.tailscale' — a config containing only # TODO: consider services.tailscale one day exited 0 "Nothing to do" comments (# and /* */) stripped first, an actual setting required; a mention-only file is reported and then patched
F9 --accept-dns=false on the server only, so MagicDNS took the resolver of the one machine that leaves the LAN both roles, cost stated out loud; a test pins the two scripts' up strings equal
F10 --role last → exit 1 printing nothing (both scripts) · --help a hardcoded range that truncated one file's SAFETY paragraph and ran the other into set -euo pipefail · no-trailing-newline aborted naming the wrong problem · "Only -3 of the 37 are tailscale's" · "$CHECK" invoked directly — a copy without its exec bit exits 126 and gets the config rolled back each fixed and pinned

F2 — measured correction, reported honestly

The reported mechanism (a SIGINT-killed foreground child does not trip errexit) did
not reproduce
on bash 5.3.15 here. With the signal confirmed to have landed — the
control being the killed child's wall time, since kill against a process with SIGINT
ignored returns 0 and looks identical to "the shell continued" — bash propagates the
child's SIGINT death and terminates the script, both child-only and process-group. The
first "it continued" observation was that ignored-signal case. The explicit capture
(rc=0; nixos-rebuild switch || rc=$?) is kept anyway and is reachable on
SIGTERM/SIGHUP, both driven against the real script: it names the signal instead of
aborting silently.

🔴 F11 — the AirVPN killswitch inverts the redundancy story (documented, NOT changed)

scripts/airvpn-updown's degraded and fallback rulesets allow egress on a literal
interface list — lo, the airvpn tun, nebula.mesh, cni0, flannel.1, docker0
and then drop. tailscale0 is not on it, and unlike nebula (three carve-outs)
tailscale has no DERP/control-plane bypass. So if that killswitch ever arms
fail-closed, nebula survives and tailscale dies — the exact inverse of the
independence this PR is for.

AirVPN is default-OFF on these hosts, so this is a latent interaction and not a live
defect. airvpn-updown is deliberately not touched here: widening a killswitch's
allow-list is its own change with its own review, and doing it inside a PR about a
different subsystem is how a killswitch quietly stops being one. The interaction is
recorded in the generated Nix block's comment for both roles and in the apply
script's header.

What was tested this round

  • Both --self-tests green (apply 42 controls, check 27) and now run by pytest, so they are part of the gate rather than something a human has to remember.
  • New scripts/tests/test_tailscale_scripts.py (22 tests): pins the two scripts' tailscale up strings equal, pins the exit-code vocabularies against each other, drives the freshly-switched state against a fake tailscale, and checks --help matches each file's own comment header exactly.
  • Mutation sweep: 17/17 mutants killed by their intended test, run under PYTHONDONTWRITEBYTECODE=1 in an isolated cp -a of the tree with its .git pointer removed. The first round had 2 not properly killed — the F6 fixtures could see neither comment-stripping nor the [.={] anchor — and the discriminating cases (a commented-out declaration; services.tailscale named in a warning string) were added rather than the result being reported as green.
  • shellcheck -S warning clean on both files, with the scanner itself negative-controlled against a known-bad script first.

🔴 NOT verified this round

  • Nothing was applied to a live host. sudo is password-gated. The apply path is exercised only against shimmed nixos-rebuild, tailscale, ip and systemctl; no real dry-build, build, switch or activation ran.
  • The rc 0 and rc 3 verifier paths are driven from fixtures, not from a real authenticated daemon. JSON nesting is still from the documented shapes, unchanged from round 1.
  • tailscale up, subnet-route approval and disabling key expiry remain untouched manual steps.
  • The F11 interaction is reasoned from airvpn-updown's source, not observed — the killswitch was never armed to watch tailscale drop.

ZacxDev and others added 10 commits September 7, 2026 21:16
…ight that stops "add a few lines" becoming an OS upgrade

Nebula is currently the ONLY remote path to the workbench, and the operator
leaves the LAN for months in ~3 days. Tailscale is the second door: different
control plane, different relays, no shared component.

nix/system/apply-tailscale.sh
  Adds `services.tailscale` to /etc/nixos/configuration.nix and switches.
  Two roles, discriminated by the nebula mesh address because `hostname` CANNOT
  tell these machines apart (both answer `nixos`): 10.42.0.30 -> subnet router
  (useRoutingFeatures = "server", forwarding, advertises 192.168.50.0/24),
  10.42.0.100 -> plain client. Anything else aborts rather than guessing.

  🔴 THE CLOSURE PREFLIGHT is the point of this PR. `nixos-rebuild switch`
  applies EVERYTHING PENDING, not just your delta — a 4-line nebula edit once
  triggered a 26.05 -> 26.11 jump that took 2h16m and rebuilt the world. That
  was recorded as an OPEN defect on #1272 and never fixed. This script measures
  the change three independent ways before switching and REFUSES by default:

    1. dry-build run TWICE — against the CURRENT config and the patched one —
       so PENDING work is reported separately from the tailscale DELTA. Blaming
       tailscale for a channel bump, or hiding one behind it, both become
       impossible.
    2. the nixpkgs release string, extracted by ONE implementation from both
       sides and cross-checked against /run/current-system/nixos-version so a
       broken extractor cannot silently agree with itself. A release change is
       refused unconditionally.
    3. the closure compared as SETS (`nix-store -qR | comm -3`), which cannot
       return the reassuring zero that a parsed `diff-closures` can.

  MEASURED on the workbench 2026-09-07: pending 40 builds / 24 fetches
  (159.4 MiB) with NO change at all; 47 / 25 (177.0 MiB) with tailscale. So
  tailscale's true cost is +7 derivations and +1 fetched path
  (`tailscale-1.102.3`). The thresholds are calibrated from those numbers, and
  the gate therefore REFUSES on this host today — correctly, since 40 queued
  derivations are not what anyone asked for. `--allow-world-rebuild` overrides.

  🔴 `readlink -f` on BOTH sides always: /nix/var/nix/profiles/system is a
  symlink TO ANOTHER SYMLINK (it reads `system-389-link`), so single-level
  readlink returns a NAME on one side and a store path on the other and they can
  never compare equal. `_store_path` resolves fully and refuses anything outside
  /nix/store, so an empty result can never read as agreement.

nix/system/check-tailscale.sh
  Read-only verifier, no sudo, runnable before and after. Reads LIVE RUNTIME
  STATE — `tailscale status --json`, `tailscale debug prefs`, /proc/sys, `ip
  route`. NOT the nix config and NOT the unit file: `systemctl cat` exits 0 for
  a dead unit, so a config that built but never activated reads as applied,
  which is why check-nebula-relays.sh was rewritten.

  🔴 ADVERTISED and APPROVED are reported as two separate claims. An advertised
  route that has not been approved in the admin console carries NO traffic and
  is INDISTINGUISHABLE from success on the node. Approval is read from
  Self.PrimaryRoutes, which only the control plane populates.

  🔴 Node key expiry is reported with its date and days remaining, and being
  ENABLED at all is an outstanding action: the 180-day default is shorter than
  the trip, and the lapse is silent.

  Exit 0 / 3 (node correct, admin-console step outstanding) / 1 / 2 are four
  different claims. 3 exists so apply-tailscale.sh does not roll back a correct
  switch because a browser tab has not been clicked.

Testing
  * Both --self-tests green; 8/8 mutants killed on the verifier's parser and
    predicates, 12/12 on the apply script's parsers and BOTH branches of the
    gate, each by its own named case, with an unmutated positive control.
  * The verifier driven end-to-end against a mock tailscale: rc 0, 1, 2 and 3
    all watched, including the forwarding and lanroute FAIL branches.
  * The public-IP ratchet watched go RED naming check-tailscale.sh with a
    planted realistic IP, then green again byte-identical — the green is earned,
    not assumed. Both scans confirmed to enumerate the new files.
  * shellcheck 0.11.0 clean; 290 tests green across the four content ratchets
    and the adjacent structural gates.

  NOT verified: nothing has been applied to a live host. sudo here is
  password-gated, so `nixos-rebuild build/switch`, the real `tailscale status`
  schema, and the post-switch verify path are all UNEXERCISED. Fixtures are
  built from the documented shapes, not from a running daemon.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WDQfnd1tsBNKSsGTD7jL7Y
…s ten more audit findings

🔴 THE BLOCKER. `apply-tailscale.sh` deliberately does not run `tailscale up` (it
needs a browser), so straight after its switch the node has never authenticated:
BackendState `NeedsLogin`, `Online` false, no TailscaleIPs, nothing advertised.
`check-tailscale.sh` turned that into FOUR entries in `fails[]` and exited **1**,
while the post-switch verify accepted only rc 0/2/3 and treated anything else as a
node-side failure -> `die` -> the EXIT trap restored the backup. Driven end to end
against the real scripts with a shimmed `nixos-rebuild`: the run printed
`ABORT ... ROLLED BACK` and left `grep -c 'services.tailscale' = 0`. Every first
run, on every host, guaranteed. The comment at the case claimed rc 3 was "the
EXPECTED state straight after a switch" — it never was.

FIX, and deliberately not a wider rc allow-list. "Never authenticated" is now a
FIRST-CLASS STATE in the checker (`_authenticated`, two signals that must agree)
with its own exit code **4 = INCOMPLETE, no defect found**, and the apply script's
post-switch `case` enumerates all five codes with what each MEANS. Same fixture,
new code: `=== SWITCHED ===`, block kept. rc 1 still rolls back — driven with
forwarding off, and pinned by a test, because a guard that can no longer go red
was removed rather than fixed.

Also fixed, each with an old-vs-new control:

* KEY EXPIRY (F7). An absent `Self.KeyExpiry` was resolved to "expiry disabled ->
  PASS" — printed on a node that had never logged in, for the single item most
  likely to kill this path silently mid-trip. Absence is ambiguous; "disabled" is
  now claimed only when the node IS authenticated, otherwise UNKNOWN.
* PARTIAL `cp` (F8). `PATCHED=1` was set AFTER `cp -p "$TMP" "$CFG"`, so a copy
  that failed part way (ENOSPC; `/` is at 77%) aborted with PATCHED=0, the trap's
  rollback branch was skipped, and `/etc/nixos/configuration.nix` was left
  truncated with a good backup unused beside it and NOTHING printed. Measured with
  a `cp` that writes 40 bytes and exits 1: OLD left a 40-byte fragment ending
  mid-word, NEW rolls back to a valid file. The auditor judged this correct; it is
  not.
* ROLLBACK MESSAGE (F3). It asserted "The system was never switched" from a flag
  that only records whether `nixos-rebuild switch` RETURNED ZERO — false in
  exactly the case it printed in, since a switch can activate and still exit
  non-zero and writes the bootloader entry first. Three states now, and only the
  establishable one is asserted.
* THE `--allow-world-rebuild` DIAGNOSTIC (F4). "Largest pending items" was
  alphabetical and cut at `head -15`, so on the real 38-derivation queue every
  `steam-*` was hidden and `wine-wow-11.0` — the culprit in the 2h16m incident
  this script exists for — sorted last of all. It now shows every one, says it is
  a name list and not a size ranking, and when it caps it says how many it omitted.
* THE GATE WAS BLIND TO DOWNLOADS (F5). `b_fetch`/`c_fetch`/`b_mib`/`c_mib` were
  printed in the summary table and read by no gate, so a fully substitutable
  pending change — 0 to build, 4 GiB to fetch — passed untouched. Measured: OLD
  proceeded to `== nixos-rebuild switch ==`, NEW refuses. Download volume is now
  gated in its own right (`_over_mib`, floating point — `-gt` cannot compare
  "159.4"), and the header's "three independent ways" is now four, each stated
  with what it CANNOT see. The release gate's major.minor blind spot is called out
  where it reports, since `26.11pre1066106 -> 26.11pre1066425` does not fire it.
* IDEMPOTENCY (F6). `grep -q 'services\.tailscale'` meant a config containing only
  `# TODO: consider services.tailscale one day` exited 0 "Nothing to do" — telling
  the operator the backup path was done on a host where nothing was applied.
  Comments (`#` and `/* */`) are stripped first and an actual SETTING is required.
* `--accept-dns=false` ON BOTH ROLES (F9). The reasoning is identical on the two
  machines; the asymmetric version handed MagicDNS the resolver of the ONE machine
  that leaves the LAN. The cost is stated rather than hidden, and a test pins the
  two scripts' `tailscale up` strings EQUAL so they cannot drift apart again.
* SIGNAL-KILLED SWITCH (F2). The status is captured (`rc=0; cmd || rc=$?`) and any
  non-zero, signals included, is a failure that names the signal. ⚠ MEASURED
  CORRECTION: the reported SIGINT mechanism did NOT reproduce on bash 5.3.15 here
  — with the signal confirmed to have landed, bash propagates the child's SIGINT
  death and terminates the script, both child-only and process-group. The first
  "it continued" observation was a `kill` against a process with SIGINT ignored
  (background from a non-interactive shell), which is indistinguishable from
  continuing. The check IS reachable on SIGTERM/SIGHUP, driven with both.
* MINORS (F10). `--role` as the final argument exited 1 printing nothing (both
  scripts); `--help` was a hardcoded line range that truncated one file's SAFETY
  paragraph and ran the other into `set -euo pipefail` (now read from the header
  itself); a config with no trailing newline aborted naming the wrong problem
  (`wc -l` counts newlines, `awk END{print NR}` counts records); a negative
  build delta rendered "Only -3 of the 37 are tailscale's"; and `"$CHECK"` is now
  `bash "$CHECK"` — measured, a copy without its exec bit made the verifier exit
  126 and the config get rolled back one line after a successful switch.
* AIRVPN KILLSWITCH (F11, documented not changed). `scripts/airvpn-updown`'s
  degraded/fallback rulesets allow a LITERAL interface list — lo, the airvpn tun,
  nebula.mesh, cni0, flannel.1, docker0 — then `drop`. `tailscale0` is not on it
  and has no DERP carve-out while nebula has three, so if that killswitch ever
  arms fail-closed NEBULA SURVIVES AND TAILSCALE DIES, inverting the redundancy
  this change is for. AirVPN is default-OFF, so this is latent; widening a
  killswitch is its own change with its own review. Noted in the generated Nix
  block for both roles and in the script header.

`scripts/tests/test_tailscale_scripts.py` is new: it pins the two scripts' `up`
commands equal, pins the exit-code vocabularies against each other, drives the
freshly-switched state against a fake `tailscale`, and runs both scripts' own
`--self-test` suites so their controls are part of the gate. All 17 mutants in a
sweep were killed by their intended test (two survived the first round — the
fixtures could not see comment-stripping or the `[.={]` anchor — and the
discriminating cases were added).

NOT VERIFIED: nothing was applied to a live host. sudo is password-gated, so the
apply path is exercised only against shimmed `nixos-rebuild`/`tailscale`/`ip`/
`systemctl`. `tailscale up`, subnet-route approval and key expiry are untouched
admin-console steps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JFTzoM7GrDvw4WX3m7Kjfp
Claude-Session-Id: 30ea5cd2-e238-4450-bf7b-dfc505be6fbe
`test_runtime_shebangs.py` caught it on the first full gate run — 1 failed,
12876 passed, and that one failure was this. A test-written stub carrying
`#!/usr/bin/env bash` execs fine on this NixOS host and ENOENTs inside the nix
build sandbox, so the defect is structurally invisible in the tier most people
run. `testlib.mockbin.write_exec` owns the shebang (`/bin/sh`) for exactly that
reason; both stub bodies here are POSIX sh.

The guard working, not a reason to widen it. Re-ran the targeted ratchets
(349 passed) and the 17-mutant sweep (17/17 killed by their intended test)
after the change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JFTzoM7GrDvw4WX3m7Kjfp
Claude-Session-Id: 30ea5cd2-e238-4450-bf7b-dfc505be6fbe
…uestion

Tekton `devrc-pytests` went red on `3a090bb3`: 20918 passed, **2 failed**, both
mine — `test_a_freshly_switched_node_is_rc_4_not_a_failure` and
`test_absent_key_expiry_on_an_authenticated_node_is_disabled`. Both are the same
defect and it is the two-tier hazard, not a code failure.

`_fake_tailscale` stubbed `tailscale` and `/proc` and then let the REAL
`ip -4 -o route show` answer the checker's "is this subnet reachable from here"
question. That passes on this workbench, which genuinely has a route to
192.168.50.0/24, and fails in the Tekton sandbox, which has neither the route nor
`ip` — so the checker adds a lanroute FAIL and returns 1 where the test asserts 4
(and 1 where it asserts 0). The claim under test had nothing to do with routing;
the fixture simply left one host input un-pinned, and the tier that disagreed is
the one nobody runs locally.

Every host input the checker reads is now stubbed — `ip` (route table and the
nebula probe) and `systemctl` alongside `tailscale` and `/proc` — with the route
a fixture parameter.

🔴 And the stub gets its own control. `test_a_subnet_router_with_no_lan_route_
still_fails` runs with `lan_route=False` and asserts rc 1 plus the checker's own
"no non-tailscale route" message. It passes ON A HOST THAT HAS THAT ROUTE, which
is what proves the stub is actually the `ip` being consulted — without it,
`lan_route=True` would be indistinguishable from not reading `ip route` at all.

24 tests green locally (23 + 1 skipped shellcheck).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JFTzoM7GrDvw4WX3m7Kjfp
Claude-Session-Id: 30ea5cd2-e238-4450-bf7b-dfc505be6fbe
…KIP, and a skip fails the gate

`6f09b1b9` was red in Tekton and locally with **`failed=0`**: 20921 passed, 0
failed, `RESULT: FAIL (exit=1)`. The reason was not a test failing, it was
`run-tests.sh`'s unpinned-skip guard —

    ERROR: 1 UNPINNED skip group(s) — coverage silently collapsed:
           test_tailscale_scripts.py: shellcheck not on PATH
    ERROR: 3 test(s) skipped, but 2 of 3 pinned entries apply here.

`shellcheck` is not in this repo's `gateTools`, so it is absent in EVERY gate
run: `test_shellcheck_is_clean_at_warning_level` could only ever skip. The
guard is right — a skip is a test that did not run.

Both remedies were worse than removing it, and the reasoning is written into
the file where the test used to be:

  * pinning it in `EXPECTED_SKIPS` ships a test that never executes anywhere,
    which is exactly the vacuous-guard shape this suite exists to prevent;
  * adding `shellcheck` to `gateTools` changes the toolchain for every target
    and every developer in order to gate two files, inside a PR about something
    else.

So shellcheck stays a manual step, and it was run for this change:
`nix-shell -p shellcheck` (0.11.0), clean at `-S warning` on both scripts, with
the scanner negative-controlled against a known-bad script first — exit 1 on the
control, exit 0 under test.

23 tests, **zero skips**.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JFTzoM7GrDvw4WX3m7Kjfp
Claude-Session-Id: 30ea5cd2-e238-4450-bf7b-dfc505be6fbe
…wnload refuses

Round-3 audit fixes. The two P1s are both cases where the script answered the
REASSURING question instead of the one it was asked.

R1 — an expired or revoked node key exited 4, "NOT YET DETERMINABLE (no defect
found)". `_authenticated` required BackendState and the address list to AGREE and
resolved every disagreement to "not authenticated", so a node whose key had lapsed
printed "THIS NODE HAS NEVER AUTHENTICATED ... That is the EXPECTED state immediately
after apply-tailscale.sh" on the same line as the 100.x address it had retained.
Keys expire at 180 days by default -- SHORTER THAN THE TRIP -- so this is the single
most likely way the backup path dies while it is being relied on, and the checker
called it "no defect".

  Replaced by `_identity_state`, printing none | ok | lost | incoherent:
    none        no address AND a logged-out backend        -> rc 4 (as before)
    ok          an address, a live backend, not Expired    -> judged as before
    lost        an address RETAINED under NeedsLogin/NoState, OR Self.Expired -> rc 1
    incoherent  a live backend holding NO address          -> rc 1, its own message
  `Self.Expired` was parsed-adjacent and discarded; it is now a record field and is
  read. Both documented expiry shapes are handled, since neither has been captured
  from a live daemon here. A KeyExpiry date in the PAST is a third, independent
  detector reading a different field again.

  Verified: NeedsLogin+addr, NoState+addr, Running+Expired and Running+empty-netmap
  all went 4 -> 1; NeedsLogin+no-addr is still 4; healthy 0; unapproved 3; future
  expiry 3; missing LAN route 1. Five mutants, each killed with its guard's own
  message ("exited 4, not 1"), the decisive one isolated so the self-test stayed green.

R2 — the download gate failed OPEN on any size string it could not parse.
`re.search(...) -> mib = 0.0` on no match, indistinguishable from a genuine zero.
Measured: `these 2400 paths will be fetched:`, `2.5 TiB`, `900000000 B` and
`4096,0 MiB` ALL returned `0|2400|0.0`, so a 2400-path substitutable world rebuild --
exactly what the gate exists to stop -- passed all four gates in silence.

  An unreadable size is now the literal `UNKNOWN` and `_gate_reasons` REFUSES on it
  (`_is_num` keeps it away from awk, which would have scored it 0). TiB/TB/B are
  understood and scaled. The FETCH COUNT is now gated too -- it was parsed correctly
  and printed in the summary table and read by no gate, the same "decorative column"
  defect, and it is the axis that still has a number when the size does not.
  `_gate_reasons` now requires all eight arguments instead of defaulting them.

  The defect lived in the SEAM -- the parser was tested on well-formed text, the gate
  on hand-typed numbers -- so the join is now driven directly, in the bash self-test
  and in pytest, with the expected size PINNED (a first version asserting only
  "not 0.0" let a default-the-unit mutant survive by reading 4 EiB as 4.0).

Also:
  R3  `_cfg_tailscale_decls` blanked comments but not STRINGS, so
      `warnings = [ "you should run services.tailscale.enable = true; here" ]` and a
      `''...''` block both reported "Nothing to do. Exiting 0" on an unapplied host.
      Replaced the three regexes with a left-to-right scanner (# / * */ / "..." /
      ''...'' with their escapes), because the constructs nest. Fixtures that can
      actually see the mutation, plus a control that a real declaration after a closed
      string is still found.
  R4  one run printed both "what this node ADVERTISES is UNKNOWN" and "<subnet> is
      advertised but NOT APPROVED", the second asserting -- and sending the operator
      to the admin console over -- the fact the first had declared unknowable. The
      approval check now reads whether the advertisement claim was EVALUATED, and says
      plainly that it cannot choose between an unticked box and a node that never
      advertised. Both say the route carries no traffic either way.
  R5  `_header_comment_lines` used the SAME "stop at the first non-# line" rule as the
      --help awk, so it could not see the truncation its docstring claimed to prevent:
      one inserted blank line cost 56 of 75 help lines with both tests green. The
      boundary is now `set -euo pipefail`, a different fact, plus a line-count floor
      and an assertion that every exit code reaches the printed output.
  R6  the comment said `-gt` on "159.4" "aborts the shell ... under set -e". Measured
      in the shape actually used: bash prints `[: 159.4: integer expected`, returns 2,
      and EXECUTION CONTINUES with the gate silent. It fails OPEN. Describing a loud
      abort where the truth is a silent fail-open is how the guard gets deleted. The
      same correction applies to the `[ "$rc" = 0 ] && rc=3` note.
  R7  `rc 4 -- EXPECTED. The switch succeeded and the node is NOT YET AUTHENTICATED`
      asserted one cause for a code with several, and is reachable two lines below
      `PASS authed`. It now names what was observed and points at the verifier's own
      NOT YET DETERMINABLE list.

Unchanged on purpose: SWITCH_ATTEMPTED's three states, rc 1 still rolling back, the
F7 both-directions expiry cases, the F11 AirVPN note.

Verified: both --self-test suites green; 40 tests in
scripts/tests/test_tailscale_scripts.py (was 23); full devrc gate PASS twice, the
second run on the exact committed tree (pytest 12895 in scripts/tests, node 1449);
shellcheck -S warning clean on both scripts, negative-controlled against a known-bad
script; 16-mutant battery under PYTHONDONTWRITEBYTECODE=1, every mutant confirmed
present in the file before scoring, all killed, with a positive and a negative control.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JFTzoM7GrDvw4WX3m7Kjfp
Claude-Session-Id: 30ea5cd2-e238-4450-bf7b-dfc505be6fbe
…, and a daemon that contradicts itself is not a rollback

All three expiry detectors read NETMAP fields -- `TailscaleIPs`, `Self.Expired`,
`Self.KeyExpiry` -- and the netmap is in-memory only: it is fed by control-plane
map responses and has no restore-from-disk path. So ONE reboot, power cut or
`nixos-rebuild switch` on a node whose key had already lapsed wiped all three at
once, and the checker printed "THIS NODE HAS NEVER AUTHENTICATED ... It is NOT a
node-side defect" and exited 4 about a backup path that was dead. `lost` only
ever survived while the daemon had run CONTINUOUSLY since before the lapse --
not an assumption worth making across a months-long absence.

The durable evidence is on DISK: `ipn.Prefs.Persist` (marshalled as `Config`),
reloaded from `tailscaled.state` on every daemon start. A non-empty NodeID or
LoginName there plus a logged-out backend is a LOST identity. Absence still
reads as `none`, and an EMPTY persisted profile -- what a never-logged-in daemon
carries -- must too, or every first run would be rc 1 and apply would roll back
the config it had just installed. Both directions are pinned. When prefs cannot
be read at all there is a narrow on-disk fallback (`"profile-` in the state
file, NOT `_current-profile`, which the empty profile also writes).

MEASURED, same fixture, old vs new: NeedsLogin + no address + a persisted
profile -> rc 4 "HAS NEVER AUTHENTICATED" before, rc 1 "HAD A TAILNET IDENTITY
AND NO LONGER HAS A VALID ONE" now; both fresh-install controls unchanged at 4.

`incoherent` was rc 1, which is apply's `die` -> EXIT trap -> restore $CFG and
report that the RUNNING system was not restored -- for a state whose own message
says "re-run this check before doing anything else". The cost is not a wasted
run: configuration.nix loses the block while the running system keeps it, so the
next `nixos-rebuild switch` by anyone silently deletes the backup path. It is
now rc 4, and the checker takes the second sample itself: on `incoherent` it
waits TS_SETTLE_SECS (default 3) and re-reads once. rc 1 is untouched for the
states that are actually measured -- expired/revoked, not Running/Online, not
advertising, forwarding off, no LAN route (all re-verified red).

Also, claims the code contradicted:
  * the `none` message asserted "`tailscale up` has never run on this host" --
    one cause out of several, from a run that cannot see a command's history. It
    now names the evidence it actually has (no address, no persisted profile).
  * `config.services.tailscale.enable` in a `mkIf` or an assertion is a READ,
    not a declaration -- and both were MEASURED printing "already DECLARES ...
    Nothing to do. Exiting 0" on a host with no tailscale at all. Guarded with a
    one-character lookbehind, and the "every way Nix can spell a setting and no
    way it can spell a mention" claim above the regex is retracted.
  * "every occurrence is inside a comment" was made false by this branch's own
    widening to string literals, and again by the read case.
  * the `lost` FAIL printed "the netmap addresses it was issued are still
    present: <none> ... would have NEITHER" whenever it was reached via the
    Expired flag. The evidence clause is built from what was observed.
  * "<subnet> carries NO traffic right now" is derived from `Self.PrimaryRoutes`,
    which is a fact about THIS node. Scoped.
  * the header claimed a 100.64.0.0/10 range test the code has never performed.
    Reworded rather than implemented -- a range test could only turn a real but
    unexpected address into "no identity", the reassuring direction.

Two test-sensitivity gaps closed: the world-sized-fetch cases now assert WHICH
gate refused (`_is_num` accepting everything survived them, because the
fetch-count gate fired anyway), and the two fetch gates are driven with distinct
pending/total values instead of masking each other.

56 pytest (was 40), 14/14 mutants killed with a survive-control, shellcheck -S
warning clean on both scripts (negative-controlled).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JFTzoM7GrDvw4WX3m7Kjfp
Claude-Session-Id: 30ea5cd2-e238-4450-bf7b-dfc505be6fbe
…RED now, not inferred

Ran a real unprivileged tailscaled 1.102.3 (the version this nixpkgs pins) with
its own statedir, never authenticated, and read it back:

  * `tailscale debug prefs` -> "Config": null   (so `Config` IS the key
    `ipn.Prefs.Persist` marshals to, and it is empty on a fresh node -- the
    control the whole R1 fix rests on)
  * "LoggedOut": true ON A NODE THAT HAS NEVER LOGGED IN. Reading LoggedOut as
    evidence of a lost identity -- which was the tempting second signal -- would
    have made EVERY first run rc 1 and rolled back the config apply-tailscale.sh
    had just installed. It is deliberately not used, and now for a measured
    reason rather than an unverified one.
  * `tailscaled.state` is the two bytes `{}`: neither `profile-` nor
    `_current-profile`, so the on-disk fallback finds nothing on a fresh host.
    Both key names do exist in the 1.102.3 binary, which is why the marker is
    the per-profile entry and not the pointer.

Comments only -- no behaviour change. 56 pytest, self-test green, shellcheck -S
warning clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JFTzoM7GrDvw4WX3m7Kjfp
Claude-Session-Id: 30ea5cd2-e238-4450-bf7b-dfc505be6fbe
…r had the unit is still rc 4

A crashed or stopped `tailscaled` exited 4: "NOT YET DETERMINABLE (no defect
found)". With the daemon down there is no `tailscale status` and no `tailscale
debug prefs` to read, and the on-disk fallback needs root, so `_identity_state`
lands on `none` and every claim comes out UNEVALUATED rather than wrong — the
checker calls a dead backup path healthy, on the one fact the operator most
needs told while he is 5000 km away. Measured two ways: a real tailscaled
1.102.3 killed mid-run did not move the exit code, and the new fixture control
is RED against dca21bf for the same reason.

`is-active` is now read for exactly ONE verdict — liveness — and no others.
Nothing about identity, advertisement, approval or forwarding is keyed on it;
the `:826` comment that justified ignoring the unit entirely is corrected
rather than left contradicting the code.

The gate is `LoadState`, NEVER `is-active`, and it is the whole risk of this
change. MEASURED on systemd 261: for a service that does not exist at all,
`systemctl is-active` prints `inactive` — byte-identical to a unit that exists
and is stopped — while `systemctl show -p LoadState --value` prints `not-found`
vs `loaded` (checked against fstrim.service / emergency.service). Keying the
FAIL on `is-active` alone would make every never-applied host a node-side
FAILURE, which is rc 1, which is `die` + rollback in apply-tailscale.sh: the
first-run blocker this script has been fixed for twice already. Only `loaded`
counts; `not-found`, `masked`, an error and an empty answer (no systemd, or a
systemctl too old for `--value`) all leave the run exactly as it was.

Controls, all run:
  * OLD code + unit exists and `inactive`         -> rc 4 (the bug reproduced)
  * NEW code + unit exists and `inactive`/`failed` -> rc 1, with its own finding
  * NEW code + no unit (`LoadState=not-found`)     -> rc 4, "no defect found"
  * NEW code + nothing answers systemctl           -> rc 4
  * NEW code + loaded and active, healthy node     -> rc 0 still reachable
  * rc 1 still reachable for every pre-existing reason (expiry, backend not
    Running, forwarding off, no LAN route)
Mutation battery on the new guard (PYTHONDONTWRITEBYTECODE=1, each mutant
verified PRESENT): widening the existence gate is killed by the no-unit guard;
inverting the liveness test, downgrading `fails` to `actions`, and rewording the
message are each killed by the dead-unit guard's own assertion; the survive
control (journalctl tail depth) survives.

Claude-Session-Id: 30ea5cd2-e238-4450-bf7b-dfc505be6fbe
… installs accepted routes in table 52

Found on the FIRST real deployment, not in review. With workbench advertising
192.168.50.0/24, approved in the admin console, and the laptop joined with
--accept-routes, the client check reported:

  ACTION REQUIRED: 192.168.50.0/24 is not in this host's routing table via
  tailscale. Either the route is not approved in the admin console, or
  --accept-routes is off here, or the workbench is not advertising.

All three suggested causes were false. The route was installed and in use.

Measured on the laptop:
  ip route show table 52          -> 192.168.50.0/24 dev tailscale0
  ip route show table main        -> 192.168.50.0/24 dev wlp170s0 (the native LAN route)
  ip route get 192.168.50.250     -> dev tailscale0 table 52 src 100.71.230.83
  ip rule                         -> 5270: from all lookup 52   (BEFORE 32766: main)

`ip route show` with no table argument reads MAIN. Tailscale puts accepted subnet
routes in table 52 with a policy rule ahead of main, so the predicate at :1167
could never see one -- it fired on every correctly-working client, which is the
state a traveller is in every single time they run this.

Fix is `table all` on that one predicate. :1148 is deliberately NOT changed: the
server-side `lanroute` check wants a NON-tailscale route in main, and table all
would let the tailscale route satisfy it.

Controls, both live, same script:
  laptop  --role client : EXIT 3 -> 0   (the false ACTION is gone)
  workbench --role server: EXIT 0 -> 0  (server role unregressed)
  --self-test green before and after.
An earlier attempt to patch this by string-replace silently matched 0 lines; the
assertion that the target line changed is what caught it, so the "fixed" run I
nearly read was the unpatched script.

Direction of the bug was safe -- it under-reported, never claimed a broken path
worked -- but it would have sent the operator to the admin console chasing
nothing, off-LAN, with no way to check the other end.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WDQfnd1tsBNKSsGTD7jL7Y
Claude-Session-Id: 30ea5cd2-e238-4450-bf7b-dfc505be6fbe
@ZacxDev
ZacxDev merged commit d8e2bbe into main Sep 8, 2026
0 of 2 checks passed
@ZacxDev
ZacxDev deleted the feat/tailscale-second-path branch September 8, 2026 19:20
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