server/cli/src/appliance.rs
//! The appliance identity ceremony, holger-side — a thin caller of edda's
//! `wg-appliance-core::ceremony` (the ONE implementation holger, gunnar and
//! korp share; nothing ceremony-shaped is implemented here).
//!
//! # The gate lives in the declaration, not in folklore
//!
//! The ceremony runs iff env [`GATE_ENV`]`=1`. The `[[build.container]]`
//! appliance cell in `.nornir/build-variants.toml` DECLARES that env into the
//! image, and the ISO shape will declare it on the kernel cmdline's
//! environment — so "this binary is an appliance" is a property of the packed
//! artifact, never of a hand-typed flag. A plain `holger-mvp start` on a dev
//! box prints nothing and serves normally.
//!
//! # Fixed paths, fixed forever
//!
//! `<root>/wg/hub.conf`, `<root>/wg/identity.key`, `<root>/wg/identity.pub`,
//! where `<root>` is [`DATA_ENV`] or `/data` — the SAME names the ISO shape
//! uses on P3 and the container shape uses on its volume (one dialect, both
//! shapes; the filenames are constants in the shared crate). Deliberately NOT
//! `$HOLGER_DATA`: that is the registry data root, movable per install; the
//! ceremony's directory is the durable identity root the volume/partition
//! mounts, and overloading one env with both meanings would let a config
//! change silently relocate the identity.
//!
//! # Fail loud, never half-up
//!
//! * Corrupt/missing-permission identity → refuse BY NAME (the shared crate's
//! error), nonzero exit — the appliance never invents an identity.
//! * `hub.conf` present but the tunnel cannot come up (no `/dev/net/tun`, no
//! `CAP_NET_ADMIN`) → `wg-appliance tunnel: FAILED — …` naming the missing
//! precondition, nonzero exit. A declared hub that is not reachable is a
//! broken appliance, not a warning.
//! * No `hub.conf` → identity-only ceremony (print the key, serve). That is
//! first-boot reality: the operator needs the public key OUT before the hub
//! can be configured at all.
//!
//! Named remainder (recorded, not invented): no interface-address writer. The
//! shared crate deliberately carries none (see its crate doc) and holger has
//! none — the tunnel handshake proves identity and reachability; putting an
//! address on `wg0` needs a netlink/ioctl writer and a ruling on which one.
use std::path::PathBuf;
use wg_appliance_core::ceremony::{
self, identity_line, load_or_mint_identity, minted_line, read_hub_config,
};
use wg_appliance_core::dataplane::{self, TunnelHandle, TunnelSpec};
use wg_appliance_core::tun::{TunDevice, DEFAULT_MTU};
/// The product name in the canonical identity line.
pub const PRODUCT: &str = "holger";
/// The gate: the ceremony runs iff this env is `1`.
pub const GATE_ENV: &str = "HOLGER_APPLIANCE";
/// Overrides the durable root (default [`DEFAULT_ROOT`]) — the test seam.
pub const DATA_ENV: &str = "HOLGER_APPLIANCE_DATA";
/// The durable identity root: P3's mountpoint on metal, the volume in a
/// container. `wg/` under it holds the ceremony files.
pub const DEFAULT_ROOT: &str = "/data";
/// The tun interface name, same on every shape.
pub const INTERFACE: &str = "wg0";
/// What the ceremony did — the caller emits testmatrix rows from this and
/// holds the guard for the life of the serve loop.
pub struct Appliance {
/// The appliance's public key, spelled for the operator.
pub public_b64: String,
/// `true` iff THIS boot minted the identity.
pub minted: bool,
/// The live tunnel, if a hub was declared. Dropping it tears the tunnel
/// down, so it rides in the `Start` arm's scope next to the ctrl-c loop.
pub tunnel: Option<TunnelHandle>,
}
/// Is the appliance gate on? (Exact `1`, nothing fuzzy: `HOLGER_APPLIANCE=true`
/// is a config typo, and a typo that half-enables an identity ceremony is
/// worse than one that visibly does nothing.)
pub fn gate_enabled() -> bool {
std::env::var(GATE_ENV).ok().as_deref() == Some("1")
}
/// The durable root, honoring the test override.
fn data_root() -> PathBuf {
std::env::var_os(DATA_ENV)
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(DEFAULT_ROOT))
}
/// Run the ceremony (the gate is the CALLER's check, so a gated-off run prints
/// nothing at all). Prints the MINTED line (first boot only), then the ONE
/// canonical identity line, then either brings the declared tunnel up or says
/// exactly why it cannot. Every error string names the thing to go look at.
/// The optional per-site UNDERLAY address: `HOLGER_UNDERLAY=<a.b.c.d>/<prefix>@<ifname>`,
/// applied through the shared crate's ONE ioctl writer before anything needs
/// the network. Static-IP appliance sites declare it (on metal it travels the
/// korp.env.* channel); a site whose network hands out addresses some other
/// way omits it. Malformed REFUSES by name — a half-parsed address is a
/// machine on the wrong network.
pub const UNDERLAY_ENV: &str = "HOLGER_UNDERLAY";
/// Parse `<a.b.c.d>/<prefix>@<ifname>`. Pure, unit-tested.
pub fn parse_underlay(v: &str) -> Result<(std::net::Ipv4Addr, u8, String), String> {
let (addr_part, ifname) = v
.split_once('@')
.ok_or_else(|| format!("{UNDERLAY_ENV}={v:?}: expected <addr>/<prefix>@<interface>"))?;
let (addr, prefix) = addr_part
.split_once('/')
.ok_or_else(|| format!("{UNDERLAY_ENV}={v:?}: missing /<prefix>"))?;
let addr: std::net::Ipv4Addr = addr
.parse()
.map_err(|e| format!("{UNDERLAY_ENV}={v:?}: bad address: {e}"))?;
let prefix: u8 = prefix
.parse()
.ok()
.filter(|p| *p <= 32)
.ok_or_else(|| format!("{UNDERLAY_ENV}={v:?}: prefix must be 0..=32"))?;
if ifname.is_empty() {
return Err(format!("{UNDERLAY_ENV}={v:?}: empty interface name"));
}
Ok((addr, prefix, ifname.to_string()))
}
pub fn run_ceremony() -> Result<Appliance, String> {
// The underlay first — the tunnel's own UDP needs a route out, and a
// declared-but-unappliable address is a broken appliance (fail loud,
// never half-networked).
if let Ok(v) = std::env::var(UNDERLAY_ENV) {
let (addr, prefix, ifname) = parse_underlay(&v)?;
wg_appliance_core::addr::assign_ipv4(&ifname, addr, prefix).map_err(|e| {
format!("wg-appliance underlay: FAILED — {addr}/{prefix} on {ifname}: {e}")
})?;
wg_appliance_core::addr::link_up(&ifname)
.map_err(|e| format!("wg-appliance underlay: FAILED — bringing {ifname} up: {e}"))?;
println!("wg-appliance underlay: {addr}/{prefix} on {ifname}");
}
let wg_dir = data_root().join("wg");
let identity = load_or_mint_identity(&wg_dir).map_err(|e| e.to_string())?;
if identity.minted {
println!(
"{}",
minted_line(PRODUCT, &wg_dir.join(ceremony::IDENTITY_KEY_FILE))
);
}
println!("{}", identity_line(PRODUCT, &identity.public_b64));
let hub_path = wg_dir.join(ceremony::HUB_CONF_FILE);
if !hub_path.exists() {
println!(
"wg-appliance hub: none configured ({} absent) — serving without a tunnel; \
register the public key above with the hub, then place its {} there",
hub_path.display(),
ceremony::HUB_CONF_FILE,
);
return Ok(Appliance {
public_b64: identity.public_b64,
minted: identity.minted,
tunnel: None,
});
}
let hub = read_hub_config(&hub_path).map_err(|e| e.to_string())?;
let hub_pub = wg_appliance_core::encode_key(&hub.public_key);
let tun = TunDevice::create(INTERFACE, DEFAULT_MTU).map_err(|e| {
format!(
"wg-appliance tunnel: FAILED — {}: {e} (a declared hub needs the tun device and \
CAP_NET_ADMIN; container: --cap-add NET_ADMIN --device /dev/net/tun)",
wg_appliance_core::tun::TUN_PATH,
)
})?;
// The site-assigned address, applied through the shared crate's ONE ioctl
// writer BEFORE the pump starts. Declared-but-unappliable is a broken
// appliance — fail loud, never half-up. Absent is legitimate: the tunnel
// still seals transport; IP through it needs the Address.
if let Some((a, p)) = hub.appliance_address {
wg_appliance_core::addr::assign_ipv4(INTERFACE, a, p)
.map_err(|e| format!("wg-appliance address: FAILED — {a}/{p} on {INTERFACE}: {e}"))?;
wg_appliance_core::addr::link_up(INTERFACE)
.map_err(|e| format!("wg-appliance address: FAILED — bringing {INTERFACE} up: {e}"))?;
println!("wg-appliance address: {a}/{p} on {INTERFACE}");
} else {
println!(
"wg-appliance address: none declared ([Interface] Address absent) — \
transport-only tunnel; add it to {} for IP through the tunnel",
hub_path.display(),
);
}
let spec = TunnelSpec {
private_key: identity.private,
listen_port: None,
peers: vec![hub.peer_spec()],
};
// HOL-NCSC: the hub is a dial like any other.
for endpoint in spec.peers.iter().filter_map(|p| p.endpoint) {
server_lib::egress::check("wg-appliance tunnel", &endpoint.to_string())
.map_err(|e| e.to_string())?;
}
// A32-14, stays a task: the userspace WireGuard pump, one thread; its handle is kept and stopped with the tunnel.
let handle = dataplane::spawn(tun, &spec)
.map_err(|e| format!("wg-appliance tunnel: FAILED — data plane would not start: {e}"))?;
println!(
"wg-appliance tunnel: initiating interface={} hub={hub_pub}",
handle.interface(),
);
Ok(Appliance {
public_b64: identity.public_b64,
minted: identity.minted,
tunnel: Some(handle),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn underlay_parses_and_refuses_by_name() {
let (a, p, i) = 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 = parse_underlay(bad).expect_err(bad);
assert!(err.contains("HOLGER_UNDERLAY"), "{err}");
}
}
}