crates/wg-appliance-core/src/ceremony.rs
//! The appliance **identity ceremony** — the one implementation every product
//! (holger, gunnar, korp) and every shape (ISO-installed metal, container)
//! routes through.
//!
//! The ceremony is two movements, and only two:
//!
//! 1. **The hub's public key comes IN** as a file ([`HUB_CONF_FILE`]) in the
//! appliance's durable directory — the same filename and the same dialect
//! whether that directory is P3 on an installed disk or a mounted container
//! volume. No second dialect, ever.
//! 2. **The appliance's own public key goes OUT**: minted once
//! ([`load_or_mint_identity`]), printed as ONE canonical line
//! ([`identity_line`]) every consumer and every test watches, and written to
//! [`IDENTITY_PUB_FILE`] so it can be read back without parsing logs.
//!
//! # Durability laws (the reason this module exists)
//!
//! * **IDEMPOTENT** — an existing key is NEVER silently re-minted. A re-mint
//! stales the operator's hub config; it is the primary failure mode this
//! design exists to prevent. Minting is `O_CREAT | O_EXCL`: the filesystem,
//! not a read-then-write race, decides who minted.
//! * **FAIL CLOSED** — a corrupt, truncated, unreadable or over-permissive key
//! file REFUSES **by name** ([`CeremonyError`]); the appliance never invents
//! an identity. A refusal is recoverable by an operator; a silently swapped
//! identity is not.
//! * **LOUD ON CHANGE** — when a key IS newly minted the caller prints
//! [`minted_line`] before the canonical line. The word `MINTED` appears in no
//! other output, so "a new identity appeared" is one grep.
//! * **SECRETS DISCIPLINE** — the hub file carries PUBLIC data only:
//! `PrivateKey` and `PresharedKey` are refused *by name*, because a hub file
//! is baked into per-site ISOs and copied freely. The appliance's private key
//! is written at mode 0600, never printed, and never appears in any line this
//! module formats.
//!
//! # What is deliberately NOT here
//!
//! This is not a general wg-quick schema (see the crate root: gunnar's and
//! tunnr's config surfaces stay their own). [`parse_hub_config`] is the
//! ceremony's OWN dialect — a single `[Peer]` with `PublicKey`, `AllowedIPs`,
//! optional `Endpoint` and `PersistentKeepalive`, plus at most one
//! `[Interface]` carrying EXACTLY `Address` (the site-assigned tunnel
//! address: config, not identity, not secret), everything else refused by
//! name — shared verbatim by every appliance product, which is exactly why it
//! lives here and not in any one of them.
use std::fmt;
use std::fs;
use std::io::{self, Read as _, Write as _};
use std::net::{Ipv4Addr, SocketAddrV4};
use std::path::{Path, PathBuf};
use zeroize::{Zeroize as _, Zeroizing};
use crate::dataplane::PeerSpec;
use crate::keys::{
decode_key, encode_key, generate_keypair, public_from_private_bytes, KeyError, KEY_LEN, REDACTED,
};
/// The appliance's private key, base64 + newline, mode 0600. The file IS the
/// identity: everything else is derived from it.
pub const IDENTITY_KEY_FILE: &str = "identity.key";
/// The appliance's public key, base64 + newline — derived data, re-derived
/// from [`IDENTITY_KEY_FILE`] whenever missing or stale, and readable by a
/// one-shot `cat` so no consumer has to parse logs.
pub const IDENTITY_PUB_FILE: &str = "identity.pub";
/// The hub's declared side of the tunnel — the file the operator (or a
/// per-site ISO build) places next to the identity.
pub const HUB_CONF_FILE: &str = "hub.conf";
/// The one regex that matches [`identity_line`] output — published here so
/// every watcher (test, robot, UI pane) greps the SAME shape instead of
/// inventing its own. Capture 1 is the product, capture 2 the padded base64
/// public key.
pub const IDENTITY_LINE_REGEX: &str =
r"^wg-appliance identity: product=(\S+) pubkey=([A-Za-z0-9+/]{43}=)$";
/// Why the ceremony refused. Every variant names the thing that failed — a
/// path, a config key, a line number — because the operator's next move is
/// always "go look at exactly that".
#[derive(Debug)]
pub enum CeremonyError {
/// The filesystem said no. `op` is what was being attempted.
Io {
/// What the ceremony was doing when the filesystem refused.
op: &'static str,
/// The file or directory it was doing it to.
path: PathBuf,
/// The OS error, verbatim.
source: io::Error,
},
/// The identity file exists but does not hold a key. REFUSED, never
/// re-minted: a truncated write or a corrupted disk must surface to the
/// operator, because the hub still trusts the identity this file held.
CorruptIdentity {
/// The identity file that failed to decode.
path: PathBuf,
/// What was wrong with it, from the key codec.
reason: KeyError,
},
/// The identity file is readable by group or world. Refused rather than
/// repaired: silently chmodding would hide that the secret may already
/// have been read.
IdentityPermissions {
/// The over-permissive identity file.
path: PathBuf,
/// Its current mode bits (the low nine).
mode: u32,
},
/// The hub config refused a line — unknown key, forbidden key, bad value.
HubConfig {
/// Where the text came from (a path, or a caller-supplied name).
source: String,
/// 1-based line, 0 when the complaint is about the file as a whole.
line: usize,
/// What was wrong, naming the key or value.
reason: String,
},
}
impl fmt::Display for CeremonyError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CeremonyError::Io { op, path, source } => {
write!(f, "wg-appliance ceremony: {op} {}: {source}", path.display())
}
CeremonyError::CorruptIdentity { path, reason } => write!(
f,
"wg-appliance ceremony: {} is not a WireGuard key ({reason}) — refusing to \
re-mint: the hub may still trust the identity this file held; restore it \
from backup or delete it DELIBERATELY to mint a new one",
path.display()
),
CeremonyError::IdentityPermissions { path, mode } => write!(
f,
"wg-appliance ceremony: {} is mode {mode:03o} (group/world-readable) — a \
private key this open may already be copied; refusing to use it",
path.display()
),
CeremonyError::HubConfig { source, line, reason } => {
if *line == 0 {
write!(f, "wg-appliance ceremony: {source}: {reason}")
} else {
write!(f, "wg-appliance ceremony: {source}:{line}: {reason}")
}
}
}
}
}
impl std::error::Error for CeremonyError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
CeremonyError::Io { source, .. } => Some(source),
CeremonyError::CorruptIdentity { reason, .. } => Some(reason),
_ => None,
}
}
}
/// The appliance's identity, loaded or freshly minted.
///
/// `Debug` is implemented BY HAND and redacts the private key: a derived impl
/// would put the secret one `{:?}` away from every log line and panic message.
pub struct Identity {
/// The private key, raw. Stays in this struct and in the 0600 file —
/// nothing in this module formats it into any string.
pub private: [u8; KEY_LEN],
/// The public key as the operator spells it: 44 characters of padded
/// base64.
pub public_b64: String,
/// `true` ONLY when this very call created the key file. The caller owes
/// the operator a [`minted_line`] when this is set.
pub minted: bool,
}
impl fmt::Debug for Identity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Identity")
.field("private", &REDACTED)
.field("public_b64", &self.public_b64)
.field("minted", &self.minted)
.finish()
}
}
// **`Identity` deliberately does NOT zeroize on drop, and that is a NAMED gap
// rather than an oversight (F41).**
//
// The obvious fix — `impl Drop for Identity { self.private.zeroize() }` — makes
// the type impossible to move a field out of, and `public_b64` is a `String`
// that shipping call sites move straight out of it: `korp-installer`
// (`datahome.rs:658`, `install.rs:380`) and `korp/src/appliance.rs` (twice),
// with holger's tier built the same way. Adding `Drop` turns a hardening change
// into a compile break in three repos, for a secret that is also sitting in the
// 0600 file this struct was just read from. Measured 2026-09-05 by doing it and
// reading the E0509, not assumed.
//
// What IS closed here: the two heap buffers that hold the key in the CLEAR on
// the way in and out — the minted `String` and the file read — are `Zeroizing`,
// `Debug` has masked the key since this type was written, and
// `forget_private_key` gives a caller that is finished with an identity a way
// to say so. Closing the rest wants `private` behind an accessor, which is a
// deliberate API revision rather than a security patch.
/// **Erase an identity's private key in place**, for a caller that is done with
/// it — the appliance has handed the bytes to `TunnelSpec` and will not need
/// them again until the next boot reads the file.
///
/// Additive on purpose: no existing caller has to change, and the alternative
/// (`impl Drop for Identity`) is a compile break in three repos — see the
/// comment above this function for the four call sites that would break.
pub fn forget_private_key(identity: &mut Identity) {
identity.private.zeroize();
}
/// Load the appliance identity from `wg_dir`, minting it on first boot.
///
/// The decision is made by `O_CREAT | O_EXCL` on [`IDENTITY_KEY_FILE`], not by
/// a lookup: whoever creates the file mints; everyone else loads. A loser of
/// that race falls back to the load path. The key file is written at mode
/// 0600 and fsynced; [`IDENTITY_PUB_FILE`] is derived data, (re)written via
/// staged-temp + atomic rename whenever missing or stale.
///
/// Fail-closed corollary: a mint interrupted between `create_new` and the
/// fsync leaves a file that does not decode — the next boot REFUSES it
/// ([`CeremonyError::CorruptIdentity`]) instead of quietly minting identity
/// number two under the operator's feet.
pub fn load_or_mint_identity(wg_dir: &Path) -> Result<Identity, CeremonyError> {
fs::create_dir_all(wg_dir).map_err(|source| CeremonyError::Io {
op: "create directory",
path: wg_dir.to_path_buf(),
source,
})?;
let key_path = wg_dir.join(IDENTITY_KEY_FILE);
let mut open = fs::OpenOptions::new();
open.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
open.mode(0o600);
}
match open.open(&key_path) {
Ok(mut file) => {
// We are the mint. Write, flush to disk, THEN derive.
let (private, public) = generate_keypair();
// The base64 private key, on the heap, on the way to a 0600 file.
// `Zeroizing` erases it when this scope ends (F41): it used to be
// an ordinary `String`, freed verbatim on every first boot.
let mut line = Zeroizing::new(encode_key(&private));
line.push('\n');
file.write_all(line.as_bytes())
.and_then(|()| file.sync_all())
.map_err(|source| CeremonyError::Io {
op: "write freshly minted key to",
path: key_path.clone(),
source,
})?;
// The KEY's dirent, durable before anything derives from it — the
// private key is the identity, so it must be findable after a
// crash even if the .pub write below never happens (it is
// re-derivable; the private key is not).
sync_dir(wg_dir)?;
let public_b64 = encode_key(&public);
write_pub_file(wg_dir, &public_b64)?;
Ok(Identity { private, public_b64, minted: true })
}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => load_identity(wg_dir, &key_path),
Err(source) => Err(CeremonyError::Io { op: "create", path: key_path, source }),
}
}
/// The load half: permissions gate, decode gate, pub-file re-derivation.
fn load_identity(wg_dir: &Path, key_path: &Path) -> Result<Identity, CeremonyError> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let meta = fs::metadata(key_path).map_err(|source| CeremonyError::Io {
op: "stat",
path: key_path.to_path_buf(),
source,
})?;
let mode = meta.permissions().mode() & 0o777;
if mode & 0o077 != 0 {
return Err(CeremonyError::IdentityPermissions {
path: key_path.to_path_buf(),
mode,
});
}
}
// The private key file's contents. `Zeroizing` because this IS the
// appliance's identity in the clear, and it is read on every boot (F41).
let mut text = Zeroizing::new(String::new());
fs::File::open(key_path)
.and_then(|mut f| f.read_to_string(&mut text))
.map_err(|source| CeremonyError::Io {
op: "read",
path: key_path.to_path_buf(),
source,
})?;
let private = decode_key(&text).map_err(|reason| CeremonyError::CorruptIdentity {
path: key_path.to_path_buf(),
reason,
})?;
let public_b64 = encode_key(&public_from_private_bytes(&private));
// identity.pub is DERIVED: absent or disagreeing, it is rewritten from the
// key file — one writer, and the .pub can never overrule the .key.
let pub_path = wg_dir.join(IDENTITY_PUB_FILE);
let stale = match fs::read_to_string(&pub_path) {
Ok(existing) => existing.trim() != public_b64,
Err(_) => true,
};
if stale {
write_pub_file(wg_dir, &public_b64)?;
}
Ok(Identity { private, public_b64, minted: false })
}
/// Write [`IDENTITY_PUB_FILE`] via staged temp + atomic rename, fsynced — a
/// reader sees the old content or the new, never a torn line.
///
/// Public because the private key does not always live in
/// [`IDENTITY_KEY_FILE`] any more: a TPM-sealed appliance keeps it in a sealed
/// blob and still owes the operator the same readable `identity.pub`, written
/// by the same writer with the same FAT-hardened durability. A second copy of
/// these thirty lines is exactly the twin this crate exists to prevent — the
/// staged-rename-then-fsync-at-the-final-name shape below was learned from a
/// real appliance that wrote a ZERO-length public key, and that lesson must not
/// have to be learned twice.
pub fn write_identity_pub(wg_dir: &Path, public_b64: &str) -> Result<(), CeremonyError> {
write_pub_file(wg_dir, public_b64)
}
/// Write [`IDENTITY_PUB_FILE`] via staged temp + atomic rename, fsynced.
fn write_pub_file(wg_dir: &Path, public_b64: &str) -> Result<(), CeremonyError> {
let final_path = wg_dir.join(IDENTITY_PUB_FILE);
let tmp_path = wg_dir.join(".identity.pub.tmp");
let io_err = |op: &'static str, path: &Path, source| CeremonyError::Io {
op,
path: path.to_path_buf(),
source,
};
let mut tmp = fs::File::create(&tmp_path).map_err(|e| io_err("stage", &tmp_path, e))?;
tmp.write_all(format!("{public_b64}\n").as_bytes())
.and_then(|()| tmp.sync_all())
.map_err(|e| io_err("write staged public key to", &tmp_path, e))?;
fs::rename(&tmp_path, &final_path)
.map_err(|e| io_err("rename staged public key onto", &final_path, e))?;
// Fsync the file AT ITS FINAL NAME. Syncing the staging file before the
// rename is not enough on every filesystem: MEASURED 2026-08-13 on a real
// appliance boot writing to a FAT data partition, the renamed file landed
// with the right name and a size of ZERO — on FAT the size lives in the
// directory entry, and the entry the rename copied still carried the size
// the staging file had when it was created. An empty identity.pub is worse
// than a missing one: it reads as "this appliance has no public key" while
// the private key exists and the operator already registered the printed
// one.
fs::File::open(&final_path)
.and_then(|f| f.sync_all())
.map_err(|e| io_err("fsync the renamed public key", &final_path, e))?;
// Fsync the DIRECTORY, not just the files. `sync_all` on a file makes its
// CONTENTS durable; the directory entry that lets anyone FIND it is a
// separate write, and a rename is a directory operation with no file to
// sync at all.
//
// MEASURED 2026-08-13, a real first boot: the appliance minted, printed
// its public key, and the machine went down moments later — and the key
// was NOT on the partition when the disk was read back. The consequence is
// not a lost file: the operator registers the PRINTED key with the hub, so
// a mint that does not survive means the next boot mints a DIFFERENT key
// and the machine can never authenticate against the registration it
// already caused. The ceremony's whole promise is that the key survives.
sync_dir(wg_dir)
}
/// Make `dir`'s entries durable (the directory's own metadata write).
fn sync_dir(dir: &Path) -> Result<(), CeremonyError> {
fs::File::open(dir)
.and_then(|d| d.sync_all())
.map_err(|source| CeremonyError::Io { op: "fsync the directory", path: dir.to_path_buf(), source })
}
#[cfg(test)]
mod durability_tests {
use super::*;
/// The mint must make the DIRECTORY durable, not just the files: a
/// machine prints its public key, the operator registers it with the hub,
/// and the machine may lose power before anything else syncs. If the
/// dirents were still in page cache the next boot finds an empty wg dir,
/// mints a DIFFERENT key, and the registration is dead.
///
/// fsync is invisible from userspace after the fact, so this asserts the
/// property it exists to protect: after a mint, EVERY file the ceremony
/// promised is present, findable by directory read (not by name alone),
/// and re-readable as the same identity. Measured 2026-08-13: a real
/// first boot minted, printed, and died — and the host readback of the
/// partition found no identity.pub.
#[test]
fn a_mint_leaves_every_promised_file_findable_in_the_directory() {
let dir = std::env::temp_dir().join(format!("wg-durability-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
let wg = dir.join("wg");
let minted = load_or_mint_identity(&wg).expect("mint");
assert!(minted.minted);
// Findable by ENUMERATION — the directory entry is what a crash loses.
let names: Vec<String> = fs::read_dir(&wg)
.expect("read the wg dir")
.filter_map(|e| e.ok().map(|e| e.file_name().to_string_lossy().into_owned()))
.collect();
assert!(names.iter().any(|n| n == IDENTITY_KEY_FILE), "key in dirents: {names:?}");
assert!(names.iter().any(|n| n == IDENTITY_PUB_FILE), "pub in dirents: {names:?}");
assert!(
!names.iter().any(|n| n.ends_with(".tmp")),
"no staging leftovers: {names:?}"
);
// And the stored public key is the one that was returned/printed.
let stored = fs::read_to_string(wg.join(IDENTITY_PUB_FILE)).expect("read pub");
assert_eq!(stored.trim(), minted.public_b64, "stored == printed");
// A second load finds the SAME identity (no re-mint).
let again = load_or_mint_identity(&wg).expect("load");
assert!(!again.minted, "the second call loads, never re-mints");
assert_eq!(again.public_b64, minted.public_b64);
let _ = fs::remove_dir_all(&dir);
}
}
/// THE canonical identity line — byte-stable across boots, matched by
/// [`IDENTITY_LINE_REGEX`], identical for every product and both shapes.
pub fn identity_line(product: &str, public_b64: &str) -> String {
format!("wg-appliance identity: product={product} pubkey={public_b64}")
}
/// The loud first-boot line, printed BEFORE the canonical line and ONLY when
/// [`Identity::minted`] is set. `MINTED` appears in no other output this
/// module or its consumers format — a new identity is one grep.
pub fn minted_line(product: &str, key_path: &Path) -> String {
format!(
"wg-appliance identity: MINTED new keypair at {} (product={product}) — \
register the public key below with the hub",
key_path.display()
)
}
/// Parse a line back into `(product, public_b64)` — the read half of
/// [`identity_line`], so watchers share ONE writer instead of a private regex.
pub fn parse_identity_line(line: &str) -> Option<(&str, &str)> {
// NOT anchored at column 0 — the marker is found ANYWHERE in the line.
//
// Measured 2026-08-15, on the installed-disk drive: this function was
// `strip_prefix`-anchored, and korp-start re-emits the ceremony's line
// through its own console prefix as
// `INSTALLER: wg-appliance identity: product=gunnar pubkey=…`
// so the ONE shared watcher MISSED the exact line it exists to watch, and
// `isoproof::minted_pubkey` returned `None` on the reboot leg of a machine
// that had in fact announced its key correctly.
//
// That is the twin this function was written to prevent, arriving by the
// back door: an anchored reader forces every caller with a log prefix to
// grow its own private regex, and then "the appliance printed its key" has
// two spellings again. Finding the marker instead keeps ONE writer and ONE
// reader across bare stdout, a serial console and a prefixed log.
//
// Nothing else is loosened: the product/pubkey shape below is unchanged, so
// the key must still be exactly 44 padded base64 chars that DECODE, and it
// must still end the line — AND the marker must begin the line or follow
// WHITESPACE. That last clause is what keeps the pre-existing control
// `x{line}` refused: a prefix glued straight onto the marker is a corrupted
// marker, not a console prefix, and a matcher that accepted it would
// green-chip a line that is not the ceremony's.
const MARKER: &str = "wg-appliance identity: product=";
let start = line
.match_indices(MARKER)
.find(|(i, _)| *i == 0 || line[..*i].ends_with(char::is_whitespace))
.map(|(i, _)| i)?;
let rest = &line[start + MARKER.len()..];
let (product, rest) = rest.split_once(" pubkey=")?;
if product.is_empty() || product.contains(' ') {
return None;
}
// The same shape the regex pins: 44 chars, padded, base64 that decodes to
// a key. (`decode_key` also tolerates whitespace; a LINE does not.)
if rest.len() != 44 || rest.ends_with(char::is_whitespace) || decode_key(rest).is_err() {
return None;
}
Some((product, rest))
}
/// The hub's declared side of the tunnel, parsed from the ceremony dialect.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HubPeer {
/// The hub's public key, raw.
pub public_key: [u8; KEY_LEN],
/// Where to dial. Optional in the type (a passive listener is expressible)
/// but in the fleet's shape the appliance dials out, so a hub file usually
/// carries one.
pub endpoint: Option<SocketAddrV4>,
/// AllowedIPs, IPv4 `addr/prefix`. Required and non-empty: a peer with no
/// routes is a tunnel that "came up" and carries nothing.
pub allowed_ips: Vec<(Ipv4Addr, u8)>,
/// Seconds between keepalives, when NAT needs holding open.
pub persistent_keepalive: Option<u16>,
/// The APPLIANCE's own tunnel address — `[Interface] Address = a.b.c.d/p`,
/// the one non-secret thing the site assigns rather than the appliance
/// minting. Optional: without it the tunnel still seals transport packets
/// (the crossed-traffic floor), but IP THROUGH the tunnel needs it.
/// Declare it with the site's real prefix: the kernel's connected-subnet
/// route (a side effect of assigning it) is what answers the hub.
pub appliance_address: Option<(Ipv4Addr, u8)>,
}
impl HubPeer {
/// The [`PeerSpec`] the data plane consumes — the ceremony's output wired
/// straight into [`crate::dataplane::spawn`] / [`crate::dataplane::probe_handshake`].
pub fn peer_spec(&self) -> PeerSpec {
PeerSpec {
public_key: self.public_key,
preshared_key: None,
endpoint: self.endpoint,
allowed_ips: self.allowed_ips.clone(),
persistent_keepalive: self.persistent_keepalive,
}
}
}
/// Read and parse [`HUB_CONF_FILE`] (or any path holding the dialect).
pub fn read_hub_config(path: &Path) -> Result<HubPeer, CeremonyError> {
let text = fs::read_to_string(path).map_err(|source| CeremonyError::Io {
op: "read hub config",
path: path.to_path_buf(),
source,
})?;
parse_hub_config(&path.display().to_string(), &text)
}
/// Parse the ceremony's hub dialect: exactly one `[Peer]` — keys `PublicKey`
/// (required), `AllowedIPs` (required, IPv4 CIDR list), `Endpoint`
/// (`a.b.c.d:port`) and `PersistentKeepalive` optional — plus at most one
/// `[Interface]` carrying EXACTLY `Address = a.b.c.d/prefix` (the appliance's
/// site-assigned tunnel address; not identity, not secret). Everything else —
/// every unknown key, every unknown section, and *by name* `PrivateKey` and
/// `PresharedKey` wherever they appear — is refused with its line number.
///
/// `source_name` is what error messages call the text (a path, or e.g.
/// `"--hub parameter"` when the dialect arrives as a parameter string —
/// same dialect, both doors, one parser).
pub fn parse_hub_config(source_name: &str, text: &str) -> Result<HubPeer, CeremonyError> {
let err = |line: usize, reason: String| CeremonyError::HubConfig {
source: source_name.to_string(),
line,
reason,
};
#[derive(PartialEq)]
enum Section {
None,
Peer,
Interface,
}
let mut section = Section::None;
let mut seen_peer = false;
let mut seen_interface = false;
let mut public_key: Option<[u8; KEY_LEN]> = None;
let mut endpoint: Option<SocketAddrV4> = None;
let mut allowed_ips: Vec<(Ipv4Addr, u8)> = Vec::new();
let mut persistent_keepalive: Option<u16> = None;
let mut appliance_address: Option<(Ipv4Addr, u8)> = None;
for item in crate::ini::items(text) {
match item {
crate::ini::Item::Section { line, name, raw_name } => match name.as_str() {
"peer" => {
if seen_peer {
return Err(err(line, format!(
"a second [{raw_name}] — the ceremony declares ONE hub; a multi-peer \
topology is a different product surface"
)));
}
seen_peer = true;
section = Section::Peer;
}
"interface" => {
if seen_interface {
return Err(err(line, format!("a second [{raw_name}]")));
}
seen_interface = true;
section = Section::Interface;
}
_ => return Err(err(line, format!("unknown section [{raw_name}]"))),
},
crate::ini::Item::Pair { line, key, value } if section == Section::Interface => {
match key.as_str() {
"address" => {
let (addr, prefix) = value
.split_once('/')
.and_then(|(a, p)| {
Some((a.trim().parse::<Ipv4Addr>().ok()?, p.trim().parse::<u8>().ok()?))
})
.filter(|(_, p)| *p <= 32)
.ok_or_else(|| {
err(line, format!(
"Address '{value}' is not IPv4 `a.b.c.d/prefix` — ONE \
address, with the site's real prefix (the connected-subnet \
route it installs is what answers the hub); IPv6 and \
multiple addresses are not in this dialect"
))
})?;
appliance_address = Some((addr, prefix));
}
"privatekey" => {
return Err(err(line, format!(
"'{key}' is a SECRET and the appliance's own side is MINTED on the \
appliance, never configured in — refused by name"
)));
}
_ => {
return Err(err(line, format!(
"unknown key '{key}' under [Interface] — the ceremony dialect \
carries EXACTLY `Address` there; DNS/MTU and the rest belong to \
the product's own config, not to {HUB_CONF_FILE}"
)));
}
}
}
crate::ini::Item::Pair { line, key, value } => {
if section == Section::None {
return Err(err(line, format!(
"'{key}' before any section — the hub file starts with [Peer]"
)));
}
match key.as_str() {
"publickey" => {
let raw = decode_key(value)
.map_err(|e| err(line, format!("PublicKey: {e}")))?;
public_key = Some(raw);
}
"privatekey" | "presharedkey" => {
return Err(err(line, format!(
"'{key}' is a SECRET and {HUB_CONF_FILE} is public data (it is baked \
into per-site images and copied freely) — refused by name"
)));
}
"endpoint" => {
let ep: SocketAddrV4 = value.parse().map_err(|_| {
err(line, format!(
"Endpoint '{value}' is not `a.b.c.d:port` — a hostname needs a \
resolver the appliance does not carry, and IPv6 is not in this \
dialect; spell the IPv4 address"
))
})?;
endpoint = Some(ep);
}
"allowedips" => {
for part in value.split(',') {
let part = part.trim();
let (addr, prefix) = part.split_once('/').ok_or_else(|| {
err(line, format!("AllowedIPs entry '{part}' is not `addr/prefix`"))
})?;
let addr: Ipv4Addr = addr.trim().parse().map_err(|_| {
err(line, format!(
"AllowedIPs entry '{part}': '{addr}' is not an IPv4 address \
(IPv6 is not in this dialect)"
))
})?;
let prefix: u8 = prefix.trim().parse().ok().filter(|p| *p <= 32)
.ok_or_else(|| {
err(line, format!(
"AllowedIPs entry '{part}': '/{prefix}' is not a prefix \
length 0–32"
))
})?;
allowed_ips.push((addr, prefix));
}
}
"persistentkeepalive" => {
let secs: u16 = value.parse().map_err(|_| {
err(line, format!(
"PersistentKeepalive '{value}' is not a number of seconds (0–65535)"
))
})?;
persistent_keepalive = Some(secs);
}
_ => {
return Err(err(line, format!(
"unknown key '{key}' — the ceremony dialect is PublicKey, AllowedIPs, \
Endpoint, PersistentKeepalive; anything a product needs beyond that \
belongs in that product's own config, not in {HUB_CONF_FILE}"
)));
}
}
}
crate::ini::Item::Malformed { line, text } => {
return Err(err(line, format!("'{text}' is not `Key = value`")));
}
}
}
if !seen_peer {
return Err(err(0, "no [Peer] section — an empty hub file declares nothing".into()));
}
if seen_interface && appliance_address.is_none() {
return Err(err(0, "[Interface] with no Address declares nothing — drop the section or fill it".into()));
}
let public_key = public_key
.ok_or_else(|| err(0, "[Peer] has no PublicKey — the hub's key is the whole point".into()))?;
if allowed_ips.is_empty() {
return Err(err(0, "[Peer] has no AllowedIPs — a tunnel that routes nothing proves nothing".into()));
}
Ok(HubPeer { public_key, endpoint, allowed_ips, persistent_keepalive, appliance_address })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dataplane::{build_tunn, probe_handshake, TunnelSpec};
/// **An `Identity`'s `Debug` must not contain the private key.**
///
/// The mask has been hand-written since the type existed and had never
/// been asserted — so a derive put back in a refactor would have passed
/// every test in this module. `{:?}` and `{:#?}` both, through the one
/// probe the crate's secret-bearing types share.
///
/// RED-WHEN-BROKEN: replace the `impl fmt::Debug for Identity` with a derive.
#[test]
fn an_identity_never_renders_its_private_key() {
let private = decode_key("gEi6mvh6b5rKDg7UkleGpsJLlA9ueOKyWhjqJqFjWHg=").unwrap();
let identity = Identity {
private,
public_b64: "R/1RsV4yjvIeEUApgRu3u584R5/WCKgKaPqHieam4TU=".into(),
minted: true,
};
crate::keys::probe::assert_masks(
&[("{:?}", format!("{identity:?}")), ("{:#?}", format!("{identity:#?}"))],
&[("the private key", private)],
&["R/1RsV4yjvIeEUApgRu3u584R5/WCKgKaPqHieam4TU=", "minted: true"],
);
}
/// **The watcher must find the line it exists to watch, PREFIXED.**
///
/// RED-when-broken: restore the `strip_prefix` anchor and the first
/// assertion fails on the exact string korp-start writes to the serial
/// console of an installed appliance. Measured 2026-08-15 — that anchor is
/// why `isoproof::minted_pubkey` returned `None` for a machine that HAD
/// announced its key.
///
/// The negative half is what stops the fix from being a loosening: a line
/// that merely mentions the marker inside other text still has to carry a
/// well-formed product and a decodable 44-char key, or it is not a match.
#[test]
fn the_identity_line_is_found_behind_a_console_prefix_but_junk_is_still_refused() {
let key = "RgjZruoBPRyHxNlSbQn+t3xfV9v67/dnQGgOdn+lySU=";
let bare = format!("wg-appliance identity: product=gunnar pubkey={key}");
// Bare, as the container and host forms print it.
assert_eq!(parse_identity_line(&bare), Some(("gunnar", key)));
// Behind korp-start's console prefix, as an INSTALLED disk prints it.
// This is the case the anchored version missed.
let prefixed = format!("INSTALLER: {bare}");
assert_eq!(
parse_identity_line(&prefixed),
Some(("gunnar", key)),
"the shared watcher must see the line through a console prefix",
);
// And behind a timestamped log prefix, the other shape a caller would
// otherwise grow a private regex for.
let logged = format!("2026-08-15T12:38:06.702056Z INFO {bare}");
assert_eq!(parse_identity_line(&logged), Some(("gunnar", key)));
// NOT loosened — every one of these still refuses:
assert_eq!(
parse_identity_line("INSTALLER: wg-appliance identity: product=gunnar pubkey=short"),
None,
"a key that is not 44 chars is not a key",
);
assert_eq!(
parse_identity_line(&format!(
"INSTALLER: wg-appliance identity: product=two words pubkey={key}"
)),
None,
"a product with a space is not a product",
);
assert_eq!(
parse_identity_line(&format!(
"INSTALLER: wg-appliance identity: product=gunnar pubkey={}",
"!".repeat(44)
)),
None,
"44 characters that do not DECODE are not a key",
);
// The boundary that keeps un-anchoring from being a loosening: a prefix
// GLUED to the marker is a corrupted marker, not a console prefix.
// (`identity_line_is_byte_stable_and_regex_matchable` asserts the same
// control on `x{line}`; it is repeated here because it is the exact
// property this test's first half could otherwise have destroyed.)
assert_eq!(
parse_identity_line(&format!("x{bare}")),
None,
"a prefix glued onto the marker is a corrupted marker",
);
}
use base64ct::Encoding as _;
use std::sync::atomic::{AtomicU32, Ordering};
/// A std-only scratch dir — this crate takes no dev-dependencies for what
/// twenty lines of std can do. 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-ceremony-{tag}-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir_all(&dir).unwrap();
Scratch(dir)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn running_as_root() -> bool {
// Root ignores permission bits; the two chmod-based refusal tests
// cannot see their condition and skip LOUDLY instead of passing green.
// SAFETY: `geteuid(2)` takes no arguments, touches no memory of ours and
// cannot fail; it is `unsafe` only for crossing the `extern "C"` boundary.
unsafe { libc::geteuid() == 0 }
}
#[test]
fn mint_then_load_is_idempotent() {
let dir = Scratch::new("idem");
let first = load_or_mint_identity(dir.path()).unwrap();
assert!(first.minted, "an empty directory is first boot");
let second = load_or_mint_identity(dir.path()).unwrap();
assert!(!second.minted, "the second boot LOADS — a re-mint here is the primary defect");
assert_eq!(first.public_b64, second.public_b64);
assert_eq!(first.private, second.private);
}
#[cfg(unix)]
#[test]
fn minted_key_file_mode_is_0600() {
use std::os::unix::fs::PermissionsExt as _;
let dir = Scratch::new("mode");
load_or_mint_identity(dir.path()).unwrap();
let mode = fs::metadata(dir.path().join(IDENTITY_KEY_FILE))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600, "the private key is the appliance's ONE secret");
}
/// FAIL CLOSED — and the file must be byte-identical afterwards. The
/// refusal text names the path (the operator's next move) and the corrupt
/// bytes stay in place as the evidence.
#[test]
fn corrupt_key_refuses_by_name_and_never_overwrites() {
let dir = Scratch::new("corrupt");
let key_path = dir.path().join(IDENTITY_KEY_FILE);
for junk in ["not a key at all", "c2hvcnQ=", ""] {
fs::write(&key_path, junk).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600)).unwrap();
}
let err = load_or_mint_identity(dir.path()).unwrap_err();
let msg = err.to_string();
assert!(
matches!(err, CeremonyError::CorruptIdentity { .. }),
"junk {junk:?} must refuse as corrupt, got: {msg}"
);
assert!(
msg.contains(IDENTITY_KEY_FILE),
"the refusal names the file: {msg}"
);
assert_eq!(
fs::read_to_string(&key_path).unwrap(),
junk,
"the corrupt file is EVIDENCE — it is never rewritten"
);
fs::remove_file(&key_path).unwrap();
}
// The control that keeps the loop honest: with the junk gone, the same
// call mints — so the refusals above were the guard, not a dead path.
assert!(load_or_mint_identity(dir.path()).unwrap().minted);
}
/// A 33-byte base64 string is 44 characters — the right LENGTH and the
/// wrong key. The decode gate must catch it (a char count cannot).
#[test]
fn a_44_char_non_key_still_refuses() {
let dir = Scratch::new("len44");
let key_path = dir.path().join(IDENTITY_KEY_FILE);
let thirty_three = base64ct::Base64::encode_string(&[7u8; 33]);
assert_eq!(thirty_three.len(), 44);
fs::write(&key_path, format!("{thirty_three}\n")).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600)).unwrap();
}
assert!(matches!(
load_or_mint_identity(dir.path()).unwrap_err(),
CeremonyError::CorruptIdentity { .. }
));
}
#[cfg(unix)]
#[test]
fn group_readable_key_is_refused_by_name() {
if running_as_root() {
eprintln!("SKIP group_readable_key_is_refused_by_name: euid 0 ignores mode bits");
return;
}
use std::os::unix::fs::PermissionsExt as _;
let dir = Scratch::new("perm");
load_or_mint_identity(dir.path()).unwrap();
let key_path = dir.path().join(IDENTITY_KEY_FILE);
fs::set_permissions(&key_path, fs::Permissions::from_mode(0o640)).unwrap();
let err = load_or_mint_identity(dir.path()).unwrap_err();
assert!(
matches!(err, CeremonyError::IdentityPermissions { mode: 0o640, .. }),
"0640 is one group away from copied: {err}"
);
// Restore and prove the guard was the only obstacle.
fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600)).unwrap();
assert!(!load_or_mint_identity(dir.path()).unwrap().minted);
}
#[test]
fn pub_file_is_rederived_when_missing_or_stale() {
let dir = Scratch::new("pub");
let minted = load_or_mint_identity(dir.path()).unwrap();
let pub_path = dir.path().join(IDENTITY_PUB_FILE);
assert_eq!(
fs::read_to_string(&pub_path).unwrap().trim(),
minted.public_b64,
"the pub file is the no-log readback"
);
fs::remove_file(&pub_path).unwrap();
load_or_mint_identity(dir.path()).unwrap();
assert_eq!(fs::read_to_string(&pub_path).unwrap().trim(), minted.public_b64);
fs::write(&pub_path, "tampered\n").unwrap();
load_or_mint_identity(dir.path()).unwrap();
assert_eq!(
fs::read_to_string(&pub_path).unwrap().trim(),
minted.public_b64,
"identity.pub can never overrule identity.key — one writer"
);
}
#[cfg(unix)]
#[test]
fn unwritable_dir_is_io_error_naming_path() {
if running_as_root() {
eprintln!("SKIP unwritable_dir_is_io_error_naming_path: euid 0 writes anywhere");
return;
}
use std::os::unix::fs::PermissionsExt as _;
let dir = Scratch::new("ro");
fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o500)).unwrap();
let err = load_or_mint_identity(dir.path()).unwrap_err();
fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o700)).unwrap();
let msg = err.to_string();
assert!(matches!(err, CeremonyError::Io { .. }), "{msg}");
assert!(msg.contains(IDENTITY_KEY_FILE), "the refusal names the path: {msg}");
}
/// The mint decision is the filesystem's: a file that appears between two
/// calls (the create_new loser) is LOADED, never clobbered.
#[test]
fn create_new_loser_falls_back_to_load() {
let dir = Scratch::new("race");
let winner = load_or_mint_identity(dir.path()).unwrap();
assert!(winner.minted);
let loser = load_or_mint_identity(dir.path()).unwrap();
assert!(!loser.minted);
assert_eq!(loser.public_b64, winner.public_b64);
}
#[test]
fn identity_line_is_byte_stable_and_regex_matchable() {
let (_, public) = crate::keys::generate_keypair_b64();
let line = identity_line("holger", &public);
assert_eq!(line, format!("wg-appliance identity: product=holger pubkey={public}"));
assert_eq!(
parse_identity_line(&line),
Some(("holger", public.as_str())),
"the parser is the regex's one writer"
);
// Controls: the shapes the regex must NOT match — a 43-char key, a
// 45-char key, a doctored prefix. A matcher that passes these would
// green-chip a line that is not the ceremony's.
assert_eq!(parse_identity_line(&line[..line.len() - 1]), None, "43 chars is not a key");
assert_eq!(parse_identity_line(&format!("{line}=")), None, "45 chars is not a key");
assert_eq!(parse_identity_line(&format!("x{line}")), None);
assert_eq!(parse_identity_line(&minted_line("holger", Path::new("/data/wg/identity.key"))), None,
"the MINTED line is NOT the canonical line");
// And the published regex string agrees with the parser on the line.
assert!(IDENTITY_LINE_REGEX.contains("wg-appliance identity"), "the regex pins the prefix");
}
// ── the hub dialect ──────────────────────────────────────────────────
const HUB_KEY: &str = "HIgo9xNzJMWLKASShiTqIybxZ0U3wGLiUeJ1PKf8ykw=";
fn hub_text() -> String {
format!(
"# per-site hub declaration\n\
[Peer]\n\
PublicKey = {HUB_KEY}\n\
Endpoint = 192.0.2.7:51820\n\
AllowedIPs = 10.77.0.0/16, 10.99.0.1/32\n\
PersistentKeepalive = 25\n"
)
}
#[test]
fn a_hub_file_parses_into_the_peer_the_dataplane_consumes() {
let hub = parse_hub_config("hub.conf", &hub_text()).unwrap();
assert_eq!(encode_key(&hub.public_key), HUB_KEY);
assert_eq!(hub.endpoint, Some("192.0.2.7:51820".parse().unwrap()));
assert_eq!(
hub.allowed_ips,
vec![("10.77.0.0".parse().unwrap(), 16), ("10.99.0.1".parse().unwrap(), 32)]
);
assert_eq!(hub.persistent_keepalive, Some(25));
assert_eq!(hub.appliance_address, None, "no [Interface] declared, none invented");
let spec = hub.peer_spec();
assert_eq!(spec.preshared_key, None, "the ceremony dialect carries no secret");
}
/// The optional `[Interface] Address` — the site-assigned tunnel address,
/// in either section order.
#[test]
fn an_interface_address_parses_and_is_optional() {
let text = format!("[Interface]\nAddress = 10.77.0.7/16\n\n{}", hub_text());
let hub = parse_hub_config("hub.conf", &text).unwrap();
assert_eq!(hub.appliance_address, Some(("10.77.0.7".parse().unwrap(), 16)));
// Section order must not matter — the operator writes wg-quick's order.
let text2 = format!("{}\n[Interface]\nAddress = 10.77.0.7/16\n", hub_text());
assert_eq!(
parse_hub_config("hub.conf", &text2).unwrap().appliance_address,
Some(("10.77.0.7".parse().unwrap(), 16))
);
}
/// The ceremony output feeds the REAL crypto: a parsed hub plus a minted
/// identity produce the 148-byte WireGuard handshake initiation.
#[test]
fn parsed_hub_plus_minted_identity_initiates_a_real_handshake() {
let dir = Scratch::new("probe");
let id = load_or_mint_identity(dir.path()).unwrap();
let hub = parse_hub_config("hub.conf", &hub_text()).unwrap();
let spec = TunnelSpec {
private_key: id.private,
listen_port: None,
peers: vec![hub.peer_spec()],
};
assert_eq!(
probe_handshake(&spec).unwrap(),
148,
"a WireGuard initiation is 148 bytes; anything else is not WireGuard"
);
}
/// RED — the WRONG hub key must fail the handshake, not look healthy. Two
/// in-process `Tunn`s: the appliance initiates toward hub key A; a
/// responder holding a DIFFERENT private key must refuse the packet, and
/// the responder holding A must accept it (the control that proves the
/// refusal is the crypto, not a broken harness).
#[test]
fn wrong_hub_public_key_never_handshakes_in_process() {
use boringtun::noise::{Tunn, TunnResult};
use boringtun::x25519::{PublicKey, StaticSecret};
let (appliance_private, appliance_public) = crate::keys::generate_keypair();
let (hub_private, hub_public) = crate::keys::generate_keypair();
let (wrong_private, _) = crate::keys::generate_keypair();
let spec = TunnelSpec {
private_key: appliance_private,
listen_port: None,
peers: vec![HubPeer {
public_key: hub_public,
endpoint: None,
allowed_ips: vec![("10.0.0.0".parse().unwrap(), 8)],
persistent_keepalive: None,
appliance_address: None,
}
.peer_spec()],
};
let mut initiator = build_tunn(&spec, 0).unwrap();
let mut packet = [0u8; 256];
let initiation = match initiator.format_handshake_initiation(&mut packet, false) {
TunnResult::WriteToNetwork(p) => p.to_vec(),
other => panic!("no initiation: {other:?}"),
};
let mut wrong_hub = Tunn::new(
StaticSecret::from(wrong_private),
PublicKey::from(appliance_public),
None,
None,
7,
None,
);
let mut buf = [0u8; 512];
assert!(
matches!(
wrong_hub.decapsulate(None, &initiation, &mut buf),
TunnResult::Err(_)
),
"a hub with the wrong private key must REFUSE the initiation"
);
let mut right_hub = Tunn::new(
StaticSecret::from(hub_private),
PublicKey::from(appliance_public),
None,
None,
8,
None,
);
let mut buf2 = [0u8; 512];
assert!(
matches!(
right_hub.decapsulate(None, &initiation, &mut buf2),
TunnResult::WriteToNetwork(_)
),
"the control: the RIGHT key answers the same bytes with a response"
);
}
// ── the reds the dialect owes, each named ────────────────────────────
#[test]
fn hub_dialect_refusals_name_the_offense_and_the_line() {
let cases: &[(&str, &str, &str)] = &[
(
"[Interface]\nAddress = 10.0.0.2/32\n",
"[Peer]",
"an interface alone declares no hub",
),
(
&format!("[Interface]\nPrivateKey = {HUB_KEY}\n{}", hub_text()),
"privatekey",
"a private key under [Interface] — minted, never configured in",
),
(
&format!("[Interface]\nDNS = 1.1.1.1\nAddress = 10.0.0.2/32\n{}", hub_text()),
"unknown key 'dns' under [Interface]",
"an unknown interface key",
),
(
&format!("[Interface]\nAddress = fd00::2/64\n{}", hub_text()),
"Address",
"an IPv6 address in the v4 dialect",
),
(
&format!("[Interface]\nAddress = 10.0.0.2\n{}", hub_text()),
"Address",
"an address with no prefix",
),
(
&format!("[Interface]\nAddress = 10.0.0.2/32\n[Interface]\nAddress = 10.0.0.3/32\n{}", hub_text()),
"second [Interface]",
"two interface sections",
),
(
&format!("[Interface]\n{}", hub_text()),
"no Address",
"an empty interface section",
),
(
&format!("[Peer]\nPrivateKey = {HUB_KEY}\nAllowedIPs = 10.0.0.0/8\n"),
"privatekey",
"a private key in the hub file",
),
(
&format!("[Peer]\nPresharedKey = {HUB_KEY}\nPublicKey = {HUB_KEY}\nAllowedIPs = 10.0.0.0/8\n"),
"presharedkey",
"a preshared key is a secret",
),
(
&format!("[Peer]\nPublicKey = {HUB_KEY}\nDNS = 1.1.1.1\nAllowedIPs = 10.0.0.0/8\n"),
"unknown key 'dns'",
"an unknown key",
),
(
&format!("[Peer]\nPublicKey = {HUB_KEY}\nEndpoint = hub.example.com:51820\nAllowedIPs = 10.0.0.0/8\n"),
"Endpoint",
"a hostname endpoint",
),
(
&format!("[Peer]\nPublicKey = {HUB_KEY}\nAllowedIPs = 10.0.0.0/33\n"),
"prefix",
"an impossible prefix",
),
(
&format!("[Peer]\nPublicKey = {HUB_KEY}\nAllowedIPs = fd00::/8\n"),
"IPv4",
"IPv6 in the v4 dialect",
),
(
&format!("[Peer]\nPublicKey = {HUB_KEY}\nthis line has no equals\nAllowedIPs = 10.0.0.0/8\n"),
"not `Key = value`",
"a malformed line",
),
(
"PublicKey = whatever\n",
"before any section",
"a pair outside any section",
),
(
&format!("[Peer]\nPublicKey = {HUB_KEY}\nAllowedIPs = 10.0.0.0/8\n[Peer]\nPublicKey = {HUB_KEY}\nAllowedIPs = 10.0.0.0/8\n"),
"second [Peer]",
"two hubs",
),
("[Peer]\nPublicKey = short\nAllowedIPs = 10.0.0.0/8\n", "PublicKey", "a bad key value"),
(&format!("[Peer]\nPublicKey = {HUB_KEY}\n"), "AllowedIPs", "no routes"),
("[Peer]\nAllowedIPs = 10.0.0.0/8\n", "PublicKey", "no hub key"),
("# nothing here\n", "[Peer]", "an empty file"),
];
for (text, must_name, why) in cases {
let err = parse_hub_config("hub.conf", text).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains(must_name),
"{why}: the refusal must name '{must_name}', got: {msg}"
);
}
// The control that keeps the loop honest: the clean text still parses.
assert!(parse_hub_config("hub.conf", &hub_text()).is_ok());
}
/// Line numbers reach the operator: the offense on line 3 says `:3:`.
#[test]
fn refusals_carry_the_line_number() {
let text = format!("[Peer]\nPublicKey = {HUB_KEY}\nDNS = 1.1.1.1\nAllowedIPs = 10.0.0.0/8\n");
let msg = parse_hub_config("site-7/hub.conf", &text).unwrap_err().to_string();
assert!(msg.contains("site-7/hub.conf:3:"), "source and line, exactly: {msg}");
}
}