Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-server/src/appliance.rs

//! **The appliance identity ceremony, gunnar-side** — a thin caller of edda's
//! `wg_appliance_core::appliance` + `::ceremony`, the ONE implementation holger,
//! gunnar and korp share. Nothing ceremony-shaped is implemented here (one implementation).
//!
//! # What gunnar was missing, precisely
//!
//! gunnar had a WireGuard tunnel before this module and it was **seeded**:
//! `wg0.conf` (or `GUNNAR_WG` + `wg0.key`) had to arrive with a private key
//! already in it, so the appliance's identity was something an operator MADE
//! and shipped rather than something the machine MINTED and announced. Two
//! consequences, both measured rather than argued:
//!
//! * The ISO ships `assets/appliance-root/data/` holding a `.keep` and nothing
//!   else, deliberately — a committed private key is a key everybody has — so a
//!   RELEASED gunnar ISO had no identity and no tunnel. The capability existed
//!   only for an operator who rebuilt the image with their own keys in it.
//! * gunnar emitted no canonical identity line. `load_or_mint_identity`
//!   references, measured 2026-08-14: holger 2, korp 2, **gunnar 0**.
//!
//! The reason gunnar seeded is gone. It seeded because `/data` on the live ISO
//! is RAM — a minted key would not survive the reboot, which is worse than no
//! key, because the operator registers the printed key with the hub and the next
//! boot then presents a different one. On INSTALLED metal that is no longer
//! true: `edda/crates/korp-installer/src/esp.rs` `format_data_partition` writes
//! a real FAT32 filesystem on P3 at install time, and gunnar takes its data root
//! as an argument rather than hardcoding `/data`, so it inherits the same
//! durable path korp and holger already mint onto.
//!
//! # The gate lives in the declaration, not in folklore
//!
//! The ceremony runs iff env [`GATE_ENV`]`=1`. `.nornir/build-variants.toml`'s
//! `[[build.installer]]` cell declares it as a boot parameter, riding the same
//! channel `GUNNAR_DATA_ROOT` and `GUNNAR_NET` already ride (Linux turns an
//! unrecognised `key=value` boot parameter into an environment variable for
//! init — `init/main.c`, `unknown_bootoption`). So "this binary is an appliance"
//! is a property of the packed artifact, never of a hand-typed flag: a plain
//! `gunnar serve` on a dev box mints nothing, writes nothing and prints nothing.
//!
//! # Fixed paths, fixed forever
//!
//! `<root>/wg/hub.conf`, `<root>/wg/identity.key`, `<root>/wg/identity.pub` —
//! the SAME names on P3, in a container volume, and under holger and korp,
//! because the filenames are constants in the shared crate and no product spells
//! them itself. `<root>` is the data root `serve` was given, overridable by
//! [`DATA_ENV`] (the test seam).
//!
//! # The seeded path is now an explicit OVERRIDE
//!
//! [`crate::wg::WgConfig::resolve`] — `wg0.conf` and `GUNNAR_WG` — is untouched
//! and still works, and is what a gate-OFF gunnar uses. It is an override
//! because it supplies the private key from outside, which is precisely what the
//! ceremony exists to stop doing.
//!
//! **Gate ON plus a seeded config present is REFUSED**, not resolved by
//! precedence. That is the same ruling `WgConfig::resolve` already makes when
//! both of ITS two sources are present, and for the same reason: two
//! configurations that disagree is the failure a precedence rule hides. The
//! operator who edited the losing one would see a working tunnel with the wrong
//! key and no way to tell from the console — and here the losing field is the
//! machine's IDENTITY, so the hub would be trusting a key nobody registered.
//!
//! # gunnar's ONE documented divergence: `[Interface] Address` is REQUIRED
//!
//! The shared dialect makes `Address` optional, and holger and korp accept a
//! hub with none — they get a transport-only tunnel that seals packets but
//! carries no IP. For gunnar that is not a degraded tunnel, it is **no product
//! at all**: this whole lane exists to give the machine "a stable address that
//! outlives the DHCP lease" (`crate::wg`'s first line), and gunnar's listeners
//! reach the tunnel by binding `0.0.0.0` and waiting for `wg0` to have an
//! address on it. A gunnar tunnel with no address is one nothing answers on.
//!
//! # …and it arrives at BOOTSTRAP, never on the medium (2026-09-21, T17)
//!
//! `Address` is a per-BOX value, and under the one-medium ruling one ISO
//! installs the primary and its twin: an address in the medium's `hub.conf` was
//! the same tunnel address on both. So the medium carries the SITE only (hub
//! key, endpoint, AllowedIPs, keepalive), and the box's own address is handed
//! over by [`METHOD_ASSIGN`] and kept in `<root>/wg/`[`ADDRESS_FILE`]. A hub with
//! no address anywhere is not refused: the box boots, announces its key, raises
//! no tunnel and names the door — the tunnel comes up when the address lands.
//! An address on an OLDER medium is still read (a box already installed keeps
//! its tunnel), said out loud every boot; the two disagreeing is refused.
//!
//! The key half was already right: the box mints it at its first start (the
//! shared ceremony, or korp-installer's `wgseal::mint_and_seal`), per box.

use std::path::{Path, PathBuf};

use anyhow::{bail, Context, Result};
use wg_appliance_core::appliance::ApplianceEnv;
use wg_appliance_core::ceremony::{self, HubPeer, Identity};

use crate::wg::{Peer, WgConfig, CONFIG_FILE};

/// The product name in the canonical identity line.
pub const PRODUCT: &str = "gunnar";
/// The gate: the ceremony runs iff this env is exactly `1`.
pub const GATE_ENV: &str = "GUNNAR_APPLIANCE";
/// Overrides the durable root — the test seam, and the escape hatch for a site
/// whose identity partition is not the store root.
///
/// Deliberately NOT `GUNNAR_DATA_ROOT`: that is the store's root, a thing an
/// operator may legitimately move, and overloading one env with both meanings
/// would let a storage decision silently relocate the identity — which re-mints
/// it, which stales the hub registration the operator already made.
pub const DATA_ENV: &str = "GUNNAR_APPLIANCE_DATA";
/// The optional per-site underlay address, `<a.b.c.d>/<prefix>@<ifname>`.
///
/// Rarely needed on gunnar, which already has `GUNNAR_NET` and its own netlink
/// writer for the outer address; carried so the shared driver's shape is whole
/// and a site that declares it on one product gets it on all three.
pub const UNDERLAY_ENV: &str = "GUNNAR_UNDERLAY";
/// The durable identity root when `serve` was given none and [`DATA_ENV`] is
/// unset: P3's mountpoint on installed metal, the volume in a container.
pub const DEFAULT_ROOT: &str = "/data";

/// gunnar's envelope for the shared driver — the five strings that differ
/// between the products, and nothing else.
pub const ENV: ApplianceEnv = ApplianceEnv {
    product: PRODUCT,
    gate_env: GATE_ENV,
    data_env: DATA_ENV,
    underlay_env: UNDERLAY_ENV,
    default_root: DEFAULT_ROOT,
};

/// Is the appliance gate on?
///
/// Exact `1`, nothing fuzzy — see [`ApplianceEnv::gate_enabled`].
pub fn gate_enabled() -> bool {
    ENV.gate_enabled()
}

/// The ceremony's directory for a given data root: `<root>/wg`, honouring
/// [`DATA_ENV`].
pub fn wg_dir(data_root: &Path) -> PathBuf {
    ApplianceEnv::wg_dir(&ENV.data_root_or(data_root))
}

/// What the ceremony produced.
pub struct Ceremony {
    /// The appliance's public key, spelled for the operator — 44 characters of
    /// padded base64, the same string [`ceremony::identity_line`] carried.
    pub public_b64: String,
    /// `true` iff THIS boot minted the identity.
    pub minted: bool,
    /// The tunnel gunnar should raise, or `None` when no hub has been declared
    /// yet (first-boot reality: the operator needs the public key OUT before the
    /// hub can be configured at all).
    pub config: Option<WgConfig>,
}

/// `Debug` BY HAND, and it does not print the config.
///
/// `WgConfig` derives `Debug` and carries `private_key` in the clear, so a
/// derived impl here would put the minted secret one `{:?}` away from every log
/// line, every `expect_err` message and every panic. The ceremony's whole
/// discipline is that the private half is written at 0600 and never formatted
/// into a string; this keeps that true one level up.
impl std::fmt::Debug for Ceremony {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Ceremony")
            .field("public_b64", &self.public_b64)
            .field("minted", &self.minted)
            .field("hub_declared", &self.config.is_some())
            .finish()
    }
}

/// **Run the ceremony**: load or mint the identity at `<root>/wg`, emit the
/// MINTED line (first boot only) and the ONE canonical identity line through
/// `out`, then read the hub declaration if the operator has placed one.
///
/// The gate is the CALLER's check, so a gated-off run does nothing at all and
/// emits nothing at all.
///
/// `out` is a sink rather than `println!` so the SAME bytes reach the serial
/// console (which on the appliance is `/dev/console`, the line
/// `installtest/src/iso.rs` greps its boot proof off) and gunnar's `tracing`
/// log, formatted once. It is also what lets a test assert the emitted LINES —
/// the applied output — instead of round-tripping the struct.
///
/// Every refusal names the thing to go look at, and NOTHING is emitted on one:
/// a corrupt, truncated, unreadable or group-readable identity file returns the
/// shared crate's error rather than minting identity number two. That is the
/// whole promise — an appliance that silently re-mints on reboot has changed its
/// identity without telling anyone, and every peer that trusted it is now
/// trusting a stranger.
pub fn run(data_root: &Path, out: &mut dyn FnMut(&str)) -> Result<Ceremony> {
    let root = ENV.data_root_or(data_root);
    let wg = ApplianceEnv::wg_dir(&root);

    // The underlay before anything needs the network — the tunnel's own UDP
    // needs a route out. Almost always absent on gunnar (`GUNNAR_NET` and
    // `appliance_pid1::net::apply` own the outer address); declared-but-unappliable fails loud.
    ENV.apply_underlay(out).map_err(|e| anyhow::anyhow!(e))?;

    let identity: Identity = ENV
        .announce_identity(&wg, out)
        .with_context(|| format!("the appliance identity ceremony at {}", wg.display()))?;

    let hub: Option<HubPeer> = ENV
        .read_hub(&wg, out)
        .with_context(|| format!("the hub declaration in {}", wg.display()))?;

    let config = match hub {
        None => None,
        Some(hub) => config_from_ceremony(&identity, &hub, &wg, out)?,
    };
    Ok(Ceremony {
        public_b64: identity.public_b64,
        minted: identity.minted,
        config,
    })
}

/// The ceremony's two halves — the minted private key and the declared hub —
/// wired into the config shape [`crate::wg::bring_up`] already establishes.
///
/// Deliberately reuses `WgConfig` rather than growing a second establish path:
/// gunnar's netlink address writer, its unrouted-`AllowedIPs` warning and its
/// handshake watch are all worth having on the ceremony lane, and duplicating
/// `establish` to get them would be the twin one implementation forbids.
fn config_from_ceremony(
    identity: &Identity,
    hub: &HubPeer,
    wg: &Path,
    out: &mut dyn FnMut(&str),
) -> Result<Option<WgConfig>> {
    let assigned = assigned_address(wg)?;
    let (address, prefix) = match (assigned, hub.appliance_address) {
        (Some(a), Some(m)) if a != m => bail!(
            "{} says this box's tunnel address is {}/{} and {} (handed over by \
             {METHOD_ASSIGN}) says {}/{}. Two answers to one per-box question, and this refuses \
             to rank them: the hub routes by one of them. Remove `[Interface]` from the medium's \
             hub.conf — the medium carries the SITE only — or remove {} if the medium is right",
            ApplianceEnv::hub_path(wg).display(),
            m.0,
            m.1,
            wg.join(ADDRESS_FILE).display(),
            a.0,
            a.1,
            wg.join(ADDRESS_FILE).display(),
        ),
        (Some(a), _) => a,
        (None, Some(m)) => {
            // A box installed from a medium mastered before the one-medium
            // ruling: accepted, because refusing would take a working box off
            // its tunnel, and said out loud every boot.
            out(&format!(
                "{PRODUCT}-appliance wg: the tunnel address {}/{} comes from the MEDIUM ({}). An address \
                 on a medium is the same address on every box installed from it; a new medium carries \
                 the site only, and each box gets its own through {METHOD_ASSIGN}",
                m.0,
                m.1,
                ApplianceEnv::hub_path(wg).display()
            ));
            m
        }
        (None, None) => {
            out(&format!(
                "{PRODUCT}-appliance wg: hub declared, NO tunnel address yet — this box's address is handed \
                 over at bootstrap by {METHOD_ASSIGN} (`gunnar control tunnel enrol`), never baked into \
                 the medium. Serving on the outer address until then; the tunnel comes up the moment the \
                 address arrives, no restart"
            ));
            return Ok(None);
        }
    };
    let spec = hub.peer_spec();
    // The interface name comes from the config file's stem, the same single
    // source `WgConfig::from_data_root` uses — two spellings of `wg0` is a way
    // for them to disagree.
    let interface = Path::new(CONFIG_FILE)
        .file_stem()
        .and_then(|s| s.to_str())
        .expect("CONFIG_FILE is a literal with a stem");
    Ok(Some(WgConfig {
        interface: interface.to_string(),
        private_key: identity.private,
        address,
        prefix,
        // `None` — the appliance only ever initiates, so the kernel picks. A
        // fixed port would be a firewall rule the site has to know about for no
        // gain.
        listen_port: None,
        peers: vec![Peer {
            public_key: spec.public_key,
            // The ceremony's dialect refuses `PresharedKey` BY NAME: a hub file
            // is baked into per-site ISOs and copied freely, so it carries
            // public data only.
            preshared_key: None,
            allowed_ips: spec.allowed_ips,
            endpoint: spec.endpoint,
            persistent_keepalive: spec.persistent_keepalive,
        }],
    }))
}

/// The gRPC door the tunnel address arrives through — named in every line
/// that tells an operator where the address comes from.
pub const METHOD_ASSIGN: &str = "Bootstrap.AssignTunnelAddress";

/// **This box's tunnel address**, `<root>/wg/address`: one line,
/// `a.b.c.d/prefix`, written by [`assign_address`] when the site hands it over
/// at bootstrap. gunnar's own file beside the shared ceremony's three — the
/// shared dialect keeps `[Interface] Address` in `hub.conf` for holger and korp,
/// and gunnar reads it there only for a box installed from an older medium.
/// 8.3-safe for P3's FAT.
pub const ADDRESS_FILE: &str = "address";

/// Parse a tunnel address the way [`assign_address`] will: `a.b.c.d/prefix`,
/// a host of its subnet.
pub fn parse_address(text: &str) -> Result<(std::net::Ipv4Addr, u8)> {
    parse_cidr(text)
}

fn parse_cidr(text: &str) -> Result<(std::net::Ipv4Addr, u8)> {
    let (a, p) = text
        .trim()
        .split_once('/')
        .with_context(|| format!("{text:?} is not `a.b.c.d/prefix`"))?;
    let addr: std::net::Ipv4Addr = a
        .trim()
        .parse()
        .with_context(|| format!("{a:?} is not an IPv4 address"))?;
    let prefix: u8 = p
        .trim()
        .parse()
        .ok()
        .filter(|p| (1..=32).contains(p))
        .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)"))?;
    if prefix < 31 {
        let mask = u32::MAX << (32 - prefix);
        let host = u32::from(addr) & !mask;
        anyhow::ensure!(
            host != 0 && host != !mask,
            "{addr}/{prefix} is the network or broadcast address of its subnet, not a host"
        );
    }
    Ok((addr, prefix))
}

/// The address [`assign_address`] recorded, if any. A file that does not parse
/// is REFUSED, never treated as absent: absent means "wait for the bootstrap",
/// and a box that silently waited again would re-ask for an address and could
/// be handed a second one.
pub fn assigned_address(wg: &Path) -> Result<Option<(std::net::Ipv4Addr, u8)>> {
    let path = wg.join(ADDRESS_FILE);
    match std::fs::read_to_string(&path) {
        Ok(text) => parse_cidr(&text)
            .map(Some)
            .with_context(|| format!("{}", path.display())),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
        Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
    }
}

/// What [`assign_address`] answers.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Assignment {
    /// This box's WireGuard public key — what the hub registers.
    pub public_b64: String,
    /// The address the box holds now (assigned, or off an older medium).
    pub address: Option<(std::net::Ipv4Addr, u8)>,
    /// `true` iff THIS call wrote the address.
    pub changed: bool,
}

/// **The door's decision, pure.** `requested = None` is a read and always
/// allowed. Writing an address is allowed while the box is FRESH (the
/// bootstrap window is open — the same rule `Enroll` and `BecomeTwin` keep), or
/// when it is the address the box already holds (an idempotent retry).
pub fn assignment_door(
    gate: bool,
    window_open: bool,
    held: Option<(std::net::Ipv4Addr, u8)>,
    requested: Option<(std::net::Ipv4Addr, u8)>,
) -> Result<()> {
    anyhow::ensure!(
        gate,
        "this box runs no appliance ceremony ({GATE_ENV} is not 1), so it has no minted WireGuard identity \
         to give an address to — a dev box takes its tunnel from wg0.conf"
    );
    match requested {
        None => Ok(()),
        Some(r) if held == Some(r) => Ok(()),
        Some(_) if window_open => Ok(()),
        Some((a, p)) => bail!(
            "{a}/{p} refused: this box is past its bootstrap (an identity is enrolled), and {METHOD_ASSIGN} \
             is unauthenticated, so it writes only while the box is fresh or repeats the address it already holds{}. \
             A gunnar-admin sets it with Admin.SetTunnelAddress (`gunnar control tunnel enrol -i <admin key>`)",
            match held {
                Some((h, hp)) => format!(" ({h}/{hp})"),
                None => " (none)".to_string(),
            }
        ),
    }
}

/// **`Bootstrap.AssignTunnelAddress`, the box half**: record `requested` as
/// this box's tunnel address, or with `None` just answer what the box holds.
///
/// Idempotent for the same address; a DIFFERENT address is refused — a tunnel
/// address is never re-pointed, because the hub has already routed the old one
/// to this key. The identity must already exist (the ceremony runs at start,
/// before the control plane listens); a box with none is refused by name rather
/// than minting one here, which would be a second minting door.
pub fn assign_address(data_root: &Path, requested: Option<&str>) -> Result<Assignment> {
    let wg = wg_dir(data_root);
    let pub_path = wg.join(ceremony::IDENTITY_PUB_FILE);
    let public_b64 = std::fs::read_to_string(&pub_path)
        .with_context(|| {
            format!(
                "{} — the ceremony has not run on this box, so there is no key to address",
                pub_path.display()
            )
        })?
        .trim()
        .to_string();
    let medium = match std::fs::metadata(ApplianceEnv::hub_path(&wg)) {
        Ok(_) => {
            ceremony::read_hub_config(&ApplianceEnv::hub_path(&wg))
                .map_err(|e| anyhow::anyhow!("{e}"))?
                .appliance_address
        }
        Err(_) => None,
    };
    let assigned = assigned_address(&wg)?;
    let held = assigned.or(medium);
    let Some(text) = requested.map(str::trim).filter(|t| !t.is_empty()) else {
        return Ok(Assignment {
            public_b64,
            address: held,
            changed: false,
        });
    };
    let want = parse_cidr(text)?;
    if let Some(have) = held {
        anyhow::ensure!(
            have == want,
            "this box already holds the tunnel address {}/{}, and a tunnel address is never re-pointed — the hub \
             routes {}/32 to this key. {}/{} refused",
            have.0,
            have.1,
            have.0,
            want.0,
            want.1
        );
        if assigned.is_some() {
            return Ok(Assignment {
                public_b64,
                address: held,
                changed: false,
            });
        }
    }
    std::fs::create_dir_all(&wg).with_context(|| format!("creating {}", wg.display()))?;
    let path = wg.join(ADDRESS_FILE);
    let tmp = wg.join("address.new");
    {
        use std::io::Write as _;
        let mut f =
            std::fs::File::create(&tmp).with_context(|| format!("writing {}", tmp.display()))?;
        writeln!(f, "{}/{}", want.0, want.1)?;
        f.sync_all()?;
    }
    std::fs::rename(&tmp, &path).with_context(|| format!("renaming into {}", path.display()))?;
    if let Ok(d) = std::fs::File::open(&wg) {
        let _ = d.sync_all();
    }
    Ok(Assignment {
        public_b64,
        address: Some(want),
        changed: true,
    })
}

/// **Both tunnel-address doors, one implementation**: `Bootstrap.AssignTunnelAddress`
/// (`may_write` = the bootstrap window is open) and `Admin.SetTunnelAddress`
/// (`may_write` = true; the service held the caller to server Admin).
pub fn tunnel_address_answer(
    root: &Path,
    address: &str,
    may_write: bool,
) -> gunnar_grpc::ControlResult<gunnar_proto::AssignTunnelAddressResponse> {
    use gunnar_grpc::ControlError;
    let pre = |err: anyhow::Error| ControlError::FailedPrecondition(format!("{err:#}"));
    let requested = if address.is_empty() {
        None
    } else {
        Some(address)
    };
    // **A paired box's tunnel is the pairing loop's** (2026-09-24): `show`
    // reports THAT tunnel — the key the door recorded, the address it
    // assigned, whether wg0 carries it now — not the ceremony's unused
    // identity; and an address is never handed over beside the door's.
    if let Some((public_b64, held, tunnel_up)) = crate::wg::pairing_tunnel(root) {
        if requested.is_some() {
            return Err(ControlError::FailedPrecondition(
                "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(),
            ));
        }
        let address = held.map(|(a, p)| format!("{a}/{p}")).unwrap_or_default();
        let detail = match (&held, tunnel_up) {
            (None, _) => "the pairing loop owns this tunnel and the door has not assigned an address yet".to_string(),
            (Some(_), true) => format!("wg0 is up at {address} (the pairing loop's tunnel; the front's relay lists {public_b64})"),
            (Some(_), false) => format!("{address} is the pairing loop's address and wg0 is NOT up (the boot log says why)"),
        };
        return Ok(gunnar_proto::AssignTunnelAddressResponse {
            wg_public_key: public_b64,
            address,
            changed: false,
            tunnel_up,
            detail,
        });
    }
    let held = assign_address(root, None).map_err(pre)?.address;
    let parsed = match requested {
        None => None,
        Some(text) => Some(
            parse_address(text).map_err(|err| ControlError::InvalidArgument(format!("{err:#}")))?,
        ),
    };
    assignment_door(gate_enabled(), may_write, held, parsed).map_err(pre)?;
    let got = assign_address(root, requested).map_err(pre)?;
    let tunnel_up = if got.address.is_some() {
        crate::wg::raise_after_assignment(root).map_err(pre)?
    } else {
        crate::wg::ceremony_tunnel_up()
    };
    let address = got
        .address
        .map(|(a, p)| format!("{a}/{p}"))
        .unwrap_or_default();
    let detail = match (&got.address, tunnel_up) {
        (None, _) => format!("no tunnel address yet: hand one over with {METHOD_ASSIGN} (`gunnar control tunnel enrol`)"),
        (Some(_), true) => format!(
            "wg0 is up at {address}; register {} on the hub with AllowedIPs = {}/32",
            got.public_b64,
            address.split('/').next().unwrap_or_default()
        ),
        (Some(_), false) => format!(
            "{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"
        ),
    };
    Ok(gunnar_proto::AssignTunnelAddressResponse {
        wg_public_key: got.public_b64,
        address,
        changed: got.changed,
        tunnel_up,
        detail,
    })
}

/// The SSH door's `key value` lines: one writer and one reader, in
/// `gunnar-proto` so the CLI (which may not link the server) reads what the
/// server writes.
pub use gunnar_proto::{parse_tunnel_answer, render_tunnel_answer};

/// The path of the identity's public half, for an operator (or a one-shot
/// container) that wants to read the key back without parsing logs.
pub fn identity_pub_path(data_root: &Path) -> PathBuf {
    wg_dir(data_root).join(ceremony::IDENTITY_PUB_FILE)
}

#[cfg(test)]
mod tests {
    use super::*;
    use wg_appliance_core::ceremony::{parse_identity_line, IDENTITY_KEY_FILE};

    /// A std-only scratch dir. Unique per test, removed on drop.
    struct Scratch(PathBuf);
    impl Scratch {
        fn new(tag: &str) -> Scratch {
            use std::sync::atomic::{AtomicU32, Ordering};
            static N: AtomicU32 = AtomicU32::new(0);
            let dir = std::env::temp_dir().join(format!(
                "gunnar-ceremony-{tag}-{}-{}",
                std::process::id(),
                N.fetch_add(1, Ordering::Relaxed)
            ));
            std::fs::create_dir_all(&dir).unwrap();
            Scratch(dir)
        }
        fn path(&self) -> &Path {
            &self.0
        }
    }
    impl Drop for Scratch {
        fn drop(&mut self) {
            let _ = std::fs::remove_dir_all(&self.0);
        }
    }

    /// Collect what the ceremony EMITTED, which is the whole product: the
    /// operator reads these bytes off a serial console and registers what is in
    /// them. Asserting the struct instead would pass with the sink wired to
    /// nothing.
    fn run_collecting(root: &Path) -> (Result<Ceremony>, Vec<String>) {
        let mut lines = Vec::new();
        let r = run(root, &mut |l| lines.push(l.to_string()));
        (r, lines)
    }

    /// gunnar emits the ONE canonical line, in the shape every product's
    /// watcher greps — and the key IN the line is the key on disk.
    ///
    /// **RED-when-broken**: the regex is the shared crate's constant, so a
    /// gunnar-private spelling of the line fails here rather than being
    /// discovered by a boot proof that quietly matched nothing.
    #[test]
    fn the_line_gunnar_emits_is_the_canonical_one_and_carries_the_key_on_disk() {
        let dir = Scratch::new("line");
        let (out, lines) = run_collecting(dir.path());
        let out = out.expect("an empty root is first boot");

        let identity_lines: Vec<&String> = lines
            .iter()
            .filter(|l| parse_identity_line(l).is_some())
            .collect();
        assert_eq!(
            identity_lines.len(),
            1,
            "exactly ONE canonical identity line, got {lines:?}"
        );
        let (product, pubkey) =
            parse_identity_line(identity_lines[0]).expect("just filtered on it");
        assert_eq!(product, "gunnar", "the line must name the product");
        assert_eq!(
            pubkey, out.public_b64,
            "the printed key must be the key the ceremony returned"
        );

        // The applied output on disk, not a state round-trip: identity.pub is
        // what a one-shot `cat` reads back, and it must agree byte for byte
        // with what the operator was told.
        let on_disk = std::fs::read_to_string(identity_pub_path(dir.path()))
            .expect("identity.pub is written next to the key");
        assert_eq!(
            on_disk.trim(),
            pubkey,
            "identity.pub and the printed line must be the same key"
        );
    }

    /// **REFUSE, NEVER RE-MINT — the guard that matters most.**
    ///
    /// An appliance that silently re-mints on reboot has changed its identity
    /// without telling anyone: the operator registered the FIRST key with the
    /// hub, and every peer that trusted it is now trusting a stranger while the
    /// console shows a healthy boot.
    ///
    /// So: mint once, corrupt the identity, and assert the second boot REFUSES —
    /// naming the file — and that it emitted NOTHING (no identity line, no
    /// MINTED line) and left the corrupt bytes in place as the evidence.
    #[test]
    fn a_corrupt_identity_refuses_and_does_not_mint_a_second_one() {
        let dir = Scratch::new("refuse");
        let (first, _) = run_collecting(dir.path());
        first.expect("first boot mints");
        let key_path = wg_dir(dir.path()).join(IDENTITY_KEY_FILE);
        let minted_bytes = std::fs::read(&key_path).expect("the key file exists");

        let corrupt = b"this is not a base64 x25519 key\n";
        std::fs::write(&key_path, corrupt).expect("corrupt it");

        let (second, lines) = run_collecting(dir.path());
        let err = second.expect_err(
            "a corrupt identity MUST refuse; minting a second one silently changes \
             the machine's identity",
        );
        let text = format!("{err:#}");
        assert!(
            text.contains(&key_path.display().to_string()),
            "the refusal must name the file the operator has to go look at: {text}"
        );
        assert!(
            lines.is_empty(),
            "a refused ceremony must emit NO identity line — a printed key an \
             operator might register: {lines:?}"
        );
        assert_eq!(
            std::fs::read(&key_path).expect("still there"),
            corrupt,
            "the corrupt bytes stay in place as the evidence; they must not be \
             overwritten by a fresh mint"
        );
        assert_ne!(
            minted_bytes, corrupt,
            "the fixture is inert unless the corruption actually changed the file"
        );
    }

    /// The identity SURVIVES a reboot, and the second boot says so by NOT
    /// saying MINTED.
    #[test]
    fn the_identity_survives_a_reboot_and_the_second_boot_is_silent_about_minting() {
        let dir = Scratch::new("survive");
        let (first, first_lines) = run_collecting(dir.path());
        let first = first.expect("first boot");
        assert!(first.minted, "an empty root is first boot");
        assert!(
            first_lines.iter().any(|l| l.contains("MINTED")),
            "the first boot must say MINTED: {first_lines:?}"
        );

        let (second, second_lines) = run_collecting(dir.path());
        let second = second.expect("second boot");
        assert_eq!(
            second.public_b64, first.public_b64,
            "the identity must survive — a re-mint stales the hub registration"
        );
        assert!(
            !second_lines.iter().any(|l| l.contains("MINTED")),
            "a MINTED line on a reboot is the appliance announcing it threw its \
             identity away: {second_lines:?}"
        );
    }

    /// No hub yet is FIRST-BOOT REALITY, not an error — and the appliance says
    /// what to do next rather than failing.
    #[test]
    fn no_hub_yet_is_the_identity_only_ceremony_and_it_says_what_to_do() {
        let dir = Scratch::new("nohub");
        let (out, lines) = run_collecting(dir.path());
        let out = out.expect("no hub is not an error");
        assert!(out.config.is_none(), "there is no tunnel to raise yet");
        let hub_line = lines
            .iter()
            .find(|l| l.contains("hub: none configured"))
            .unwrap_or_else(|| panic!("the operator must be told the next move: {lines:?}"));
        assert!(
            hub_line.contains(ceremony::HUB_CONF_FILE),
            "and told the exact filename to place: {hub_line}"
        );
    }

    /// A declared hub becomes the config gunnar's own `establish` raises —
    /// asserted field by field on the WIRE-SHAPED output, because a hub whose
    /// endpoint or AllowedIPs were dropped in translation is a tunnel that
    /// "came up" and carries nothing.
    #[test]
    fn a_declared_hub_becomes_gunnars_own_tunnel_config() {
        let dir = Scratch::new("hub");
        // Mint first so the hub file lands next to a real identity.
        let (first, _) = run_collecting(dir.path());
        let first = first.expect("first boot");
        let wg = wg_dir(dir.path());
        std::fs::write(
            ApplianceEnv::hub_path(&wg),
            "[Interface]\n\
             Address = 10.99.0.7/24\n\
             \n\
             [Peer]\n\
             PublicKey = 6yUuA2NvUdRuoHwFmS9OD1V8pFpXmDgVQjJ+YEptGm4=\n\
             AllowedIPs = 10.99.0.0/24\n\
             Endpoint = 10.0.2.2:51820\n\
             PersistentKeepalive = 25\n",
        )
        .expect("write hub.conf");

        let (second, _) = run_collecting(dir.path());
        let cfg = second
            .expect("a well-formed hub")
            .config
            .expect("a declared hub raises a tunnel");

        assert_eq!(cfg.interface, "wg0");
        assert_eq!(cfg.address.octets(), [10, 99, 0, 7], "the site's address");
        assert_eq!(cfg.prefix, 24);
        assert_eq!(cfg.listen_port, None, "the appliance only ever initiates");
        assert_eq!(cfg.peers.len(), 1, "the ceremony declares exactly one hub");
        let p = &cfg.peers[0];
        assert_eq!(p.allowed_ips, vec![("10.99.0.0".parse().unwrap(), 24)]);
        assert_eq!(
            p.endpoint,
            Some("10.0.2.2:51820".parse().unwrap()),
            "the endpoint must survive the translation or the appliance dials nowhere"
        );
        assert_eq!(p.persistent_keepalive, Some(25));
        assert_eq!(
            p.preshared_key, None,
            "the dialect refuses PresharedKey by name — a hub file is public data"
        );
        // The private key in the wire config is the MINTED one, not a seeded
        // one: this is the whole point of the ceremony.
        assert_eq!(
            wg_appliance_core::encode_key(&cfg.public_key()),
            first.public_b64,
            "the tunnel must carry the identity the ceremony announced"
        );
    }

    /// The medium's hub.conf as the one-medium ruling wants it: the SITE only.
    const SITE_ONLY_HUB: &str = "[Peer]\n\
         PublicKey = 6yUuA2NvUdRuoHwFmS9OD1V8pFpXmDgVQjJ+YEptGm4=\n\
         AllowedIPs = 10.99.0.0/24\n\
         Endpoint = 10.0.2.2:51820\n\
         PersistentKeepalive = 25\n";

    /// **ONE MEDIUM, MANY BOXES** (2026-09-21, T17): the medium's `hub.conf`
    /// carries only the site — hub key, endpoint, AllowedIPs, keepalive — and
    /// NO `[Interface] Address`, because an address on the medium is the same
    /// address on every box installed from it. A box that has not been handed
    /// its address yet is NOT refused: it boots, announces its identity, raises
    /// no tunnel, and says which door hands the address over.
    ///
    /// RED on d54e3f8: `config_from_ceremony` bailed ("declares a hub but no
    /// `[Interface] Address`"), so a correct one-medium box refused to start its
    /// ceremony at all.
    #[test]
    fn a_site_only_hub_waits_for_the_bootstrap_address_and_is_not_refused() {
        let dir = Scratch::new("siteonly");
        let (_, _) = run_collecting(dir.path());
        std::fs::write(ApplianceEnv::hub_path(&wg_dir(dir.path())), SITE_ONLY_HUB)
            .expect("write hub.conf");

        let (out, lines) = run_collecting(dir.path());
        let out = out.expect("a site-only hub.conf is the correct medium, not an error");
        assert!(out.config.is_none(), "no address yet, so no tunnel yet");
        assert!(
            lines
                .iter()
                .any(|l| l.contains("Bootstrap.AssignTunnelAddress")),
            "the box must name the door its address arrives through: {lines:?}"
        );
    }

    /// **Two boxes, one medium, two tunnel addresses** — the defect T9 found,
    /// closed on the box: the same site-only hub.conf on two data roots, each
    /// handed its own address, raises two different `wg0` addresses under two
    /// different minted keys.
    #[test]
    fn two_boxes_from_one_medium_raise_the_address_each_was_handed() {
        let (a, b) = (Scratch::new("box-a"), Scratch::new("box-b"));
        let mut keys = Vec::new();
        for (dir, addr) in [(&a, "10.99.0.2/24"), (&b, "10.99.0.3/24")] {
            let (first, _) = run_collecting(dir.path());
            keys.push(first.expect("first boot").public_b64);
            std::fs::write(ApplianceEnv::hub_path(&wg_dir(dir.path())), SITE_ONLY_HUB).unwrap();
            let got =
                assign_address(dir.path(), Some(addr)).expect("a fresh box takes its address");
            assert!(got.changed);
            assert_eq!(
                got.public_b64,
                *keys.last().unwrap(),
                "the door answers the key the hub must register"
            );
        }
        let cfg = |d: &Scratch| {
            run_collecting(d.path())
                .0
                .expect("boot")
                .config
                .expect("an address raises the tunnel")
        };
        let (ca, cb) = (cfg(&a), cfg(&b));
        assert_eq!((ca.address.octets(), ca.prefix), ([10, 99, 0, 2], 24));
        assert_eq!((cb.address.octets(), cb.prefix), ([10, 99, 0, 3], 24));
        assert_ne!(keys[0], keys[1], "each box minted its own key");
        assert_eq!(
            ca.peers[0].endpoint, cb.peers[0].endpoint,
            "and both follow the one site the medium declared"
        );
    }

    /// The same address again is a no-op (a retried enrol); a different one is
    /// refused — the hub already routes the first to this key.
    #[test]
    fn the_same_address_is_idempotent_and_a_second_one_is_refused() {
        let dir = Scratch::new("reassign");
        run_collecting(dir.path()).0.expect("first boot");
        assert!(
            assign_address(dir.path(), Some("10.99.0.2/24"))
                .unwrap()
                .changed
        );
        let again = assign_address(dir.path(), Some("10.99.0.2/24")).unwrap();
        assert!(!again.changed, "a retry writes nothing");
        let err = format!(
            "{:#}",
            assign_address(dir.path(), Some("10.99.0.3/24")).unwrap_err()
        );
        assert!(
            err.contains("never re-pointed") && err.contains("10.99.0.2/24"),
            "{err}"
        );
        let read = assign_address(dir.path(), None).unwrap();
        assert_eq!(
            read.address,
            Some(("10.99.0.2".parse().unwrap(), 24)),
            "a read answers what is held"
        );
    }

    #[test]
    fn a_network_or_broadcast_address_and_a_box_with_no_identity_are_refused() {
        let dir = Scratch::new("badaddr");
        let err = format!(
            "{:#}",
            assign_address(dir.path(), Some("10.99.0.2/24")).unwrap_err()
        );
        assert!(
            err.contains("identity.pub"),
            "no ceremony yet, no key to address: {err}"
        );
        run_collecting(dir.path()).0.expect("first boot");
        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",
        ] {
            assert!(
                assign_address(dir.path(), Some(bad)).is_err(),
                "{bad} must be refused"
            );
        }
        assert!(
            !wg_dir(dir.path()).join(ADDRESS_FILE).exists(),
            "no refusal writes anything"
        );
    }

    /// An older medium's address still raises the tunnel (and says where it
    /// came from); a bootstrap address that disagrees with it is refused.
    #[test]
    fn an_older_mediums_address_is_read_and_a_disagreeing_one_is_refused() {
        let dir = Scratch::new("legacy");
        run_collecting(dir.path()).0.expect("first boot");
        let wg = wg_dir(dir.path());
        std::fs::write(
            ApplianceEnv::hub_path(&wg),
            format!("[Interface]\nAddress = 10.99.0.7/24\n\n{SITE_ONLY_HUB}"),
        )
        .unwrap();
        let (out, lines) = run_collecting(dir.path());
        assert_eq!(
            out.unwrap().config.expect("legacy medium").address.octets(),
            [10, 99, 0, 7]
        );
        assert!(
            lines.iter().any(|l| l.contains("from the MEDIUM")),
            "{lines:?}"
        );
        assert!(
            assign_address(dir.path(), Some("10.99.0.8/24")).is_err(),
            "the medium's address is held"
        );
        std::fs::write(wg.join(ADDRESS_FILE), "10.99.0.8/24\n").unwrap();
        let err = format!("{:#}", run_collecting(dir.path()).0.unwrap_err());
        assert!(err.contains("refuses"), "{err}");
    }

    #[test]
    fn the_door_writes_only_while_fresh_or_the_same_address() {
        let a = Some(("10.99.0.2".parse().unwrap(), 24));
        let b = Some(("10.99.0.3".parse().unwrap(), 24));
        assert!(
            assignment_door(false, true, None, a).is_err(),
            "no ceremony, no door"
        );
        assert!(
            assignment_door(true, false, a, None).is_ok(),
            "a read is always answered"
        );
        assert!(
            assignment_door(true, true, None, a).is_ok(),
            "a fresh box takes one"
        );
        assert!(
            assignment_door(true, false, a, a).is_ok(),
            "a retry of the held address"
        );
        assert!(
            assignment_door(true, false, None, a).is_err(),
            "an enrolled box does not take one unauthenticated"
        );
        assert!(assignment_door(true, false, a, b).is_err());
    }

    #[test]
    fn the_ssh_doors_answer_round_trips_and_a_refusal_is_an_error() {
        let a = gunnar_proto::AssignTunnelAddressResponse {
            wg_public_key: "6yUuA2NvUdRuoHwFmS9OD1V8pFpXmDgVQjJ+YEptGm4=".into(),
            address: "10.77.0.2/16".into(),
            changed: true,
            tunnel_up: false,
            detail: "two\nlines".into(),
        };
        let back = parse_tunnel_answer(&render_tunnel_answer(&a)).unwrap();
        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)
        );
        assert_eq!(back.detail, "two lines");
        let none = gunnar_proto::AssignTunnelAddressResponse {
            wg_public_key: "k".into(),
            ..Default::default()
        };
        assert_eq!(
            parse_tunnel_answer(&render_tunnel_answer(&none))
                .unwrap()
                .address,
            ""
        );
        assert_eq!(
            parse_tunnel_answer("error: never re-pointed\n").unwrap_err(),
            "never re-pointed"
        );
    }

    /// The identity root is the ceremony's OWN env, never gunnar's store root.
    /// Overloading one env with both meanings lets a storage decision relocate
    /// the identity, which re-mints it, which stales the hub registration.
    #[test]
    fn the_data_env_is_its_own_and_is_not_gunnars_store_root() {
        assert_ne!(DATA_ENV, "GUNNAR_DATA_ROOT");
        assert_eq!(
            wg_dir(Path::new("/srv/gunnar")),
            Path::new("/srv/gunnar/wg"),
            "with no override the ceremony lives under the root serve was given"
        );
    }
}