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