Skip to main content

gunnar_server/
appliance.rs

1//! **The appliance identity ceremony, gunnar-side** — a thin caller of edda's
2//! `wg_appliance_core::appliance` + `::ceremony`, the ONE implementation holger,
3//! gunnar and korp share. Nothing ceremony-shaped is implemented here (one implementation).
4//!
5//! # What gunnar was missing, precisely
6//!
7//! gunnar had a WireGuard tunnel before this module and it was **seeded**:
8//! `wg0.conf` (or `GUNNAR_WG` + `wg0.key`) had to arrive with a private key
9//! already in it, so the appliance's identity was something an operator MADE
10//! and shipped rather than something the machine MINTED and announced. Two
11//! consequences, both measured rather than argued:
12//!
13//! * The ISO ships `assets/appliance-root/data/` holding a `.keep` and nothing
14//!   else, deliberately — a committed private key is a key everybody has — so a
15//!   RELEASED gunnar ISO had no identity and no tunnel. The capability existed
16//!   only for an operator who rebuilt the image with their own keys in it.
17//! * gunnar emitted no canonical identity line. `load_or_mint_identity`
18//!   references, measured 2026-08-14: holger 2, korp 2, **gunnar 0**.
19//!
20//! The reason gunnar seeded is gone. It seeded because `/data` on the live ISO
21//! is RAM — a minted key would not survive the reboot, which is worse than no
22//! key, because the operator registers the printed key with the hub and the next
23//! boot then presents a different one. On INSTALLED metal that is no longer
24//! true: `edda/crates/korp-installer/src/esp.rs` `format_data_partition` writes
25//! a real FAT32 filesystem on P3 at install time, and gunnar takes its data root
26//! as an argument rather than hardcoding `/data`, so it inherits the same
27//! durable path korp and holger already mint onto.
28//!
29//! # The gate lives in the declaration, not in folklore
30//!
31//! The ceremony runs iff env [`GATE_ENV`]`=1`. `.nornir/build-variants.toml`'s
32//! `[[build.installer]]` cell declares it as a boot parameter, riding the same
33//! channel `GUNNAR_DATA_ROOT` and `GUNNAR_NET` already ride (Linux turns an
34//! unrecognised `key=value` boot parameter into an environment variable for
35//! init — `init/main.c`, `unknown_bootoption`). So "this binary is an appliance"
36//! is a property of the packed artifact, never of a hand-typed flag: a plain
37//! `gunnar serve` on a dev box mints nothing, writes nothing and prints nothing.
38//!
39//! # Fixed paths, fixed forever
40//!
41//! `<root>/wg/hub.conf`, `<root>/wg/identity.key`, `<root>/wg/identity.pub` —
42//! the SAME names on P3, in a container volume, and under holger and korp,
43//! because the filenames are constants in the shared crate and no product spells
44//! them itself. `<root>` is the data root `serve` was given, overridable by
45//! [`DATA_ENV`] (the test seam).
46//!
47//! # The seeded path is now an explicit OVERRIDE
48//!
49//! [`crate::wg::WgConfig::resolve`] — `wg0.conf` and `GUNNAR_WG` — is untouched
50//! and still works, and is what a gate-OFF gunnar uses. It is an override
51//! because it supplies the private key from outside, which is precisely what the
52//! ceremony exists to stop doing.
53//!
54//! **Gate ON plus a seeded config present is REFUSED**, not resolved by
55//! precedence. That is the same ruling `WgConfig::resolve` already makes when
56//! both of ITS two sources are present, and for the same reason: two
57//! configurations that disagree is the failure a precedence rule hides. The
58//! operator who edited the losing one would see a working tunnel with the wrong
59//! key and no way to tell from the console — and here the losing field is the
60//! machine's IDENTITY, so the hub would be trusting a key nobody registered.
61//!
62//! # gunnar's ONE documented divergence: `[Interface] Address` is REQUIRED
63//!
64//! The shared dialect makes `Address` optional, and holger and korp accept a
65//! hub with none — they get a transport-only tunnel that seals packets but
66//! carries no IP. For gunnar that is not a degraded tunnel, it is **no product
67//! at all**: this whole lane exists to give the machine "a stable address that
68//! outlives the DHCP lease" (`crate::wg`'s first line), and gunnar's listeners
69//! reach the tunnel by binding `0.0.0.0` and waiting for `wg0` to have an
70//! address on it. A gunnar tunnel with no address is one nothing answers on.
71//!
72//! # …and it arrives at BOOTSTRAP, never on the medium (2026-09-21, T17)
73//!
74//! `Address` is a per-BOX value, and under the one-medium ruling one ISO
75//! installs the primary and its twin: an address in the medium's `hub.conf` was
76//! the same tunnel address on both. So the medium carries the SITE only (hub
77//! key, endpoint, AllowedIPs, keepalive), and the box's own address is handed
78//! over by [`METHOD_ASSIGN`] and kept in `<root>/wg/`[`ADDRESS_FILE`]. A hub with
79//! no address anywhere is not refused: the box boots, announces its key, raises
80//! no tunnel and names the door — the tunnel comes up when the address lands.
81//! An address on an OLDER medium is still read (a box already installed keeps
82//! its tunnel), said out loud every boot; the two disagreeing is refused.
83//!
84//! The key half was already right: the box mints it at its first start (the
85//! shared ceremony, or korp-installer's `wgseal::mint_and_seal`), per box.
86
87use std::path::{Path, PathBuf};
88
89use anyhow::{bail, Context, Result};
90use wg_appliance_core::appliance::ApplianceEnv;
91use wg_appliance_core::ceremony::{self, HubPeer, Identity};
92
93use crate::wg::{Peer, WgConfig, CONFIG_FILE};
94
95/// The product name in the canonical identity line.
96pub const PRODUCT: &str = "gunnar";
97/// The gate: the ceremony runs iff this env is exactly `1`.
98pub const GATE_ENV: &str = "GUNNAR_APPLIANCE";
99/// Overrides the durable root — the test seam, and the escape hatch for a site
100/// whose identity partition is not the store root.
101///
102/// Deliberately NOT `GUNNAR_DATA_ROOT`: that is the store's root, a thing an
103/// operator may legitimately move, and overloading one env with both meanings
104/// would let a storage decision silently relocate the identity — which re-mints
105/// it, which stales the hub registration the operator already made.
106pub const DATA_ENV: &str = "GUNNAR_APPLIANCE_DATA";
107/// The optional per-site underlay address, `<a.b.c.d>/<prefix>@<ifname>`.
108///
109/// Rarely needed on gunnar, which already has `GUNNAR_NET` and its own netlink
110/// writer for the outer address; carried so the shared driver's shape is whole
111/// and a site that declares it on one product gets it on all three.
112pub const UNDERLAY_ENV: &str = "GUNNAR_UNDERLAY";
113/// The durable identity root when `serve` was given none and [`DATA_ENV`] is
114/// unset: P3's mountpoint on installed metal, the volume in a container.
115pub const DEFAULT_ROOT: &str = "/data";
116
117/// gunnar's envelope for the shared driver — the five strings that differ
118/// between the products, and nothing else.
119pub const ENV: ApplianceEnv = ApplianceEnv {
120    product: PRODUCT,
121    gate_env: GATE_ENV,
122    data_env: DATA_ENV,
123    underlay_env: UNDERLAY_ENV,
124    default_root: DEFAULT_ROOT,
125};
126
127/// Is the appliance gate on?
128///
129/// Exact `1`, nothing fuzzy — see [`ApplianceEnv::gate_enabled`].
130pub fn gate_enabled() -> bool {
131    ENV.gate_enabled()
132}
133
134/// The ceremony's directory for a given data root: `<root>/wg`, honouring
135/// [`DATA_ENV`].
136pub fn wg_dir(data_root: &Path) -> PathBuf {
137    ApplianceEnv::wg_dir(&ENV.data_root_or(data_root))
138}
139
140/// What the ceremony produced.
141pub struct Ceremony {
142    /// The appliance's public key, spelled for the operator — 44 characters of
143    /// padded base64, the same string [`ceremony::identity_line`] carried.
144    pub public_b64: String,
145    /// `true` iff THIS boot minted the identity.
146    pub minted: bool,
147    /// The tunnel gunnar should raise, or `None` when no hub has been declared
148    /// yet (first-boot reality: the operator needs the public key OUT before the
149    /// hub can be configured at all).
150    pub config: Option<WgConfig>,
151}
152
153/// `Debug` BY HAND, and it does not print the config.
154///
155/// `WgConfig` derives `Debug` and carries `private_key` in the clear, so a
156/// derived impl here would put the minted secret one `{:?}` away from every log
157/// line, every `expect_err` message and every panic. The ceremony's whole
158/// discipline is that the private half is written at 0600 and never formatted
159/// into a string; this keeps that true one level up.
160impl std::fmt::Debug for Ceremony {
161    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162        f.debug_struct("Ceremony")
163            .field("public_b64", &self.public_b64)
164            .field("minted", &self.minted)
165            .field("hub_declared", &self.config.is_some())
166            .finish()
167    }
168}
169
170/// **Run the ceremony**: load or mint the identity at `<root>/wg`, emit the
171/// MINTED line (first boot only) and the ONE canonical identity line through
172/// `out`, then read the hub declaration if the operator has placed one.
173///
174/// The gate is the CALLER's check, so a gated-off run does nothing at all and
175/// emits nothing at all.
176///
177/// `out` is a sink rather than `println!` so the SAME bytes reach the serial
178/// console (which on the appliance is `/dev/console`, the line
179/// `installtest/src/iso.rs` greps its boot proof off) and gunnar's `tracing`
180/// log, formatted once. It is also what lets a test assert the emitted LINES —
181/// the applied output — instead of round-tripping the struct.
182///
183/// Every refusal names the thing to go look at, and NOTHING is emitted on one:
184/// a corrupt, truncated, unreadable or group-readable identity file returns the
185/// shared crate's error rather than minting identity number two. That is the
186/// whole promise — an appliance that silently re-mints on reboot has changed its
187/// identity without telling anyone, and every peer that trusted it is now
188/// trusting a stranger.
189pub fn run(data_root: &Path, out: &mut dyn FnMut(&str)) -> Result<Ceremony> {
190    let root = ENV.data_root_or(data_root);
191    let wg = ApplianceEnv::wg_dir(&root);
192
193    // The underlay before anything needs the network — the tunnel's own UDP
194    // needs a route out. Almost always absent on gunnar (`GUNNAR_NET` and
195    // `appliance_pid1::net::apply` own the outer address); declared-but-unappliable fails loud.
196    ENV.apply_underlay(out).map_err(|e| anyhow::anyhow!(e))?;
197
198    let identity: Identity = ENV
199        .announce_identity(&wg, out)
200        .with_context(|| format!("the appliance identity ceremony at {}", wg.display()))?;
201
202    let hub: Option<HubPeer> = ENV
203        .read_hub(&wg, out)
204        .with_context(|| format!("the hub declaration in {}", wg.display()))?;
205
206    let config = match hub {
207        None => None,
208        Some(hub) => config_from_ceremony(&identity, &hub, &wg, out)?,
209    };
210    Ok(Ceremony {
211        public_b64: identity.public_b64,
212        minted: identity.minted,
213        config,
214    })
215}
216
217/// The ceremony's two halves — the minted private key and the declared hub —
218/// wired into the config shape [`crate::wg::bring_up`] already establishes.
219///
220/// Deliberately reuses `WgConfig` rather than growing a second establish path:
221/// gunnar's netlink address writer, its unrouted-`AllowedIPs` warning and its
222/// handshake watch are all worth having on the ceremony lane, and duplicating
223/// `establish` to get them would be the twin one implementation forbids.
224fn config_from_ceremony(
225    identity: &Identity,
226    hub: &HubPeer,
227    wg: &Path,
228    out: &mut dyn FnMut(&str),
229) -> Result<Option<WgConfig>> {
230    let assigned = assigned_address(wg)?;
231    let (address, prefix) = match (assigned, hub.appliance_address) {
232        (Some(a), Some(m)) if a != m => bail!(
233            "{} says this box's tunnel address is {}/{} and {} (handed over by \
234             {METHOD_ASSIGN}) says {}/{}. Two answers to one per-box question, and this refuses \
235             to rank them: the hub routes by one of them. Remove `[Interface]` from the medium's \
236             hub.conf — the medium carries the SITE only — or remove {} if the medium is right",
237            ApplianceEnv::hub_path(wg).display(),
238            m.0,
239            m.1,
240            wg.join(ADDRESS_FILE).display(),
241            a.0,
242            a.1,
243            wg.join(ADDRESS_FILE).display(),
244        ),
245        (Some(a), _) => a,
246        (None, Some(m)) => {
247            // A box installed from a medium mastered before the one-medium
248            // ruling: accepted, because refusing would take a working box off
249            // its tunnel, and said out loud every boot.
250            out(&format!(
251                "{PRODUCT}-appliance wg: the tunnel address {}/{} comes from the MEDIUM ({}). An address \
252                 on a medium is the same address on every box installed from it; a new medium carries \
253                 the site only, and each box gets its own through {METHOD_ASSIGN}",
254                m.0,
255                m.1,
256                ApplianceEnv::hub_path(wg).display()
257            ));
258            m
259        }
260        (None, None) => {
261            out(&format!(
262                "{PRODUCT}-appliance wg: hub declared, NO tunnel address yet — this box's address is handed \
263                 over at bootstrap by {METHOD_ASSIGN} (`gunnar control tunnel enrol`), never baked into \
264                 the medium. Serving on the outer address until then; the tunnel comes up the moment the \
265                 address arrives, no restart"
266            ));
267            return Ok(None);
268        }
269    };
270    let spec = hub.peer_spec();
271    // The interface name comes from the config file's stem, the same single
272    // source `WgConfig::from_data_root` uses — two spellings of `wg0` is a way
273    // for them to disagree.
274    let interface = Path::new(CONFIG_FILE)
275        .file_stem()
276        .and_then(|s| s.to_str())
277        .expect("CONFIG_FILE is a literal with a stem");
278    Ok(Some(WgConfig {
279        interface: interface.to_string(),
280        private_key: identity.private,
281        address,
282        prefix,
283        // `None` — the appliance only ever initiates, so the kernel picks. A
284        // fixed port would be a firewall rule the site has to know about for no
285        // gain.
286        listen_port: None,
287        peers: vec![Peer {
288            public_key: spec.public_key,
289            // The ceremony's dialect refuses `PresharedKey` BY NAME: a hub file
290            // is baked into per-site ISOs and copied freely, so it carries
291            // public data only.
292            preshared_key: None,
293            allowed_ips: spec.allowed_ips,
294            endpoint: spec.endpoint,
295            persistent_keepalive: spec.persistent_keepalive,
296        }],
297    }))
298}
299
300/// The gRPC door the tunnel address arrives through — named in every line
301/// that tells an operator where the address comes from.
302pub const METHOD_ASSIGN: &str = "Bootstrap.AssignTunnelAddress";
303
304/// **This box's tunnel address**, `<root>/wg/address`: one line,
305/// `a.b.c.d/prefix`, written by [`assign_address`] when the site hands it over
306/// at bootstrap. gunnar's own file beside the shared ceremony's three — the
307/// shared dialect keeps `[Interface] Address` in `hub.conf` for holger and korp,
308/// and gunnar reads it there only for a box installed from an older medium.
309/// 8.3-safe for P3's FAT.
310pub const ADDRESS_FILE: &str = "address";
311
312/// Parse a tunnel address the way [`assign_address`] will: `a.b.c.d/prefix`,
313/// a host of its subnet.
314pub fn parse_address(text: &str) -> Result<(std::net::Ipv4Addr, u8)> {
315    parse_cidr(text)
316}
317
318fn parse_cidr(text: &str) -> Result<(std::net::Ipv4Addr, u8)> {
319    let (a, p) = text
320        .trim()
321        .split_once('/')
322        .with_context(|| format!("{text:?} is not `a.b.c.d/prefix`"))?;
323    let addr: std::net::Ipv4Addr = a.trim().parse().with_context(|| format!("{a:?} is not an IPv4 address"))?;
324    let prefix: u8 = p
325        .trim()
326        .parse()
327        .ok()
328        .filter(|p| (1..=32).contains(p))
329        .with_context(|| format!("/{p} is not a prefix 1–32 (the site's real prefix: the connected route it installs is what answers the hub)"))?;
330    if prefix < 31 {
331        let mask = u32::MAX << (32 - prefix);
332        let host = u32::from(addr) & !mask;
333        anyhow::ensure!(host != 0 && host != !mask, "{addr}/{prefix} is the network or broadcast address of its subnet, not a host");
334    }
335    Ok((addr, prefix))
336}
337
338/// The address [`assign_address`] recorded, if any. A file that does not parse
339/// is REFUSED, never treated as absent: absent means "wait for the bootstrap",
340/// and a box that silently waited again would re-ask for an address and could
341/// be handed a second one.
342pub fn assigned_address(wg: &Path) -> Result<Option<(std::net::Ipv4Addr, u8)>> {
343    let path = wg.join(ADDRESS_FILE);
344    match std::fs::read_to_string(&path) {
345        Ok(text) => parse_cidr(&text).map(Some).with_context(|| format!("{}", path.display())),
346        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
347        Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
348    }
349}
350
351/// What [`assign_address`] answers.
352#[derive(Debug, Clone, PartialEq, Eq)]
353pub struct Assignment {
354    /// This box's WireGuard public key — what the hub registers.
355    pub public_b64: String,
356    /// The address the box holds now (assigned, or off an older medium).
357    pub address: Option<(std::net::Ipv4Addr, u8)>,
358    /// `true` iff THIS call wrote the address.
359    pub changed: bool,
360}
361
362/// **The door's decision, pure.** `requested = None` is a read and always
363/// allowed. Writing an address is allowed while the box is FRESH (the
364/// bootstrap window is open — the same rule `Enroll` and `BecomeTwin` keep), or
365/// when it is the address the box already holds (an idempotent retry).
366pub fn assignment_door(
367    gate: bool,
368    window_open: bool,
369    held: Option<(std::net::Ipv4Addr, u8)>,
370    requested: Option<(std::net::Ipv4Addr, u8)>,
371) -> Result<()> {
372    anyhow::ensure!(
373        gate,
374        "this box runs no appliance ceremony ({GATE_ENV} is not 1), so it has no minted WireGuard identity \
375         to give an address to — a dev box takes its tunnel from wg0.conf"
376    );
377    match requested {
378        None => Ok(()),
379        Some(r) if held == Some(r) => Ok(()),
380        Some(_) if window_open => Ok(()),
381        Some((a, p)) => bail!(
382            "{a}/{p} refused: this box is past its bootstrap (an identity is enrolled), and {METHOD_ASSIGN} \
383             is unauthenticated, so it writes only while the box is fresh or repeats the address it already holds{}. \
384             A gunnar-admin sets it with Admin.SetTunnelAddress (`gunnar control tunnel enrol -i <admin key>`)",
385            match held {
386                Some((h, hp)) => format!(" ({h}/{hp})"),
387                None => " (none)".to_string(),
388            }
389        ),
390    }
391}
392
393/// **`Bootstrap.AssignTunnelAddress`, the box half**: record `requested` as
394/// this box's tunnel address, or with `None` just answer what the box holds.
395///
396/// Idempotent for the same address; a DIFFERENT address is refused — a tunnel
397/// address is never re-pointed, because the hub has already routed the old one
398/// to this key. The identity must already exist (the ceremony runs at start,
399/// before the control plane listens); a box with none is refused by name rather
400/// than minting one here, which would be a second minting door.
401pub fn assign_address(data_root: &Path, requested: Option<&str>) -> Result<Assignment> {
402    let wg = wg_dir(data_root);
403    let pub_path = wg.join(ceremony::IDENTITY_PUB_FILE);
404    let public_b64 = std::fs::read_to_string(&pub_path)
405        .with_context(|| format!("{} — the ceremony has not run on this box, so there is no key to address", pub_path.display()))?
406        .trim()
407        .to_string();
408    let medium = match std::fs::metadata(ApplianceEnv::hub_path(&wg)) {
409        Ok(_) => ceremony::read_hub_config(&ApplianceEnv::hub_path(&wg))
410            .map_err(|e| anyhow::anyhow!("{e}"))?
411            .appliance_address,
412        Err(_) => None,
413    };
414    let assigned = assigned_address(&wg)?;
415    let held = assigned.or(medium);
416    let Some(text) = requested.map(str::trim).filter(|t| !t.is_empty()) else {
417        return Ok(Assignment { public_b64, address: held, changed: false });
418    };
419    let want = parse_cidr(text)?;
420    if let Some(have) = held {
421        anyhow::ensure!(
422            have == want,
423            "this box already holds the tunnel address {}/{}, and a tunnel address is never re-pointed — the hub \
424             routes {}/32 to this key. {}/{} refused",
425            have.0,
426            have.1,
427            have.0,
428            want.0,
429            want.1
430        );
431        if assigned.is_some() {
432            return Ok(Assignment { public_b64, address: held, changed: false });
433        }
434    }
435    std::fs::create_dir_all(&wg).with_context(|| format!("creating {}", wg.display()))?;
436    let path = wg.join(ADDRESS_FILE);
437    let tmp = wg.join("address.new");
438    {
439        use std::io::Write as _;
440        let mut f = std::fs::File::create(&tmp).with_context(|| format!("writing {}", tmp.display()))?;
441        writeln!(f, "{}/{}", want.0, want.1)?;
442        f.sync_all()?;
443    }
444    std::fs::rename(&tmp, &path).with_context(|| format!("renaming into {}", path.display()))?;
445    if let Ok(d) = std::fs::File::open(&wg) {
446        let _ = d.sync_all();
447    }
448    Ok(Assignment { public_b64, address: Some(want), changed: true })
449}
450
451/// **Both tunnel-address doors, one implementation**: `Bootstrap.AssignTunnelAddress`
452/// (`may_write` = the bootstrap window is open) and `Admin.SetTunnelAddress`
453/// (`may_write` = true; the service held the caller to server Admin).
454pub fn tunnel_address_answer(
455    root: &Path,
456    address: &str,
457    may_write: bool,
458) -> gunnar_grpc::ControlResult<gunnar_proto::AssignTunnelAddressResponse> {
459    use gunnar_grpc::ControlError;
460    let pre = |err: anyhow::Error| ControlError::FailedPrecondition(format!("{err:#}"));
461    let requested = if address.is_empty() { None } else { Some(address) };
462    // **A paired box's tunnel is the pairing loop's** (2026-09-24): `show`
463    // reports THAT tunnel — the key the door recorded, the address it
464    // assigned, whether wg0 carries it now — not the ceremony's unused
465    // identity; and an address is never handed over beside the door's.
466    if let Some((public_b64, held, tunnel_up)) = crate::wg::pairing_tunnel(root) {
467        if requested.is_some() {
468            return Err(ControlError::FailedPrecondition(
469                "REFUSED [tunnel-owned-by-pairing] this box's tunnel address is the installer door's answer (<data>/pairing/); it is not handed over here".into(),
470            ));
471        }
472        let address = held.map(|(a, p)| format!("{a}/{p}")).unwrap_or_default();
473        let detail = match (&held, tunnel_up) {
474            (None, _) => "the pairing loop owns this tunnel and the door has not assigned an address yet".to_string(),
475            (Some(_), true) => format!("wg0 is up at {address} (the pairing loop's tunnel; the front's relay lists {public_b64})"),
476            (Some(_), false) => format!("{address} is the pairing loop's address and wg0 is NOT up (the boot log says why)"),
477        };
478        return Ok(gunnar_proto::AssignTunnelAddressResponse { wg_public_key: public_b64, address, changed: false, tunnel_up, detail });
479    }
480    let held = assign_address(root, None).map_err(pre)?.address;
481    let parsed = match requested {
482        None => None,
483        Some(text) => Some(parse_address(text).map_err(|err| ControlError::InvalidArgument(format!("{err:#}")))?),
484    };
485    assignment_door(gate_enabled(), may_write, held, parsed).map_err(pre)?;
486    let got = assign_address(root, requested).map_err(pre)?;
487    let tunnel_up = if got.address.is_some() {
488        crate::wg::raise_after_assignment(root).map_err(pre)?
489    } else {
490        crate::wg::ceremony_tunnel_up()
491    };
492    let address = got.address.map(|(a, p)| format!("{a}/{p}")).unwrap_or_default();
493    let detail = match (&got.address, tunnel_up) {
494        (None, _) => format!("no tunnel address yet: hand one over with {METHOD_ASSIGN} (`gunnar control tunnel enrol`)"),
495        (Some(_), true) => format!(
496            "wg0 is up at {address}; register {} on the hub with AllowedIPs = {}/32",
497            got.public_b64,
498            address.split('/').next().unwrap_or_default()
499        ),
500        (Some(_), false) => format!(
501            "{address} is recorded and wg0 is NOT up (no hub.conf yet, or the kernel refused — the boot log says which); it comes up at the next start"
502        ),
503    };
504    Ok(gunnar_proto::AssignTunnelAddressResponse { wg_public_key: got.public_b64, address, changed: got.changed, tunnel_up, detail })
505}
506
507/// The SSH door's `key value` lines: one writer and one reader, in
508/// `gunnar-proto` so the CLI (which may not link the server) reads what the
509/// server writes.
510pub use gunnar_proto::{parse_tunnel_answer, render_tunnel_answer};
511
512/// The path of the identity's public half, for an operator (or a one-shot
513/// container) that wants to read the key back without parsing logs.
514pub fn identity_pub_path(data_root: &Path) -> PathBuf {
515    wg_dir(data_root).join(ceremony::IDENTITY_PUB_FILE)
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521    use wg_appliance_core::ceremony::{parse_identity_line, IDENTITY_KEY_FILE};
522
523    /// A std-only scratch dir. Unique per test, removed on drop.
524    struct Scratch(PathBuf);
525    impl Scratch {
526        fn new(tag: &str) -> Scratch {
527            use std::sync::atomic::{AtomicU32, Ordering};
528            static N: AtomicU32 = AtomicU32::new(0);
529            let dir = std::env::temp_dir().join(format!(
530                "gunnar-ceremony-{tag}-{}-{}",
531                std::process::id(),
532                N.fetch_add(1, Ordering::Relaxed)
533            ));
534            std::fs::create_dir_all(&dir).unwrap();
535            Scratch(dir)
536        }
537        fn path(&self) -> &Path {
538            &self.0
539        }
540    }
541    impl Drop for Scratch {
542        fn drop(&mut self) {
543            let _ = std::fs::remove_dir_all(&self.0);
544        }
545    }
546
547    /// Collect what the ceremony EMITTED, which is the whole product: the
548    /// operator reads these bytes off a serial console and registers what is in
549    /// them. Asserting the struct instead would pass with the sink wired to
550    /// nothing.
551    fn run_collecting(root: &Path) -> (Result<Ceremony>, Vec<String>) {
552        let mut lines = Vec::new();
553        let r = run(root, &mut |l| lines.push(l.to_string()));
554        (r, lines)
555    }
556
557    /// gunnar emits the ONE canonical line, in the shape every product's
558    /// watcher greps — and the key IN the line is the key on disk.
559    ///
560    /// **RED-when-broken**: the regex is the shared crate's constant, so a
561    /// gunnar-private spelling of the line fails here rather than being
562    /// discovered by a boot proof that quietly matched nothing.
563    #[test]
564    fn the_line_gunnar_emits_is_the_canonical_one_and_carries_the_key_on_disk() {
565        let dir = Scratch::new("line");
566        let (out, lines) = run_collecting(dir.path());
567        let out = out.expect("an empty root is first boot");
568
569        let identity_lines: Vec<&String> = lines
570            .iter()
571            .filter(|l| parse_identity_line(l).is_some())
572            .collect();
573        assert_eq!(
574            identity_lines.len(),
575            1,
576            "exactly ONE canonical identity line, got {lines:?}"
577        );
578        let (product, pubkey) =
579            parse_identity_line(identity_lines[0]).expect("just filtered on it");
580        assert_eq!(product, "gunnar", "the line must name the product");
581        assert_eq!(
582            pubkey, out.public_b64,
583            "the printed key must be the key the ceremony returned"
584        );
585
586        // The applied output on disk, not a state round-trip: identity.pub is
587        // what a one-shot `cat` reads back, and it must agree byte for byte
588        // with what the operator was told.
589        let on_disk = std::fs::read_to_string(identity_pub_path(dir.path()))
590            .expect("identity.pub is written next to the key");
591        assert_eq!(
592            on_disk.trim(),
593            pubkey,
594            "identity.pub and the printed line must be the same key"
595        );
596    }
597
598    /// **REFUSE, NEVER RE-MINT — the guard that matters most.**
599    ///
600    /// An appliance that silently re-mints on reboot has changed its identity
601    /// without telling anyone: the operator registered the FIRST key with the
602    /// hub, and every peer that trusted it is now trusting a stranger while the
603    /// console shows a healthy boot.
604    ///
605    /// So: mint once, corrupt the identity, and assert the second boot REFUSES —
606    /// naming the file — and that it emitted NOTHING (no identity line, no
607    /// MINTED line) and left the corrupt bytes in place as the evidence.
608    #[test]
609    fn a_corrupt_identity_refuses_and_does_not_mint_a_second_one() {
610        let dir = Scratch::new("refuse");
611        let (first, _) = run_collecting(dir.path());
612        first.expect("first boot mints");
613        let key_path = wg_dir(dir.path()).join(IDENTITY_KEY_FILE);
614        let minted_bytes = std::fs::read(&key_path).expect("the key file exists");
615
616        let corrupt = b"this is not a base64 x25519 key\n";
617        std::fs::write(&key_path, corrupt).expect("corrupt it");
618
619        let (second, lines) = run_collecting(dir.path());
620        let err = second.expect_err(
621            "a corrupt identity MUST refuse; minting a second one silently changes \
622             the machine's identity",
623        );
624        let text = format!("{err:#}");
625        assert!(
626            text.contains(&key_path.display().to_string()),
627            "the refusal must name the file the operator has to go look at: {text}"
628        );
629        assert!(
630            lines.is_empty(),
631            "a refused ceremony must emit NO identity line — a printed key an \
632             operator might register: {lines:?}"
633        );
634        assert_eq!(
635            std::fs::read(&key_path).expect("still there"),
636            corrupt,
637            "the corrupt bytes stay in place as the evidence; they must not be \
638             overwritten by a fresh mint"
639        );
640        assert_ne!(
641            minted_bytes, corrupt,
642            "the fixture is inert unless the corruption actually changed the file"
643        );
644    }
645
646    /// The identity SURVIVES a reboot, and the second boot says so by NOT
647    /// saying MINTED.
648    #[test]
649    fn the_identity_survives_a_reboot_and_the_second_boot_is_silent_about_minting() {
650        let dir = Scratch::new("survive");
651        let (first, first_lines) = run_collecting(dir.path());
652        let first = first.expect("first boot");
653        assert!(first.minted, "an empty root is first boot");
654        assert!(
655            first_lines.iter().any(|l| l.contains("MINTED")),
656            "the first boot must say MINTED: {first_lines:?}"
657        );
658
659        let (second, second_lines) = run_collecting(dir.path());
660        let second = second.expect("second boot");
661        assert_eq!(
662            second.public_b64, first.public_b64,
663            "the identity must survive — a re-mint stales the hub registration"
664        );
665        assert!(
666            !second_lines.iter().any(|l| l.contains("MINTED")),
667            "a MINTED line on a reboot is the appliance announcing it threw its \
668             identity away: {second_lines:?}"
669        );
670    }
671
672    /// No hub yet is FIRST-BOOT REALITY, not an error — and the appliance says
673    /// what to do next rather than failing.
674    #[test]
675    fn no_hub_yet_is_the_identity_only_ceremony_and_it_says_what_to_do() {
676        let dir = Scratch::new("nohub");
677        let (out, lines) = run_collecting(dir.path());
678        let out = out.expect("no hub is not an error");
679        assert!(out.config.is_none(), "there is no tunnel to raise yet");
680        let hub_line = lines
681            .iter()
682            .find(|l| l.contains("hub: none configured"))
683            .unwrap_or_else(|| panic!("the operator must be told the next move: {lines:?}"));
684        assert!(
685            hub_line.contains(ceremony::HUB_CONF_FILE),
686            "and told the exact filename to place: {hub_line}"
687        );
688    }
689
690    /// A declared hub becomes the config gunnar's own `establish` raises —
691    /// asserted field by field on the WIRE-SHAPED output, because a hub whose
692    /// endpoint or AllowedIPs were dropped in translation is a tunnel that
693    /// "came up" and carries nothing.
694    #[test]
695    fn a_declared_hub_becomes_gunnars_own_tunnel_config() {
696        let dir = Scratch::new("hub");
697        // Mint first so the hub file lands next to a real identity.
698        let (first, _) = run_collecting(dir.path());
699        let first = first.expect("first boot");
700        let wg = wg_dir(dir.path());
701        std::fs::write(
702            ApplianceEnv::hub_path(&wg),
703            "[Interface]\n\
704             Address = 10.99.0.7/24\n\
705             \n\
706             [Peer]\n\
707             PublicKey = 6yUuA2NvUdRuoHwFmS9OD1V8pFpXmDgVQjJ+YEptGm4=\n\
708             AllowedIPs = 10.99.0.0/24\n\
709             Endpoint = 10.0.2.2:51820\n\
710             PersistentKeepalive = 25\n",
711        )
712        .expect("write hub.conf");
713
714        let (second, _) = run_collecting(dir.path());
715        let cfg = second
716            .expect("a well-formed hub")
717            .config
718            .expect("a declared hub raises a tunnel");
719
720        assert_eq!(cfg.interface, "wg0");
721        assert_eq!(cfg.address.octets(), [10, 99, 0, 7], "the site's address");
722        assert_eq!(cfg.prefix, 24);
723        assert_eq!(cfg.listen_port, None, "the appliance only ever initiates");
724        assert_eq!(cfg.peers.len(), 1, "the ceremony declares exactly one hub");
725        let p = &cfg.peers[0];
726        assert_eq!(p.allowed_ips, vec![("10.99.0.0".parse().unwrap(), 24)]);
727        assert_eq!(
728            p.endpoint,
729            Some("10.0.2.2:51820".parse().unwrap()),
730            "the endpoint must survive the translation or the appliance dials nowhere"
731        );
732        assert_eq!(p.persistent_keepalive, Some(25));
733        assert_eq!(
734            p.preshared_key, None,
735            "the dialect refuses PresharedKey by name — a hub file is public data"
736        );
737        // The private key in the wire config is the MINTED one, not a seeded
738        // one: this is the whole point of the ceremony.
739        assert_eq!(
740            wg_appliance_core::encode_key(&cfg.public_key()),
741            first.public_b64,
742            "the tunnel must carry the identity the ceremony announced"
743        );
744    }
745
746    /// The medium's hub.conf as the one-medium ruling wants it: the SITE only.
747    const SITE_ONLY_HUB: &str = "[Peer]\n\
748         PublicKey = 6yUuA2NvUdRuoHwFmS9OD1V8pFpXmDgVQjJ+YEptGm4=\n\
749         AllowedIPs = 10.99.0.0/24\n\
750         Endpoint = 10.0.2.2:51820\n\
751         PersistentKeepalive = 25\n";
752
753    /// **ONE MEDIUM, MANY BOXES** (2026-09-21, T17): the medium's `hub.conf`
754    /// carries only the site — hub key, endpoint, AllowedIPs, keepalive — and
755    /// NO `[Interface] Address`, because an address on the medium is the same
756    /// address on every box installed from it. A box that has not been handed
757    /// its address yet is NOT refused: it boots, announces its identity, raises
758    /// no tunnel, and says which door hands the address over.
759    ///
760    /// RED on d54e3f8: `config_from_ceremony` bailed ("declares a hub but no
761    /// `[Interface] Address`"), so a correct one-medium box refused to start its
762    /// ceremony at all.
763    #[test]
764    fn a_site_only_hub_waits_for_the_bootstrap_address_and_is_not_refused() {
765        let dir = Scratch::new("siteonly");
766        let (_, _) = run_collecting(dir.path());
767        std::fs::write(ApplianceEnv::hub_path(&wg_dir(dir.path())), SITE_ONLY_HUB).expect("write hub.conf");
768
769        let (out, lines) = run_collecting(dir.path());
770        let out = out.expect("a site-only hub.conf is the correct medium, not an error");
771        assert!(out.config.is_none(), "no address yet, so no tunnel yet");
772        assert!(
773            lines.iter().any(|l| l.contains("Bootstrap.AssignTunnelAddress")),
774            "the box must name the door its address arrives through: {lines:?}"
775        );
776    }
777
778    /// **Two boxes, one medium, two tunnel addresses** — the defect T9 found,
779    /// closed on the box: the same site-only hub.conf on two data roots, each
780    /// handed its own address, raises two different `wg0` addresses under two
781    /// different minted keys.
782    #[test]
783    fn two_boxes_from_one_medium_raise_the_address_each_was_handed() {
784        let (a, b) = (Scratch::new("box-a"), Scratch::new("box-b"));
785        let mut keys = Vec::new();
786        for (dir, addr) in [(&a, "10.99.0.2/24"), (&b, "10.99.0.3/24")] {
787            let (first, _) = run_collecting(dir.path());
788            keys.push(first.expect("first boot").public_b64);
789            std::fs::write(ApplianceEnv::hub_path(&wg_dir(dir.path())), SITE_ONLY_HUB).unwrap();
790            let got = assign_address(dir.path(), Some(addr)).expect("a fresh box takes its address");
791            assert!(got.changed);
792            assert_eq!(got.public_b64, *keys.last().unwrap(), "the door answers the key the hub must register");
793        }
794        let cfg = |d: &Scratch| run_collecting(d.path()).0.expect("boot").config.expect("an address raises the tunnel");
795        let (ca, cb) = (cfg(&a), cfg(&b));
796        assert_eq!((ca.address.octets(), ca.prefix), ([10, 99, 0, 2], 24));
797        assert_eq!((cb.address.octets(), cb.prefix), ([10, 99, 0, 3], 24));
798        assert_ne!(keys[0], keys[1], "each box minted its own key");
799        assert_eq!(ca.peers[0].endpoint, cb.peers[0].endpoint, "and both follow the one site the medium declared");
800    }
801
802    /// The same address again is a no-op (a retried enrol); a different one is
803    /// refused — the hub already routes the first to this key.
804    #[test]
805    fn the_same_address_is_idempotent_and_a_second_one_is_refused() {
806        let dir = Scratch::new("reassign");
807        run_collecting(dir.path()).0.expect("first boot");
808        assert!(assign_address(dir.path(), Some("10.99.0.2/24")).unwrap().changed);
809        let again = assign_address(dir.path(), Some("10.99.0.2/24")).unwrap();
810        assert!(!again.changed, "a retry writes nothing");
811        let err = format!("{:#}", assign_address(dir.path(), Some("10.99.0.3/24")).unwrap_err());
812        assert!(err.contains("never re-pointed") && err.contains("10.99.0.2/24"), "{err}");
813        let read = assign_address(dir.path(), None).unwrap();
814        assert_eq!(read.address, Some(("10.99.0.2".parse().unwrap(), 24)), "a read answers what is held");
815    }
816
817    #[test]
818    fn a_network_or_broadcast_address_and_a_box_with_no_identity_are_refused() {
819        let dir = Scratch::new("badaddr");
820        let err = format!("{:#}", assign_address(dir.path(), Some("10.99.0.2/24")).unwrap_err());
821        assert!(err.contains("identity.pub"), "no ceremony yet, no key to address: {err}");
822        run_collecting(dir.path()).0.expect("first boot");
823        for bad in ["10.99.0.0/24", "10.99.0.255/24", "10.99.0.2", "10.99.0.2/33", "fe80::1/64"] {
824            assert!(assign_address(dir.path(), Some(bad)).is_err(), "{bad} must be refused");
825        }
826        assert!(!wg_dir(dir.path()).join(ADDRESS_FILE).exists(), "no refusal writes anything");
827    }
828
829    /// An older medium's address still raises the tunnel (and says where it
830    /// came from); a bootstrap address that disagrees with it is refused.
831    #[test]
832    fn an_older_mediums_address_is_read_and_a_disagreeing_one_is_refused() {
833        let dir = Scratch::new("legacy");
834        run_collecting(dir.path()).0.expect("first boot");
835        let wg = wg_dir(dir.path());
836        std::fs::write(ApplianceEnv::hub_path(&wg), format!("[Interface]\nAddress = 10.99.0.7/24\n\n{SITE_ONLY_HUB}")).unwrap();
837        let (out, lines) = run_collecting(dir.path());
838        assert_eq!(out.unwrap().config.expect("legacy medium").address.octets(), [10, 99, 0, 7]);
839        assert!(lines.iter().any(|l| l.contains("from the MEDIUM")), "{lines:?}");
840        assert!(assign_address(dir.path(), Some("10.99.0.8/24")).is_err(), "the medium's address is held");
841        std::fs::write(wg.join(ADDRESS_FILE), "10.99.0.8/24\n").unwrap();
842        let err = format!("{:#}", run_collecting(dir.path()).0.unwrap_err());
843        assert!(err.contains("refuses"), "{err}");
844    }
845
846    #[test]
847    fn the_door_writes_only_while_fresh_or_the_same_address() {
848        let a = Some(("10.99.0.2".parse().unwrap(), 24));
849        let b = Some(("10.99.0.3".parse().unwrap(), 24));
850        assert!(assignment_door(false, true, None, a).is_err(), "no ceremony, no door");
851        assert!(assignment_door(true, false, a, None).is_ok(), "a read is always answered");
852        assert!(assignment_door(true, true, None, a).is_ok(), "a fresh box takes one");
853        assert!(assignment_door(true, false, a, a).is_ok(), "a retry of the held address");
854        assert!(assignment_door(true, false, None, a).is_err(), "an enrolled box does not take one unauthenticated");
855        assert!(assignment_door(true, false, a, b).is_err());
856    }
857
858    #[test]
859    fn the_ssh_doors_answer_round_trips_and_a_refusal_is_an_error() {
860        let a = gunnar_proto::AssignTunnelAddressResponse {
861            wg_public_key: "6yUuA2NvUdRuoHwFmS9OD1V8pFpXmDgVQjJ+YEptGm4=".into(),
862            address: "10.77.0.2/16".into(),
863            changed: true,
864            tunnel_up: false,
865            detail: "two\nlines".into(),
866        };
867        let back = parse_tunnel_answer(&render_tunnel_answer(&a)).unwrap();
868        assert_eq!((back.wg_public_key.as_str(), back.address.as_str(), back.changed, back.tunnel_up), (a.wg_public_key.as_str(), "10.77.0.2/16", true, false));
869        assert_eq!(back.detail, "two lines");
870        let none = gunnar_proto::AssignTunnelAddressResponse { wg_public_key: "k".into(), ..Default::default() };
871        assert_eq!(parse_tunnel_answer(&render_tunnel_answer(&none)).unwrap().address, "");
872        assert_eq!(parse_tunnel_answer("error: never re-pointed\n").unwrap_err(), "never re-pointed");
873    }
874
875    /// The identity root is the ceremony's OWN env, never gunnar's store root.
876    /// Overloading one env with both meanings lets a storage decision relocate
877    /// the identity, which re-mints it, which stales the hub registration.
878    #[test]
879    fn the_data_env_is_its_own_and_is_not_gunnars_store_root() {
880        assert_ne!(DATA_ENV, "GUNNAR_DATA_ROOT");
881        assert_eq!(
882            wg_dir(Path::new("/srv/gunnar")),
883            Path::new("/srv/gunnar/wg"),
884            "with no override the ceremony lives under the root serve was given"
885        );
886    }
887}