feat(tailscale): the independent second path — and the closure preflight #1272 left open - #1378
Merged
Conversation
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.shAdds
services.tailscaleto/etc/nixos/configuration.nix, then switches.Role discrimination. 🔴
hostnameCANNOT tell these machines apart — both answernixos. The guard is the nebula mesh address, unique by construction:10.42.0.30→ subnet router (useRoutingFeatures = "server", IP forwarding, advertises192.168.50.0/24);10.42.0.100→ plain client (useRoutingFeatures = "client", which is what setscheckReversePath = "loose"so accepted subnet routes actually work). Anything else aborts before any write.🔴 The closure preflight — the actual point of this PR
nixos-rebuild switchapplies 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 (largelywine-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/nixosis untouched until they pass, so a refusal has nothing to roll back:dry-buildrun 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./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.nix-store -qR | comm -3), not a parse ofdiff-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:
The
+1istailscale-1.102.3; the+7are 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 (runnixos-rebuild switchas its own deliberate operation first, then re-run) and prints--allow-world-rebuild.🔴
readlink -fon both sides, always./nix/var/nix/profiles/systemis a symlink to another symlink — measured, it readssystem-389-link, a bare name — while/run/current-systempoints straight at the store. Single-levelreadlinkreturns 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_pathresolves fully and refuses anything outside/nix/store, so an empty string cannot masquerade as agreement.nix/system/check-tailscale.shRead-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 catexits 0 for a dead unit, so a config that built but never activated reads as applied; that is exactly whycheck-nebula-relays.shwas rewritten, and this does not repeat it.systemctl is-activeis 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:
0all good ·3node correct, an admin-console step outstanding ·1node-side failure ·2cannot determine.3exists soapply-tailscale.shdoes not roll back a correct switch because a browser tab has not been clicked yet.Role auto-detection degrades honestly: if
nebula.meshhas no address — the expected case when tailscale matters most — it refuses and prints the--roleremedy rather than guessing.What was tested
--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,.drvstripping, dashed hostnames,readlinkvsreadlink -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.tailscale: rc 0, 1, 2 and 3 all watched, including the forwarding and lanroute FAIL branches (TS_PROC_ROOTexists 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).check-tailscale.shwith a planted realistic IP, then green again byte-identical. Both content scans confirmed to enumerate the new files. Fixtures use CGNAT/RFC1918/.example.testonly.tailscale1.102.3 binary (the exact version the closure installs):tailscale debug prefsexists ("Print prefs") andtailscale status --jsonexists, 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.--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 ownmkOverride 97forwarding sysctls.🔴 NOT verified
Nothing has been applied to a live host.
sudohere is password-gated, sonixos-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 documentedipn.Prefs/ipnstate.Statusshapes, 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:
sudo tailscale up --advertise-routes=192.168.50.0/24 --accept-dns=false(workbench) /sudo tailscale up --accept-routes(laptop) — needs a browser.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/systemctlshimmed, so each control shows the OLD behaviour reproducing and the NEW onecorrecting it.
🔴 F1 — the first apply run was GUARANTEED to roll back
This script deliberately does not run
tailscale up(it needs a browser), so straightafter its switch the node has never authenticated:
NeedsLogin,Onlinefalse, noTailscaleIPs, nothing advertised. The verifier turned that into four entries infails[]and exited 1 — not 3 — while the post-switch verify accepted only rc 0/2/3and
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
3is one of four exit codes was describing a state the script could notreach.
fec530c1)65c391f6)ABORT … ROLLED BACK,grep -c services.tailscale= 0=== SWITCHED ===, = 1Not 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/10and a
BackendStatethat is notNeedsLogin/NoState) with its own exit code 4 =INCOMPLETE, and the apply script's
caseenumerates all five codes with what eachmeans. 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
cpleft/etc/nixos/configuration.nixtruncated, silentlyPATCHED=1was set aftercp -p "$TMP" "$CFG".cptruncates then writes, so afailure part way through (ENOSPC —
/on this host is at 77%) aborted underset -ewith
PATCHED=0, the trap's rollback branch was skipped, and the config was lefthalf-written with a good backup unused beside it and nothing printed. Measured with a
cpthat writes 40 bytes and exits 1:configuration.nixis a 40-byte fragment ending mid-word, no rollback, no message.The auditor judged the original sequencing correct; it is not. Moving the flag first is
safe in the other direction — a
cpthat fails before writing restores a byte-identicalfile.
The rest
Self.KeyExpiry→PASS keyexpiry : disabled, printed on a node that had never logged inhead -15— on the real 38-drv queue everysteam-*was hidden andwine-wow-11.0sorted lastswitch)_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 reportsgrep -q 'services\.tailscale'— a config containing only# TODO: consider services.tailscale one dayexited 0 "Nothing to do"#and/* */) stripped first, an actual setting required; a mention-only file is reported and then patched--accept-dns=falseon the server only, so MagicDNS took the resolver of the one machine that leaves the LANupstrings equal--rolelast → exit 1 printing nothing (both scripts) ·--helpa hardcoded range that truncated one file's SAFETY paragraph and ran the other intoset -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 backF2 — 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
killagainst a process with SIGINTignored 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 onSIGTERM/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 literalinterface list —
lo, the airvpn tun,nebula.mesh,cni0,flannel.1,docker0—and then
drop.tailscale0is 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-updownis deliberately not touched here: widening a killswitch'sallow-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
--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.scripts/tests/test_tailscale_scripts.py(22 tests): pins the two scripts'tailscale upstrings equal, pins the exit-code vocabularies against each other, drives the freshly-switched state against a faketailscale, and checks--helpmatches each file's own comment header exactly.PYTHONDONTWRITEBYTECODE=1in an isolatedcp -aof the tree with its.gitpointer 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.tailscalenamed in a warning string) were added rather than the result being reported as green.shellcheck -S warningclean on both files, with the scanner itself negative-controlled against a known-bad script first.🔴 NOT verified this round
sudois password-gated. The apply path is exercised only against shimmednixos-rebuild,tailscale,ipandsystemctl; no realdry-build,build,switchor activation ran.tailscale up, subnet-route approval and disabling key expiry remain untouched manual steps.airvpn-updown's source, not observed — the killswitch was never armed to watch tailscale drop.