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"
);
}
}