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

nordisk/holger

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