crates/wg-appliance-core/src/appliance.rs
//! **The ceremony's DRIVER** — the product-shaped half of
//! [`crate::ceremony`], hoisted out of the products themselves.
//!
//! [`crate::ceremony`] is pure: given a directory it mints or loads a key, and
//! given text it parses a hub. What every product then wrote for itself was the
//! same forty lines of envelope — is the gate on, where is the durable root,
//! parse the optional underlay, print the two canonical lines, say what to do
//! when there is no hub yet. holger and korp each carry a copy of that envelope
//! (`holger/server/cli/src/appliance.rs`, `korp/src/appliance.rs`), and they are
//! the same code with three strings changed.
//!
//! A THIRD copy is what this module exists to prevent (LAW 5: reuse, never
//! twin). [`ApplianceEnv`] is the envelope with those strings as data, so a
//! product's appliance module becomes a `const` and a call.
//!
//! # The output is a SINK, not `println!`
//!
//! The identity line is the ceremony's whole product — it is what the operator
//! reads off a serial console and what every boot proof greps
//! ([`crate::ceremony::IDENTITY_LINE_REGEX`]). Emitting it through a
//! caller-supplied sink rather than `println!` buys two things that matter:
//!
//! * a product that logs through `tracing` (gunnar) can write the SAME bytes to
//! both stdout and its log without the line being formatted twice, and
//! * a test can assert the LINES THEMSELVES — the applied output — instead of
//! round-tripping the struct the ceremony returned. A test that checks
//! `identity.public_b64` proves the key was read; only a test that reads the
//! emitted line proves the operator was told.
//!
//! # What stays in the product
//!
//! Raising the tunnel. holger and korp use this crate's ioctl writer
//! ([`crate::addr`]); gunnar puts addresses on with netlink, through the same
//! writer every other address on its appliance goes through, because netlink
//! ACKNOWLEDGES and an ioctl `send` that returned does not mean the address is
//! on. Forcing one of those on the other would be twinning in the other
//! direction — so the driver hands back the parsed [`HubPeer`] and the
//! [`Identity`] and stops there.
use std::net::Ipv4Addr;
use std::path::{Path, PathBuf};
use crate::ceremony::{
self, identity_line, load_or_mint_identity, minted_line, read_hub_config, CeremonyError,
HubPeer, Identity,
};
/// The four strings and one path that differ between holger, gunnar and korp.
///
/// Every field is a `&'static str` on purpose: these are properties of the
/// PRODUCT, fixed at compile time, and a runtime-configurable identity root is
/// exactly the thing that lets a config change silently relocate — and
/// therefore re-mint — an appliance's identity.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ApplianceEnv {
/// The product name in the canonical identity line (`holger`, `gunnar`,
/// `korp`).
pub product: &'static str,
/// The gate: the ceremony runs iff this env is exactly `1`.
pub gate_env: &'static str,
/// Overrides [`default_root`](Self::default_root) — the test seam, and the
/// container shape's volume when it is not mounted at the default.
pub data_env: &'static str,
/// The optional per-site underlay address,
/// `<a.b.c.d>/<prefix>@<ifname>`.
pub underlay_env: &'static str,
/// The durable identity root: P3's mountpoint on installed metal, the
/// mounted volume in a container.
pub default_root: &'static str,
}
impl ApplianceEnv {
/// Is the gate on?
///
/// **Exactly `1`, nothing fuzzy.** `X_APPLIANCE=true` is a config typo, and
/// a typo that half-enables an identity ceremony is worse than one that
/// visibly does nothing: the operator sees no identity line, concludes the
/// build is wrong, and rebuilds — rather than getting an appliance that
/// minted a key it will not mention again.
pub fn gate_enabled(&self) -> bool {
std::env::var(self.gate_env).ok().as_deref() == Some("1")
}
/// The durable root, honouring [`data_env`](Self::data_env).
pub fn data_root(&self) -> PathBuf {
self.data_root_or(Path::new(self.default_root))
}
/// The durable root, honouring [`data_env`](Self::data_env), falling back
/// to a root the CALLER already knows rather than to
/// [`default_root`](Self::default_root).
///
/// The seam exists for gunnar, which is handed its data root by `serve`
/// (`stores.root()`) instead of hardcoding `/data`: hardcoding would give
/// that product two answers to "where does the identity live", and two
/// answers is how an identity gets relocated and therefore re-minted.
/// [`data_root`](Self::data_root) is this function with the declared
/// default — one writer, two entry points.
pub fn data_root_or(&self, fallback: &Path) -> PathBuf {
std::env::var_os(self.data_env)
.map(PathBuf::from)
.unwrap_or_else(|| fallback.to_path_buf())
}
/// The ceremony's own directory under a given root — `<root>/wg`.
///
/// Takes the root rather than reading it, because a product that already
/// knows its data root (gunnar hands `serve` one) must not have a second
/// answer to where the identity lives.
pub fn wg_dir(root: &Path) -> PathBuf {
root.join("wg")
}
/// The hub declaration's path under a ceremony directory.
pub fn hub_path(wg_dir: &Path) -> PathBuf {
wg_dir.join(ceremony::HUB_CONF_FILE)
}
/// Parse `<a.b.c.d>/<prefix>@<ifname>`. Pure, unit-tested.
///
/// A malformed value REFUSES by name rather than being ignored: a
/// half-parsed address is a machine on the wrong network, which is worse
/// than a machine that did not come up.
pub fn parse_underlay(&self, v: &str) -> Result<(Ipv4Addr, u8, String), String> {
let env = self.underlay_env;
let (addr_part, ifname) = v
.split_once('@')
.ok_or_else(|| format!("{env}={v:?}: expected <addr>/<prefix>@<interface>"))?;
let (addr, prefix) = addr_part
.split_once('/')
.ok_or_else(|| format!("{env}={v:?}: missing /<prefix>"))?;
let addr: Ipv4Addr = addr
.parse()
.map_err(|e| format!("{env}={v:?}: bad address: {e}"))?;
let prefix: u8 = prefix
.parse()
.ok()
.filter(|p| *p <= 32)
.ok_or_else(|| format!("{env}={v:?}: prefix must be 0..=32"))?;
if ifname.is_empty() {
return Err(format!("{env}={v:?}: empty interface name"));
}
Ok((addr, prefix, ifname.to_string()))
}
/// Apply [`underlay_env`](Self::underlay_env) if it is set, emitting one
/// line when it is.
///
/// Called BEFORE anything needs the network: the tunnel's own UDP needs a
/// route out. A declared-but-unappliable address is a broken appliance and
/// fails loud — never half-networked.
pub fn apply_underlay(&self, out: &mut dyn FnMut(&str)) -> Result<bool, String> {
let Ok(v) = std::env::var(self.underlay_env) else {
return Ok(false);
};
let (addr, prefix, ifname) = self.parse_underlay(&v)?;
let product = self.product;
crate::addr::assign_ipv4(&ifname, addr, prefix).map_err(|e| {
format!("{product}-appliance underlay: FAILED — {addr}/{prefix} on {ifname}: {e}")
})?;
crate::addr::link_up(&ifname).map_err(|e| {
format!("{product}-appliance underlay: FAILED — bringing {ifname} up: {e}")
})?;
out(&format!(
"{product}-appliance underlay: {addr}/{prefix} on {ifname}"
));
Ok(true)
}
/// **The ceremony proper**: load or mint at `wg_dir`, then emit the MINTED
/// line (first boot only) followed by the ONE canonical identity line.
///
/// The order is load-bearing and is the same on every product: `MINTED`
/// appears in no other output the suite formats, so "this machine's
/// identity changed" is one grep, and it must come BEFORE the line carrying
/// the key it is warning about.
///
/// Refuse-never-remint lives in [`load_or_mint_identity`] and is propagated
/// here unchanged: a corrupt, truncated, unreadable or group-readable key
/// file returns [`CeremonyError`] and NOTHING is emitted. An appliance that
/// re-mints on reboot has changed its identity without telling anyone, and
/// every peer that trusted it is now trusting a stranger.
pub fn announce_identity(
&self,
wg_dir: &Path,
out: &mut dyn FnMut(&str),
) -> Result<Identity, CeremonyError> {
let identity = load_or_mint_identity(wg_dir)?;
if identity.minted {
out(&minted_line(
self.product,
&wg_dir.join(ceremony::IDENTITY_KEY_FILE),
));
}
out(&identity_line(self.product, &identity.public_b64));
Ok(identity)
}
/// Read the hub declaration if the operator has placed one.
///
/// `Ok(None)` is FIRST-BOOT REALITY, not an error: the operator needs the
/// public key OUT before the hub can be configured at all, so a missing
/// `hub.conf` emits the instruction and the appliance serves on without a
/// tunnel. A hub file that is PRESENT and malformed is an error — a
/// declaration that lies must stop the boot.
pub fn read_hub(
&self,
wg_dir: &Path,
out: &mut dyn FnMut(&str),
) -> Result<Option<HubPeer>, CeremonyError> {
let path = Self::hub_path(wg_dir);
if !path.exists() {
out(&format!(
"{}-appliance hub: none configured ({} absent) — no tunnel; register the \
public key above with the hub, then place its {} there",
self.product,
path.display(),
ceremony::HUB_CONF_FILE,
));
return Ok(None);
}
read_hub_config(&path).map(Some)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
const TESTP: ApplianceEnv = ApplianceEnv {
product: "testp",
gate_env: "TESTP_APPLIANCE",
data_env: "TESTP_APPLIANCE_DATA",
underlay_env: "TESTP_UNDERLAY",
default_root: "/data",
};
/// A std-only scratch dir — this crate takes no dev-dependencies for what
/// twenty lines of std can do (the same `Scratch` `ceremony`'s own tests
/// use). Unique per test, removed on drop.
struct Scratch(PathBuf);
impl Scratch {
fn new(tag: &str) -> Scratch {
static N: AtomicU32 = AtomicU32::new(0);
let dir = std::env::temp_dir().join(format!(
"wg-appliance-{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);
}
}
/// **RED-when-broken: a malformed underlay REFUSES, and names the env.**
#[test]
fn underlay_parses_and_refuses_by_name() {
let (a, p, i) = TESTP.parse_underlay("10.0.2.15/24@eth0").expect("well-formed");
assert_eq!((a.octets(), p, i.as_str()), ([10, 0, 2, 15], 24, "eth0"));
for bad in [
"10.0.2.15@eth0",
"10.0.2.15/24",
"x/24@eth0",
"10.0.2.15/33@eth0",
"10.0.2.15/24@",
] {
let err = TESTP.parse_underlay(bad).expect_err(bad);
assert!(
err.contains("TESTP_UNDERLAY"),
"the error must name the env: {err}"
);
}
}
/// The ceremony directory is `<root>/wg` and the hub file sits in it — the
/// SAME two names on P3 and in a container volume. Asserted as strings
/// because "one dialect, both shapes" is a claim about the literal paths.
#[test]
fn the_directory_layout_is_the_one_both_shapes_share() {
let wg = ApplianceEnv::wg_dir(Path::new("/data"));
assert_eq!(wg, Path::new("/data/wg"));
assert_eq!(
ApplianceEnv::hub_path(&wg),
Path::new("/data/wg/hub.conf"),
"the hub file's name is the ceremony's constant, not a product's choice"
);
}
/// **RED-when-broken: announce emits the MINTED line BEFORE the identity
/// line on a first boot, and NOT AT ALL on the second.**
///
/// Asserted on the emitted LINES (the applied output the operator reads),
/// not on `Identity::minted` — a state round-trip would pass just as well
/// with the sink wired to nothing.
#[test]
fn the_minted_line_is_emitted_once_and_only_on_the_boot_that_minted() {
let dir = Scratch::new("minted-once");
let wg = dir.path().join("wg");
let mut first: Vec<String> = Vec::new();
let id1 = TESTP
.announce_identity(&wg, &mut |l| first.push(l.to_string()))
.expect("first boot mints");
assert_eq!(first.len(), 2, "first boot emits MINTED then identity: {first:?}");
assert!(first[0].contains("MINTED"), "{:?}", first[0]);
assert_eq!(first[1], identity_line("testp", &id1.public_b64));
let mut second: Vec<String> = Vec::new();
let id2 = TESTP
.announce_identity(&wg, &mut |l| second.push(l.to_string()))
.expect("second boot loads");
assert_eq!(
second,
vec![identity_line("testp", &id1.public_b64)],
"the second boot must emit the identity line ALONE — a MINTED line on a \
reboot is the appliance telling the operator it threw its identity away"
);
assert_eq!(
id2.public_b64, id1.public_b64,
"the identity must survive the reboot"
);
}
}