cloud/reconciler/headscale.rs
1//! [`Reconciler`] implementation for `kind = "headscale"` components (R861-T1).
2//!
3//! Owns the API objects that live *inside* a running headscale coordinator —
4//! users, pre-auth keys and the ACL policy — the way
5//! [`cloudflare_worker`](super::cloudflare_worker) owns what lives inside a
6//! Cloudflare account. It does not deploy headscale; that is the appliance
7//! [`WorkloadSpec`] in `yubaba::headscale_appliance`, and it stays there.
8//!
9//! ## Why this exists
10//!
11//! Two pieces of headscale's state were previously nobody's declared config:
12//!
13//! 1. **`acls.yaml`** — 77 bytes on us-west-001's local disk, mtime Jun 22,
14//! replicated by nothing (litestream carries only `headscale.db`). A
15//! failover to another node would come up with different ACLs, look
16//! healthy, and partially work. Declaring the policy here means the new
17//! node is *reconciled* to the declared value rather than needing the file
18//! carried to it.
19//! 2. **Pre-auth keys** — `headscale-preauth-key` is classified
20//! `Band::Automatable` in the credential spec and its purpose text already
21//! says `headscale-api-key` mints these per machine. That automation
22//! existed only ad hoc, at provision time, for one machine, with no
23//! declared desired state behind it.
24//!
25//! ## Policy mode is load-bearing, and the reconciler adapts to it
26//!
27//! headscale 0.23 sources its ACL policy from either a file or the database,
28//! per `policy.mode` in `config.yaml`. That bounds what this reconciler can do
29//! about ACLs:
30//!
31//! - `GET /api/v1/policy` works in **both** modes (verified: 200 with the
32//! file's contents against the live file-mode coordinator), so declared-vs-
33//! live drift is always *detectable*.
34//! - `PUT /api/v1/policy` is refused in file mode — the pinned v0.23.0's
35//! `SetPolicy` returns `ErrPolicyUpdateIsDisabled` before it even reads the
36//! payload, because the file is the source of truth and a write would be
37//! overwritten at the next reload.
38//!
39//! So the ACL step compares first and only writes on drift. The write lands on
40//! a database-mode coordinator and the policy ends up in `headscale.db`, which
41//! litestream already replicates — the point at which `acls.yaml` stops being
42//! unreplicated failover state. On a coordinator still in file mode the drift
43//! surfaces as a loud, actionable error naming the exact file and the migration
44//! that makes it pushable, instead of a silent no-op that *reads* like success.
45//!
46//! **R861-T2 flipped that default.** Every in-tree renderer now emits
47//! `policy.mode: database` ([`crate::mesh::POLICY_MODE`] and yubaba's
48//! `HEADSCALE_POLICY_MODE`), so newly-provisioned coordinators come up with
49//! policy in the database and no `acls.yaml` at all. **Boxes provisioned
50//! earlier are still on file mode until they are migrated** — us-west-001 was
51//! measured on 2026-09-04 running
52//!
53//! ```yaml
54//! policy:
55//! mode: file
56//! path: /var/lib/yah-cloud/headscale/acls.yaml
57//! ```
58//!
59//! and that migration is [`policy_migration`], which is a planner plus a
60//! rehearsal, not something this reconciler performs as a side effect: the flip
61//! reaches a live node only through a yubaba release + roll and it wants the
62//! operator's hand on it.
63//!
64//! ## Declared config
65//!
66//! Lives in the component's `workload.toml`, in the kind-specific tables each
67//! reconciler parses for itself (`cloudflare-worker` reads `[build]` +
68//! `[[bindings]]` the same way — the strong `workload_spec::Workload` types
69//! do not model per-kind tables, and the generated JSON schema sets no
70//! `additionalProperties: false`, so this needs no schema change and no
71//! regen).
72//!
73//! ```toml
74//! schema_version = 1
75//! kind = "headscale"
76//!
77//! [headscale]
78//! # Optional. Falls back to the `mesh-url` vault slot / HEADSCALE_URL.
79//! server_url = "https://cloud.mesh.yah.dev"
80//! # Users that must exist. Created if absent; NEVER deleted — a user carries
81//! # the nodes registered under it.
82//! users = ["yah"]
83//! # HuJSON ACL policy, path relative to the workload dir.
84//! acl_policy = "acls.hujson"
85//!
86//! [[headscale.preauth_keys]]
87//! user = "yah"
88//! tags = ["tag:cloud-runner"]
89//! reusable = true
90//! ephemeral = false
91//! ttl_hours = 168
92//! # Optional vault slot the minted key is written to.
93//! store_as = "headscale-preauth-key"
94//! ```
95//!
96//! Every one of those fields is validated **before the first API call** — the
97//! R330-B5 fail-fast discipline the reconciler docs already name. A typo in a
98//! preauth key's `user` must not be discovered after two users have already
99//! been created.
100//!
101//! @yah:ticket(R861-T2, "Decide headscale policy source: keep mode=file with a reconciled acls.yaml, or flip the fleet to mode=database")
102//! @yah:status(review)
103//! @yah:at(2026-09-04T23:24:31Z)
104//! @yah:assignee(agent:bundle-anthropic-glimmerstone)
105//! @yah:parent(R861)
106//! @yah:gotcha("MEASURED, NOT ASSUMED: us-west-001's /var/lib/yah-cloud/headscale/config.yaml carries `policy:` / `mode: file` / `path: /var/lib/yah-cloud/headscale/acls.yaml` (read over SSH 2026-09-04 during R861-T1), and all three in-tree renderers emit the same. So acls.yaml is LIVE. It was NOT deleted, and any plan that assumes it is vestigial is wrong.")
107//! @yah:assumes("That headscale 0.23's `policy.mode: database` accepts the same HuJSON policy document via the admin API that `mode: file` reads from disk, so R861-T1's get_policy/set_policy pair works unchanged under either mode. Not verified against a running instance in database mode — verify before flipping anything.")
108//! @yah:next("THE CALL (operator): R861-T1 landed a HeadscaleReconciler that owns ACLs as declared config, but left the fleet on `policy.mode: file`. Under mode=file the reconciler WRITES acls.yaml, so the file still exists on disk and litestream still does not replicate it (litestream carries only headscale.db) — the failover-state gap R861 was filed against is only PARTLY closed: acls.yaml is now reconstructible from declared config rather than being unique unreplicated state, but it is still state on a box. Flipping to mode=database puts policy inside headscale.db, which litestream already replicates, and the file stops mattering entirely — which is what R861's framing actually wanted.")
109//! @yah:next("WHY THIS IS AN OPERATOR CALL AND NOT A DEFAULT: flipping policy.mode on the live fleet is an outward-facing change to running mesh infrastructure, and a wrong ACL state mid-flip is a connectivity outage, not a failed build. It also needs a migration step (read the current acls.yaml, push it through set_policy BEFORE the mode flip, verify, then remove the file) — a sequencing decision with no defensible default. Option A: keep mode=file, accept that acls.yaml stays on disk but is now reconciled/reconstructible. Option B: migrate to mode=database so litestream covers policy and the file is deleted for good.")
110//! @yah:next("OPERATOR DECIDED 2026-09-04: Option B — migrate to `policy.mode: database`. Policy moves into headscale.db (which litestream already replicates) and acls.yaml is deleted for good. This is the answer to the call; the ticket is released. Option A (keep mode=file) is off the table — do not re-litigate it.")
111//! @yah:next("SEQUENCE, and it is not optional — a wrong ACL state mid-flip is a mesh connectivity outage, not a failed build. (1) VERIFY THE ASSUMPTION FIRST: confirm the pinned headscale version's `mode: database` accepts the same HuJSON document through the admin API that `mode: file` reads from disk, so R861-T1's get_policy/set_policy pair works unchanged. If it does not, stop and report — everything downstream rests on it. (2) Read the live acls.yaml and push it through set_policy. (3) Verify the policy read back from the API matches byte-for-byte semantically. (4) Only then flip the rendered config to mode=database. (5) Only after a confirmed-good flip, delete acls.yaml. Each step must be idempotent and re-runnable.")
112//! @yah:next("SCOPE SPLIT: build and rehearse the migration in code first — the renderer change plus a re-runnable migration path, tested. EXECUTING it against the live fleet (us-west-001, us-west-003, and any other headscale-bearing box) is a separate, gated step: the operator authorized the migration, not an unattended production run. Land the code, prove it dry, then surface the execution moment.")
113//! @yah:gotcha("THE POLICY BLOCK IS EMITTED AT FOUR SITES ACROSS TWO FILES, and R861-T1's \"three in-tree renderers\" undercounts. Grepped by @Glimmerstone:polaris 2026-09-04 (line numbers are grep hits, not reads): cloud/src/mesh.rs:554 (acl_path :521); yubaba/src/lib.rs:4760 (acl_path :4729); yubaba/src/lib.rs:5170 (acl_path :5134). THE THIRD SITE IS SPELLED WITH ESCAPED SPACES — `\\x20\\x20mode: file` — so a naive `rg \" mode: file\"` MISSES IT. That is almost certainly the source of the undercount. Missing it leaves newly-provisioned boxes silently on mode=file while every other signal says the migration completed. Search on `mode:` broadly and on `acls.yaml`, never on a literal two-space prefix. NOTE: headscale_appliance.rs does NOT render a policy block at all — do not go looking there.")
114//! @yah:next("IN SCOPE FOR THIS TICKET, not followups — the flip breaks these. Two assertions that will fail: yubaba/src/lib.rs:7865 (`cfg.contains(\"mode: file\")`) and cloud/src/mesh.rs:755 (`config.contains(\"acls.yaml\")`). Two prose/doc sites that go stale, both asserting the live coordinator \"runs policy.mode: file\": cloud/src/reconciler/headscale.rs:281 and cloud/src/mesh.rs:367. All four found by grep, not opened — verify each before editing.")
115//! @yah:gotcha("THE SEQUENCE ORIGINALLY FILED ON THIS TICKET IS IMPOSSIBLE — DO NOT FOLLOW IT. It said push the policy via set_policy FIRST and flip the config to mode=database afterward. headscale v0.23.0's SetPolicy returns ErrPolicyUpdateIsDisabled *before it reads the payload* unless mode==database is already in effect, so the push cannot precede the flip. CORRECTED ORDER: flip the rendered config to mode=database and restart headscale FIRST, then push the policy through set_policy, then verify the read-back, then delete acls.yaml. Established from headscale v0.23.0 source during R861-T2, not inferred.")
116//! @yah:gotcha("THE CORRECTED ORDER OPENS A REAL WINDOW: between the mode flip and the policy push, headscale has NO policy row. That window is safe FOR THIS FLEET AS IT STANDS TODAY, and only for that reason — the live acls.yaml is the permissive default, and headscale compiles a missing policy row to FilterAllowAll, so the gap state and the current effective state are both allow-all and no connectivity is lost. THIS SAFETY ARGUMENT EXPIRES THE MOMENT acls.yaml STOPS BEING PERMISSIVE. Anyone re-running this migration after real ACL rules are declared must re-derive it: with a restrictive policy live, the same window is a fail-OPEN (allow-all) interval, which is a security exposure rather than an outage. Re-read the live acls.yaml and confirm it is still the permissive default before executing.")
117//! @yah:verify("ASSUMPTION DISCHARGED: R861-T2's @yah:assumes — that mode=database accepts the same HuJSON document the file mode reads — is VERIFIED from headscale v0.23.0 source, where both modes funnel through the same LoadACLPolicyFromBytes. R861-T1's get_policy/set_policy therefore work unchanged under either mode. Verified by reading the pinned version's source, not from documentation or recollection.")
118//! @yah:handoff("ASSUMPTION VERIFIED — IT HOLDS, and it was settled from the pinned version's own source, not from recollection or from a live probe. Downloaded juanfont/headscale v0.23.0 (the pin: cloud::mesh::HEADSCALE_VERSION and yubaba::DEFAULT_HEADSCALE_VERSION, both \"0.23.0\") and read four call sites. (1) hscontrol/app.go loadACLPolicy: file mode calls policy.LoadACLPolicyFromPath, which is os.Open + io.ReadAll + LoadACLPolicyFromBytes; database mode calls LoadACLPolicyFromBytes on the stored row. Same function, same document. (2) hscontrol/policy/acls.go LoadACLPolicyFromBytes: hujson.Parse -> Standardize -> json.Unmarshal into the same ACLPolicy struct. There is no second format. (3) hscontrol/grpcv1.go SetPolicy: parses the request body with that same LoadACLPolicyFromBytes before storing. (4) hscontrol/grpcv1.go GetPolicy: database mode returns the stored row's Data verbatim, file mode returns the file's bytes as-is. (5) hscontrol/types/config.go: policy.mode is exactly \"file\" or \"database\" (PolicyModeFile/PolicyModeDB), viper defaults it to \"file\" when absent, and policy.path is read only in file mode — config-example.yaml says the same. So R861-T1's get_policy/set_policy pair works unchanged under either mode. Nothing downstream is blocked on this.")
119//! @yah:handoff("FINDING 1 — THE TICKET'S SEQUENCE IS IMPOSSIBLE AS WRITTEN, AND THE CODE INVERTS IT. R861-T2 step (2) said \"read the live acls.yaml and push it through set_policy\", step (4) \"only then flip the rendered config to mode=database\". That cannot run: hscontrol/grpcv1.go SetPolicy opens with `if api.h.cfg.Policy.Mode != types.PolicyModeDB { return nil, types.ErrPolicyUpdateIsDisabled }` — it refuses BEFORE reading the payload. A file-mode coordinator cannot be pushed to at all, so the config flip has to come FIRST. Implemented order (policy_migration::next_step): (1) rewrite config.yaml to mode: database and restart headscale; (2) push the carried acls.yaml through set_policy; (3) verify the read-back semantically; (4) only then delete acls.yaml. The ticket's step (5) — delete last — is preserved and is load-bearing, see FINDING 3.")
120//! @yah:handoff("FINDING 2 — THE WINDOW THAT INVERSION OPENS IS FAIL-OPEN, AND IT IS SAFE FOR THIS FLEET FOR A SPECIFIC REASON THAT WILL EXPIRE. Between the flip and the push, a database-mode coordinator has no policy row. That is NOT an error: app.go loadACLPolicy maps types.ErrPolicyNotFound to a nil *ACLPolicy and returns nil, and acls.go (*ACLPolicy).CompileFilterRules on a nil receiver returns tailcfg.FilterAllowAll (CompileSSHPolicy returns nil, nil). So the coordinator serves ALLOW-ALL during that window. For us-west-001 today this is a semantic no-op: its live acls.yaml is measured (R861-T1, 2026-09-04) as exactly the permissive default {\"acls\":[{\"action\":\"accept\",\"src\":[\"*\"],\"dst\":[\"*:*\"]}]}, i.e. allow-all already. THE SAFETY ARGUMENT IS ENTIRELY CONTINGENT ON THAT — the day acls.yaml stops being permissive, the same window becomes a real (brief) widening of the tailnet. MigrationPlan::widens_before_push() reports it rather than hiding it, and a test asserts it is reported. Also relevant: GET /api/v1/policy against a database-mode coordinator with no row is an ERROR (grpcv1.go wraps ErrPolicyNotFound as \"loading ACL from database\"), not an empty string; Observation::live_policy models that as None.")
121//! @yah:handoff("FINDING 3 — acls.yaml IS THE JOURNAL, WHICH IS WHY IT IS DELETED LAST AND WHY NO STATE FILE WAS NEEDED. Re-runnability was a hard requirement and it is met without writing any migration state anywhere: until the coordinator serves an equivalent policy from its database, acls.yaml is still on disk and the entire migration is re-derivable from it. next_step() is a pure function of an Observation {config_yaml, acls_file, live_policy, headscale_dir}, so a half-completed run is recovered by re-observing and re-running. Delete the file any earlier and an interruption loses the policy. MigrationPlan::deletes_the_file_early() is the invariant check; a test walks all three interruption midpoints (after flip / after push / after delete) and asserts each converges to Done with the invariant intact. One more property worth knowing, from hscontrol/db/policy.go: db.SetPolicy INSERTS a new `policies` row on every call and GetPolicy reads ORDER BY id DESC LIMIT 1 — so a push is idempotent in EFFECT but not in STORAGE. Both the reconciler and the migration compare before writing, so the table does not grow on repeat runs.")
122//! @yah:handoff("WHAT LANDED, FILE BY FILE. NEW oss/yubaba/crates/cloud/src/reconciler/headscale/policy_migration.rs (a submodule of headscale.rs, not a sibling of reconciler/mod.rs — the module path is cloud::reconciler::headscale::policy_migration): PolicyMode {File{path}|Database|Unrecognised}, read_policy_mode() and rewrite_to_database_mode() (hand-written line surgery over the policy: block, NOT a serde_yaml round trip — serde_yaml is dev-only in this crate and a reserialize would reformat a config a human may have edited on the box; every other byte is preserved, unknown keys inside the block are kept, policy.path is dropped because headscale reads it only in file mode, an absent policy: block is appended because viper defaults it to file); Observation/Step/next_step() as the state machine; Execution{DryRun|Live} as a type rather than a bool so a dry run cannot arm a write by argument order; apply_push() which after a live PUT reads back and re-compares, and BAILS rather than reporting success if the coordinator serves back something different; rehearse() which drives the machine to a terminal state over a simulated coordinator; Step::on_box_commands() which returns the on-box steps as DATA (systemctl restart — not reload, since policy.mode is read once at startup in loadACLPolicy) instead of executing them. RENDERERS, all three flipped to mode: database with the path line removed: cloud/src/mesh.rs generate_headscale_config (new pub const POLICY_MODE), yubaba/src/lib.rs generate_remote_headscale_config and generate_bootstrap_headscale_config (new pub const HEADSCALE_POLICY_MODE, kept in lockstep by convention like DEFAULT_HEADSCALE_VERSION — there is no dependency edge from yubaba to yah-cloud). The escaped-space site (\\x20\\x20mode: file) was caught: my grep was on `mode: file` broadly, not on a two-space prefix.")
123//! @yah:handoff("DISCOVERED WORK DONE, NOT FILED AS FOLLOWUPS — the four sites the leader named plus five more the flip broke or made wrong. FIXED ASSERTIONS: cloud/src/mesh.rs config_contains_server_url (now asserts `mode: database` AND that neither `acls.yaml` nor `policy.path` survives); yubaba/src/lib.rs bootstrap config test (same, and the comment now names the \\x20\\x20 spelling so the next reader does not re-lose the site). FIXED PROSE: reconciler/headscale.rs module header (rewritten — it claimed the fleet runs file mode and that flipping is \"deliberately NOT done here\"; both were the pre-flip world) and its set_policy error text (now names the migration instead of telling the reader file mode is the norm); mesh.rs get_policy/set_policy doc comments (now record what the v0.23.0 handlers actually do, including SetPolicy's pre-store validation and the append-only storage). STOPPED WRITING acls.yaml AT THREE PROVISIONING SITES, because leaving code that recreates the very file the migration deletes is the same bug pointing the other way: yubaba headscale_bootstrap (wrote DEFAULT_ACL_POLICY_HUJSON — behaviourally identical to dropping it, since an absent policy row is FilterAllowAll), yubaba headscale_deploy, and `yah mesh start` in app/yah/cli/src/mesh.rs. THE ONE PLACE THAT COULD HAVE LOST DATA IS NOW LOUD: headscale_deploy used to write the carried acl_policy to acls.yaml. Under database mode nothing reads that file, so a carried policy would have silently vanished and the destination would come up allow-all. It now REFUSES the deploy with 400 when the carried policy is not the permissive default (new pub fn carried_policy_is_permissive_default, whitespace-insensitive, deliberately coarse — a false negative only ever means \"refuse\", the safe direction). Callers updated: app/yah/cli/src/mesh.rs build_deploy_request forwards a leftover acls.yaml verbatim (so the refusal fires) and carries \"\" when there is none, instead of substituting DEFAULT_ACL_POLICY; crates/yah/cloud-client test fixture likewise. Three test fixtures sent `---\\nacls: []`, which headscale's HuJSON loader would have rejected outright — it was never a policy headscale could have booted on; they now send \"\".")
124//! @yah:verify("cargo test --manifest-path oss/yubaba/Cargo.toml -p yah-cloud --lib = 1078 passed / 0 failed / 4 ignored, against the R861-T1 baseline of 1060 / 0 / 4 — +18, all new, all in reconciler::headscale::policy_migration. cargo test --manifest-path oss/yubaba/Cargo.toml -p yubaba --lib = 639 passed / 0 failed, baseline 637, +2 (headscale_deploy_refuses_a_carried_non_default_policy, permissive_default_is_recognised_through_reformatting), both confirmed to run by name with --exact. NOTE FOR THE NEXT RUNNER: `cargo test -p yah-cloud --lib` from the repo root FAILS with \"cannot be tested because it requires dev-dependencies and is not a member of the workspace\" — yah-cloud lives in the oss/yubaba workspace, so only the --manifest-path form produces these numbers. cargo check -p yah --lib = no errors from any file I touched; the crate's only errors are the pre-existing E0425 in app/yah/cli/src/keys_doctor.rs (@Ashguard:polaris, R856), so `cargo test -p yah` could NOT be run and the two app/yah/cli/src/mesh.rs tests (build_deploy_request_round_trips_files and the new build_deploy_request_carries_no_policy_when_there_is_no_acls_file) are UNVERIFIED — they are pure-fs unit tests with no new API surface, but say so rather than assume. rustfmt --check (read mode only, never write mode on this tree) is clean on policy_migration.rs, cloud/src/mesh.rs, app/yah/cli/src/mesh.rs and cloud-client/src/lib.rs; it still reports four pre-existing hunks in reconciler/headscale.rs (lines ~261/380/790/887, all R861-T1's code, none in anything I wrote) which I deliberately did NOT reformat, since the leader was actively writing annotations into that file and formatting churn on a shared tree is how peers' hunks get lost.")
125//! @yah:gotcha("THE LIVE FLEET WAS NOT TOUCHED, AND THE MIGRATION HAS NOT BEEN EXECUTED — stated plainly because everything above reads like it was. No write of any kind reached us-west-001, us-west-003 or any other box: no config.yaml was rewritten, no headscale was restarted, no policy was pushed, no acls.yaml was deleted. apply_push was never called with Execution::Live against anything. The renderer changes are LOCAL — they reach nodes only through a yubaba release plus a roll, so every live coordinator is still on `policy.mode: file` right now and the reconciler's ACL push is still refused there. The only network I used was an HTTPS GET of the headscale v0.23.0 source tarball from codeload.github.com into /tmp. Every claim about headscale's behaviour in this ticket comes from reading that source; NOTHING was verified against a running database-mode instance, because standing one up is itself a write. The remaining risk the code cannot retire: the rewriter and the state machine are tested against the exact config shape measured on us-west-001, but no real config.yaml has been round-tripped through them on a box. First live run should be a read-only observe (cat config.yaml, cat acls.yaml, GET /api/v1/policy) fed into next_step, and the printed step compared against expectation, BEFORE anything writes.")
126//! @yah:next("EXECUTION IS THE GATED STEP AND IT IS NOT DONE. Order for whoever runs it: (1) cut and roll a yubaba release, because the renderer change reaches nodes no other way and an un-rolled node re-renders `mode: file` on its next provision; (2) per box, read-only observe (config.yaml, acls.yaml, GET /api/v1/policy) and feed policy_migration::next_step, checking the step it names matches expectation before any write; (3) execute in the printed order — flip+restart, push, verify read-back, delete — re-observing between each, since next_step is designed to be re-derived rather than batched; (4) us-west-001 first and alone, since it is the only node that currently holds the mesh (R858 gotchas), then us-west-003. Confirm before starting that the box's acls.yaml is still the permissive default: if it is not, the fail-open window between flip and push is a real widening of the tailnet and needs a maintenance slot rather than an in-place flip.")
127//! @yah:cleanup("HeadscaleDeployRequest.acl_policy (yubaba/src/lib.rs and crates/yah/cloud-client/src/lib.rs) is now a field whose only remaining job is to be refused when non-default. Once every headscale-bearing box is migrated it can be deleted outright, along with carried_policy_is_permissive_default and the 400 branch. Left in place deliberately rather than removed now: while un-migrated boxes exist it is the only thing standing between a file-mode source coordinator and a silently allow-all destination. Also: DEFAULT_ACL_POLICY (cloud/src/mesh.rs) and DEFAULT_ACL_POLICY_HUJSON (yubaba/src/lib.rs) now have no non-test callers — both are pub so neither warns, and both stay useful as the documented \"what allow-all looks like\" reference, but they are dead weight the day the field goes.")
128//! @yah:gotcha("SEQUENCING SLIP WORTH RECORDING SO IT IS NOT REPEATED: I added `pub mod policy_migration;` to reconciler/headscale.rs in one edit and wrote reconciler/headscale/policy_migration.rs in the next, leaving a ~2-minute window (16:02:34 to 16:04:28, 2026-09-04) where yah-cloud would not compile with E0583 and every crate downstream of it stopped there. @Ashguard:polaris hit it on an unrelated ticket. On a shared working tree a `mod` line ahead of its file is a camp-wide outage, not a harmless intermediate state: create the file first, then declare the module, and sequence edits so the tree builds at every point you pause. I also initially dismissed the peer's report because the path they quoted (reconciler/policy_migration.rs) was the Rust-2015 sibling location rather than the real submodule path — the path was wrong but the outage was real, and re-running my own build and finding it green *now* was not evidence about a window two minutes earlier. Check mtimes against the reporting build's timestamp before disputing a build report.")
129//! @yah:assumes("That `policy.mode: database` needs no additional headscale config key to work — i.e. the `policies` table exists on an already-provisioned coordinator's headscale.db without a migration step. Grounded but not proven live: hscontrol/db/db.go runs `tx.AutoMigrate(&types.Policy{})` unconditionally as part of the normal migration set, so the table should be created on any headscale that has started at least once at v0.23.0, regardless of policy mode. Not confirmed against us-west-001's actual headscale.db, and it is one `sqlite3 headscale.db '.tables'` (read-only) away — worth doing during step (2) of the execution runbook before the first flip.")
130//! @yah:handoff("ASSUMPTION DISCHARGED FIRST, AS THE TICKET REQUIRED. headscale v0.23.0's file and database policy modes both funnel through the same LoadACLPolicyFromBytes, established by reading the pinned version's source rather than probing a live box or trusting documentation. R861-T1's get_policy/set_policy therefore work unchanged under either mode, and everything downstream rests on solid ground.")
131//! @yah:handoff("WHAT LANDED: new module cloud::reconciler::headscale::policy_migration (740 lines) — an idempotent state machine, config rewriter, dry-run-typed push, and a `rehearse` driver over a simulated coordinator. All three policy emit sites flipped to `database` and their `path:` lines removed: cloud/src/mesh.rs:598 (const POLICY_MODE at mesh.rs:552), yubaba/src/lib.rs:4811 and yubaba/src/lib.rs:5222 (const HEADSCALE_POLICY_MODE at lib.rs:568). THE ESCAPED-SPACE SITE WAS CAUGHT — that was the one an earlier pass undercounted, and independent verification confirms a repo-wide sweep for `\\x20\\x20mode` now finds only those two plus one comment.")
132//! @yah:handoff("THE TWO BROKEN ASSERTIONS AND BOTH STALE PROSE SITES ARE FIXED, in-ticket rather than deferred. cloud/src/mesh.rs:801-802 and yubaba/src/lib.rs:7990-7991 now assert `mode: {POLICY_MODE}` AND `!contains(\"acls.yaml\")`, with lib.rs:7987-7989 naming the `\\x20\\x20` spelling explicitly for the next reader — the trap is now documented at the site instead of only on the board. The headscale.rs module header (:39-62) and mesh.rs:363-379 were rewritten so the old \"runs policy.mode: file\" claims read as past-tense 2026-09-04 measurements, and note that pre-existing boxes stay on file mode until migrated. Also made yubaba's deploy path REFUSE a carried non-permissive policy rather than silently dropping it.")
133//! @yah:verify("INDEPENDENTLY RE-VERIFIED by a second agent reading the source and re-running the builds, not by re-reading the courier's claim. cargo test --manifest-path oss/yubaba/Cargo.toml -p yah-cloud --lib = 1078 passed / 0 failed / 4 ignored (baseline 1060, +18). Same manifest -p yubaba --lib = 639 passed / 0 failed (baseline 637, +2). cargo check -p yah --lib = exit 0, zero errors, 19 warnings — the E0425 in keys_doctor.rs that was red earlier in this relay has since been fixed by @Ashguard:polaris. INVOCATION TRAP for anyone re-running: `cargo test -p yah-cloud --lib` FROM THE REPO ROOT fails with \"requires dev-dependencies and is not a member of the workspace\" — yah-cloud lives in the oss/yubaba workspace, so the --manifest-path form is the only one that yields these numbers.")
134//! @yah:verify("NO LIVE-BOX WRITE EXISTS, verified three independent ways rather than asserted. (1) apply_push has NO production caller — rg over oss/ app/ crates/ finds Execution::Live only at its own definition (policy_migration.rs:222), its match arm (:243) and one test (:735). (2) Execution::{DryRun,Live} is a TYPE not a bool, so a dry run cannot arm a write by argument order. (3) Step::RemoveAclsFile never calls fs::remove_file — on_box_commands (:144-156) returns `sudo rm ...` as a Vec<String> for an operator rail. The live migration has not been run; nothing reached us-west-001, us-west-003 or any other box.")
135//! @yah:handoff("EXECUTION IS FILED AS R861-T3 (blocked_on operator), NOT PARKED HERE. This ticket delivered the migration in code, rehearsed; running it against the live fleet is the remaining work and carries the order and fail-open caveats as gotchas there. Peer-file safety confirmed clean: oss/yah-base/crates/keys/src/spec.rs and oss/yubaba/crates/cloud/src/lib.rs are both UNTOUCHED by this ticket (git status empty for both) — lib.rs needed no export edit because policy_migration is declared as a submodule at headscale.rs:699. Nothing was committed by this work; the `sync` commits in history (be2680f4 etc.) are the camp's automatic wip-sweep, not this ticket's.")
136
137use std::collections::BTreeSet;
138use std::path::Path;
139
140use anyhow::{Context, Result};
141use async_trait::async_trait;
142use tracing::info;
143
144use super::{ReconcileCtx, Reconciler, RunningWorkload};
145use crate::mesh::{HeadscaleClient, PreauthKeyRequest};
146
147/// Workload kind this reconciler handles. Matches `ServiceComponent.kind`
148/// and the `kind = "..."` line in `workload.toml`.
149pub const WORKLOAD_KIND: &str = "headscale";
150
151/// Keystore slot (and env fallback) holding the coordinator's admin API key.
152/// Already the canonical slot — `crate::mesh`, `app/yah/cli/src/mesh.rs` and
153/// `crates/yah/cloud-client` all read it.
154pub const API_KEY_SLOT: &str = "headscale-api-key";
155/// Env fallback paired with [`API_KEY_SLOT`].
156pub const API_KEY_ENV: &str = "HEADSCALE_API_KEY";
157/// Keystore slot (and env fallback) holding the coordinator base URL.
158pub const MESH_URL_SLOT: &str = "mesh-url";
159/// Env fallback paired with [`MESH_URL_SLOT`].
160pub const MESH_URL_ENV: &str = "HEADSCALE_URL";
161
162/// Reconciles `kind = "headscale"` components.
163pub struct HeadscaleReconciler;
164
165impl HeadscaleReconciler {
166 pub fn new() -> Self {
167 Self
168 }
169}
170
171impl Default for HeadscaleReconciler {
172 fn default() -> Self {
173 Self::new()
174 }
175}
176
177#[async_trait]
178impl Reconciler for HeadscaleReconciler {
179 fn kind(&self) -> &'static str {
180 WORKLOAD_KIND
181 }
182
183 async fn up(&self, ctx: ReconcileCtx<'_>) -> Result<RunningWorkload> {
184 // (1) Materialize a git-sourced component before reading its dir.
185 ctx.materialize().await?;
186
187 // (2) Workload kind on disk must agree with the component's kind.
188 let kind = ctx.workload_kind().context("loading workload.toml")?;
189 if kind != WORKLOAD_KIND {
190 anyhow::bail!(
191 "component {component_id} kind=\"{WORKLOAD_KIND}\" but {workload_dir}/workload.toml declares kind=\"{kind}\"",
192 component_id = ctx.component.id,
193 workload_dir = ctx.workload_dir().display(),
194 );
195 }
196
197 // (3) Parse + FULLY validate the declared config. Nothing below this
198 // line may run before every field has been checked (R330-B5).
199 let workload_dir = ctx.workload_dir();
200 let declared = DeclaredHeadscale::load(&workload_dir)?;
201
202 // (4) Resolve the coordinator URL + admin key. Still no API call.
203 let base_url = match &declared.server_url {
204 Some(u) => u.clone(),
205 None => fob::get_or_env(MESH_URL_SLOT, MESH_URL_ENV)
206 .context("reading the mesh-url keystore slot")?
207 .with_context(|| {
208 format!(
209 "no coordinator URL: {workload}/workload.toml sets no `[headscale].server_url` \
210 and neither the `{MESH_URL_SLOT}` keystore slot nor ${MESH_URL_ENV} is set",
211 workload = workload_dir.display(),
212 )
213 })?,
214 };
215 let api_key = fob::get_or_env(API_KEY_SLOT, API_KEY_ENV)
216 .context("reading the headscale-api-key keystore slot")?
217 .with_context(|| {
218 format!(
219 "no headscale admin credential: set the `{API_KEY_SLOT}` keystore slot \
220 (`yah keys set {API_KEY_SLOT} <key>`) or ${API_KEY_ENV}. Mint one on the \
221 coordinator with `headscale apikeys create`."
222 )
223 })?;
224 let client = HeadscaleClient::new(&base_url, api_key)?;
225
226 let mut notes = Vec::new();
227
228 // (5) Users — list first, create only what is missing. Never delete:
229 // a headscale user owns the nodes registered under it, so a removal
230 // here would evict machines nobody asked to evict.
231 let live_users = client
232 .list_users()
233 .await
234 .with_context(|| format!("listing headscale users on {base_url}"))?;
235 for user in &declared.users {
236 if live_users.iter().any(|u| u == user) {
237 continue;
238 }
239 client
240 .create_user(user)
241 .await
242 .with_context(|| format!("creating headscale user {user}"))?;
243 info!(user, coordinator = %base_url, "headscale user created");
244 notes.push(format!("created user {user}"));
245 }
246
247 // (6) Pre-auth keys — list per user, mint only when no live key already
248 // satisfies the declared spec.
249 let now = chrono::Utc::now();
250 for spec in &declared.preauth_keys {
251 let existing = client
252 .list_preauth_keys(&spec.user)
253 .await
254 .with_context(|| format!("listing preauth keys for user {}", spec.user))?;
255 let satisfied = existing
256 .iter()
257 .any(|k| k.is_usable_at(now) && spec.matches_record(k));
258 if satisfied {
259 continue;
260 }
261 let minted = client
262 .create_preauth_key_with(&PreauthKeyRequest {
263 user: spec.user.clone(),
264 tags: spec.tags.clone(),
265 reusable: spec.reusable,
266 ephemeral: spec.ephemeral,
267 ttl_hours: spec.ttl_hours,
268 })
269 .await
270 .with_context(|| format!("minting preauth key for user {}", spec.user))?;
271 // The key value never reaches a log line or a note — only the fact
272 // that one was minted and where it went.
273 info!(
274 user = %spec.user,
275 tags = %spec.tags.join(","),
276 "headscale preauth key minted"
277 );
278 match &spec.store_as {
279 Some(slot) => {
280 fob::KeysStore::open()
281 .context("opening the keystore to store the minted preauth key")?
282 .set(slot, &minted.key)
283 .with_context(|| format!("writing the minted preauth key to slot {slot}"))?;
284 notes.push(format!("minted preauth key for {} -> {slot}", spec.user));
285 }
286 None => notes.push(format!(
287 "minted preauth key for {} (no `store_as`, value discarded)",
288 spec.user
289 )),
290 }
291 }
292
293 // (7) ACL policy — compare before writing. See the module docs for why
294 // a file-mode coordinator can be read but not written.
295 if let Some(policy) = &declared.acl_policy {
296 let live = client
297 .get_policy()
298 .await
299 .with_context(|| format!("reading the ACL policy from {base_url}"))?;
300 if policies_equivalent(&live, &policy.hujson)? {
301 notes.push("ACL policy in sync".to_string());
302 } else {
303 client.set_policy(&policy.hujson).await.with_context(|| {
304 format!(
305 "the declared ACL policy ({declared_path}) differs from the one \
306 {base_url} has loaded, and pushing it was refused. headscale accepts a \
307 policy write only under `policy.mode: database`, which every in-tree \
308 renderer now emits (R861-T2) — so a refusal here means this coordinator \
309 has not been migrated yet and is still reading `policy.path` off disk. \
310 Run the file-to-database migration on it \
311 ({migration}) and retry; until then the declared policy has to reach \
312 the node as the file `policy.path` names",
313 declared_path = policy.path.display(),
314 migration = policy_migration::MODULE_PATH,
315 )
316 })?;
317 info!(coordinator = %base_url, "headscale ACL policy updated");
318 notes.push("ACL policy updated".to_string());
319 }
320 }
321
322 Ok(
323 RunningWorkload::adopted(WORKLOAD_KIND, ctx.component.role.clone(), None)
324 .with_notes(notes),
325 )
326 }
327}
328
329// ─── declared config ─────────────────────────────────────────────────────────
330
331/// The `[headscale]` table of a `kind = "headscale"` workload, validated.
332#[derive(Debug, Clone, PartialEq, Eq)]
333pub struct DeclaredHeadscale {
334 pub server_url: Option<String>,
335 pub users: Vec<String>,
336 pub preauth_keys: Vec<DeclaredPreauthKey>,
337 pub acl_policy: Option<DeclaredPolicy>,
338}
339
340/// One `[[headscale.preauth_keys]]` entry.
341#[derive(Debug, Clone, PartialEq, Eq)]
342pub struct DeclaredPreauthKey {
343 pub user: String,
344 pub tags: Vec<String>,
345 pub reusable: bool,
346 pub ephemeral: bool,
347 pub ttl_hours: i64,
348 pub store_as: Option<String>,
349}
350
351impl DeclaredPreauthKey {
352 /// Whether a live key already satisfies this declaration.
353 ///
354 /// TTL is deliberately NOT compared: a key minted a week ago against a
355 /// 168-hour declaration has a shorter remaining life than a fresh one and
356 /// is still exactly the key that was asked for. Comparing it would mint a
357 /// new key on every single run.
358 fn matches_record(&self, record: &crate::mesh::PreauthKeyRecord) -> bool {
359 record.user == self.user
360 && record.reusable == self.reusable
361 && record.ephemeral == self.ephemeral
362 && record.acl_tags.iter().collect::<BTreeSet<_>>()
363 == self.tags.iter().collect::<BTreeSet<_>>()
364 }
365}
366
367/// The declared ACL policy: where it was read from, and its contents.
368#[derive(Debug, Clone, PartialEq, Eq)]
369pub struct DeclaredPolicy {
370 pub path: std::path::PathBuf,
371 pub hujson: String,
372}
373
374impl DeclaredHeadscale {
375 /// Read and validate `<workload_dir>/workload.toml`'s `[headscale]` table.
376 ///
377 /// Every check that can be made without the network is made here, so a
378 /// misdeclared component fails before it has half-created anything.
379 pub fn load(workload_dir: &Path) -> Result<Self> {
380 let path = workload_dir.join("workload.toml");
381 let src = std::fs::read_to_string(&path)
382 .with_context(|| format!("reading {}", path.display()))?;
383 let value: toml::Value =
384 toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))?;
385 Self::from_toml(&value, workload_dir)
386 .with_context(|| format!("validating {}", path.display()))
387 }
388
389 fn from_toml(value: &toml::Value, workload_dir: &Path) -> Result<Self> {
390 let table = value
391 .get("headscale")
392 .context("missing `[headscale]` table — a kind=\"headscale\" workload declares the users, preauth keys and ACL policy the coordinator must have")?;
393
394 let server_url = match table.get("server_url") {
395 None => None,
396 Some(v) => {
397 let s = v
398 .as_str()
399 .context("[headscale].server_url must be a string")?
400 .trim();
401 if !(s.starts_with("http://") || s.starts_with("https://")) {
402 anyhow::bail!(
403 "[headscale].server_url must be an http(s) URL, got {s:?}"
404 );
405 }
406 Some(s.trim_end_matches('/').to_string())
407 }
408 };
409
410 let mut users: Vec<String> = Vec::new();
411 for entry in table
412 .get("users")
413 .map(|v| {
414 v.as_array()
415 .context("[headscale].users must be an array of strings")
416 })
417 .transpose()?
418 .cloned()
419 .unwrap_or_default()
420 {
421 let name = entry
422 .as_str()
423 .context("[headscale].users entries must be strings")?
424 .trim()
425 .to_string();
426 if name.is_empty() {
427 anyhow::bail!("[headscale].users contains an empty name");
428 }
429 if name.chars().any(char::is_whitespace) {
430 anyhow::bail!("[headscale].users entry {name:?} contains whitespace");
431 }
432 if users.contains(&name) {
433 anyhow::bail!("[headscale].users lists {name:?} twice");
434 }
435 users.push(name);
436 }
437
438 let mut preauth_keys = Vec::new();
439 for entry in table
440 .get("preauth_keys")
441 .map(|v| {
442 v.as_array()
443 .context("[[headscale.preauth_keys]] must be an array of tables")
444 })
445 .transpose()?
446 .cloned()
447 .unwrap_or_default()
448 {
449 preauth_keys.push(parse_preauth_key(&entry, &users)?);
450 }
451
452 let acl_policy = match table.get("acl_policy") {
453 None => None,
454 Some(v) => {
455 let rel = v
456 .as_str()
457 .context("[headscale].acl_policy must be a path string")?;
458 let abs = workload_dir.join(rel);
459 let hujson = std::fs::read_to_string(&abs).with_context(|| {
460 format!(
461 "reading the declared ACL policy {} — [headscale].acl_policy is relative to the workload dir",
462 abs.display()
463 )
464 })?;
465 // Parse it here, not at push time: an unparseable policy must
466 // fail before any user or key is created.
467 let parsed = parse_hujson(&hujson).with_context(|| {
468 format!("parsing the declared ACL policy {}", abs.display())
469 })?;
470 if !parsed.get("acls").map(|a| a.is_array()).unwrap_or(false) {
471 anyhow::bail!(
472 "{}: a headscale policy needs a top-level `acls` array",
473 abs.display()
474 );
475 }
476 Some(DeclaredPolicy { path: abs, hujson })
477 }
478 };
479
480 if users.is_empty() && preauth_keys.is_empty() && acl_policy.is_none() {
481 anyhow::bail!(
482 "[headscale] declares nothing — set at least one of `users`, \
483 `[[headscale.preauth_keys]]` or `acl_policy`"
484 );
485 }
486
487 Ok(Self {
488 server_url,
489 users,
490 preauth_keys,
491 acl_policy,
492 })
493 }
494}
495
496fn parse_preauth_key(entry: &toml::Value, declared_users: &[String]) -> Result<DeclaredPreauthKey> {
497 let user = entry
498 .get("user")
499 .and_then(|v| v.as_str())
500 .context("[[headscale.preauth_keys]] entry needs a `user` string")?
501 .trim()
502 .to_string();
503 // The typo guard: a key minted against a user headscale does not have
504 // answers 500 (R608-B19), and by then users may already have been created.
505 if !declared_users.contains(&user) {
506 anyhow::bail!(
507 "[[headscale.preauth_keys]] names user {user:?}, which [headscale].users does not \
508 declare (declared: {declared}). Add it there — the reconciler only creates users it \
509 was told about.",
510 declared = if declared_users.is_empty() {
511 "none".to_string()
512 } else {
513 declared_users.join(", ")
514 }
515 );
516 }
517
518 let mut tags = Vec::new();
519 for tag in entry
520 .get("tags")
521 .map(|v| {
522 v.as_array()
523 .context("[[headscale.preauth_keys]].tags must be an array of strings")
524 })
525 .transpose()?
526 .cloned()
527 .unwrap_or_default()
528 {
529 let tag = tag
530 .as_str()
531 .context("[[headscale.preauth_keys]].tags entries must be strings")?
532 .trim()
533 .to_string();
534 if !tag.starts_with("tag:") {
535 anyhow::bail!("ACL tag {tag:?} must start with `tag:` — headscale rejects the rest");
536 }
537 // headscale answers "tag should be lowercase" for a mixed-case tag.
538 if tag != tag.to_lowercase() {
539 anyhow::bail!("ACL tag {tag:?} must be lowercase — headscale rejects mixed case");
540 }
541 if tags.contains(&tag) {
542 anyhow::bail!("[[headscale.preauth_keys]] for {user} lists tag {tag:?} twice");
543 }
544 tags.push(tag);
545 }
546
547 let ttl_hours = match entry.get("ttl_hours") {
548 None => 1,
549 Some(v) => v
550 .as_integer()
551 .context("[[headscale.preauth_keys]].ttl_hours must be an integer")?,
552 };
553 if ttl_hours <= 0 {
554 anyhow::bail!(
555 "[[headscale.preauth_keys]] for {user} has ttl_hours={ttl_hours}; it must be positive"
556 );
557 }
558
559 let store_as = match entry.get("store_as") {
560 None => None,
561 Some(v) => {
562 let slot = v
563 .as_str()
564 .context("[[headscale.preauth_keys]].store_as must be a keystore slot name")?
565 .trim()
566 .to_string();
567 if slot.is_empty() {
568 anyhow::bail!("[[headscale.preauth_keys]].store_as is empty");
569 }
570 Some(slot)
571 }
572 };
573
574 Ok(DeclaredPreauthKey {
575 user,
576 tags,
577 reusable: entry
578 .get("reusable")
579 .and_then(|v| v.as_bool())
580 .unwrap_or(false),
581 ephemeral: entry
582 .get("ephemeral")
583 .and_then(|v| v.as_bool())
584 .unwrap_or(false),
585 ttl_hours,
586 store_as,
587 })
588}
589
590// ─── HuJSON ──────────────────────────────────────────────────────────────────
591
592/// Compare two headscale policies for semantic equality.
593///
594/// A byte compare is the wrong test: headscale round-trips the policy through
595/// its own storage, and the declared file carries comments and formatting that
596/// say nothing about the tailnet. Both sides are normalized from HuJSON to
597/// JSON and compared as values, so reformatting the declared file does not
598/// trigger a spurious push and a genuinely different rule always does.
599fn policies_equivalent(live: &str, declared: &str) -> Result<bool> {
600 let live = parse_hujson(live).context("parsing the coordinator's current ACL policy")?;
601 let declared = parse_hujson(declared).context("parsing the declared ACL policy")?;
602 Ok(live == declared)
603}
604
605/// Parse HuJSON (`policy.path` is documented as "a policy file in HuJSON
606/// format") into a JSON value.
607///
608/// HuJSON is JSON plus `//` and `/* */` comments and trailing commas. There is
609/// no HuJSON crate in this workspace, so the two extensions are stripped here
610/// and the remainder handed to `serde_json`. String literals are tracked so a
611/// `//` or a comma inside a value is left alone.
612fn parse_hujson(src: &str) -> Result<serde_json::Value> {
613 let stripped = strip_hujson_extensions(src);
614 serde_json::from_str(&stripped).context("not valid HuJSON (JSON with comments/trailing commas)")
615}
616
617fn strip_hujson_extensions(src: &str) -> String {
618 let mut out = String::with_capacity(src.len());
619 let mut chars = src.chars().peekable();
620 let mut in_string = false;
621 let mut escaped = false;
622
623 while let Some(c) = chars.next() {
624 if in_string {
625 out.push(c);
626 if escaped {
627 escaped = false;
628 } else if c == '\\' {
629 escaped = true;
630 } else if c == '"' {
631 in_string = false;
632 }
633 continue;
634 }
635 match c {
636 '"' => {
637 in_string = true;
638 out.push(c);
639 }
640 '/' if chars.peek() == Some(&'/') => {
641 for c in chars.by_ref() {
642 if c == '\n' {
643 out.push('\n');
644 break;
645 }
646 }
647 }
648 '/' if chars.peek() == Some(&'*') => {
649 chars.next();
650 let mut prev_star = false;
651 for c in chars.by_ref() {
652 if prev_star && c == '/' {
653 break;
654 }
655 prev_star = c == '*';
656 }
657 // Keep a separator so `1/* */2` cannot fuse into `12`.
658 out.push(' ');
659 }
660 _ => out.push(c),
661 }
662 }
663
664 strip_trailing_commas(&out)
665}
666
667/// Drop commas that are followed only by whitespace/comments and a `}` or `]`.
668/// Comments are already gone by the time this runs.
669fn strip_trailing_commas(src: &str) -> String {
670 let bytes: Vec<char> = src.chars().collect();
671 let mut out = String::with_capacity(src.len());
672 let mut in_string = false;
673 let mut escaped = false;
674
675 for (i, &c) in bytes.iter().enumerate() {
676 if in_string {
677 out.push(c);
678 if escaped {
679 escaped = false;
680 } else if c == '\\' {
681 escaped = true;
682 } else if c == '"' {
683 in_string = false;
684 }
685 continue;
686 }
687 if c == '"' {
688 in_string = true;
689 out.push(c);
690 continue;
691 }
692 if c == ',' {
693 let next = bytes[i + 1..].iter().find(|c| !c.is_whitespace());
694 if matches!(next, Some('}') | Some(']')) {
695 continue;
696 }
697 }
698 out.push(c);
699 }
700
701 out
702}
703
704// ─── file → database policy migration (R861-T2) ──────────────────────────────
705
706pub mod policy_migration;
707
708#[cfg(test)]
709mod tests {
710 use super::*;
711
712 fn workload_toml(body: &str) -> toml::Value {
713 toml::from_str(body).expect("test fixture parses as TOML")
714 }
715
716 #[test]
717 fn declared_config_round_trips() {
718 let v = workload_toml(
719 r#"
720schema_version = 1
721kind = "headscale"
722
723[headscale]
724server_url = "https://cloud.mesh.yah.dev/"
725users = ["yah"]
726
727[[headscale.preauth_keys]]
728user = "yah"
729tags = ["tag:cloud-runner"]
730reusable = true
731ttl_hours = 168
732store_as = "headscale-preauth-key"
733"#,
734 );
735 let declared = DeclaredHeadscale::from_toml(&v, Path::new("/nonexistent")).unwrap();
736 assert_eq!(
737 declared.server_url.as_deref(),
738 Some("https://cloud.mesh.yah.dev")
739 );
740 assert_eq!(declared.users, vec!["yah".to_string()]);
741 assert_eq!(declared.preauth_keys.len(), 1);
742 let key = &declared.preauth_keys[0];
743 assert!(key.reusable);
744 assert!(!key.ephemeral);
745 assert_eq!(key.ttl_hours, 168);
746 assert_eq!(key.store_as.as_deref(), Some("headscale-preauth-key"));
747 }
748
749 #[test]
750 fn preauth_key_against_undeclared_user_is_rejected() {
751 let v = workload_toml(
752 r#"
753[headscale]
754users = ["yah"]
755
756[[headscale.preauth_keys]]
757user = "defualt"
758"#,
759 );
760 let err = DeclaredHeadscale::from_toml(&v, Path::new("/nonexistent")).unwrap_err();
761 let msg = format!("{err:#}");
762 assert!(msg.contains("defualt"), "{msg}");
763 assert!(msg.contains("yah"), "{msg}");
764 }
765
766 #[test]
767 fn tags_must_be_prefixed_and_lowercase() {
768 for (tags, needle) in [
769 (r#"["cloud-runner"]"#, "tag:"),
770 (r#"["tag:Cloud-Runner"]"#, "lowercase"),
771 ] {
772 let v = workload_toml(&format!(
773 "[headscale]\nusers = [\"yah\"]\n\n[[headscale.preauth_keys]]\nuser = \"yah\"\ntags = {tags}\n"
774 ));
775 let err = DeclaredHeadscale::from_toml(&v, Path::new("/nonexistent")).unwrap_err();
776 assert!(format!("{err:#}").contains(needle), "{err:#} for {tags}");
777 }
778 }
779
780 #[test]
781 fn non_positive_ttl_is_rejected() {
782 let v = workload_toml(
783 r#"
784[headscale]
785users = ["yah"]
786
787[[headscale.preauth_keys]]
788user = "yah"
789ttl_hours = 0
790"#,
791 );
792 let err = DeclaredHeadscale::from_toml(&v, Path::new("/nonexistent")).unwrap_err();
793 assert!(format!("{err:#}").contains("positive"), "{err:#}");
794 }
795
796 #[test]
797 fn empty_declaration_is_rejected() {
798 let v = workload_toml("[headscale]\n");
799 let err = DeclaredHeadscale::from_toml(&v, Path::new("/nonexistent")).unwrap_err();
800 assert!(format!("{err:#}").contains("declares nothing"), "{err:#}");
801 }
802
803 #[test]
804 fn duplicate_user_is_rejected() {
805 let v = workload_toml("[headscale]\nusers = [\"yah\", \"yah\"]\n");
806 let err = DeclaredHeadscale::from_toml(&v, Path::new("/nonexistent")).unwrap_err();
807 assert!(format!("{err:#}").contains("twice"), "{err:#}");
808 }
809
810 #[test]
811 fn server_url_must_be_http() {
812 let v = workload_toml("[headscale]\nusers = [\"yah\"]\nserver_url = \"cloud.mesh.yah.dev\"\n");
813 let err = DeclaredHeadscale::from_toml(&v, Path::new("/nonexistent")).unwrap_err();
814 assert!(format!("{err:#}").contains("http(s) URL"), "{err:#}");
815 }
816
817 /// The whole on-disk path: a workload dir with a `workload.toml` and the
818 /// HuJSON policy file it points at, read through [`DeclaredHeadscale::load`].
819 #[test]
820 fn loads_a_workload_dir_off_disk() {
821 let dir = tempfile::tempdir().unwrap();
822 std::fs::write(
823 dir.path().join("workload.toml"),
824 r#"schema_version = 1
825kind = "headscale"
826
827[headscale]
828users = ["yah"]
829acl_policy = "acls.hujson"
830
831[[headscale.preauth_keys]]
832user = "yah"
833tags = ["tag:cloud-runner", "tag:voter-candidate"]
834reusable = true
835ttl_hours = 168
836store_as = "headscale-preauth-key"
837"#,
838 )
839 .unwrap();
840 std::fs::write(
841 dir.path().join("acls.hujson"),
842 "// permissive default\n{ \"acls\": [ { \"action\": \"accept\", \"src\": [\"*\"], \"dst\": [\"*:*\"] } ] }\n",
843 )
844 .unwrap();
845
846 let declared = DeclaredHeadscale::load(dir.path()).unwrap();
847 assert_eq!(declared.users, vec!["yah".to_string()]);
848 assert_eq!(declared.preauth_keys[0].tags.len(), 2);
849 let policy = declared.acl_policy.as_ref().unwrap();
850 assert_eq!(policy.path, dir.path().join("acls.hujson"));
851 // What the live coordinator already serves — so this fixture is in
852 // sync and a reconcile against it would push nothing.
853 assert!(policies_equivalent(LIVE_POLICY, &policy.hujson).unwrap());
854 }
855
856 #[test]
857 fn a_missing_policy_file_names_the_path_it_looked_for() {
858 let dir = tempfile::tempdir().unwrap();
859 std::fs::write(
860 dir.path().join("workload.toml"),
861 "[headscale]\nusers = [\"yah\"]\nacl_policy = \"acls.hujson\"\n",
862 )
863 .unwrap();
864 let err = DeclaredHeadscale::load(dir.path()).unwrap_err();
865 assert!(format!("{err:#}").contains("acls.hujson"), "{err:#}");
866 }
867
868 #[test]
869 fn a_policy_without_an_acls_array_is_rejected() {
870 let dir = tempfile::tempdir().unwrap();
871 std::fs::write(
872 dir.path().join("workload.toml"),
873 "[headscale]\nusers = [\"yah\"]\nacl_policy = \"acls.hujson\"\n",
874 )
875 .unwrap();
876 std::fs::write(dir.path().join("acls.hujson"), "{ \"tagOwners\": {} }").unwrap();
877 let err = DeclaredHeadscale::load(dir.path()).unwrap_err();
878 assert!(format!("{err:#}").contains("`acls` array"), "{err:#}");
879 }
880
881 /// The exact bytes on us-west-001, read 2026-09-04.
882 const LIVE_POLICY: &str = "{\n \"acls\": [\n { \"action\": \"accept\", \"src\": [\"*\"], \"dst\": [\"*:*\"] }\n ]\n}\n";
883
884 #[test]
885 fn reformatting_the_policy_is_not_drift() {
886 let declared = r#"
887// The permissive default: every node may reach every node.
888{
889 "acls": [
890 {
891 "action": "accept",
892 "src": ["*"],
893 "dst": ["*:*"],
894 },
895 ],
896}
897"#;
898 assert!(policies_equivalent(LIVE_POLICY, declared).unwrap());
899 }
900
901 #[test]
902 fn a_different_rule_is_drift() {
903 let declared = r#"{ "acls": [ { "action": "accept", "src": ["tag:cloud-runner"], "dst": ["*:*"] } ] }"#;
904 assert!(!policies_equivalent(LIVE_POLICY, declared).unwrap());
905 }
906
907 #[test]
908 fn comment_markers_inside_strings_survive() {
909 let src = r#"{ "acls": [ { "action": "accept", "src": ["*"], "dst": ["https://x/*:443"] } ] }"#;
910 let parsed = parse_hujson(src).unwrap();
911 assert_eq!(parsed["acls"][0]["dst"][0], "https://x/*:443");
912 }
913
914 #[test]
915 fn block_comments_are_stripped() {
916 let parsed = parse_hujson("{ /* header */ \"acls\": [] }").unwrap();
917 assert!(parsed["acls"].as_array().unwrap().is_empty());
918 }
919
920 #[test]
921 fn unparseable_policy_is_an_error() {
922 assert!(parse_hujson("{ \"acls\": [ ").is_err());
923 }
924
925 #[test]
926 fn preauth_spec_matches_ignore_ttl_but_not_tags() {
927 let spec = DeclaredPreauthKey {
928 user: "yah".into(),
929 tags: vec!["tag:cloud-runner".into()],
930 reusable: true,
931 ephemeral: false,
932 ttl_hours: 168,
933 store_as: None,
934 };
935 let mut record = crate::mesh::PreauthKeyRecord {
936 id: "1".into(),
937 user: "yah".into(),
938 key: "secret".into(),
939 reusable: true,
940 ephemeral: false,
941 used: false,
942 expiration: Some(chrono::Utc::now() + chrono::TimeDelta::try_hours(1).unwrap()),
943 acl_tags: vec!["tag:cloud-runner".into()],
944 };
945 assert!(spec.matches_record(&record));
946 assert!(record.is_usable_at(chrono::Utc::now()));
947
948 record.acl_tags = vec!["tag:voter".into()];
949 assert!(!spec.matches_record(&record));
950 }
951
952 #[test]
953 fn an_expired_or_spent_key_does_not_satisfy_a_spec() {
954 let now = chrono::Utc::now();
955 let expired = crate::mesh::PreauthKeyRecord {
956 id: "1".into(),
957 user: "yah".into(),
958 key: "secret".into(),
959 reusable: false,
960 ephemeral: false,
961 used: false,
962 expiration: Some(now - chrono::TimeDelta::try_hours(1).unwrap()),
963 acl_tags: vec![],
964 };
965 assert!(!expired.is_usable_at(now));
966
967 let spent = crate::mesh::PreauthKeyRecord {
968 used: true,
969 expiration: Some(now + chrono::TimeDelta::try_hours(1).unwrap()),
970 ..expired.clone()
971 };
972 assert!(!spent.is_usable_at(now));
973
974 // A reusable key stays usable after being used.
975 let reusable = crate::mesh::PreauthKeyRecord {
976 reusable: true,
977 ..spent.clone()
978 };
979 assert!(reusable.is_usable_at(now));
980
981 // A key with no expiry never ages out.
982 let forever = crate::mesh::PreauthKeyRecord {
983 expiration: None,
984 used: false,
985 reusable: false,
986 ..expired
987 };
988 assert!(forever.is_usable_at(now));
989 }
990}