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

nordisk/edda

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