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

nordisk/holger

server/cli/tests/appliance_gate.rs

//! The appliance ceremony, proven on the REAL binary's applied output — the
//! stdout/stderr lines and the on-disk files, never a state round-trip.
//!
//! Run with the appliance tier's own feature set:
//!
//! ```text
//! cargo test -p holger --no-default-features \
//!     --features mvp-server,appliance --test appliance_gate
//! ```
//!
//! (The `[[test]]` target carries `required-features = ["appliance"]`, so a
//! build without the feature does not compile an empty green shell of this
//! file — the dark-test-file disease measured in korp.)
//!
//! Trick that keeps this fast and port-free: the ceremony runs BEFORE the
//! config load, so every case passes a config path that does not exist — the
//! ceremony's whole output is emitted, then the process exits on the missing
//! config instead of binding ports and serving forever. The one case that
//! needs a running server (tunnel actually up) is the container robot's job.

use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicU32, Ordering};

const HUB_KEY: &str = "HIgo9xNzJMWLKASShiTqIybxZ0U3wGLiUeJ1PKf8ykw=";

/// A std-only scratch dir, 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!(
            "holger-appliance-{tag}-{}-{}",
            std::process::id(),
            N.fetch_add(1, Ordering::Relaxed)
        ));
        fs::create_dir_all(&dir).unwrap();
        Scratch(dir)
    }
    fn path(&self) -> &Path {
        &self.0
    }
    fn wg(&self) -> PathBuf {
        self.0.join("wg")
    }
}
impl Drop for Scratch {
    fn drop(&mut self) {
        let _ = fs::remove_dir_all(&self.0);
    }
}

/// Start the real server binary as an appliance against `root`, with a config
/// that does not exist (see the module doc). Returns the completed output.
fn start_appliance(root: &Path) -> Output {
    Command::new(env!("CARGO_BIN_EXE_holger"))
        .args([
            "start",
            "--config",
            "/nonexistent/holger-appliance-test.ron",
        ])
        .env("HOLGER_APPLIANCE", "1")
        .env("HOLGER_APPLIANCE_DATA", root)
        .output()
        .expect("spawn holger-server")
}

fn stdout(o: &Output) -> String {
    String::from_utf8_lossy(&o.stdout).into_owned()
}
fn stderr(o: &Output) -> String {
    String::from_utf8_lossy(&o.stderr).into_owned()
}

/// The canonical line, extracted via the SHARED parser — the same one writer
/// every watcher uses (never a test-private regex).
fn canonical_pubkey(out: &str) -> Option<String> {
    out.lines()
        .find_map(|l| wg_appliance_core::parse_identity_line(l))
        .map(|(product, pubkey)| {
            assert_eq!(product, "holger", "the product in the line is holger");
            pubkey.to_string()
        })
}

#[test]
fn first_boot_mints_second_boot_loads_and_the_pub_file_agrees() {
    let root = Scratch::new("mint");

    let first = start_appliance(root.path());
    let out1 = stdout(&first);
    assert!(
        out1.contains("MINTED"),
        "first boot mints, and says so loudly:\n{out1}"
    );
    let key1 =
        canonical_pubkey(&out1).unwrap_or_else(|| panic!("no canonical identity line in:\n{out1}"));

    // The MINTED line comes BEFORE the canonical line (operator reads the
    // alarm, then the key), and the canonical line is the FIRST non-MINTED
    // stdout line — nothing precedes the ceremony.
    let minted_idx = out1.find("MINTED").unwrap();
    let canon_idx = out1.find(&format!("pubkey={key1}")).unwrap();
    assert!(minted_idx < canon_idx, "MINTED precedes the canonical line");

    // Readable-without-logs law: identity.pub carries the same key.
    let pub_file = fs::read_to_string(root.wg().join("identity.pub")).unwrap();
    assert_eq!(pub_file.trim(), key1, "identity.pub == the printed pubkey");

    // Second boot: same key, NO MINTED — the absence assert is the
    // idempotence proof (a re-mint here stales the hub silently).
    let second = start_appliance(root.path());
    let out2 = stdout(&second);
    assert!(
        !out2.contains("MINTED"),
        "the second boot must not re-mint:\n{out2}"
    );
    assert_eq!(
        canonical_pubkey(&out2).unwrap(),
        key1,
        "the identity survives the reboot"
    );
}

/// RED — delete the key: a NEW identity is minted and the console says so.
/// This is the deliberate-recovery path, and it must be LOUD.
#[test]
fn a_deleted_key_mints_a_new_identity_loudly() {
    let root = Scratch::new("delete");
    let key1 = canonical_pubkey(&stdout(&start_appliance(root.path()))).unwrap();

    fs::remove_file(root.wg().join("identity.key")).unwrap();
    let out = stdout(&start_appliance(root.path()));
    assert!(out.contains("MINTED"), "a re-mint must be loud:\n{out}");
    let key2 = canonical_pubkey(&out).unwrap();
    assert_ne!(
        key1, key2,
        "a deleted key is a NEW identity, not a resurrected one"
    );
}

/// RED — corrupt the key: refuse BY NAME, exit nonzero, and leave the file
/// byte-identical (it is the operator's evidence).
#[test]
fn a_corrupt_key_refuses_by_name_and_serves_nothing() {
    let root = Scratch::new("corrupt");
    start_appliance(root.path());

    let key_path = root.wg().join("identity.key");
    fs::write(&key_path, "garbage, not a key\n").unwrap();
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt as _;
        fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600)).unwrap();
    }

    let out = start_appliance(root.path());
    assert!(!out.status.success(), "a corrupt identity must not serve");
    let err = stderr(&out);
    assert!(
        err.contains("identity.key") && err.contains("refusing to re-mint"),
        "the refusal names the file and the rule:\n{err}"
    );
    assert!(
        !stdout(&out).contains("MINTED"),
        "corrupt NEVER silently re-mints — that is the primary defect"
    );
    assert_eq!(
        fs::read_to_string(&key_path).unwrap(),
        "garbage, not a key\n",
        "the corrupt file is evidence, never rewritten"
    );
}

/// RED — an unknown key in hub.conf refuses with its name and line.
#[test]
fn an_unknown_hub_key_refuses_with_name_and_line() {
    let root = Scratch::new("hubkey");
    start_appliance(root.path());
    fs::write(
        root.wg().join("hub.conf"),
        format!("[Peer]\nPublicKey = {HUB_KEY}\nDNS = 1.1.1.1\nAllowedIPs = 10.0.0.0/8\n"),
    )
    .unwrap();

    let out = start_appliance(root.path());
    assert!(!out.status.success());
    let err = stderr(&out);
    assert!(
        err.contains("unknown key 'dns'") && err.contains("hub.conf:3"),
        "the refusal names the key and the line:\n{err}"
    );
}

/// RED — a valid hub.conf without the tun device / CAP_NET_ADMIN: the loud
/// FAILED line naming the precondition, and a nonzero exit. Never half-up.
/// (As an unprivileged user, TUNSETIFF is EPERM — which is exactly the
/// container-without---cap-add shape, proven here with no container.)
#[test]
fn a_declared_hub_without_tun_fails_loudly_and_exits() {
    if unsafe { libc::geteuid() } == 0 {
        eprintln!(
            "SKIP a_declared_hub_without_tun_fails_loudly_and_exits: running as root, \
             TUNSETIFF would SUCCEED and this test's precondition (no CAP_NET_ADMIN) \
             does not hold"
        );
        return;
    }
    let root = Scratch::new("tun");
    start_appliance(root.path());
    fs::write(
        root.wg().join("hub.conf"),
        format!(
            "[Peer]\nPublicKey = {HUB_KEY}\nEndpoint = 192.0.2.7:51820\nAllowedIPs = 10.77.0.0/16\n"
        ),
    )
    .unwrap();

    let out = start_appliance(root.path());
    assert!(
        !out.status.success(),
        "a hub that cannot come up must not serve"
    );
    let err = stderr(&out);
    assert!(
        err.contains("wg-appliance tunnel: FAILED")
            && err.contains("/dev/net/tun")
            && err.contains("CAP_NET_ADMIN"),
        "the FAILED line names the missing precondition:\n{err}"
    );
    // The identity half still ran — the key is out before the tunnel attempt.
    assert!(
        canonical_pubkey(&stdout(&out)).is_some(),
        "identity precedes the tunnel: the key line is printed even when the tunnel fails"
    );
}

/// Gate-off control: without HOLGER_APPLIANCE=1 the ceremony does not exist —
/// zero wg-appliance output, no files. (This is what keeps every dev-box
/// `start` byte-for-byte pre-appliance.)
#[test]
fn without_the_gate_there_is_no_ceremony() {
    let root = Scratch::new("gateoff");
    let out = Command::new(env!("CARGO_BIN_EXE_holger"))
        .args([
            "start",
            "--config",
            "/nonexistent/holger-appliance-test.ron",
        ])
        .env("HOLGER_APPLIANCE_DATA", root.path())
        .env_remove("HOLGER_APPLIANCE")
        .output()
        .expect("spawn holger-server");
    let all = format!("{}{}", stdout(&out), stderr(&out));
    assert!(
        !all.contains("wg-appliance"),
        "no gate, no ceremony, not one line:\n{all}"
    );
    assert!(
        !root.wg().exists(),
        "no gate, no files — the ceremony must not leak onto non-appliance hosts"
    );
}