crates/gunnar-server/src/wg.rs
//! **WireGuard on the appliance**: a stable address that outlives the DHCP lease.
//!
//! The appliance's outer address is whatever a DHCP server felt like granting
//! (`appliance_pid1::dhcp`, `crate::boot::bring_up_network`), and it can change mid-life —
//! `appliance_pid1::runtime::dhcp_renewal` logs exactly that event and re-applies it. An address
//! nobody can predict is an address nobody can put in a config file, so this
//! module gives the machine a **second, declared** address on a tunnel: ssh and
//! the control plane already bind `0.0.0.0`, so the moment `wg0` exists with an
//! address on it they answer there too, and nobody has to read a lease table.
//!
//! # The lane: USERSPACE, compiled in
//!
//! The WireGuard data plane is **linked into this binary** (`boringtun`, through
//! `wg-appliance-core` — the one implementation the suite has, shared with
//! tunnr's `wg-appliance` PID-1 payload). The tunnel needs:
//!
//! ```text
//! CONFIG_TUN builtin (=y) on the kernel the ISO packs, MEASURED 2026-08-12
//! — and on every ordinary distribution kernel
//! CONFIG_WIREGUARD NOTHING. Not y, not m, not present.
//! modules NONE. No finit_module, no /lib/modules, no staged .ko.zst
//! tooling NONE. No wg(8), no wg-quick, no ip(8), no shell
//! ```
//!
//! This is the correction of the kernel lane that stood here until now
//! (8aa6103a). That lane worked, and it worked on **one box**: it needed
//! `CONFIG_WIREGUARD=m` plus four `.ko.zst` files matched to the exact kernel
//! release, staged into the image by hand. The files were declared nowhere and
//! shipped nowhere, so a RELEASED ISO had no tunnel — the capability existed
//! only for whoever rebuilt the image on the same machine that had the modules.
//! OS-LESS means everything is in the compiled heap; a tunnel that depends on
//! what the packed kernel was compiled with is not.
//!
//! The cost is honest and was paid on purpose: boringtun brings x25519-dalek,
//! chacha20poly1305, blake2 and ring into the appliance's dependency graph.
//! Measured, not estimated — see `.nornir/build-variants.toml`.
//!
//! # Seeding, and why not the kernel command line
//!
//! A WireGuard private key is a **secret**, and `/proc/cmdline` is world
//! readable — so `GUNNAR_WG=…` would publish the key to every process on the
//! machine, which is the opposite of what the tunnel is for. The configuration
//! is a file in the data root instead, arriving by the same route as every other
//! piece of appliance state:
//!
//! ```text
//! ISO assets/appliance-root/data/wg0.conf → /data/wg0.conf (RAM)
//! container the /data volume
//! service $GUNNAR_DATA_ROOT/wg0.conf on real disk
//! ```
//!
//! On the ISO that tree is `[[build.iso]].staging`, and it ships holding a
//! `.keep` and nothing else — deliberately, because a committed key is a key
//! everybody has. An operator who wants a tunnel builds their own ISO with their
//! own keys in it, which is also the only shape in which the private key is not
//! in this repository.
//!
//! # Strictly additive
//!
//! No `wg0.conf` ⇒ one INFO line and nothing else changes. A config that is
//! present but **malformed** is an error, matching how a malformed `GUNNAR_NET`
//! is fatal: a declaration that lies must stop the boot. A config that is well
//! formed but that the KERNEL refuses (no `/dev/net/tun`, no `CAP_NET_ADMIN`)
//! is a WARN and the boot goes on — the DHCP posture, because there the world
//! failed to provide, not the declaration.
//!
//! # What this does NOT do
//!
//! * **No routes beyond the address's own.** `Address = 10.99.0.2/24` gives the
//! kernel a connected route for `10.99.0.0/24` when [`appliance_pid1::net::apply`]
//! installs it, and that covers the ordinary case where `AllowedIPs` is inside
//! the tunnel subnet. An `AllowedIPs` OUTSIDE it needs a route this module
//! does not add, and says so at startup rather than silently blackholing.
//! (The data plane's AllowedIPs table decides which PEER an outbound packet
//! belongs to; it does not decide which packets the kernel hands to `wg0`.)
//! * **No endpoint re-resolution.** An `Endpoint` is parsed as `IP:port`, not a
//! hostname: the appliance has no resolver configured at this point in the
//! boot and a DNS lookup here would be a new failure mode before the network
//! is proven.
//! * **No IPv6 transport.** The outer socket is `AF_INET`. Inner IPv6 would be
//! carried, but nothing configures it and `Address`/`AllowedIPs` are IPv4.
//! * **Nothing about the outer address changing.** When DHCP renewal moves the
//! appliance's outer address (`appliance_pid1::runtime::dhcp_renewal`'s `Renewed { changed:
//! Some(..) }`), the tunnel keeps working in the direction the appliance
//! initiates — WireGuard's own roaming updates the PEER's idea of the endpoint
//! from the next authenticated packet. What breaks is the peer reaching the
//! appliance FIRST after such a move, until the appliance sends something;
//! `PersistentKeepalive` is the operator's answer and is why it is honoured.
//! Not solved here, and not papered over.
use std::net::{Ipv4Addr, SocketAddrV4};
use std::path::Path;
use std::sync::Arc;
use anyhow::{bail, Context, Result};
use wg_appliance_core::dataplane::{self, PeerSpec, TunnelHandle, TunnelSpec};
use wg_appliance_core::ini;
use wg_appliance_core::keys;
use wg_appliance_core::tun::{TunDevice, DEFAULT_MTU};
/// A WireGuard key: 32 raw bytes, however it was spelled.
const KEY_LEN: usize = keys::KEY_LEN;
/// The file, relative to the data root, that turns the tunnel on by existing.
pub const CONFIG_FILE: &str = "wg0.conf";
/// The **private key alone**, relative to the data root: one base64 line.
///
/// The companion of [`BOOT_PARAM`]. The split exists because exactly one field
/// of a WireGuard configuration is a secret, and the other five are the ones an
/// operator wants to declare, version and repeat per site.
pub const KEY_FILE: &str = "wg0.key";
/// The boot parameter carrying the **non-secret half** of the tunnel.
///
/// `GUNNAR_WG=addr=10.99.0.2/24,peer=<base64>,endpoint=10.0.2.2:51820,allowed=10.99.0.0/24,keepalive=25`
///
/// Linux turns an unrecognised `key=value` boot parameter into an environment
/// variable for init (`init/main.c`, `unknown_bootoption`), which is how
/// `GUNNAR_NET` already arrives — so this rides a channel that is proven and
/// declared (`.nornir/build-variants.toml`, `[[build.iso]].boot_params`).
///
/// **What may and may not travel this way, and why the line is exactly here.**
/// `/proc/cmdline` is world readable. A hub's public key, an endpoint and an
/// AllowedIPs list are not secrets — they are published to every peer of the
/// network by definition, and WireGuard's security does not rest on hiding
/// them. A PRIVATE key is a secret, and putting it here would hand it to every
/// process on the machine, which is the opposite of what a tunnel is for. So
/// the private key comes from [`KEY_FILE`] in the data root and nothing else
/// does.
pub const BOOT_PARAM: &str = "GUNNAR_WG";
/// **The one read of [`BOOT_PARAM`]**, and the only line in this module that
/// touches the process environment.
///
/// Blank is absent: a boot parameter Linux passed through as `GUNNAR_WG=` (or
/// one an operator left empty) declares nothing, and treating it as a
/// declaration is how a machine ends up refusing to boot because two lanes are
/// "both armed" when one of them is an empty string.
///
/// Every decision that depends on it takes the answer as an argument —
/// [`WgConfig::resolve_with`], [`seeded_lane_conflict`] — so the rules are
/// testable without a process-wide mutation. See `resolve`'s note for the
/// flake that made this a rule.
pub fn boot_param() -> Option<String> {
std::env::var(BOOT_PARAM)
.ok()
.filter(|s| !s.trim().is_empty())
}
// ── the configuration ───────────────────────────────────────────────────────
/// One peer of the tunnel.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Peer {
/// The peer's public key, 32 raw bytes.
pub public_key: [u8; KEY_LEN],
/// An optional pre-shared key, 32 raw bytes, mixed in on top of the
/// Curve25519 handshake.
pub preshared_key: Option<[u8; KEY_LEN]>,
/// What this peer is allowed to send from and be routed to: `(address,
/// prefix)` pairs.
pub allowed_ips: Vec<(Ipv4Addr, u8)>,
/// Where to reach the peer, if this end initiates. `IP:port` only — see the
/// module docs on why a hostname is refused.
pub endpoint: Option<SocketAddrV4>,
/// Seconds between keepalives, or `None` for off.
pub persistent_keepalive: Option<u16>,
}
/// A whole `wg0.conf`, parsed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WgConfig {
/// The interface to create, taken from the config FILE's stem so the name
/// and the file cannot disagree.
pub interface: String,
/// This end's private key, 32 raw bytes.
pub private_key: [u8; KEY_LEN],
/// The tunnel address to put on the interface, and its prefix.
pub address: Ipv4Addr,
/// The tunnel address's prefix length.
pub prefix: u8,
/// The UDP port to listen on. `None` lets the kernel choose, which is right
/// for a machine that only ever initiates.
pub listen_port: Option<u16>,
/// The peers. At least one, or there is no tunnel to speak of.
pub peers: Vec<Peer>,
}
impl WgConfig {
/// Parse the `wg-quick(8)` INI subset gunnar understands.
///
/// A **subset**, named exhaustively so an operator is never guessing which
/// half of their file was read:
///
/// ```text
/// [Interface] PrivateKey Address ListenPort
/// [Peer] PublicKey PresharedKey AllowedIPs Endpoint PersistentKeepalive
/// ```
///
/// Everything `wg-quick` itself implements with a shell — `DNS`, `MTU`,
/// `Table`, `PreUp`, `PostUp`, `SaveConfig` — is **refused by name** rather
/// than ignored. An ignored `PostUp` is a command the operator believes ran;
/// The no-OS rule bans gunnar from running one at all, so the honest answer is to say so
/// at parse time and not at 3 a.m.
///
/// The line SCANNER is `wg_appliance_core::ini`, shared with tunnr's payload
/// so there is one answer to "where does a comment end" — the POLICY below
/// is gunnar's alone, because tunnr's payload deliberately accepts keys this
/// one refuses.
///
/// `interface` is the config file's stem, not a field: two sources for one
/// name is a way for them to disagree.
pub fn parse(interface: &str, text: &str) -> Result<WgConfig> {
/// The keys `wg-quick` has and gunnar deliberately does not.
const REFUSED: [&str; 8] = [
"dns",
"mtu",
"table",
"preup",
"postup",
"predown",
"postdown",
"saveconfig",
];
#[derive(PartialEq)]
enum Section {
None,
Interface,
Peer,
}
let mut section = Section::None;
let mut private_key: Option<[u8; KEY_LEN]> = None;
let mut cidr: Option<(Ipv4Addr, u8)> = None;
let mut listen_port: Option<u16> = None;
let mut peers: Vec<Peer> = Vec::new();
for item in ini::items(text) {
let line = item.line();
let (key, value) = match item {
ini::Item::Section { name, raw_name, .. } => {
section = match name.as_str() {
"interface" => Section::Interface,
"peer" => {
peers.push(Peer {
public_key: [0; KEY_LEN],
preshared_key: None,
allowed_ips: Vec::new(),
endpoint: None,
persistent_keepalive: None,
});
Section::Peer
}
_ => bail!("{interface}.conf:{line}: unknown section [{raw_name}]"),
};
continue;
}
ini::Item::Malformed { text, .. } => {
bail!("{interface}.conf:{line}: {text:?} is not `Key = value`")
}
ini::Item::Pair { key, value, .. } => (key, value),
};
if REFUSED.contains(&key.as_str()) {
bail!(
"{interface}.conf:{line}: `{key}` is a wg-quick key gunnar does NOT implement. \
It is refused rather than ignored, because an ignored key reads as one that \
was honoured. (PreUp/PostUp/PostDown run commands, and this server spawns \
nothing; DNS/MTU/Table are not configured here.)"
);
}
match (§ion, key.as_str()) {
(Section::None, _) => {
bail!(
"{interface}.conf:{line}: `{key}` before any [Interface] or [Peer] section"
)
}
(Section::Interface, "privatekey") => {
private_key = Some(
parse_key(value)
.with_context(|| format!("{interface}.conf:{line}: PrivateKey"))?,
);
}
(Section::Interface, "address") => {
cidr = Some(
parse_cidr(value)
.with_context(|| format!("{interface}.conf:{line}: Address"))?,
);
}
(Section::Interface, "listenport") => {
listen_port = Some(value.parse::<u16>().with_context(|| {
format!("{interface}.conf:{line}: ListenPort {value:?} is not a port")
})?);
}
(Section::Peer, "publickey") => {
let k = parse_key(value)
.with_context(|| format!("{interface}.conf:{line}: PublicKey"))?;
peers
.last_mut()
.expect("a [Peer] section pushed one")
.public_key = k;
}
(Section::Peer, "presharedkey") => {
let k = parse_key(value)
.with_context(|| format!("{interface}.conf:{line}: PresharedKey"))?;
peers
.last_mut()
.expect("a [Peer] section pushed one")
.preshared_key = Some(k);
}
(Section::Peer, "allowedips") => {
let mut ips = Vec::new();
for one in value.split(',') {
let one = one.trim();
if one.is_empty() {
continue;
}
ips.push(parse_cidr(one).with_context(|| {
format!("{interface}.conf:{line}: AllowedIPs entry {one:?}")
})?);
}
if ips.is_empty() {
bail!("{interface}.conf:{line}: AllowedIPs is empty");
}
peers
.last_mut()
.expect("a [Peer] section pushed one")
.allowed_ips = ips;
}
(Section::Peer, "endpoint") => {
let addr: SocketAddrV4 = value.parse().with_context(|| {
format!(
"{interface}.conf:{line}: Endpoint {value:?} is not <IPv4>:<port>. A \
HOSTNAME is deliberately not accepted: the appliance has no resolver \
configured this early in the boot"
)
})?;
peers
.last_mut()
.expect("a [Peer] section pushed one")
.endpoint = Some(addr);
}
(Section::Peer, "persistentkeepalive") => {
let secs: u16 = value.parse().with_context(|| {
format!(
"{interface}.conf:{line}: PersistentKeepalive {value:?} is not a \
number of seconds"
)
})?;
peers
.last_mut()
.expect("a [Peer] section pushed one")
.persistent_keepalive = (secs != 0).then_some(secs);
}
(_, other) => {
bail!("{interface}.conf:{line}: unknown key `{other}` in this section")
}
}
}
let private_key = private_key
.with_context(|| format!("{interface}.conf: [Interface] has no PrivateKey"))?;
let (address, prefix) =
cidr.with_context(|| format!("{interface}.conf: [Interface] has no Address"))?;
if peers.is_empty() {
bail!("{interface}.conf: no [Peer] section — a tunnel with no peer reaches nothing");
}
for (i, peer) in peers.iter().enumerate() {
if peer.public_key == [0; KEY_LEN] {
bail!("{interface}.conf: [Peer] #{} has no PublicKey", i + 1);
}
if peer.allowed_ips.is_empty() {
bail!(
"{interface}.conf: [Peer] #{} has no AllowedIPs — nothing would be routed \
into the tunnel for it and nothing accepted from it",
i + 1
);
}
}
Ok(WgConfig {
interface: interface.to_string(),
private_key,
address,
prefix,
listen_port,
peers,
})
}
/// Parse the [`BOOT_PARAM`] form: the non-secret half from the kernel
/// command line, the private key from [`KEY_FILE`] in the data root.
///
/// ```text
/// addr=10.99.0.2/24,peer=<base64>,endpoint=10.0.2.2:51820,allowed=10.99.0.0/24,keepalive=25
/// ```
///
/// Comma-separated because a WireGuard endpoint contains a colon and a
/// base64 key contains `+`, `/` and `=` — a colon-separated grammar cannot
/// hold both without quoting, and a kernel command line has no quoting.
/// Each field splits on its FIRST `=` so a key's trailing padding survives.
///
/// `addr`, `peer` and `allowed` are required; `endpoint` and `keepalive`
/// are not. One peer only: this form is for the ordinary appliance shape —
/// one machine dialling one hub — and a second peer wants the file.
pub fn parse_boot_param(
interface: &str,
spec: &str,
private_key: [u8; KEY_LEN],
) -> Result<WgConfig> {
let (mut addr, mut peer, mut allowed, mut endpoint, mut keepalive) =
(None, None, None, None, None);
for field in spec.split(',') {
let field = field.trim();
if field.is_empty() {
continue;
}
let (key, value) = field
.split_once('=')
.with_context(|| format!("{BOOT_PARAM}: {field:?} is not `key=value`"))?;
match key.trim().to_ascii_lowercase().as_str() {
"addr" => addr = Some(parse_cidr(value).context("addr=")?),
"peer" => peer = Some(parse_key(value).context("peer=")?),
"allowed" => {
let mut ips = Vec::new();
// `allowed=` may be repeated rather than list-separated,
// because the list separator is already the field separator.
for one in value.split_whitespace() {
ips.push(parse_cidr(one).context("allowed=")?);
}
let mut all: Vec<_> = allowed.unwrap_or_default();
all.extend(ips);
allowed = Some(all);
}
"endpoint" => {
endpoint = Some(value.parse::<SocketAddrV4>().with_context(|| {
format!(
"{BOOT_PARAM}: endpoint={value:?} is not <IPv4>:<port>; a hostname \
is deliberately not accepted (no resolver this early in the boot)"
)
})?)
}
"keepalive" => {
let secs: u16 = value.parse().with_context(|| {
format!("{BOOT_PARAM}: keepalive={value:?} is not a number of seconds")
})?;
keepalive = (secs != 0).then_some(secs);
}
other => bail!(
"{BOOT_PARAM}: unknown field `{other}`. This form takes addr, peer, allowed, \
endpoint and keepalive — and deliberately NOT a private key, which would be \
world-readable in /proc/cmdline"
),
}
}
let (address, prefix) =
addr.with_context(|| format!("{BOOT_PARAM}: no `addr=<ip>/<prefix>`"))?;
let public_key =
peer.with_context(|| format!("{BOOT_PARAM}: no `peer=<hub public key>`"))?;
let allowed_ips = allowed
.filter(|v: &Vec<_>| !v.is_empty())
.with_context(|| format!("{BOOT_PARAM}: no `allowed=<cidr>`"))?;
Ok(WgConfig {
interface: interface.to_string(),
private_key,
address,
prefix,
// The kernel picks a port. A machine that only ever dials out does
// not need a fixed one, and a fixed one is a thing to collide.
listen_port: None,
peers: vec![Peer {
public_key,
preshared_key: None,
allowed_ips,
endpoint,
persistent_keepalive: keepalive,
}],
})
}
/// Read `<data_root>/wg0.conf`, if it is there.
///
/// `Ok(None)` is "no tunnel was asked for" and is the shipped state.
/// `Err` is "a tunnel was asked for and the request does not parse".
pub fn from_data_root(data_root: &Path) -> Result<Option<WgConfig>> {
let path = data_root.join(CONFIG_FILE);
let text = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(err) => {
return Err(err).with_context(|| format!("reading {}", path.display()));
}
};
let interface = Path::new(CONFIG_FILE)
.file_stem()
.and_then(|s| s.to_str())
.expect("CONFIG_FILE is a literal with a stem");
Ok(Some(WgConfig::parse(interface, &text)?))
}
/// **The one resolver**: whichever of the two declared sources is present.
///
/// | source | non-secret half | private key |
/// |---|---|---|
/// | [`CONFIG_FILE`] | the file | the file |
/// | [`BOOT_PARAM`] | the kernel command line | [`KEY_FILE`] |
///
/// Both present is an **error**, not a precedence rule. Two configurations
/// that disagree is the failure a precedence rule hides: the operator who
/// edited the one that lost would see a working tunnel with the wrong
/// settings and no way to tell from the console.
///
/// Neither present is `Ok(None)` — the shipped state, and the only reason
/// this is safe to call unconditionally.
///
/// **The env read happens here and nowhere below.** This is the edge; the
/// decision is [`WgConfig::resolve_with`], which takes the boot parameter as
/// an argument. The split is the same one [`seeded_lane_conflict`] already
/// makes and for the same measured reason: a rule that can only be
/// exercised by `set_var` on the test process is a rule whose tests flake
/// the moment `cargo test` runs them on two threads — `std::env::set_var`
/// is process-wide, and two tests that both own `GUNNAR_WG` are one test
/// run twice. It was red intermittently in exactly that shape.
pub fn resolve(data_root: &Path) -> Result<Option<WgConfig>> {
WgConfig::resolve_with(data_root, boot_param().as_deref())
}
/// [`WgConfig::resolve`] with the boot parameter handed in.
///
/// `None` is "the parameter is not set"; `Some(spec)` is its value, already
/// known to be non-blank. Nothing here reads the environment, so every rule
/// below is testable on any number of threads at once.
pub fn resolve_with(data_root: &Path, param: Option<&str>) -> Result<Option<WgConfig>> {
let file = WgConfig::from_data_root(data_root)?;
let param = param.map(str::to_owned);
match (file, param) {
(Some(_), Some(_)) => bail!(
"both {} and {BOOT_PARAM} configure WireGuard. They are two ways to say the same \
thing and this refuses to guess which one you meant: delete the file to use the \
boot parameter, or drop {BOOT_PARAM} from the command line to use the file",
data_root.join(CONFIG_FILE).display()
),
(Some(config), None) => Ok(Some(config)),
(None, Some(spec)) => {
let path = data_root.join(KEY_FILE);
let text = std::fs::read_to_string(&path).with_context(|| {
format!(
"{BOOT_PARAM} is set, so the private key must be seeded at {} — it is \
the ONE field that may not ride the world-readable kernel command line",
path.display()
)
})?;
let private_key = parse_key(text.trim())
.with_context(|| format!("reading the private key from {}", path.display()))?;
let interface = Path::new(CONFIG_FILE)
.file_stem()
.and_then(|s| s.to_str())
.expect("CONFIG_FILE is a literal with a stem");
Ok(Some(WgConfig::parse_boot_param(
interface,
spec.trim(),
private_key,
)?))
}
(None, None) => Ok(None),
}
}
/// The `AllowedIPs` entries that fall OUTSIDE the interface's own subnet,
/// and therefore need a route nothing here installs.
///
/// Returned rather than logged from inside, so the caller can state it once
/// and a test can assert it without reading a log.
pub fn unrouted_allowed_ips(&self) -> Vec<(Ipv4Addr, u8)> {
let mine = masked(self.address, self.prefix);
self.peers
.iter()
.flat_map(|p| p.allowed_ips.iter().copied())
.filter(|(addr, prefix)| *prefix < self.prefix || masked(*addr, self.prefix) != mine)
.collect()
}
/// **This appliance's WireGuard identity**, derived from the seeded private
/// key.
///
/// Computed here rather than read back from a kernel device, which is what
/// the deleted kernel lane did: the curve is linked into this binary now, so
/// there is nothing to ask. `wg pubkey` computes exactly this, and
/// `keys::tests::derives_the_public_keys_wg_pubkey_prints` pins it against
/// two vectors that came out of the tool.
pub fn public_key(&self) -> [u8; KEY_LEN] {
keys::public_from_private_bytes(&self.private_key)
}
/// The data plane's view of this config — the seam between gunnar's declared
/// config surface and the shared userspace WireGuard implementation.
fn tunnel_spec(&self) -> TunnelSpec {
TunnelSpec {
private_key: self.private_key,
listen_port: self.listen_port,
peers: self
.peers
.iter()
.map(|p| PeerSpec {
public_key: p.public_key,
preshared_key: p.preshared_key,
endpoint: p.endpoint,
allowed_ips: p.allowed_ips.clone(),
persistent_keepalive: p.persistent_keepalive,
})
.collect(),
}
}
}
/// `addr` with everything below `prefix` cleared.
fn masked(addr: Ipv4Addr, prefix: u8) -> u32 {
let bits = u32::from(addr);
match prefix {
0 => 0,
p if p >= 32 => bits,
p => bits & !(u32::MAX >> p),
}
}
/// A base64 WireGuard key → 32 raw bytes, through the shared decoder.
///
/// Length is checked **after** decoding, not by counting characters: a 44-char
/// string that decodes to 33 bytes is a real thing base64 can express, and a
/// key of the wrong length handed to the crypto is a tunnel that never
/// handshakes with no error anywhere.
fn parse_key(text: &str) -> Result<[u8; KEY_LEN]> {
keys::decode_key(text).map_err(|e| anyhow::anyhow!("{e}"))
}
/// `10.99.0.2/24` → `(10.99.0.2, 24)`.
fn parse_cidr(text: &str) -> Result<(Ipv4Addr, u8)> {
let (addr, prefix) = text
.split_once('/')
.with_context(|| format!("{text:?} is not <address>/<prefix>"))?;
let addr: Ipv4Addr = addr
.trim()
.parse()
.with_context(|| format!("{addr:?} is not an IPv4 address"))?;
let prefix: u8 = prefix
.trim()
.parse()
.with_context(|| format!("{prefix:?} is not a prefix length"))?;
if prefix > 32 {
bail!("/{prefix} is not a legal IPv4 prefix length");
}
Ok((addr, prefix))
}
// ── minting an identity ─────────────────────────────────────────────────────
/// **Mint a WireGuard keypair.** `wg genkey | wg pubkey`, without a shell.
///
/// The curve is linked into this binary for the tunnel itself, so this costs
/// nothing that was not already there — and the alternative costs a shell, a
/// `wg(8)` binary and a pipe, none of which exist on an appliance or in the
/// image that builds one. The installer epic needs exactly this: seed a machine
/// with an identity before it has a filesystem worth speaking of.
///
/// Entropy is `getrandom(2)`, not a read of `/dev/urandom` — an appliance mints
/// its identity before `/dev` is necessarily populated.
pub fn generate_keypair() -> ([u8; KEY_LEN], [u8; KEY_LEN]) {
keys::generate_keypair()
}
/// `gunnar wg keygen [--private <path>]`.
///
/// With `--private`, the secret goes to the file (mode 0600) and only the
/// public half is printed — the [`KEY_FILE`] shape. Without it, both are
/// printed, which is what a person pasting into two config files wants.
///
/// **An existing file is refused, never overwritten.** A regenerated identity
/// is a silent fleet-wide outage: every hub still allowlists the old public key
/// and a peer whose key is unknown gets silence, not an error.
pub fn keygen_verb(command: gunnar_common::cli::WgCommand) -> Result<()> {
let gunnar_common::cli::WgCommand::Keygen { private } = command;
let (secret, public) = generate_keypair();
let Some(path) = private else {
println!("private {}", base64_key(&secret));
println!("public {}", base64_key(&public));
return Ok(());
};
if path.exists() {
bail!(
"{} already exists. This refuses to overwrite an identity: every hub that peers \
with this machine allowlists its CURRENT public key, and a peer whose key is \
unknown to the other end gets silence rather than an error. Delete the file \
deliberately if you really mean to mint a new identity.",
path.display()
);
}
write_secret(&path, &format!("{}\n", base64_key(&secret)))
.with_context(|| format!("writing the private key to {}", path.display()))?;
println!("public {}", base64_key(&public));
Ok(())
}
/// Write a secret so it is unreadable to anyone but its owner FROM THE MOMENT
/// IT EXISTS — the mode goes in the `open(2)`, not in a `chmod` afterwards.
/// A create-then-chmod leaves a window in which the key is world readable, and
/// on the box this is used to provision a fleet that window is enough.
fn write_secret(path: &Path, body: &str) -> std::io::Result<()> {
use std::io::Write as _;
use std::os::unix::fs::OpenOptionsExt as _;
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)?;
f.write_all(body.as_bytes())
}
// ── the orchestrator ────────────────────────────────────────────────────────
/// A tunnel that was brought up, and what is needed to report on it later.
#[derive(Clone)]
pub struct Tunnel {
/// The interface, e.g. `wg0`.
pub interface: String,
/// The tunnel address ssh and the control plane now also answer on.
pub address: Ipv4Addr,
/// The tunnel address's prefix length.
pub prefix: u8,
/// How many peers were configured.
pub peers: usize,
/// **This appliance's WireGuard public key**, derived from the seeded
/// private key.
///
/// It goes in the boot banner, and the reason is a property of WireGuard
/// rather than a nicety: peering is mutual allowlisting BY PUBLIC KEY, and
/// a key the hub does not know produces **silence**, not an error. So the
/// one question an operator asks when a tunnel does not come up — "is the
/// key on this box the key I put in the hub's `[Peer]` stanza?" — has to be
/// answerable from the console, which on a shell-less appliance is the only
/// channel there is.
pub public_key: [u8; KEY_LEN],
/// The UDP port the data plane bound.
pub local_port: u16,
/// The configuration the pump runs on — what a later [`raise_from_config`]
/// compares the configuration on disk against. Shared by every clone, so
/// a re-point is seen by all of them.
config: Arc<std::sync::Mutex<WgConfig>>,
/// The running pump. Shared rather than owned, because the handshake watch
/// polls it from a task while `serve` holds it for the process lifetime —
/// and dropping the last one deletes the interface.
///
/// A SLOT since 2026-10-01 (recharge-front-new-key-deadlock part 2): a
/// re-feed that names another front relay stops this pump and starts the
/// next one in the same slot ([`Tunnel::repoint`]), and every clone — the
/// one `serve` holds, the pairing loop's — runs on the new one. `None`
/// while a re-point is between the two, or after one that failed.
handle: Arc<std::sync::Mutex<Option<TunnelHandle>>>,
}
impl std::fmt::Debug for Tunnel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Tunnel")
.field("interface", &self.interface)
.field("address", &self.address)
.field("prefix", &self.prefix)
.field("peers", &self.peers)
.field("public_key", &base64_key(&self.public_key))
.field("local_port", &self.local_port)
.finish()
}
}
impl Tunnel {
/// The banner line: what a person reads off the console to check the
/// appliance against their hub.
pub fn banner_line(&self) -> String {
banner_line(
&self.interface,
self.address,
self.prefix,
&self.public_key,
self.peers,
self.local_port,
)
}
/// True when any peer has completed a handshake — "the tunnel WORKS", as
/// opposed to "the tunnel is configured".
pub fn handshaken(&self) -> bool {
self.handle
.lock()
.unwrap_or_else(|p| p.into_inner())
.as_ref()
.map(TunnelHandle::handshaken)
.unwrap_or(false)
}
/// Every peer as the running pump last saw it (none while no pump runs).
pub fn peer_stats(&self) -> Vec<dataplane::PeerStats> {
self.handle
.lock()
.unwrap_or_else(|p| p.into_inner())
.as_ref()
.map(TunnelHandle::peers)
.unwrap_or_default()
}
/// The configuration the pump runs on, or `None` when no pump runs.
pub fn running_config(&self) -> Option<WgConfig> {
let up = self
.handle
.lock()
.unwrap_or_else(|p| p.into_inner())
.is_some();
up.then(|| {
self.config
.lock()
.unwrap_or_else(|p| p.into_inner())
.clone()
})
}
/// **Re-point the tunnel at `wanted`, in this process** — the front's
/// relay moved (a front recharged onto new addresses) or its key changed.
/// The old pump is stopped first (its tun fd closes, which deletes the
/// interface), then `wanted` is raised on the same interface; the address
/// is this box's own and does not change. A failure leaves no pump: the
/// next re-feed, the pairing loop or the next start raises it.
pub fn repoint(&self, wanted: &WgConfig) -> Result<()> {
let mut slot = self.handle.lock().unwrap_or_else(|p| p.into_inner());
if let Some(mut old) = slot.take() {
old.shutdown();
}
let handle = open_pump(wanted)?;
*slot = Some(handle);
*self.config.lock().unwrap_or_else(|p| p.into_inner()) = wanted.clone();
crate::tunnel_addr::publish(Some(wanted.address));
Ok(())
}
}
/// The peers of a configuration, for a log line: `<key fingerprint>@<endpoint>`.
fn peers_said(c: &WgConfig) -> String {
c.peers
.iter()
.map(|p| {
format!(
"{}@{}",
&base64_key(&p.public_key)[..8],
p.endpoint
.map(|e| e.to_string())
.unwrap_or_else(|| "-".into())
)
})
.collect::<Vec<_>>()
.join(", ")
}
/// **Bring the tunnel up**, if the data root asks for one.
///
/// Called from `serve::run` AFTER the outer address is applied, because a
/// tunnel whose endpoint is reached over the outer address cannot handshake
/// before that address exists.
///
/// The three outcomes, and they are deliberately different grades:
///
/// * **no config** ⇒ `Ok(None)` and one INFO line. The shipped state.
/// * **config that does not parse** ⇒ `Err`. A declaration that lies stops the
/// boot, exactly as a malformed `GUNNAR_NET` does.
/// * **config the kernel refuses** ⇒ `Ok(None)` and a WARN naming the reason.
/// The world failed to provide; the declaration did not lie, and an appliance
/// that refuses to serve git because a tunnel would not come up is worse than
/// one that serves on its outer address and says so.
///
/// # Two lanes, and only one of them may be armed
///
/// With `GUNNAR_APPLIANCE=1` (declared by the `[[build.installer]]` cell) the
/// **ceremony** owns the identity: [`crate::appliance::run`] mints or loads it
/// on the durable root, announces it, and hands back the config a declared
/// `hub.conf` implies. Without the gate the **seeded** lane below is what runs,
/// unchanged — `wg0.conf` or [`BOOT_PARAM`] + [`KEY_FILE`].
///
/// Both armed at once is REFUSED rather than resolved by precedence. It is the
/// same ruling [`WgConfig::resolve`] already makes between its own two sources
/// and for a sharper reason: the field that loses here is the machine's
/// IDENTITY, so the operator who registered the losing key with the hub would
/// watch a healthy boot present a key nobody trusts.
pub fn bring_up(data_root: &Path) -> Result<Option<Tunnel>> {
bring_up_with(crate::appliance::gate_enabled(), data_root)
}
/// [`bring_up`] with the gate handed in (testable without the env).
pub fn bring_up_with(gate: bool, data_root: &Path) -> Result<Option<Tunnel>> {
if boot_lane(gate, data_root) == BootLane::Ceremony {
return ceremony_lane(data_root);
}
let Some(config) = WgConfig::resolve(data_root)? else {
tracing::info!(
path = %data_root.join(CONFIG_FILE).display(),
"no WireGuard configuration: serving on the outer address only. Either drop a \
wg-quick style {CONFIG_FILE} in the data root, or set {BOOT_PARAM} on the kernel \
command line and seed {KEY_FILE} beside it. Nothing else is needed — the WireGuard \
data plane is compiled into this binary and wants no kernel module."
);
return Ok(None);
};
match establish(&config) {
Ok(tunnel) => {
// Kept where the pairing loop's driver sees it: `raise_from_config`
// answers "already up", and `config_tunnel_handshaken` can say
// whether the front has answered.
*LATE_TUNNEL.lock().unwrap_or_else(|p| p.into_inner()) = Some(tunnel.clone());
Ok(Some(tunnel))
}
Err(err) => {
tracing::warn!(
interface = %config.interface,
"the WireGuard configuration parsed and the tunnel would NOT come up: {err:#}. \
Serving on the outer address only — no tunnel address exists, so a client \
pointed at {} will not connect.",
config.address
);
Ok(None)
}
}
}
/// Which lane raises the tunnel at boot.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BootLane {
/// The identity ceremony (`GUNNAR_APPLIANCE=1`, no pairing).
Ceremony,
/// `wg0.conf` / [`BOOT_PARAM`] — and the pairing loop's `wg0.conf`.
Config,
}
/// **Does the pairing loop own this box's tunnel?** A pairing seed on the
/// data root, or a `wg0.conf` the pairing loop wrote.
pub fn pairing_owns_tunnel(data_root: &Path) -> bool {
data_root.join(crate::pairing::SEED_REL).exists()
|| std::fs::read_to_string(data_root.join(CONFIG_FILE))
.map(|t| t.starts_with(crate::pairing::CONF_MARK))
.unwrap_or(false)
}
/// **The boot's lane, decided once** (2026-09-24). The appliance medium sets
/// `GUNNAR_APPLIANCE=1`, and a PAIRED box has a `wg0.conf` its pairing loop
/// wrote: taking the ceremony lane there refused the boot ("both lanes armed")
/// and PID 1 faulted — a reboot bricked every paired box. When the pairing
/// loop owns the tunnel ([`pairing_owns_tunnel`]) the CONFIG lane raises its
/// `wg0.conf` (or, before the first join, nothing — the loop raises it later)
/// and the ceremony stays out, whatever the gate says.
pub fn boot_lane(gate: bool, data_root: &Path) -> BootLane {
if gate && !pairing_owns_tunnel(data_root) {
BootLane::Ceremony
} else {
BootLane::Config
}
}
/// [`pairing_tunnel`]'s answer: public key, address and prefix (if assigned),
/// and whether `wg0` carries that address now.
pub type PairingTunnel = (String, Option<(Ipv4Addr, u8)>, bool);
/// **The tunnel the pairing loop owns, as `tunnel show` reports it**: this
/// box's public key off `wg0.key`, the address off `wg0.conf` (none before the
/// door assigned one), and whether `wg0` carries that address NOW. `None` when
/// the pairing loop does not own the tunnel (the ceremony's answer stands).
pub fn pairing_tunnel(data_root: &Path) -> Option<PairingTunnel> {
if !pairing_owns_tunnel(data_root) {
return None;
}
let public_b64 = std::fs::read_to_string(data_root.join(KEY_FILE))
.ok()
.and_then(|k| wg_appliance_core::public_from_private(k.trim()).ok())
.unwrap_or_default();
let config = WgConfig::resolve_with(data_root, None).ok().flatten();
let address = config.as_ref().map(|c| (c.address, c.prefix));
let up = config
.as_ref()
.map(|c| {
appliance_pid1::net::interface_ipv4(&c.interface)
.ok()
.flatten()
== Some(c.address)
})
.unwrap_or(false);
Some((public_b64, address, up))
}
/// **Are both lanes armed?** `Some(refusal)` when they are.
///
/// Pure — the two "is it present" answers arrive as booleans rather than being
/// read here — so the refusal's WORDING is testable without a process-wide env
/// mutation. A guard that can only be exercised by setting `GUNNAR_APPLIANCE`
/// on the test process is a guard that gets an `#[ignore]` the first time it
/// races another test, and then it guards nothing.
fn seeded_lane_conflict(
data_root: &Path,
seeded_file_present: bool,
seeded_param_present: bool,
) -> Option<String> {
if !seeded_file_present && !seeded_param_present {
return None;
}
let named = if seeded_param_present {
BOOT_PARAM.to_string()
} else {
data_root.join(CONFIG_FILE).display().to_string()
};
let gate = crate::appliance::GATE_ENV;
Some(format!(
"{gate}=1 arms the identity ceremony, and {named} is also declared. They are two \
different answers to what this machine's identity IS, and this refuses to guess: \
the ceremony MINTS a key on {} and announces it, the seeded lane takes one you \
supplied. Drop the seeded configuration to use the ceremony, or unset {gate} to \
keep seeding.",
crate::appliance::wg_dir(data_root).display(),
))
}
/// **The ceremony lane** — the gate is on, so the identity is MINTED here and
/// announced, never seeded from outside.
///
/// The announcement goes to **stdout and the log**, formatted once through the
/// shared crate's writer and written twice (one implementation). stdout is load-bearing and
/// not a convenience: on the appliance it is `/dev/console`, the serial line
/// `installtest/src/iso.rs` greps its boot proof off, and the identity line is
/// the ONE thing an operator must read off a shell-less machine before the hub
/// can be configured at all. `tracing` alone would put it behind a filter.
fn ceremony_lane(data_root: &Path) -> Result<Option<Tunnel>> {
// The seeded lane must be DISARMED, not merely out-ranked. Checked before
// the ceremony runs so a misconfigured appliance refuses BEFORE minting a
// key the operator might see and register.
let seeded_param = boot_param();
if let Some(refusal) = seeded_lane_conflict(
data_root,
data_root.join(CONFIG_FILE).exists(),
seeded_param.is_some(),
) {
bail!(refusal);
}
// The proof row is the ceremony's OWN verdict, both ways: `Ok` carries the
// key it announced, `Err` carries the refusal text verbatim. Never a
// hardcoded `true` — a row that cannot go red proves nothing, and the
// rollup's whole discipline is that "no evidence" and "proven" must be
// impossible to confuse.
let outcome = match crate::appliance::run(data_root, &mut |line| {
println!("{line}");
tracing::info!("{line}");
}) {
Ok(outcome) => {
crate::functional_status(
"gunnar-appliance",
"identity_ready",
true,
&format!(
"pubkey={} minted={} hub={}",
outcome.public_b64,
outcome.minted,
outcome.config.is_some()
),
);
outcome
}
Err(err) => {
crate::functional_status(
"gunnar-appliance",
"identity_ready",
false,
&format!("{err:#}"),
);
return Err(err);
}
};
let Some(config) = outcome.config else {
return Ok(None);
};
match establish(&config) {
Ok(tunnel) => {
CEREMONY_TUNNEL_UP.store(true, std::sync::atomic::Ordering::SeqCst);
Ok(Some(tunnel))
}
Err(err) => {
tracing::warn!(
interface = %config.interface,
"the hub declaration parsed and the tunnel would NOT come up: {err:#}. \
Serving on the outer address only — the identity above is minted and \
durable, so registering it with the hub is still the right next move.",
);
Ok(None)
}
}
}
/// Set once the ceremony lane has a tunnel up — at start, or after the
/// address arrived at bootstrap.
static CEREMONY_TUNNEL_UP: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
/// The tunnel raised AFTER start, by [`raise_after_assignment`]. `serve` holds
/// the start-up one for the process lifetime; this is the same lifetime for
/// the one that arrived later (dropping it deletes `wg0`).
static LATE_TUNNEL: std::sync::Mutex<Option<Tunnel>> = std::sync::Mutex::new(None);
/// The config lane's tunnel (raised at boot or by [`raise_from_config`]):
/// `None` when there is none in this process, else whether a peer has
/// completed a handshake.
pub fn config_tunnel_handshaken() -> Option<bool> {
LATE_TUNNEL
.lock()
.unwrap_or_else(|p| p.into_inner())
.as_ref()
.map(Tunnel::handshaken)
}
/// Is the ceremony's tunnel up in this process?
pub fn ceremony_tunnel_up() -> bool {
CEREMONY_TUNNEL_UP.load(std::sync::atomic::Ordering::SeqCst)
}
/// **The address just arrived** (`Bootstrap.AssignTunnelAddress`): raise the
/// tunnel NOW, without a restart. `Ok(true)` when it is up (already, or just
/// now); `Ok(false)` when the ceremony still has nothing to raise or the
/// kernel refused (said by [`ceremony_lane`]'s own WARN).
pub fn raise_after_assignment(data_root: &Path) -> Result<bool> {
if ceremony_tunnel_up() {
return Ok(true);
}
let Some(tunnel) = ceremony_lane(data_root)? else {
return Ok(false);
};
if let Ok(rt) = tokio::runtime::Handle::try_current() {
// A32-14, stays a task: a bounded watch (20 looks, 3 s apart) of one handshake, said at its end.
rt.spawn(handshake_watch(
tunnel.clone(),
20,
std::time::Duration::from_secs(3),
));
}
*LATE_TUNNEL.lock().unwrap_or_else(|p| p.into_inner()) = Some(tunnel);
Ok(true)
}
/// **What [`raise_from_config`] does with the configuration on disk**, given
/// the tunnel this process already runs (if any). Pure, so the decision is
/// held without a tun device.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LateRaise {
/// No configuration to raise.
NoConfig,
/// The tunnel is up as the configuration says.
AlreadyUp,
/// Raise it now.
Raise,
/// The tunnel runs, and the configuration names other peers (or another
/// address): re-point it. `from`/`to` say the peers, for the log.
Repoint { from: String, to: String },
}
/// [`LateRaise`], decided.
pub fn late_raise(
running: Option<&WgConfig>,
wanted: Option<&WgConfig>,
interface_carries_address: bool,
) -> LateRaise {
if let Some(running) = running {
return match wanted {
// A configuration that went away does not take a running tunnel down.
None => LateRaise::AlreadyUp,
Some(w)
if w.peers == running.peers
&& w.address == running.address
&& w.prefix == running.prefix
&& w.private_key == running.private_key =>
{
LateRaise::AlreadyUp
}
Some(w) => LateRaise::Repoint {
from: peers_said(running),
to: peers_said(w),
},
};
}
match wanted {
None => LateRaise::NoConfig,
Some(_) if interface_carries_address => LateRaise::AlreadyUp,
Some(_) => LateRaise::Raise,
}
}
/// **A `wg0.conf` that appeared after boot: raise it NOW.** The pairing loop
/// (`crate::pairing`) learns this box's tunnel address from the installer's
/// door, writes the configuration, and calls this — the same config lane
/// [`bring_up`] runs at start, without a restart. `Ok(true)` when the tunnel is
/// up (already — raised at boot from a configuration a previous boot wrote —
/// or just now).
pub fn raise_from_config(data_root: &Path) -> Result<bool> {
let late = LATE_TUNNEL
.lock()
.unwrap_or_else(|p| p.into_inner())
.clone();
let running = late.as_ref().and_then(Tunnel::running_config);
let wanted = WgConfig::resolve(data_root)?;
let carries = wanted
.as_ref()
.map(|c| {
appliance_pid1::net::interface_ipv4(&c.interface)
.ok()
.flatten()
== Some(c.address)
})
.unwrap_or(false);
match late_raise(running.as_ref(), wanted.as_ref(), carries) {
LateRaise::NoConfig => return Ok(false),
LateRaise::AlreadyUp => return Ok(true),
LateRaise::Repoint { from, to } => {
let tunnel = late.expect("Repoint is planned only for a running tunnel");
let wanted = wanted.expect("Repoint is planned only for a configuration");
tunnel.repoint(&wanted).with_context(|| {
format!("re-pointing {} from [{from}] to [{to}]", wanted.interface)
})?;
tracing::info!(
interface = %wanted.interface,
from = %from,
to = %to,
"WireGuard RE-POINTED in this process: the configuration names another peer (the front's relay \
moved or was re-keyed — a front recharged onto new addresses); the old pump is stopped and \
the new one dials it now, without a restart"
);
if let Ok(rt) = tokio::runtime::Handle::try_current() {
// A32-14, stays a task: a bounded watch (20 looks, 3 s apart) of one handshake, said at its end.
rt.spawn(handshake_watch(
tunnel,
20,
std::time::Duration::from_secs(3),
));
}
return Ok(true);
}
LateRaise::Raise => {}
}
let config = wanted.expect("Raise is planned only for a configuration");
let tunnel = establish(&config)?;
if let Ok(rt) = tokio::runtime::Handle::try_current() {
// A32-14, stays a task: a bounded watch (20 looks, 3 s apart) of one handshake, said at its end.
rt.spawn(handshake_watch(
tunnel.clone(),
20,
std::time::Duration::from_secs(3),
));
}
*LATE_TUNNEL.lock().unwrap_or_else(|p| p.into_inner()) = Some(tunnel);
Ok(true)
}
/// The part that touches the machine: the tun device, the address, the pump.
fn open_pump(config: &WgConfig) -> Result<TunnelHandle> {
// 1. The tun device. The ONE kernel feature this lane needs, and it is
// builtin on the appliance kernel — no module, no modprobe, no staged
// `.ko`. Held by the pump for the process lifetime: closing this fd is
// what deletes the interface.
let tun = TunDevice::create(&config.interface, DEFAULT_MTU).with_context(|| {
format!(
"creating the tun device {}. This needs CAP_NET_ADMIN and /dev/net/tun \
(CONFIG_TUN, builtin on the appliance kernel) — and nothing else: no wireguard \
module, no wg(8)",
config.interface
)
})?;
// 2. The address and the link state, through the SAME writer every other
// address on this appliance goes through (one implementation). `gateway: None` — a
// tunnel is not a default route, and making it one would black-hole the
// machine the moment the peer went away.
//
// Chosen over the ioctl path (`SIOCSIFADDR`/`SIOCSIFNETMASK`) tunnr's
// payload uses: netlink ACKNOWLEDGES every message, so "the address is
// on" is a fact read back from the kernel rather than a `send` that
// succeeded. gunnar already has that writer and every other address on
// the box goes through it.
appliance_pid1::net::apply(&appliance_pid1::net::NetPlan {
interface: config.interface.clone(),
address: config.address,
prefix: config.prefix,
gateway: None,
})
.with_context(|| {
format!(
"putting {}/{} on {}",
config.address, config.prefix, config.interface
)
})?;
// 3. The data plane: boringtun, in this process, on one thread.
// 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, &config.tunnel_spec()).with_context(|| {
format!(
"starting the userspace WireGuard pump on {}",
config.interface
)
})?;
Ok(handle)
}
fn establish(config: &WgConfig) -> Result<Tunnel> {
let handle = open_pump(config)?;
for (addr, prefix) in config.unrouted_allowed_ips() {
tracing::warn!(
allowed_ip = %format!("{addr}/{prefix}"),
interface = %config.interface,
tunnel_subnet = %format!("{}/{}", config.address, config.prefix),
"an AllowedIPs entry falls outside this interface's own subnet, so the kernel has \
no route to it. gunnar installs no routes beyond the address's connected one; \
traffic to that prefix will not enter the tunnel"
);
}
let tunnel = Tunnel {
interface: config.interface.clone(),
address: config.address,
prefix: config.prefix,
peers: config.peers.len(),
public_key: config.public_key(),
local_port: handle.local_port(),
config: Arc::new(std::sync::Mutex::new(config.clone())),
handle: Arc::new(std::sync::Mutex::new(Some(handle))),
};
tracing::info!(
interface = %tunnel.interface,
address = %tunnel.address,
prefix = tunnel.prefix,
public_key = base64_key(&tunnel.public_key),
listen_port = tunnel.local_port,
peers = tunnel.peers,
mtu = DEFAULT_MTU,
"WireGuard up in USERSPACE: boringtun compiled into this binary, no kernel module, no \
wg(8), no wg-quick, no shell. This address is SEEDED state and does not move when the \
DHCP lease does — but it is only reachable once a peer whose [Peer] stanza carries the \
public key above completes a handshake"
);
// The listeners that follow the tunnel (the replication door) learn where it is.
crate::tunnel_addr::publish(Some(tunnel.address));
Ok(tunnel)
}
/// Watch for the first completed handshake and say so, once, on the console.
///
/// Spawned by `serve::run` beside the DHCP renewal task. It exists because
/// "the tunnel is configured" and "the tunnel WORKS" are different claims and
/// only a completed handshake settles the second: boringtun reports no
/// handshake time at all until a peer has actually answered, which is the same
/// fact `wg show latest-handshake` prints on the other end.
///
/// Gives up after `attempts` polls and says that too — an appliance that keeps
/// printing "still no handshake" forever is noise, and one that goes quiet
/// without a verdict is worse.
pub async fn handshake_watch(tunnel: Tunnel, attempts: usize, every: std::time::Duration) {
for attempt in 1..=attempts {
tokio::time::sleep(every).await;
let peers = tunnel.peer_stats();
if let Some(up) = peers.iter().find(|p| p.handshaken()) {
tracing::info!(
interface = %tunnel.interface,
address = %tunnel.address,
peer = %base64_key(&up.public_key),
handshake_age_secs = up.last_handshake_ago.map(|d| d.as_secs()),
rx_bytes = up.rx_bytes,
tx_bytes = up.tx_bytes,
endpoint = ?up.endpoint,
"WireGuard HANDSHAKE COMPLETED: this appliance is reachable at {} \
over the tunnel, and that address does not change when the DHCP \
lease does",
tunnel.address
);
return;
}
tracing::debug!(
interface = %tunnel.interface,
attempt,
peers = peers.len(),
"no WireGuard handshake yet"
);
}
tracing::warn!(
interface = %tunnel.interface,
peers = tunnel.peers,
"WireGuard is configured and NO peer has completed a handshake. The tunnel address {} \
is on the interface but nothing is on the other end of it — check the peer's endpoint, \
its AllowedIPs, and that UDP reaches this machine",
tunnel.address
);
}
/// A key as `wg(8)` prints it, for a log line a human can compare by eye.
pub(crate) fn base64_key(key: &[u8; KEY_LEN]) -> String {
keys::encode_key(key)
}
/// The banner sentence, as a pure function of what it says.
///
/// Split out from [`Tunnel::banner_line`] so the ONE line an operator actually
/// reads off a shell-less console can be asserted without creating a network
/// device — a banner that only exists inside a live tunnel is a banner nobody
/// checks.
fn banner_line(
interface: &str,
address: Ipv4Addr,
prefix: u8,
public_key: &[u8; KEY_LEN],
peers: usize,
local_port: u16,
) -> String {
format!(
"wg {address}/{prefix} on {interface} key {} ({peers} peer{}, udp {local_port})",
base64_key(public_key),
if peers == 1 { "" } else { "s" },
)
}
#[cfg(test)]
mod tests {
/// **A re-feed naming another front relay re-points the running tunnel**
/// (stinkies recharge-front-new-key-deadlock part 2 / R3). MEASURED d-life
/// lf10 R3 2026-10-01: after `estate recharge front` laid the front again
/// on NEW addresses, main and the twin kept dialling the dead relay until
/// they restarted ("the mesh did not carry within 120 s") — this process's
/// tunnel answered "already up" to any configuration.
#[test]
fn a_refeed_naming_another_front_relay_repoints_the_running_tunnel() {
let conf = |endpoint: &str, key: &str| {
WgConfig::parse(
"wg0",
&format!(
"[Interface]\nPrivateKey = {}\nAddress = 10.44.0.2/24\n\n[Peer]\nPublicKey = {key}\nAllowedIPs = 10.44.0.0/24\nEndpoint = {endpoint}\nPersistentKeepalive = 25\n",
base64_key(&[7u8; KEY_LEN])
),
)
.unwrap()
};
let old = conf("127.86.33.51:51821", &base64_key(&[1u8; KEY_LEN]));
let moved = conf("127.86.33.10:51821", &base64_key(&[1u8; KEY_LEN]));
let rekeyed = conf("127.86.33.10:51821", &base64_key(&[2u8; KEY_LEN]));
assert_eq!(
late_raise(Some(&old), Some(&old), true),
LateRaise::AlreadyUp,
"the same front: nothing to do"
);
assert!(
matches!(
late_raise(Some(&old), Some(&moved), true),
LateRaise::Repoint { .. }
),
"the front moved: re-point, not 'already up'"
);
assert!(
matches!(
late_raise(Some(&old), Some(&rekeyed), true),
LateRaise::Repoint { .. }
),
"the relay's key changed: re-point"
);
assert_eq!(late_raise(None, Some(&old), false), LateRaise::Raise);
assert_eq!(late_raise(None, Some(&old), true), LateRaise::AlreadyUp);
assert_eq!(late_raise(None, None, false), LateRaise::NoConfig);
assert_eq!(
late_raise(Some(&old), None, true),
LateRaise::AlreadyUp,
"a configuration that went away does not take the running tunnel down"
);
}
use super::*;
/// A data root as a paired box leaves it: the seed, `wg0.key`, and the
/// `wg0.conf` the pairing loop wrote for the door's address.
fn paired_root() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("pairing")).unwrap();
std::fs::write(root.join(crate::pairing::SEED_REL), "# the medium's seed\n").unwrap();
let private = crate::pairing::ensure_tunnel_key(root).unwrap();
let conf = format!(
"{}: test\n[Interface]\nPrivateKey = {private}\nAddress = 10.44.0.2/24\n\n[Peer]\nPublicKey = xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg=\nAllowedIPs = 10.44.0.0/24\nEndpoint = 95.111.211.189:51820\nPersistentKeepalive = 25\n",
crate::pairing::CONF_MARK
);
std::fs::write(root.join(CONFIG_FILE), conf).unwrap();
dir
}
/// Fix 2026-09-24: the appliance gate + the pairing's `wg0.conf` boots the
/// CONFIG lane — never the ceremony, which refused "both lanes armed" and
/// faulted PID 1 on every reboot of a paired box.
#[test]
fn a_paired_data_root_boots_the_config_lane_and_never_the_ceremony() {
let paired = paired_root();
assert_eq!(boot_lane(true, paired.path()), BootLane::Config);
// The pairing's conf alone (no seed) is enough; so is the seed alone
// (before the first join: nothing to raise yet, the loop raises it).
std::fs::remove_file(paired.path().join(crate::pairing::SEED_REL)).unwrap();
assert_eq!(boot_lane(true, paired.path()), BootLane::Config);
let seed_only = tempfile::tempdir().unwrap();
std::fs::create_dir_all(seed_only.path().join("pairing")).unwrap();
std::fs::write(seed_only.path().join(crate::pairing::SEED_REL), "x").unwrap();
assert_eq!(boot_lane(true, seed_only.path()), BootLane::Config);
// Unchanged: no pairing ⇒ the gate means the ceremony, and an
// OPERATOR's wg0.conf beside the gate is still the refused conflict.
let bare = tempfile::tempdir().unwrap();
assert_eq!(boot_lane(true, bare.path()), BootLane::Ceremony);
assert_eq!(boot_lane(false, bare.path()), BootLane::Config);
std::fs::write(bare.path().join(CONFIG_FILE), "[Interface]\n").unwrap();
assert_eq!(boot_lane(true, bare.path()), BootLane::Ceremony);
}
/// The real boot entry, gate ON, over a paired root: it does not refuse
/// (the old fault), and the ceremony never minted its identity.
#[test]
fn booting_a_paired_root_with_the_gate_on_does_not_refuse_and_mints_no_ceremony_identity() {
if rustix::process::geteuid().is_root() {
return; // would create a real wg0 on the test host
}
let paired = paired_root();
let got = bring_up_with(true, paired.path());
assert!(got.is_ok(), "a paired box must boot: {:?}", got.err());
assert!(
!crate::appliance::wg_dir(paired.path()).exists(),
"the ceremony must stay out"
);
}
/// `tunnel show` on a paired box reports the pairing loop's tunnel, and an
/// address handed over beside the door's is refused by name.
#[test]
fn tunnel_show_on_a_paired_box_reports_the_pairing_tunnel() {
let paired = paired_root();
let root = paired.path();
let private = crate::pairing::ensure_tunnel_key(root).unwrap();
let want = wg_appliance_core::public_from_private(&private).unwrap();
let shown = crate::appliance::tunnel_address_answer(root, "", false).expect("show answers");
assert_eq!(shown.wg_public_key, want);
assert_eq!(shown.address, "10.44.0.2/24");
assert!(!shown.changed);
let refused = crate::appliance::tunnel_address_answer(root, "10.44.0.9/24", true)
.expect_err("refused");
assert!(
format!("{refused:?}").contains("tunnel-owned-by-pairing"),
"{refused:?}"
);
}
/// **The two lanes may not both be armed** — and the refusal names which
/// seeded source it found, plus the ceremony directory it would otherwise
/// have minted into.
///
/// The field that loses a precedence rule here is the machine's IDENTITY,
/// so a silent winner means the operator registers one key with the hub and
/// the appliance presents another, on a console that looks healthy.
///
/// **RED-when-broken**: the "neither present" case is asserted too, because
/// a guard that refuses unconditionally would break every ordinary
/// gate-on boot and a guard that never refuses is the defect itself.
#[test]
fn arming_both_lanes_is_refused_and_the_refusal_names_the_source() {
let root = Path::new("/srv/gunnar-data");
assert_eq!(
seeded_lane_conflict(root, false, false),
None,
"the ordinary ceremony boot — no seeded source — must NOT refuse"
);
let by_file = seeded_lane_conflict(root, true, false).expect("wg0.conf conflicts");
assert!(
by_file.contains("/srv/gunnar-data/wg0.conf"),
"name the file the operator has to remove: {by_file}"
);
assert!(
by_file.contains("/srv/gunnar-data/wg"),
"and the ceremony directory it would have minted into: {by_file}"
);
assert!(
by_file.contains(crate::appliance::GATE_ENV),
"and the gate to unset instead: {by_file}"
);
let by_param = seeded_lane_conflict(root, false, true).expect("the boot param conflicts");
assert!(
by_param.contains(BOOT_PARAM),
"name the boot parameter, not the absent file: {by_param}"
);
assert!(
!by_param.contains("wg0.conf"),
"a file that is not there must not be blamed: {by_param}"
);
// Both at once still names something actionable rather than panicking
// or producing an empty complaint.
let both = seeded_lane_conflict(root, true, true).expect("both conflict");
assert!(both.contains(BOOT_PARAM), "{both}");
}
/// A whole, ordinary config — the one in the guide.
const GOOD: &str = "\
[Interface]
PrivateKey = gEi6mvh6b5rKDg7UkleGpsJLlA9ueOKyWhjqJqFjWHg=
Address = 10.99.0.2/24
ListenPort = 51820
# the box on the other end
[Peer]
PublicKey = aD8IQp7Jf8KgM5RqJUP1NaPJu6SQ1EUkPEqk118XIRo=
AllowedIPs = 10.99.0.1/32, 10.99.0.0/24
Endpoint = 10.0.2.2:51820
PersistentKeepalive = 25
";
#[test]
fn a_full_config_parses_into_every_field() {
let cfg = WgConfig::parse("wg0", GOOD).expect("a legal config");
assert_eq!(cfg.interface, "wg0");
assert_eq!(cfg.address, Ipv4Addr::new(10, 99, 0, 2));
assert_eq!(cfg.prefix, 24);
assert_eq!(cfg.listen_port, Some(51820));
assert_eq!(cfg.peers.len(), 1);
let peer = &cfg.peers[0];
assert_eq!(
peer.allowed_ips,
vec![
(Ipv4Addr::new(10, 99, 0, 1), 32),
(Ipv4Addr::new(10, 99, 0, 0), 24),
]
);
assert_eq!(peer.endpoint, Some("10.0.2.2:51820".parse().unwrap()));
assert_eq!(peer.persistent_keepalive, Some(25));
assert_eq!(peer.preshared_key, None);
// Both keys really are 32 bytes, not "44 characters that looked right".
assert_eq!(cfg.private_key.len(), KEY_LEN);
assert_ne!(cfg.private_key, [0; KEY_LEN]);
}
/// Measure, never assert on the parser: it must REFUSE, or "parsed" means nothing.
#[test]
fn malformed_configs_are_refused_rather_than_defaulted() {
for (why, bad) in [
("no PrivateKey", "[Interface]\nAddress = 10.0.0.1/24\n[Peer]\nPublicKey = aD8IQp7Jf8KgM5RqJUP1NaPJu6SQ1EUkPEqk118XIRo=\nAllowedIPs = 10.0.0.2/32\n"),
("no Address", "[Interface]\nPrivateKey = gEi6mvh6b5rKDg7UkleGpsJLlA9ueOKyWhjqJqFjWHg=\n[Peer]\nPublicKey = aD8IQp7Jf8KgM5RqJUP1NaPJu6SQ1EUkPEqk118XIRo=\nAllowedIPs = 10.0.0.2/32\n"),
("no peer at all", "[Interface]\nPrivateKey = gEi6mvh6b5rKDg7UkleGpsJLlA9ueOKyWhjqJqFjWHg=\nAddress = 10.0.0.1/24\n"),
("a peer with no PublicKey", "[Interface]\nPrivateKey = gEi6mvh6b5rKDg7UkleGpsJLlA9ueOKyWhjqJqFjWHg=\nAddress = 10.0.0.1/24\n[Peer]\nAllowedIPs = 10.0.0.2/32\n"),
("a peer with no AllowedIPs", "[Interface]\nPrivateKey = gEi6mvh6b5rKDg7UkleGpsJLlA9ueOKyWhjqJqFjWHg=\nAddress = 10.0.0.1/24\n[Peer]\nPublicKey = aD8IQp7Jf8KgM5RqJUP1NaPJu6SQ1EUkPEqk118XIRo=\n"),
("a key that is not 32 bytes", "[Interface]\nPrivateKey = c2hvcnQ=\nAddress = 10.0.0.1/24\n[Peer]\nPublicKey = aD8IQp7Jf8KgM5RqJUP1NaPJu6SQ1EUkPEqk118XIRo=\nAllowedIPs = 10.0.0.2/32\n"),
("a key that is not base64", "[Interface]\nPrivateKey = ****\nAddress = 10.0.0.1/24\n[Peer]\nPublicKey = aD8IQp7Jf8KgM5RqJUP1NaPJu6SQ1EUkPEqk118XIRo=\nAllowedIPs = 10.0.0.2/32\n"),
("a /33 address", "[Interface]\nPrivateKey = gEi6mvh6b5rKDg7UkleGpsJLlA9ueOKyWhjqJqFjWHg=\nAddress = 10.0.0.1/33\n[Peer]\nPublicKey = aD8IQp7Jf8KgM5RqJUP1NaPJu6SQ1EUkPEqk118XIRo=\nAllowedIPs = 10.0.0.2/32\n"),
("a hostname endpoint", "[Interface]\nPrivateKey = gEi6mvh6b5rKDg7UkleGpsJLlA9ueOKyWhjqJqFjWHg=\nAddress = 10.0.0.1/24\n[Peer]\nPublicKey = aD8IQp7Jf8KgM5RqJUP1NaPJu6SQ1EUkPEqk118XIRo=\nAllowedIPs = 10.0.0.2/32\nEndpoint = peer.example.com:51820\n"),
("a key before any section", "PrivateKey = gEi6mvh6b5rKDg7UkleGpsJLlA9ueOKyWhjqJqFjWHg=\n"),
("a line that is not Key = value", "[Interface]\nPrivateKey\n"),
("an unknown section", "[Tunnel]\nPrivateKey = x\n"),
("an unknown key", "[Interface]\nWibble = 3\n"),
] {
assert!(
WgConfig::parse("wg0", bad).is_err(),
"WgConfig::parse accepted a config with {why}; a parser that accepts everything \
configures nothing and reports success"
);
}
}
/// A wg-quick key gunnar does not implement must be REFUSED, not skipped.
///
/// This is the difference between an operator whose `PostUp` never ran and
/// an operator who was told it never ran.
#[test]
fn wg_quick_keys_gunnar_does_not_implement_are_refused_by_name() {
for key in [
"DNS = 1.1.1.1",
"MTU = 1380",
"Table = off",
"PostUp = /bin/true",
] {
let text = format!(
"[Interface]\nPrivateKey = gEi6mvh6b5rKDg7UkleGpsJLlA9ueOKyWhjqJqFjWHg=\n\
Address = 10.0.0.1/24\n{key}\n[Peer]\n\
PublicKey = aD8IQp7Jf8KgM5RqJUP1NaPJu6SQ1EUkPEqk118XIRo=\n\
AllowedIPs = 10.0.0.2/32\n"
);
let err = WgConfig::parse("wg0", &text).expect_err("must be refused");
let msg = format!("{err:#}");
assert!(
msg.contains("does NOT implement"),
"{key} was refused for the wrong reason: {msg}"
);
}
}
/// The shared tokenizer must not have changed what a comment is, what a
/// trailing `=` means, or which line a message names. Three rules, on one
/// config that exercises all of them.
#[test]
fn comments_padding_and_line_numbers_survive_the_shared_tokenizer() {
let cfg = WgConfig::parse(
"wg0",
"# a leading comment\n\
[Interface] ; and a trailing one\n\
PrivateKey = gEi6mvh6b5rKDg7UkleGpsJLlA9ueOKyWhjqJqFjWHg=\n\
Address = 10.0.0.1/24\n\
\n\
[Peer]\n\
PublicKey = aD8IQp7Jf8KgM5RqJUP1NaPJu6SQ1EUkPEqk118XIRo=\n\
AllowedIPs = 10.0.0.2/32\n\
PersistentKeepalive = 25 # keep the NAT open\n",
)
.expect("comments and blank lines are not errors");
assert_eq!(
cfg.private_key,
parse_key("gEi6mvh6b5rKDg7UkleGpsJLlA9ueOKyWhjqJqFjWHg=").unwrap(),
"a base64 key's trailing `=` padding must survive the key/value split"
);
assert_eq!(cfg.peers[0].persistent_keepalive, Some(25));
// And the line number in an error names the offending line, not the
// count of lines the tokenizer chose to emit.
let err = WgConfig::parse("wg0", "# one\n\n[Interface]\nWibble = 3\n").unwrap_err();
assert!(format!("{err:#}").contains("wg0.conf:4:"), "{err:#}");
}
#[test]
fn allowed_ips_outside_the_tunnel_subnet_are_reported_as_unrouted() {
let cfg = WgConfig::parse("wg0", GOOD).expect("a legal config");
// 10.99.0.1/32 and 10.99.0.0/24 are both inside 10.99.0.0/24.
assert!(
cfg.unrouted_allowed_ips().is_empty(),
"the ordinary case must be silent, or the warning is noise: {:?}",
cfg.unrouted_allowed_ips()
);
// A prefix somewhere else entirely gets no route from the address.
let wide = GOOD.replace(
"AllowedIPs = 10.99.0.1/32, 10.99.0.0/24",
"AllowedIPs = 192.168.7.0/24",
);
let cfg = WgConfig::parse("wg0", &wide).expect("a legal config");
assert_eq!(
cfg.unrouted_allowed_ips(),
vec![(Ipv4Addr::new(192, 168, 7, 0), 24)]
);
}
// ── the boot-parameter form ─────────────────────────────────────────────
const HUB_PUB: &str = "aD8IQp7Jf8KgM5RqJUP1NaPJu6SQ1EUkPEqk118XIRo=";
#[test]
fn the_boot_param_carries_the_whole_non_secret_half() {
let spec = format!(
"addr=10.99.0.2/24,peer={HUB_PUB},endpoint=10.0.2.2:51820,\
allowed=10.99.0.0/24,keepalive=25"
);
let cfg = WgConfig::parse_boot_param("wg0", &spec, [0x11; KEY_LEN]).expect("a legal spec");
assert_eq!(cfg.address, Ipv4Addr::new(10, 99, 0, 2));
assert_eq!(cfg.prefix, 24);
assert_eq!(
cfg.private_key, [0x11; KEY_LEN],
"the key comes from the FILE, not the spec"
);
assert_eq!(
cfg.listen_port, None,
"a dial-out appliance needs no fixed port"
);
assert_eq!(cfg.peers.len(), 1);
assert_eq!(cfg.peers[0].public_key, parse_key(HUB_PUB).unwrap());
assert_eq!(
cfg.peers[0].endpoint,
Some("10.0.2.2:51820".parse().unwrap())
);
assert_eq!(cfg.peers[0].persistent_keepalive, Some(25));
assert_eq!(
cfg.peers[0].allowed_ips,
vec![(Ipv4Addr::new(10, 99, 0, 0), 24)]
);
}
#[test]
fn the_boot_param_needs_only_addr_peer_and_allowed() {
let spec = format!("addr=10.99.0.2/24,peer={HUB_PUB},allowed=10.99.0.0/24");
let cfg = WgConfig::parse_boot_param("wg0", &spec, [0x11; KEY_LEN]).expect("a legal spec");
assert_eq!(
cfg.peers[0].endpoint, None,
"a hub that dials US needs no endpoint here"
);
assert_eq!(cfg.peers[0].persistent_keepalive, None);
}
/// Measure, never assert, and the security-relevant half: the boot parameter must REFUSE a
/// private key rather than quietly accept one, because `/proc/cmdline` is
/// world readable and an accepted `key=` would publish it.
#[test]
fn the_boot_param_refuses_garbage_and_refuses_to_carry_a_private_key() {
let good = format!("addr=10.99.0.2/24,peer={HUB_PUB},allowed=10.99.0.0/24");
for (why, bad) in [
("no addr", format!("peer={HUB_PUB},allowed=10.99.0.0/24")),
(
"no peer",
"addr=10.99.0.2/24,allowed=10.99.0.0/24".to_string(),
),
("no allowed", format!("addr=10.99.0.2/24,peer={HUB_PUB}")),
("a field that is not key=value", format!("{good},nonsense")),
(
"a bad endpoint",
format!("{good},endpoint=hub.example.com:51820"),
),
(
"a bad prefix",
format!("addr=10.99.0.2/33,peer={HUB_PUB},allowed=10.0.0.0/8"),
),
(
"a peer key that is not 32 bytes",
"addr=10.99.0.2/24,peer=c2hvcnQ=,allowed=10.0.0.0/8".to_string(),
),
] {
assert!(
WgConfig::parse_boot_param("wg0", &bad, [0x11; KEY_LEN]).is_err(),
"the boot parameter accepted a spec with {why}"
);
}
// The one that matters most: a private key offered on the command line
// must be refused BY NAME, not ignored.
for secret in ["privatekey", "private", "key", "PrivateKey"] {
let bad = format!("{good},{secret}=gEi6mvh6b5rKDg7UkleGpsJLlA9ueOKyWhjqJqFjWHg=");
let err = WgConfig::parse_boot_param("wg0", &bad, [0x11; KEY_LEN])
.expect_err("a private key on the cmdline must be refused");
assert!(
format!("{err:#}").contains("world-readable"),
"the refusal must say WHY: {err:#}"
);
}
}
/// Two sources that disagree is the failure a precedence rule hides.
///
/// The boot parameter is HANDED IN. It used to be `set_var`'d and removed
/// again around the call, which made this test and the one below two
/// writers of one process-wide variable: on a threaded `cargo test` either
/// could clear `GUNNAR_WG` while the other was inside `resolve`, and the
/// refusal it exists to prove would simply not happen. That is the flake,
/// and `resolve_with` is why it cannot come back.
#[test]
fn a_file_and_a_boot_param_together_are_refused_rather_than_ranked() {
let dir = tempfile::tempdir().expect("a scratch directory");
let dir = dir.path();
std::fs::write(dir.join(CONFIG_FILE), GOOD).expect("write a config");
let spec = format!("addr=10.99.0.2/24,peer={HUB_PUB},allowed=10.99.0.0/24");
let err = WgConfig::resolve_with(dir, Some(&spec))
.expect_err("two configurations must not be ranked");
assert!(format!("{err:#}").contains("refuses to guess"), "{err:#}");
}
/// The boot-param path without a seeded key must name the file it wants —
/// this is the whole stable-identity story, and a vague error here is an
/// operator minting a new identity by accident.
#[test]
fn the_boot_param_without_a_seeded_key_says_which_file_is_missing() {
let dir = tempfile::tempdir().expect("a scratch directory");
let dir = dir.path();
let spec = format!("addr=10.99.0.2/24,peer={HUB_PUB},allowed=10.99.0.0/24");
let err =
WgConfig::resolve_with(dir, Some(&spec)).expect_err("no key file means no tunnel");
assert!(
format!("{err:#}").contains(KEY_FILE),
"the error must name the file: {err:#}"
);
// And with the key seeded it resolves.
std::fs::write(
dir.join(KEY_FILE),
"gEi6mvh6b5rKDg7UkleGpsJLlA9ueOKyWhjqJqFjWHg=\n",
)
.expect("seed the key");
let ok = WgConfig::resolve_with(dir, Some(&spec))
.expect("a seeded key completes the boot-param form")
.expect("a configuration");
assert_eq!(
ok.private_key,
parse_key("gEi6mvh6b5rKDg7UkleGpsJLlA9ueOKyWhjqJqFjWHg=").unwrap()
);
assert_eq!(ok.address, Ipv4Addr::new(10, 99, 0, 2));
}
/// **Both other arms of the resolver**, so the two refusals above are not
/// the only thing this function is seen to do: neither source is
/// `Ok(None)` (the shipped state), and the file alone resolves.
///
/// Without this a `resolve_with` that returned an error for everything
/// would pass the two tests above.
#[test]
fn neither_source_is_none_and_the_file_alone_resolves() {
let dir = tempfile::tempdir().expect("a scratch directory");
let dir = dir.path();
assert!(
WgConfig::resolve_with(dir, None)
.expect("no sources is not a failure")
.is_none(),
"an unconfigured data root must mean no tunnel, not a broken boot"
);
std::fs::write(dir.join(CONFIG_FILE), GOOD).expect("write a config");
let cfg = WgConfig::resolve_with(dir, None)
.expect("a legal config file")
.expect("present");
assert_eq!(cfg.interface, "wg0");
assert_eq!(cfg.address, Ipv4Addr::new(10, 99, 0, 2));
}
#[test]
fn a_missing_config_file_is_none_and_not_an_error() {
let dir = std::env::temp_dir().join(format!("gunnar-wg-none-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("a scratch directory");
assert!(
WgConfig::from_data_root(&dir)
.expect("absence is not a failure")
.is_none(),
"no wg0.conf must mean no tunnel, not a broken boot"
);
std::fs::write(dir.join(CONFIG_FILE), GOOD).expect("write a config");
let cfg = WgConfig::from_data_root(&dir)
.expect("a legal config")
.expect("present");
assert_eq!(
cfg.interface, "wg0",
"the interface name comes from the file's stem"
);
std::fs::remove_dir_all(&dir).ok();
}
// ── the identity ────────────────────────────────────────────────────────
/// **The public key in the banner is the key `wg pubkey` prints.**
///
/// This is the assertion the deleted kernel lane could only make by asking
/// a live kernel to derive the key, in a root-only `#[ignore]` probe. The
/// curve is linked in now, so it is an ordinary unit test that runs on every
/// `cargo test` — and `R/1Rs…` is what the `wg` tool prints for `gEi6…`,
/// recorded from the tool rather than computed here.
#[test]
fn the_banner_prints_the_public_key_wg_pubkey_derives() {
const ITS_PUBLIC: &str = "R/1RsV4yjvIeEUApgRu3u584R5/WCKgKaPqHieam4TU=";
let cfg = WgConfig::parse("wg0", GOOD).expect("a legal config");
assert_eq!(
base64_key(&cfg.public_key()),
ITS_PUBLIC,
"the identity gunnar prints is not the one wg derives from the same private key"
);
// A DIFFERENT private key must derive a different identity — the
// control that keeps the equality above from passing on a constant.
let other = GOOD.replace(
"gEi6mvh6b5rKDg7UkleGpsJLlA9ueOKyWhjqJqFjWHg=",
"cGVlcktleVRoYXRJc0RpZmZlcmVudEZyb21UaGVPbjA=",
);
let other = WgConfig::parse("wg0", &other).expect("a legal config");
assert_ne!(other.public_key(), cfg.public_key());
}
/// The console line an operator compares against their hub, asserted for
/// every field it is supposed to carry. Two peer counts, because the plural
/// is the sort of thing that only ever reads right in the singular case the
/// author tested.
#[test]
fn the_banner_line_carries_address_key_port_and_peer_count() {
let cfg = WgConfig::parse("wg0", GOOD).expect("a legal config");
let line = banner_line("wg0", cfg.address, cfg.prefix, &cfg.public_key(), 1, 51820);
assert!(line.contains("10.99.0.2/24"), "{line}");
assert!(line.contains("on wg0"), "{line}");
assert!(
line.contains("R/1RsV4yjvIeEUApgRu3u584R5/WCKgKaPqHieam4TU="),
"the banner must print the identity a hub allowlists: {line}"
);
assert!(line.contains("(1 peer,"), "singular: {line}");
assert!(line.contains("udp 51820"), "{line}");
let two = banner_line("wg0", cfg.address, cfg.prefix, &cfg.public_key(), 2, 40000);
assert!(two.contains("(2 peers,"), "plural: {two}");
}
#[test]
fn base64_round_trips_the_way_wg_prints_it() {
let key = [0x22u8; KEY_LEN];
let text = base64_key(&key);
assert_eq!(
text.len(),
44,
"a 32-byte key is 44 padded base64 characters"
);
assert_eq!(parse_key(&text).expect("its own output"), key);
}
/// The spec handed to the shared data plane must carry every field the
/// config declared. A translation that dropped `AllowedIPs` would produce a
/// tunnel that comes up and routes nothing, with no error anywhere.
#[test]
fn the_data_plane_spec_carries_every_configured_field() {
let cfg = WgConfig::parse("wg0", GOOD).expect("a legal config");
let spec = cfg.tunnel_spec();
assert_eq!(spec.private_key, cfg.private_key);
assert_eq!(spec.listen_port, Some(51820));
assert_eq!(spec.peers.len(), 1);
assert_eq!(spec.peers[0].public_key, cfg.peers[0].public_key);
assert_eq!(
spec.peers[0].endpoint,
Some("10.0.2.2:51820".parse().unwrap())
);
assert_eq!(spec.peers[0].persistent_keepalive, Some(25));
assert_eq!(
spec.peers[0].allowed_ips,
vec![
(Ipv4Addr::new(10, 99, 0, 1), 32),
(Ipv4Addr::new(10, 99, 0, 0), 24)
],
"every AllowedIPs entry must reach the router, or the tunnel carries nothing"
);
// And the router built from it really claims the tunnel subnet.
let router = dataplane::Router::new(&spec.peers);
assert_eq!(router.select("10.99.0.1".parse().unwrap()), Some(0));
assert_eq!(router.select("10.99.0.77".parse().unwrap()), Some(0));
assert_eq!(
router.select("8.8.8.8".parse().unwrap()),
None,
"a config with no default route must not silently acquire one"
);
}
}