cloud/reconciler/headscale/policy_migration.rs
1//! Migrate a headscale coordinator from `policy.mode: file` to
2//! `policy.mode: database` (R861-T2).
3//!
4//! ## Why
5//!
6//! Under `file` mode the ACL policy is `acls.yaml` on the coordinator's local
7//! disk, and litestream replicates only `headscale.db` — so policy is failover
8//! state nobody carries. Under `database` mode the policy is a row in
9//! `headscale.db`, which litestream already replicates, and the file stops
10//! mattering at all. That is the whole point of the flip.
11//!
12//! ## What was verified before any of this was written
13//!
14//! R861-T2's load-bearing assumption was that the pinned headscale **v0.23.0**
15//! (`crate::mesh::HEADSCALE_VERSION`) accepts, in `database` mode, the same
16//! HuJSON document `file` mode reads off disk. **It holds**, read from that
17//! version's own source rather than recalled:
18//!
19//! - `hscontrol/app.go` `loadACLPolicy` — file mode does
20//! `policy.LoadACLPolicyFromPath`, which is `io.ReadAll` followed by
21//! `LoadACLPolicyFromBytes`; database mode does `LoadACLPolicyFromBytes` on
22//! the stored row. Same function, same document, no second format.
23//! - `hscontrol/grpcv1.go` `SetPolicy` — parses the request body with that
24//! same `LoadACLPolicyFromBytes` before storing it.
25//! - `hscontrol/grpcv1.go` `GetPolicy` — database mode returns the stored
26//! row's `Data` verbatim; file mode returns the file's bytes as-is.
27//! - `hscontrol/types/config.go` — `policy.mode` is exactly `"file"` or
28//! `"database"`, defaulting to `"file"` when the key is absent, and
29//! `policy.path` is read only in file mode.
30//!
31//! So [`crate::mesh::HeadscaleClient::get_policy`] / `set_policy` work
32//! unchanged under either mode.
33//!
34//! ## Three findings that reshape the migration
35//!
36//! **1. The ticket's stated order is impossible, and this module inverts it.**
37//! R861-T2 specified "push the policy through `set_policy`, verify, *then*
38//! flip the mode". `SetPolicy` returns `ErrPolicyUpdateIsDisabled` *before
39//! looking at the payload* unless `cfg.Policy.Mode == database`
40//! (`hscontrol/grpcv1.go`). A file-mode coordinator cannot be pushed to at all,
41//! so the config flip has to come FIRST. See [`next_step`].
42//!
43//! **2. The window the inverted order opens is safe here, and the reason is
44//! specific rather than general.** Between the flip and the push, a
45//! database-mode coordinator with no policy row is not an error: `loadACLPolicy`
46//! maps `ErrPolicyNotFound` to a nil `*ACLPolicy`, and
47//! `(*ACLPolicy).CompileFilterRules` on nil returns `tailcfg.FilterAllowAll`
48//! (`hscontrol/policy/acls.go`). us-west-001's live `acls.yaml` is the
49//! permissive default `{"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}`
50//! (measured 2026-09-04), so for THIS fleet the window is semantically a no-op.
51//! On a coordinator with a restrictive policy the same window is a silent
52//! widening to allow-all, which is why [`next_step`] still drives the push
53//! immediately and why [`MigrationPlan::widens_before_push`] reports it.
54//!
55//! **3. `acls.yaml` is the journal, which is why it is deleted last.** Nothing
56//! here writes a state file: until the coordinator serves an equivalent policy
57//! from its database, `acls.yaml` is still on disk and the whole migration is
58//! re-derivable from it. Re-running after any interruption re-observes and
59//! resumes. Delete it earlier and a half-completed run loses the policy.
60//!
61//! ## What this module does NOT do
62//!
63//! It does not touch a live coordinator. [`next_step`] is a pure function of an
64//! [`Observation`], and the steps that must run on the box come back as data
65//! ([`Step::on_box_commands`]) for whatever rail the operator uses. The one
66//! step that can be driven from here is the policy push, and
67//! [`apply_push`] takes an explicit [`Execution`] so a dry run cannot write by
68//! omission.
69//!
70//! ## The rail that does (R861-T3)
71//!
72//! `yah mesh migrate-policy <machine> [--execute]`
73//! (`app/yah/cli/src/mesh.rs::handle_migrate_policy`) is the one production
74//! caller. It observes over SSH + the headscale API, prints the next step and
75//! the rehearsed remainder, and performs exactly one step per invocation — but
76//! only under `--execute`, which is the sole place [`Execution::Live`] is
77//! constructed outside tests. It also refuses, before doing anything, when the
78//! carried policy is not [`is_permissive`]: the flip-then-push window is a
79//! widening to allow-all, and that is a no-op only while the policy being
80//! carried across it is itself allow-all.
81//!
82//! @yah:ticket(R861-T3, "Execute the headscale file-to-database policy migration against the live fleet")
83//! @yah:status(review)
84//! @yah:at(2026-09-05T01:54:53Z)
85//! @yah:assignee(agent:bundle-anthropic-ashguard)
86//! @yah:parent(R861)
87//! @yah:gotcha("ORDER IS LOAD-BEARING AND COUNTERINTUITIVE: config-flip to mode=database and restart FIRST, then push the policy. headscale v0.23.0's SetPolicy returns ErrPolicyUpdateIsDisabled BEFORE it reads the payload unless mode is already database, so the intuitive push-then-flip is silently impossible. The state machine in policy_migration.rs already encodes the correct order (next_step matches PolicyMode::File first and returns Step::FlipConfigToDatabaseMode; PushCarriedPolicy is only reachable after the Database arm). Do not \"fix\" it to push first.")
88//! @yah:gotcha("RE-CHECK THE FAIL-OPEN WINDOW BEFORE EXECUTING — the safety argument is fleet-state-dependent and may have expired. Between the mode flip and the policy push, headscale has no policy row, and a missing row compiles to FilterAllowAll. That is harmless ONLY because the live acls.yaml is currently the permissive default, making the gap state and the current effective state both allow-all. If real ACL rules have been declared since, the same window becomes a fail-OPEN interval — a security exposure, not an outage. Read the live acls.yaml and confirm it is still permissive as the first action of this ticket.")
89//! @yah:handoff("PHASE A — THE OPERATOR'S \"do I need to ship new builds first?\" QUESTION, ANSWERED FROM CODE. (1) NO BUILD/SHIP IS REQUIRED TO RUN THE MIGRATION. Both rails originate on this machine. The API half is HTTPS from a LOCAL yah: HeadscaleClient holds only base_url + api_key and PUT/GETs {base_url}/api/v1/policy with bearer auth (oss/yubaba/crates/cloud/src/mesh.rs:131-160, :380, :421), resolved from the vault's headscale-api-key + mesh-url via from_vault_or_env (:151). The on-box half is SSH (`ssh <target> bash -s`, the same transport app/yah/cli/src/rollout/apply.rs:155 uses for installs), target read from [connect].ssh in .yah/infra/machines/us-west-001.toml:71 = debian@15.204.89.240. Nothing on the appliance needs new bytes. (2) THE RENDERER FLIP DOES NEED A SHIPPED ARTIFACT, AND ONLY FOR NEW-COORDINATOR STAND-UP. Two of the three emit sites run ON THE BOX, inside the yubaba daemon: generate_remote_headscale_config is called at oss/yubaba/crates/yubaba/src/lib.rs:4705 inside the `headscale_deploy` handler (route registered lib.rs:1933) and generate_bootstrap_headscale_config at :4916 inside `headscale_bootstrap` (route :1934) — both `std::fs::write(dir.join(\"config.yaml\"))`. Appliance yubaba binaries come only from cdn.yah.dev's signed release manifest via scripts/roll-node.sh (its header: \"THE INPUT IS THE PUBLISHED MANIFEST, NEVER A LOCAL BUILD\"), so for `yah mesh promote` / `yah mesh bootstrap` to stand a coordinator up in database mode the sequence is scripts/publish-yubaba-release.sh then a roll. The third site, cloud::mesh::generate_headscale_config (mesh.rs:561), is called only from the LOCAL CLI (app/yah/cli/src/mesh.rs:389 in handle_start, :934 in handle_promote_abort), so `cargo xtask install` is all that one needs. (3) NO CLOBBER RISK ON THE EXISTING BOX, verified rather than assumed: leader.rs::start_headscale (oss/yubaba/crates/yubaba/src/leader.rs:358-400) materializes the noise key and deploys the appliance spec — it does NOT write config.yaml — so a raft leadership change or a kamaji redeploy will not revert a flipped config. Only an operator-driven POST /headscale/deploy or /headscale/bootstrap rewrites it. (4) VERSION/COMPAT IS CLEAN: `/var/lib/yah-cloud/headscale/headscale version` on us-west-001 returns v0.23.0, exactly the pinned version constant in cloud/src/mesh.rs:628 and yubaba/src/lib.rs:555.")
90//! @yah:handoff("PHASE C — TARGETS AND THE FAIL-OPEN GATE, RE-MEASURED. THE TARGET LIST IS ONE BOX, NOT TWO: us-west-003 CARRIES NO HEADSCALE. Enumerated from .yah/infra/machines/ (9 declared) and cross-checked against the mesh itself (`yah mesh status` reports coordinator = us-west-001, 10 nodes, 9 online), then probed read-only over SSH on every reachable one: us-west-001 has /var/lib/yah-cloud/headscale/{config.yaml,acls.yaml,headscale.db,headscale binary}; us-south-001, us-east-001, us-west-003, us-west-011, us-west-013, us-west-014 all answer \"No such file or directory\" for that directory and `systemctl is-active headscale` = inactive; us-west-015 is the fleet's Mac (no systemctl, no directory); us-west-002 is unreachable (SSH to 100.64.0.4 timed out — it is the known-offline WSL box, [connect].ssh points at its mesh address). There is no camp-local coordinator either (~/.local/share/yah/mesh does not exist). So exactly one box migrates. THE FAIL-OPEN PRECONDITION STILL HOLDS: us-west-001's /var/lib/yah-cloud/headscale/acls.yaml is 77 bytes, mtime Jun 22, and is byte-for-byte the permissive default {\"acls\":[{\"action\":\"accept\",\"src\":[\"*\"],\"dst\":[\"*:*\"]}]} — semantically equal to cloud::mesh::DEFAULT_ACL_POLICY. No real ACL rules anywhere; the flip-then-push window is a no-op for this fleet and the migration's safety argument is intact. Its config.yaml lines 32-34 still read `policy:` / ` mode: file` / ` path: /var/lib/yah-cloud/headscale/acls.yaml`.")
91//! @yah:gotcha("DISCOVERED AND FIXED IN-TICKET — `sudo systemctl restart headscale` IS THE WRONG RESTART FOR THE ONLY LIVE COORDINATOR, AND IT FAILS SILENTLY. Measured on us-west-001 2026-09-04: `systemctl is-active headscale` = inactive and `is-enabled` = disabled, while `headscale serve --config /var/lib/yah-cloud/headscale/config.yaml` IS running as pid 515991 whose PPID is /usr/local/bin/kamaji (started 23:11, listening on *:443 and *:80, https://cloud.mesh.yah.dev/key?v=138 returns 200). That is leader.rs::start_headscale's kamaji path (oss/yubaba/crates/yubaba/src/leader.rs:381 does `systemctl disable --now headscale` before deploying the appliance under kamaji). So the systemctl restart R861-T2's Step::on_box_commands emitted would have forked a SECOND headscale against the same :443 — it fails to bind while the kamaji-supervised process keeps serving the PRE-FLIP config, i.e. a config on disk saying `mode: database` and a coordinator still in file mode, reported to the operator as a successful step. `policy.mode` is read once at startup, so that state is invisible until the next real restart. FIX (this ticket, policy_migration.rs): new `Supervisor {Systemd | Kamaji{pid} | Unknown}` on Observation, `on_box_commands(dir, &supervisor)`, and `restart_commands()` which emits `sudo kill <pid>` for the kamaji case (kamaji's native supervisor re-execs the child per RestartPolicy::Always — see oss/kamaji/crates/kamaji/src/native.rs, ALWAYS_RESTART_DELAY = 1s — so ending the process IS the restart) plus a sleep and a pgrep confirmation, and for Unknown emits ONLY comments so no rail can execute a guess. Two new tests pin both.")
92//! @yah:handoff("PHASE B — THE EXECUTOR IS WIRED AND ARMED, AND NOTHING HAS BEEN FIRED. New subcommand `yah mesh migrate-policy [machine] [--path .] [--execute]` (app/yah/cli/src/mesh.rs: enum arm in MeshCommands, dispatch arm in handle_mesh_command, handler `handle_migrate_policy` + helpers `ssh_bash`, `sudo_prefix`, `observe_coordinator`). READ-ONLY BY DEFAULT, which is deliberately INVERTED relative to its `--dry-run` siblings (promote/bootstrap): this command's live half rewrites the coordinator's config, restarts it and deletes the only on-disk copy of the policy, so the safe mode has to be the one you get by forgetting a flag. `Execution::Live` is now constructed in exactly ONE production place, app/yah/cli/src/mesh.rs inside the `execute` branch of the PushCarriedPolicy arm — `rg \"Execution::Live\"` over oss/ app/ crates/ finds that one call plus the enum's own arm, the module doc, and one test. One step per invocation, re-observed from scratch each time, so it resumes correctly after an interruption. Machine resolution: [connect].ssh via crate::rollout::fleet::FleetTopology::load, defaulting to the vault's mesh-coordinator-machine. THE FAIL-OPEN GATE IS ENFORCED IN CODE, not just documented: new `policy_migration::is_permissive()` compares the carried policy semantically against cloud::mesh::DEFAULT_ACL_POLICY, and the handler BAILS before doing anything if a flip is pending against a non-permissive acls.yaml. Also refuses to flip when the supervisor is Unknown. ALSO ADDED, both used by the rail and by tests so the operator's rehearsal cannot drift from the tested model: `Step::label()` (one line instead of a Debug that embeds a whole config.yaml) and `pub fn simulate_step()` — the step-effect model promoted out of the test module, so `rehearse()` can run against a REAL observation.")
93//! @yah:verify("REHEARSED AGAINST THE REAL COORDINATOR, READ-ONLY — `./target/debug/yah mesh migrate-policy us-west-001` (no --execute) run twice. It resolved debian@15.204.89.240, read the live config.yaml and acls.yaml over SSH and the served policy over the API, and reported: policy.mode = File{path: /var/lib/yah-cloud/headscale/acls.yaml}; supervisor = Kamaji{pid: 515991}; acls.yaml = 77 bytes, the permissive allow-all default; served policy = 77 bytes; and the rehearsed plan from that observed state = 1. FlipConfigToDatabaseMode 2. PushCarriedPolicy (77 bytes) 3. RemoveAclsFile 4. Done, with widens_before_push = true and deletes_the_file_early = FALSE. Flip-then-push, never push-then-flip; the file is never removed before the push. The rewritten config it would write was inspected: `policy:` / ` mode: database` with the `path:` line dropped, every other byte of the 1060-byte config preserved (server_url, tls_letsencrypt_*, noise.private_key_path, database.sqlite.path, unix_socket, dns, prefixes, derp all unchanged). The on-box command list it emits is `sudo kill 515991` + sleep + pgrep, NOT systemctl. Run ended with \"DRY RUN — nothing was changed.\"")
94//! @yah:verify("BASELINES, ALL MET. `cargo test --manifest-path oss/yubaba/Cargo.toml -p yah-cloud --lib` = 1081 passed / 0 failed / 4 ignored (baseline 1078/0/4; +3 are this ticket's new tests: a_kamaji_supervised_coordinator_is_never_told_to_systemctl_restart, an_unidentified_supervisor_yields_no_runnable_restart, the_live_acls_file_reads_as_permissive_and_a_real_rule_does_not). `cargo test --manifest-path oss/yubaba/Cargo.toml -p yubaba --lib` = 649 passed / 0 failed (baseline 639/0 — the +10 are a peer's, untouched by this ticket, all green). `cargo check -p yah --lib` = exit 0, 19 warnings all pre-existing and on files this ticket did not touch; `cargo build -p yah --bin yah` also clean. rustfmt --check on exactly the two edited files (app/yah/cli/src/mesh.rs, policy_migration.rs) = clean, hand-applied 4 hunks; write-mode `cargo fmt` was NOT run, per the standing rule about cloud/src/lib.rs following mod decls into peer files. ONE TRANSIENT PEER BREAKAGE seen and not touched: an unterminated block comment at oss/yah-base/crates/keys/src/spec.rs:1225 failed the `fob` crate mid-session (E0758); it was gone on the retry and is not this ticket's.")
95//! @yah:next("MIGRATION DONE — nothing left to execute on the existing fleet. The `acls.yaml` replication gap that motivated R858-T2 is now moot on us-west-001: the policy lives in headscale.db, which litestream already replicates. Tell R858 it can drop acls.yaml from its failover-state enumeration.")
96//! @yah:next("ROLLBACK PATH, if a policy problem surfaces later: /var/lib/yah-cloud/headscale/config.yaml.pre-R861-T3 is the pre-flip config on the box (mode=file + path). Restoring it and killing the kamaji-supervised pid reverts — but acls.yaml is gone, so re-create it from the DB policy (GET /api/v1/policy) first.")
97//! @yah:handoff("EXECUTED AGAINST THE LIVE FLEET 2026-09-04 18:53-18:58 PDT by @Ashguard:rune (picked up after session:0c65aeae errored on session limits mid-verification). ONE TARGET, us-west-001, driven with the rehearsed ./target/debug/yah (built 16:55, the same binary T3's dry-run rehearsal used; NOT installed to ~/.local/bin, tree still carries peers' in-flight work). PRE-FLIGHT re-observe matched the rehearsal byte for byte: mode=File{/var/lib/yah-cloud/headscale/acls.yaml}, supervisor=Kamaji{pid 515991}, acls.yaml 77 bytes permissive, served 77 bytes — fail-open gate satisfied. STEP 1 FlipConfigToDatabaseMode: config rewritten (old kept at config.yaml.pre-R861-T3), `sudo kill 515991`, kamaji respawned as pid 517125 serving the same argv. STEP 2 PushCarriedPolicy: re-observe showed mode=Database and `served policy: none (no policy row)` — independent proof the restart really re-read the config, and the fail-open window opened and was closed within one invocation; PUT succeeded with read-back verified. STEP 3 RemoveAclsFile: `sudo rm acls.yaml`, taken only after a read-only run confirmed served policy = 77 bytes and https://cloud.mesh.yah.dev/key?v=138 = 200. FINAL: `next step: Done`, acls.yaml absent, served policy 77 bytes from the DB. `yah mesh status` = api reachable HTTP 200, 10 nodes / 9 online — identical to the pre-migration count (us-west-002 is the known-offline WSL box). No node dropped, no re-auth, zero downtime observed.")
98//! @yah:verify("LIVE VERIFICATION, POST-MIGRATION (2026-09-04 18:58 PDT): `yah mesh migrate-policy us-west-001` (read-only) reports policy.mode = Database, supervisor = Kamaji{pid 517125}, acls.yaml = absent, served policy = 77 bytes, next step = Done. `curl https://cloud.mesh.yah.dev/key?v=138` = 200. `yah mesh status` = api reachable (HTTP 200), 10 nodes / 9 online, unchanged from pre-migration. The one target was us-west-001; T3's Phase C enumeration (no other box carries /var/lib/yah-cloud/headscale) was re-confirmed by the migrator's own machine resolution.")
99
100use anyhow::{Context, Result};
101
102use super::policies_equivalent;
103use crate::mesh::{HeadscaleClient, POLICY_MODE};
104
105/// Where this module lives, for error messages that want to point a reader at
106/// the migration without hardcoding a path in three places.
107pub const MODULE_PATH: &str = "cloud::reconciler::headscale::policy_migration";
108
109/// Default on-box location of a coordinator's headscale state.
110/// Matches us-west-001 (measured 2026-09-04) and `yubaba::DEFAULT_HEADSCALE_DIR`.
111pub const DEFAULT_HEADSCALE_DIR: &str = "/var/lib/yah-cloud/headscale";
112
113/// The `policy` block of a coordinator's `config.yaml`, as headscale reads it.
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub enum PolicyMode {
116 /// `mode: file` — or no `policy:` block at all, since headscale's viper
117 /// default for `policy.mode` is `"file"`.
118 File { path: Option<String> },
119 /// `mode: database`. The migrated state.
120 Database,
121 /// Something headscale would `log.Fatal` on at startup. Surfaced rather
122 /// than coerced: a coordinator in this state is already broken and the
123 /// migration must not paper over it.
124 Unrecognised(String),
125}
126
127/// What is keeping `headscale serve` alive on the box.
128///
129/// This is not decoration: `policy.mode` is read once at startup, so the flip
130/// is only real after a restart, and the two supervisors restart by completely
131/// different means. Guessing produces the worst outcome available — a
132/// coordinator still serving the old mode while the config on disk says
133/// otherwise and the operator has been told the step succeeded.
134///
135/// Measured on us-west-001 2026-09-04: `headscale.service` is `inactive` and
136/// `disabled`, and the live `headscale serve` is a child of
137/// `/usr/local/bin/kamaji`. So [`Supervisor::Kamaji`] is the shape the one live
138/// coordinator actually has, and the systemd arm is the fallback path
139/// `leader.rs::start_headscale` takes when no kamaji backend is attached.
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub enum Supervisor {
142 /// `headscale.service` is the active unit. `systemctl restart` is correct.
143 Systemd,
144 /// kamaji supervises the process as a native workload. Its spec carries
145 /// `RestartPolicy::Always` (`yubaba::headscale_appliance::appliance_spec`),
146 /// so kamaji's own restart loop respawns the child on exit with the same
147 /// argv — which is what re-reads `config.yaml`.
148 ///
149 /// **`systemctl restart` is actively wrong here.** The unit is disabled, so
150 /// it would fork a *second* headscale against the same `0.0.0.0:443`; that
151 /// one fails to bind while the kamaji-supervised process keeps serving the
152 /// pre-flip config.
153 Kamaji { pid: u32 },
154 /// The probe could not tell. Emitted as a refusal to act rather than a
155 /// guess, because both wrong answers above are silent.
156 Unknown,
157}
158
159/// Everything the migration needs to know about one coordinator, gathered
160/// read-only.
161#[derive(Debug, Clone)]
162pub struct Observation {
163 /// Verbatim contents of the coordinator's `config.yaml`.
164 pub config_yaml: String,
165 /// Verbatim contents of `acls.yaml`, or `None` if the file is gone.
166 pub acls_file: Option<String>,
167 /// What `GET /api/v1/policy` returned, or `None` when the coordinator has
168 /// no policy to serve. In database mode with no row that GET is an error
169 /// (`loading ACL from database: policy not found`), not an empty string —
170 /// callers map that error to `None`, which is what it means.
171 pub live_policy: Option<String>,
172 /// Directory holding `config.yaml` / `acls.yaml` on the box.
173 pub headscale_dir: String,
174 /// What restarts `headscale serve` here — see [`Supervisor`].
175 pub supervisor: Supervisor,
176}
177
178impl Observation {
179 /// Read the observed `policy.mode`.
180 pub fn policy_mode(&self) -> PolicyMode {
181 read_policy_mode(&self.config_yaml)
182 }
183}
184
185/// The one action to take next. Re-derive it after every step; never queue a
186/// batch, because each step changes what the correct next one is.
187#[derive(Debug, Clone, PartialEq, Eq)]
188pub enum Step {
189 /// Write `config_yaml` back and restart headscale. Carries the rewritten
190 /// config so the caller never has to re-derive it.
191 FlipConfigToDatabaseMode { config_yaml: String },
192 /// `PUT /api/v1/policy` with this document. Only reachable once the
193 /// coordinator is in database mode.
194 PushCarriedPolicy { hujson: String },
195 /// The coordinator serves the carried policy from its database. The file
196 /// is now genuinely redundant and can go.
197 RemoveAclsFile,
198 /// Migrated. Re-running from here changes nothing.
199 Done,
200 /// The coordinator is in a state this migration refuses to guess at.
201 Blocked { reason: String },
202}
203
204impl Step {
205 /// A one-line name for the step, for plans and progress output.
206 ///
207 /// [`Step`]'s `Debug` embeds a whole `config.yaml` or policy document, which
208 /// is right for a failure message and unreadable in a list of steps.
209 pub fn label(&self) -> String {
210 match self {
211 Step::FlipConfigToDatabaseMode { .. } => {
212 "FlipConfigToDatabaseMode (rewrite config.yaml + restart)".to_string()
213 }
214 Step::PushCarriedPolicy { hujson } => {
215 format!(
216 "PushCarriedPolicy (PUT /api/v1/policy, {} bytes)",
217 hujson.len()
218 )
219 }
220 Step::RemoveAclsFile => "RemoveAclsFile (rm acls.yaml)".to_string(),
221 Step::Done => "Done".to_string(),
222 Step::Blocked { reason } => format!("Blocked: {reason}"),
223 }
224 }
225
226 /// Shell the operator (or a gated rail) runs on the box for the steps that
227 /// are not API calls. Empty for [`Step::PushCarriedPolicy`], which is
228 /// driven through [`apply_push`], and for the terminal states.
229 ///
230 /// The restart line depends on `supervisor` and there is no safe default —
231 /// see [`Supervisor`] for why emitting `systemctl restart` against a
232 /// kamaji-supervised coordinator is worse than emitting nothing.
233 pub fn on_box_commands(&self, headscale_dir: &str, supervisor: &Supervisor) -> Vec<String> {
234 match self {
235 Step::FlipConfigToDatabaseMode { .. } => {
236 let mut cmds = vec![format!(
237 "# write the rewritten config to {headscale_dir}/config.yaml"
238 )];
239 // Restart, not reload: `policy.mode` is read once, in
240 // loadACLPolicy, during startup.
241 cmds.extend(restart_commands(supervisor));
242 cmds.push(format!("grep -A2 '^policy:' {headscale_dir}/config.yaml"));
243 cmds
244 }
245 Step::RemoveAclsFile => vec![format!("sudo rm {headscale_dir}/acls.yaml")],
246 _ => Vec::new(),
247 }
248 }
249}
250
251/// How to make `headscale serve` re-read `config.yaml` under each supervisor.
252///
253/// [`Supervisor::Unknown`] returns a refusal line rather than a command: a
254/// restart aimed at the wrong supervisor either does nothing visible or stands
255/// up a second coordinator, and both failure modes look like success.
256pub fn restart_commands(supervisor: &Supervisor) -> Vec<String> {
257 match supervisor {
258 Supervisor::Systemd => vec!["sudo systemctl restart headscale".to_string()],
259 // kamaji's native supervisor owns the child and re-execs it per
260 // `RestartPolicy::Always`, so ending the process IS the restart. SIGTERM
261 // rather than SIGKILL so headscale closes its SQLite handles.
262 Supervisor::Kamaji { pid } => vec![
263 format!(
264 "sudo kill {pid} # kamaji (RestartPolicy::Always) respawns it with the same argv"
265 ),
266 // kamaji's ALWAYS_RESTART_DELAY is 1s (kamaji::native), so the new
267 // child does not exist for at least that long; 5s leaves headscale
268 // time to bind :443 too.
269 "sleep 5".to_string(),
270 "pgrep -af 'headscale serve' || true # confirm a NEW pid is serving".to_string(),
271 ],
272 Supervisor::Unknown => vec![
273 "# REFUSING to emit a restart: the supervisor could not be identified.".to_string(),
274 "# Determine it first — `systemctl is-active headscale` and the ppid of".to_string(),
275 "# `pgrep -af 'headscale serve'` — then re-run. A restart aimed at the".to_string(),
276 "# wrong supervisor fails silently or binds a second coordinator to :443.".to_string(),
277 ],
278 }
279}
280
281/// Whether a policy document is the permissive allow-all default
282/// ([`crate::mesh::DEFAULT_ACL_POLICY`]), compared semantically rather than
283/// byte-wise.
284///
285/// This is the fail-open gate. The migration necessarily opens a window in
286/// which the coordinator has no policy row and therefore compiles
287/// `FilterAllowAll`; that is a no-op only when the policy being carried across
288/// the window is itself allow-all. On any other document the same window is a
289/// silent widening, which a caller must refuse rather than narrate.
290pub fn is_permissive(hujson: &str) -> Result<bool> {
291 policies_equivalent(hujson, crate::mesh::DEFAULT_ACL_POLICY)
292 .context("comparing a policy against the permissive allow-all default")
293}
294
295/// Decide the single next action from a read-only observation.
296///
297/// The ordering is forced by headscale's own behaviour, not chosen:
298///
299/// 1. **Flip the config first** — `SetPolicy` is refused outright in file mode.
300/// 2. **Push immediately after** — the interval in between is allow-all.
301/// 3. **Delete `acls.yaml` last** — it is the only copy of the policy until the
302/// coordinator serves an equivalent one from its database, so it is what
303/// makes an interrupted run resumable.
304pub fn next_step(obs: &Observation) -> Result<Step> {
305 match obs.policy_mode() {
306 PolicyMode::Unrecognised(mode) => {
307 return Ok(Step::Blocked {
308 reason: format!(
309 "{}/config.yaml declares `policy.mode: {mode}`, which headscale v0.23.0 \
310 rejects at startup (it accepts only \"file\" or \"database\"). Fix that \
311 before migrating anything.",
312 obs.headscale_dir,
313 ),
314 });
315 }
316 PolicyMode::File { .. } => {
317 let config_yaml = rewrite_to_database_mode(&obs.config_yaml)?;
318 return Ok(Step::FlipConfigToDatabaseMode { config_yaml });
319 }
320 PolicyMode::Database => {}
321 }
322
323 // Database mode from here on.
324 let Some(carried) = obs.acls_file.as_deref() else {
325 // No file left. Whatever the database holds is the whole truth, and
326 // there is nothing left to carry or delete.
327 return Ok(Step::Done);
328 };
329
330 let in_sync = match obs.live_policy.as_deref() {
331 // No policy row yet: the coordinator is serving allow-all and the file
332 // is the only copy. Always a push, never a delete.
333 None => false,
334 Some(live) => policies_equivalent(live, carried).with_context(|| {
335 format!(
336 "comparing the policy {} serves against the carried {}/acls.yaml",
337 "the coordinator", obs.headscale_dir,
338 )
339 })?,
340 };
341
342 if in_sync {
343 Ok(Step::RemoveAclsFile)
344 } else {
345 Ok(Step::PushCarriedPolicy {
346 hujson: carried.to_string(),
347 })
348 }
349}
350
351/// Whether writes are permitted. An explicit type rather than a `bool`, so a
352/// caller cannot arm a live push by getting an argument order wrong.
353#[derive(Debug, Clone, Copy, PartialEq, Eq)]
354pub enum Execution {
355 /// Describe the write; perform nothing.
356 DryRun,
357 /// Actually `PUT /api/v1/policy`.
358 Live,
359}
360
361/// Perform the one migration step that is an API call.
362///
363/// Returns the human-readable account of what happened (or, under
364/// [`Execution::DryRun`], what would have). Any other [`Step`] is a no-op here
365/// — the on-box steps come back from [`Step::on_box_commands`] instead.
366pub async fn apply_push(
367 client: &HeadscaleClient,
368 step: &Step,
369 execution: Execution,
370) -> Result<String> {
371 let Step::PushCarriedPolicy { hujson } = step else {
372 return Ok(format!("no API step to run for {step:?}"));
373 };
374 match execution {
375 Execution::DryRun => Ok(format!(
376 "DRY RUN: would PUT /api/v1/policy with {} bytes of HuJSON",
377 hujson.len()
378 )),
379 Execution::Live => {
380 client
381 .set_policy(hujson)
382 .await
383 .context("pushing the carried acls.yaml into the coordinator's database")?;
384 // Read back through the same comparison the reconciler uses, so a
385 // coordinator that accepted the write but stored something else
386 // cannot read as success.
387 let live = client
388 .get_policy()
389 .await
390 .context("reading the policy back after pushing it")?;
391 if !policies_equivalent(&live, hujson)? {
392 anyhow::bail!(
393 "the coordinator accepted the policy push but serves a different document \
394 back — refusing to treat this as migrated, and acls.yaml must NOT be deleted"
395 );
396 }
397 Ok("pushed the carried policy and verified the read-back".to_string())
398 }
399 }
400}
401
402/// A rehearsal: drive [`next_step`] to a terminal state over a simulated
403/// coordinator, so the whole sequence can be inspected without a live box.
404///
405/// `advance` applies one step to the observation exactly as the real world
406/// would, and returns the next observation.
407pub fn rehearse<F>(mut obs: Observation, mut advance: F) -> Result<MigrationPlan>
408where
409 F: FnMut(&Step, &Observation) -> Observation,
410{
411 let mut steps = Vec::new();
412 // Five is generous: the longest real path is flip -> push -> remove ->
413 // done. Anything longer means the state machine is cycling, which is a bug
414 // worth failing on rather than looping forever.
415 for _ in 0..5 {
416 let step = next_step(&obs)?;
417 steps.push(step.clone());
418 if matches!(step, Step::Done | Step::Blocked { .. }) {
419 return Ok(MigrationPlan { steps });
420 }
421 obs = advance(&step, &obs);
422 }
423 anyhow::bail!("migration did not converge in 5 steps: {steps:?}")
424}
425
426/// Apply one step to an observation the way the real world would, for
427/// [`rehearse`].
428///
429/// This is the honest model of each step's effect, and it is public so an
430/// operator rail can rehearse a plan against a *real* observation rather than
431/// only against a hand-built one: read the coordinator, then
432/// `rehearse(obs, simulate_step)` shows the whole sequence before anything is
433/// armed.
434pub fn simulate_step(step: &Step, obs: &Observation) -> Observation {
435 let mut next = obs.clone();
436 match step {
437 Step::FlipConfigToDatabaseMode { config_yaml } => {
438 next.config_yaml = config_yaml.clone();
439 // Restarting in database mode with no row: headscale serves a nil
440 // policy, and GET /api/v1/policy errors -> None.
441 next.live_policy = None;
442 }
443 Step::PushCarriedPolicy { hujson } => next.live_policy = Some(hujson.clone()),
444 Step::RemoveAclsFile => next.acls_file = None,
445 Step::Done | Step::Blocked { .. } => {}
446 }
447 next
448}
449
450/// The ordered steps a [`rehearse`] produced.
451#[derive(Debug, Clone)]
452pub struct MigrationPlan {
453 pub steps: Vec<Step>,
454}
455
456impl MigrationPlan {
457 /// Whether this migration passes through a window where the coordinator
458 /// serves allow-all because the config was flipped before the policy was
459 /// pushed.
460 ///
461 /// True whenever a flip is followed by a push. Harmless when the carried
462 /// policy is itself permissive (us-west-001's is); a real, if brief,
463 /// widening otherwise — and the operator should know which one they have
464 /// before running it, not after.
465 pub fn widens_before_push(&self) -> bool {
466 self.steps.windows(2).any(|w| {
467 matches!(w[0], Step::FlipConfigToDatabaseMode { .. })
468 && matches!(w[1], Step::PushCarriedPolicy { .. })
469 })
470 }
471
472 /// Whether the plan ever removes `acls.yaml` before the coordinator is
473 /// serving the policy from its database. Must always be false — this is
474 /// the invariant that makes an interrupted run recoverable.
475 pub fn deletes_the_file_early(&self) -> bool {
476 let remove_at = self.steps.iter().position(|s| *s == Step::RemoveAclsFile);
477 let push_at = self
478 .steps
479 .iter()
480 .position(|s| matches!(s, Step::PushCarriedPolicy { .. }));
481 match (remove_at, push_at) {
482 (Some(r), Some(p)) => r < p,
483 _ => false,
484 }
485 }
486}
487
488// ─── config.yaml surgery ─────────────────────────────────────────────────────
489
490/// Read `policy.mode` (and `policy.path`) out of a headscale `config.yaml`.
491///
492/// Hand-parsed rather than round-tripped through a YAML crate, for two reasons:
493/// `serde_yaml` is a dev-dependency of this crate, and a reserialize would
494/// reorder and reformat a config a human may have edited on the box. The
495/// subset handled is exactly what headscale's own config is: a top-level
496/// `policy:` key whose block-mapping children are plain `key: value` scalars.
497/// Anything else reads as [`PolicyMode::Unrecognised`] rather than being
498/// guessed at.
499///
500/// A missing `policy:` block is [`PolicyMode::File`] with no path, because
501/// that is what headscale does — `viper.SetDefault("policy.mode", "file")`.
502pub fn read_policy_mode(config_yaml: &str) -> PolicyMode {
503 let Some(block) = policy_block(config_yaml) else {
504 return PolicyMode::File { path: None };
505 };
506 let mut mode = None;
507 let mut path = None;
508 for line in &config_yaml.lines().collect::<Vec<_>>()[block.first_child..block.end] {
509 let Some((key, value)) = line.split_once(':') else {
510 continue;
511 };
512 let value = value.trim().trim_matches('"').to_string();
513 match key.trim() {
514 "mode" => mode = Some(value),
515 "path" => path = Some(value),
516 _ => {}
517 }
518 }
519 match mode.as_deref() {
520 None | Some("") | Some("file") => PolicyMode::File { path },
521 Some("database") => PolicyMode::Database,
522 Some(other) => PolicyMode::Unrecognised(other.to_string()),
523 }
524}
525
526/// Rewrite a coordinator's `config.yaml` so its policy block reads
527/// `mode: database` and carries no `path:`.
528///
529/// Every other byte is preserved: only the `policy:` block's own lines are
530/// replaced, at whatever indentation that block already uses. Idempotent —
531/// running it on an already-migrated config returns the same bytes.
532///
533/// `policy.path` is dropped rather than left in place because headscale reads
534/// it only in file mode; a leftover path documents a file that is no longer
535/// the source of truth, which is exactly the confusion this ticket exists to
536/// remove.
537pub fn rewrite_to_database_mode(config_yaml: &str) -> Result<String> {
538 let lines: Vec<&str> = config_yaml.lines().collect();
539 let trailing_newline = config_yaml.ends_with('\n');
540
541 let Some(block) = policy_block(config_yaml) else {
542 // No `policy:` block at all — headscale defaults to file mode, so this
543 // config still needs migrating. Append one.
544 let mut out = config_yaml.to_string();
545 if !out.is_empty() && !trailing_newline {
546 out.push('\n');
547 }
548 out.push_str(&format!("policy:\n mode: {POLICY_MODE}\n"));
549 return Ok(out);
550 };
551
552 let indent = block.child_indent.clone();
553 let mut out: Vec<String> = lines[..block.first_child]
554 .iter()
555 .map(|l| l.to_string())
556 .collect();
557 out.push(format!("{indent}mode: {POLICY_MODE}"));
558 // Anything in the block that is neither `mode:` nor `path:` is somebody
559 // else's key and is kept — dropping a key this module does not understand
560 // would be exactly the silent damage it is supposed to avoid.
561 for line in &lines[block.first_child..block.end] {
562 let key = line.split_once(':').map(|(k, _)| k.trim()).unwrap_or("");
563 if key == "mode" || key == "path" || line.trim().is_empty() {
564 continue;
565 }
566 out.push((*line).to_string());
567 }
568 out.extend(lines[block.end..].iter().map(|l| l.to_string()));
569
570 let mut rendered = out.join("\n");
571 if trailing_newline {
572 rendered.push('\n');
573 }
574 Ok(rendered)
575}
576
577/// The `policy:` mapping's extent within a config, in line indices.
578struct PolicyBlock {
579 /// First line of the block's children (one past the `policy:` line).
580 first_child: usize,
581 /// One past the block's last child.
582 end: usize,
583 /// The indentation the children use, reused verbatim on rewrite.
584 child_indent: String,
585}
586
587fn policy_block(config_yaml: &str) -> Option<PolicyBlock> {
588 let lines: Vec<&str> = config_yaml.lines().collect();
589 // A top-level key: no leading whitespace, and nothing after the colon.
590 let header = lines
591 .iter()
592 .position(|l| l.trim_end() == "policy:" && !l.starts_with(char::is_whitespace))?;
593
594 let first_child = header + 1;
595 let child_indent = lines
596 .get(first_child)
597 .map(|l| l[..l.len() - l.trim_start().len()].to_string())
598 .filter(|i| !i.is_empty())
599 // An empty `policy:` block (next line is another top-level key) still
600 // needs children written; two spaces is what every renderer emits.
601 .unwrap_or_else(|| " ".to_string());
602
603 let end = lines[first_child..]
604 .iter()
605 .position(|l| !l.trim().is_empty() && !l.starts_with(char::is_whitespace))
606 .map(|offset| first_child + offset)
607 .unwrap_or(lines.len());
608
609 Some(PolicyBlock {
610 first_child,
611 end,
612 child_indent,
613 })
614}
615
616#[cfg(test)]
617mod tests {
618 use super::*;
619
620 /// The exact policy block us-west-001 was measured to carry on 2026-09-04,
621 /// embedded in a config shaped like the one `generate_remote_headscale_config`
622 /// produced when that box was provisioned.
623 fn live_file_mode_config() -> String {
624 "---\n\
625 server_url: https://cloud.mesh.yah.dev\n\
626 listen_addr: 0.0.0.0:443\n\
627 database:\n\
628 \x20\x20type: sqlite\n\
629 \x20\x20sqlite:\n\
630 \x20\x20\x20\x20path: /var/lib/yah-cloud/headscale/headscale.db\n\
631 policy:\n\
632 \x20\x20mode: file\n\
633 \x20\x20path: /var/lib/yah-cloud/headscale/acls.yaml\n\
634 derp:\n\
635 \x20\x20server:\n\
636 \x20\x20\x20\x20enabled: false\n"
637 .to_string()
638 }
639
640 /// us-west-001's live acls.yaml, 77 bytes, measured 2026-09-04.
641 const LIVE_ACLS: &str = r#"{"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}"#;
642
643 fn observation(config: &str, acls: Option<&str>, live: Option<&str>) -> Observation {
644 Observation {
645 config_yaml: config.to_string(),
646 acls_file: acls.map(str::to_string),
647 live_policy: live.map(str::to_string),
648 headscale_dir: DEFAULT_HEADSCALE_DIR.to_string(),
649 // The shape us-west-001 was measured to have on 2026-09-04.
650 supervisor: Supervisor::Kamaji { pid: 515991 },
651 }
652 }
653
654 #[test]
655 fn the_live_config_reads_as_file_mode() {
656 assert_eq!(
657 read_policy_mode(&live_file_mode_config()),
658 PolicyMode::File {
659 path: Some("/var/lib/yah-cloud/headscale/acls.yaml".to_string()),
660 }
661 );
662 }
663
664 #[test]
665 fn a_config_with_no_policy_block_is_file_mode() {
666 // headscale's own default: viper.SetDefault("policy.mode", "file").
667 assert_eq!(
668 read_policy_mode("---\nserver_url: https://x\n"),
669 PolicyMode::File { path: None }
670 );
671 }
672
673 #[test]
674 fn an_unknown_mode_is_surfaced_not_coerced() {
675 let cfg = "policy:\n mode: postgres\n";
676 assert_eq!(
677 read_policy_mode(cfg),
678 PolicyMode::Unrecognised("postgres".to_string())
679 );
680 }
681
682 #[test]
683 fn all_three_renderers_now_emit_database_mode() {
684 let dir = std::path::PathBuf::from("/var/lib/yah-cloud/headscale");
685 let cloud = crate::mesh::generate_headscale_config("https://mesh.example.com", &dir);
686 assert_eq!(read_policy_mode(&cloud), PolicyMode::Database, "{cloud}");
687 // yubaba's two renderers live in a crate this one does not depend on,
688 // so their outputs are pinned by tests over there. What is asserted
689 // here is that the mode string both crates hardcode agrees.
690 assert_eq!(POLICY_MODE, "database");
691 }
692
693 #[test]
694 fn rewriting_the_live_config_flips_the_mode_and_drops_the_path() {
695 let out = rewrite_to_database_mode(&live_file_mode_config()).unwrap();
696 assert_eq!(read_policy_mode(&out), PolicyMode::Database, "{out}");
697 assert!(!out.contains("acls.yaml"), "{out}");
698 // Everything else survives, including the sibling `path:` under
699 // `database.sqlite` that a careless line-based edit would eat.
700 assert!(
701 out.contains("server_url: https://cloud.mesh.yah.dev"),
702 "{out}"
703 );
704 assert!(
705 out.contains("/var/lib/yah-cloud/headscale/headscale.db"),
706 "{out}"
707 );
708 assert!(out.contains("listen_addr: 0.0.0.0:443"), "{out}");
709 assert!(out.contains("enabled: false"), "{out}");
710 assert!(out.ends_with('\n'), "{out}");
711 }
712
713 #[test]
714 fn rewriting_is_idempotent() {
715 let once = rewrite_to_database_mode(&live_file_mode_config()).unwrap();
716 let twice = rewrite_to_database_mode(&once).unwrap();
717 assert_eq!(once, twice);
718 }
719
720 #[test]
721 fn rewriting_a_config_without_a_policy_block_appends_one() {
722 let out = rewrite_to_database_mode("---\nserver_url: https://x\n").unwrap();
723 assert_eq!(read_policy_mode(&out), PolicyMode::Database, "{out}");
724 assert!(out.contains("server_url: https://x"), "{out}");
725 }
726
727 #[test]
728 fn rewriting_keeps_a_policy_key_this_module_does_not_understand() {
729 let cfg = "policy:\n mode: file\n path: /x/acls.yaml\n something_new: 7\n";
730 let out = rewrite_to_database_mode(cfg).unwrap();
731 assert!(out.contains("something_new: 7"), "{out}");
732 assert!(!out.contains("acls.yaml"), "{out}");
733 }
734
735 #[test]
736 fn a_file_mode_coordinator_is_told_to_flip_the_config_first() {
737 // Not to push: SetPolicy is refused outright in file mode.
738 let obs = observation(&live_file_mode_config(), Some(LIVE_ACLS), Some(LIVE_ACLS));
739 match next_step(&obs).unwrap() {
740 Step::FlipConfigToDatabaseMode { config_yaml } => {
741 assert_eq!(read_policy_mode(&config_yaml), PolicyMode::Database);
742 }
743 other => panic!("expected a config flip, got {other:?}"),
744 }
745 }
746
747 #[test]
748 fn a_database_mode_coordinator_with_no_policy_row_is_pushed_to() {
749 let flipped = rewrite_to_database_mode(&live_file_mode_config()).unwrap();
750 let obs = observation(&flipped, Some(LIVE_ACLS), None);
751 assert_eq!(
752 next_step(&obs).unwrap(),
753 Step::PushCarriedPolicy {
754 hujson: LIVE_ACLS.to_string()
755 }
756 );
757 }
758
759 #[test]
760 fn the_file_is_removed_only_once_the_database_serves_it() {
761 let flipped = rewrite_to_database_mode(&live_file_mode_config()).unwrap();
762 // Same policy, reformatted by headscale's own round trip — semantic
763 // comparison, not bytes, so this is in sync.
764 let served = "{\n \"acls\": [\n { \"action\": \"accept\", \"src\": [\"*\"], \"dst\": [\"*:*\"] }\n ]\n}";
765 let obs = observation(&flipped, Some(LIVE_ACLS), Some(served));
766 assert_eq!(next_step(&obs).unwrap(), Step::RemoveAclsFile);
767 }
768
769 #[test]
770 fn drift_between_the_file_and_the_database_is_a_push_not_a_delete() {
771 let flipped = rewrite_to_database_mode(&live_file_mode_config()).unwrap();
772 let served = r#"{"acls":[{"action":"accept","src":["tag:ci"],"dst":["*:22"]}]}"#;
773 let obs = observation(&flipped, Some(LIVE_ACLS), Some(served));
774 assert!(matches!(
775 next_step(&obs).unwrap(),
776 Step::PushCarriedPolicy { .. }
777 ));
778 }
779
780 #[test]
781 fn a_migrated_coordinator_is_done_and_stays_done() {
782 let flipped = rewrite_to_database_mode(&live_file_mode_config()).unwrap();
783 let obs = observation(&flipped, None, Some(LIVE_ACLS));
784 assert_eq!(next_step(&obs).unwrap(), Step::Done);
785 // A freshly-bootstrapped box: database mode, no file, no policy row.
786 let fresh = observation(&flipped, None, None);
787 assert_eq!(next_step(&fresh).unwrap(), Step::Done);
788 }
789
790 #[test]
791 fn an_unrecognised_mode_blocks_instead_of_guessing() {
792 let obs = observation("policy:\n mode: postgres\n", Some(LIVE_ACLS), None);
793 match next_step(&obs).unwrap() {
794 Step::Blocked { reason } => assert!(reason.contains("postgres"), "{reason}"),
795 other => panic!("expected Blocked, got {other:?}"),
796 }
797 }
798
799 /// Apply a step the way the real world would, so [`rehearse`] walks the
800 /// same path a live run would.
801 /// The production model, exercised here rather than a test-local twin — a
802 /// second copy would let the rehearsal an operator sees drift from the one
803 /// these tests prove.
804 fn simulate(step: &Step, obs: &Observation) -> Observation {
805 simulate_step(step, obs)
806 }
807
808 #[test]
809 fn the_whole_migration_converges_from_the_live_starting_state() {
810 let start = observation(&live_file_mode_config(), Some(LIVE_ACLS), Some(LIVE_ACLS));
811 let plan = rehearse(start, simulate).unwrap();
812 assert!(
813 matches!(
814 plan.steps.as_slice(),
815 [
816 Step::FlipConfigToDatabaseMode { .. },
817 Step::PushCarriedPolicy { .. },
818 Step::RemoveAclsFile,
819 Step::Done,
820 ]
821 ),
822 "{:?}",
823 plan.steps
824 );
825 // The recoverability invariant: never delete the only copy first.
826 assert!(!plan.deletes_the_file_early());
827 // And the window this ordering opens is reported, not hidden.
828 assert!(plan.widens_before_push());
829 }
830
831 #[test]
832 fn resuming_from_any_midpoint_reaches_the_same_end_state() {
833 // Every interruption point is re-derivable from what is on the box,
834 // which is the property that makes a half-completed run safe to rerun.
835 let flipped = rewrite_to_database_mode(&live_file_mode_config()).unwrap();
836 let midpoints = [
837 // Interrupted right after the flip, before the push.
838 observation(&flipped, Some(LIVE_ACLS), None),
839 // Interrupted after the push, before the delete.
840 observation(&flipped, Some(LIVE_ACLS), Some(LIVE_ACLS)),
841 // Interrupted after the delete: already done.
842 observation(&flipped, None, Some(LIVE_ACLS)),
843 ];
844 for obs in midpoints {
845 let plan = rehearse(obs, simulate).unwrap();
846 assert_eq!(*plan.steps.last().unwrap(), Step::Done, "{:?}", plan.steps);
847 assert!(!plan.deletes_the_file_early(), "{:?}", plan.steps);
848 }
849 }
850
851 #[test]
852 fn the_on_box_steps_restart_rather_than_reload() {
853 // policy.mode is read once, in loadACLPolicy, at startup — a SIGHUP
854 // would leave the coordinator on the old mode with a config that says
855 // otherwise, which is the worst of both.
856 let step = Step::FlipConfigToDatabaseMode {
857 config_yaml: String::new(),
858 };
859 let cmds = step.on_box_commands(DEFAULT_HEADSCALE_DIR, &Supervisor::Systemd);
860 assert!(cmds
861 .iter()
862 .any(|c| c.contains("systemctl restart headscale")));
863 assert!(!cmds.iter().any(|c| c.contains("reload")));
864 assert_eq!(
865 Step::RemoveAclsFile.on_box_commands(DEFAULT_HEADSCALE_DIR, &Supervisor::Systemd),
866 vec!["sudo rm /var/lib/yah-cloud/headscale/acls.yaml".to_string()]
867 );
868 // The push is not an on-box command; it goes through apply_push.
869 assert!(Step::PushCarriedPolicy {
870 hujson: LIVE_ACLS.to_string()
871 }
872 .on_box_commands(DEFAULT_HEADSCALE_DIR, &Supervisor::Systemd)
873 .is_empty());
874 }
875
876 #[test]
877 fn a_kamaji_supervised_coordinator_is_never_told_to_systemctl_restart() {
878 // us-west-001's live shape (measured 2026-09-04): headscale.service is
879 // inactive+disabled and the running `headscale serve` is a child of
880 // /usr/local/bin/kamaji. `systemctl restart` there forks a SECOND
881 // headscale onto the same :443 — it fails to bind while the real one
882 // keeps serving the pre-flip config, i.e. a silent no-op dressed as a
883 // successful step.
884 let cmds = Step::FlipConfigToDatabaseMode {
885 config_yaml: String::new(),
886 }
887 .on_box_commands(DEFAULT_HEADSCALE_DIR, &Supervisor::Kamaji { pid: 515991 });
888 assert!(!cmds.iter().any(|c| c.contains("systemctl")), "{cmds:?}");
889 assert!(
890 cmds.iter().any(|c| c.starts_with("sudo kill 515991")),
891 "{cmds:?}"
892 );
893 }
894
895 #[test]
896 fn an_unidentified_supervisor_yields_no_runnable_restart() {
897 // Every emitted line is a comment: there is nothing here a rail could
898 // execute, which is the intended refusal.
899 let cmds = restart_commands(&Supervisor::Unknown);
900 assert!(!cmds.is_empty());
901 assert!(cmds.iter().all(|c| c.starts_with('#')), "{cmds:?}");
902 }
903
904 #[test]
905 fn the_live_acls_file_reads_as_permissive_and_a_real_rule_does_not() {
906 // The whole fail-open safety argument rests on this comparison.
907 assert!(is_permissive(LIVE_ACLS).unwrap());
908 assert!(is_permissive(crate::mesh::DEFAULT_ACL_POLICY).unwrap());
909 let restrictive = r#"{"acls":[{"action":"accept","src":["tag:dev"],"dst":["tag:dev:*"]}]}"#;
910 assert!(!is_permissive(restrictive).unwrap());
911 }
912
913 #[tokio::test]
914 async fn a_dry_run_push_touches_no_coordinator() {
915 // The URL is unroutable on purpose: if DryRun ever performed the PUT,
916 // this test would fail with a connection error instead of passing.
917 let client = HeadscaleClient::new("http://127.0.0.1:1", "unused".to_string()).unwrap();
918 let step = Step::PushCarriedPolicy {
919 hujson: LIVE_ACLS.to_string(),
920 };
921 let out = apply_push(&client, &step, Execution::DryRun).await.unwrap();
922 assert!(out.starts_with("DRY RUN"), "{out}");
923 assert!(out.contains(&LIVE_ACLS.len().to_string()), "{out}");
924 // And a non-push step is a no-op even when execution is Live.
925 let out = apply_push(&client, &Step::RemoveAclsFile, Execution::Live)
926 .await
927 .unwrap();
928 assert!(out.contains("no API step"), "{out}");
929 }
930}