Skip to main content

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}