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};
const KEY_LEN: usize = keys::KEY_LEN;
pub const CONFIG_FILE: &str = "wg0.conf";
pub const KEY_FILE: &str = "wg0.key";
pub const BOOT_PARAM: &str = "GUNNAR_WG";
pub fn boot_param() -> Option<String> {
std::env::var(BOOT_PARAM)
.ok()
.filter(|s| !s.trim().is_empty())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Peer {
pub public_key: [u8; KEY_LEN],
pub preshared_key: Option<[u8; KEY_LEN]>,
pub allowed_ips: Vec<(Ipv4Addr, u8)>,
pub endpoint: Option<SocketAddrV4>,
pub persistent_keepalive: Option<u16>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WgConfig {
pub interface: String,
pub private_key: [u8; KEY_LEN],
pub address: Ipv4Addr,
pub prefix: u8,
pub listen_port: Option<u16>,
pub peers: Vec<Peer>,
}
impl WgConfig {
pub fn parse(interface: &str, text: &str) -> Result<WgConfig> {
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,
})
}
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();
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,
listen_port: None,
peers: vec![Peer {
public_key,
preshared_key: None,
allowed_ips,
endpoint,
persistent_keepalive: keepalive,
}],
})
}
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)?))
}
pub fn resolve(data_root: &Path) -> Result<Option<WgConfig>> {
WgConfig::resolve_with(data_root, boot_param().as_deref())
}
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),
}
}
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()
}
pub fn public_key(&self) -> [u8; KEY_LEN] {
keys::public_from_private_bytes(&self.private_key)
}
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(),
}
}
}
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),
}
}
fn parse_key(text: &str) -> Result<[u8; KEY_LEN]> {
keys::decode_key(text).map_err(|e| anyhow::anyhow!("{e}"))
}
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))
}
pub fn generate_keypair() -> ([u8; KEY_LEN], [u8; KEY_LEN]) {
keys::generate_keypair()
}
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(())
}
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())
}
#[derive(Clone)]
pub struct Tunnel {
pub interface: String,
pub address: Ipv4Addr,
pub prefix: u8,
pub peers: usize,
pub public_key: [u8; KEY_LEN],
pub local_port: u16,
config: Arc<std::sync::Mutex<WgConfig>>,
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 {
pub fn banner_line(&self) -> String {
banner_line(
&self.interface,
self.address,
self.prefix,
&self.public_key,
self.peers,
self.local_port,
)
}
pub fn handshaken(&self) -> bool {
self.handle.lock().unwrap_or_else(|p| p.into_inner()).as_ref().map(TunnelHandle::handshaken).unwrap_or(false)
}
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()
}
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())
}
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(())
}
}
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(", ")
}
pub fn bring_up(data_root: &Path) -> Result<Option<Tunnel>> {
bring_up_with(crate::appliance::gate_enabled(), data_root)
}
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) => {
*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)
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BootLane {
Ceremony,
Config,
}
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)
}
pub fn boot_lane(gate: bool, data_root: &Path) -> BootLane {
if gate && !pairing_owns_tunnel(data_root) {
BootLane::Ceremony
} else {
BootLane::Config
}
}
pub type PairingTunnel = (String, Option<(Ipv4Addr, u8)>, bool);
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))
}
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(),
))
}
fn ceremony_lane(data_root: &Path) -> Result<Option<Tunnel>> {
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);
}
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)
}
}
}
static CEREMONY_TUNNEL_UP: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
static LATE_TUNNEL: std::sync::Mutex<Option<Tunnel>> = std::sync::Mutex::new(None);
pub fn config_tunnel_handshaken() -> Option<bool> {
LATE_TUNNEL.lock().unwrap_or_else(|p| p.into_inner()).as_ref().map(Tunnel::handshaken)
}
pub fn ceremony_tunnel_up() -> bool {
CEREMONY_TUNNEL_UP.load(std::sync::atomic::Ordering::SeqCst)
}
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() {
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)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LateRaise {
NoConfig,
AlreadyUp,
Raise,
Repoint { from: String, to: String },
}
pub fn late_raise(running: Option<&WgConfig>, wanted: Option<&WgConfig>, interface_carries_address: bool) -> LateRaise {
if let Some(running) = running {
return match wanted {
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,
}
}
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() {
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() {
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)
}
fn open_pump(config: &WgConfig) -> Result<TunnelHandle> {
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
)
})?;
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
)
})?;
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"
);
crate::tunnel_addr::publish(Some(tunnel.address));
Ok(tunnel)
}
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
);
}
pub(crate) fn base64_key(key: &[u8; KEY_LEN]) -> String {
keys::encode_key(key)
}
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 {
#[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::*;
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
}
#[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);
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);
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);
}
#[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; }
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");
}
#[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:?}");
}
#[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}"
);
let both = seeded_lane_conflict(root, true, true).expect("both conflict");
assert!(both.contains(BOOT_PARAM), "{both}");
}
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);
assert_eq!(cfg.private_key.len(), KEY_LEN);
assert_ne!(cfg.private_key, [0; KEY_LEN]);
}
#[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"
);
}
}
#[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}"
);
}
}
#[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));
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");
assert!(
cfg.unrouted_allowed_ips().is_empty(),
"the ordinary case must be silent, or the warning is noise: {:?}",
cfg.unrouted_allowed_ips()
);
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)]
);
}
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);
}
#[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}"
);
}
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:#}"
);
}
}
#[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:#}");
}
#[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:#}"
);
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));
}
#[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();
}
#[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"
);
let other = GOOD.replace(
"gEi6mvh6b5rKDg7UkleGpsJLlA9ueOKyWhjqJqFjWHg=",
"cGVlcktleVRoYXRJc0RpZmZlcmVudEZyb21UaGVPbjA=",
);
let other = WgConfig::parse("wg0", &other).expect("a legal config");
assert_ne!(other.public_key(), cfg.public_key());
}
#[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);
}
#[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"
);
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"
);
}
}