//! **The seam.** A relay plus a registration becomes a peer set the one data
//! plane consumes, and this module is the only place that conversion happens.
//!
//! # Why the output type is imported and not defined
//!
//! [`wg_appliance_core::dataplane::PeerSpec`] and `TunnelSpec` are the
//! appliance's own types. This crate depends on that crate rather than
//! declaring a lookalike, and that is the whole architectural point of the
//! repository: **two providers must never become two tunnel implementations.**
//! A `tunnr::PeerSet` that a consumer converted into a `PeerSpec` would be a
//! second definition of the same contract, and the two would drift in the one
//! place a mistake is invisible — the packet path.
//!
//! What *is* here is the conversion's failure modes, and there are more of them
//! than there look. A relay's key is base64 that may not decode to 32 bytes; a
//! relay may be listed and inactive; the address the registration assigned may
//! not parse; an endpoint port may be one nothing listens on. Every one of
//! those produces a tunnel that completes or half-completes and then carries
//! nothing, which is the failure shape with no error message anywhere.
//!
//! # F15 — the underlay is never configured, and that is edda's gap not ours
//!
//! A peer set built here names a relay by its public IPv4. Reaching it needs a
//! configured underlay — an address, a route, a default gateway on the physical
//! interface — and the appliance core does not configure one (F15, open). So a
//! peer set from this module is **correct and still unreachable** on a node
//! whose underlay nobody brought up. That is stated here rather than worked
//! around: a workaround in this crate would hide a gap that belongs to the
//! crate that owns the interface.
use std::net::{Ipv4Addr, SocketAddrV4};
use wg_appliance_core::dataplane::{PeerSpec, TunnelSpec};
use wg_appliance_core::keys::{decode_key, KEY_LEN};
use crate::facts;
use crate::relays::Relay;
/// Why a relay could not be turned into a peer.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PeerError {
/// The relay's `active` flag is false. Mullvad lists relays they have taken
/// out of service, and one of those answers nothing — a tunnel to it sends
/// handshake initiations into silence for ever.
RelayInactive {
/// The relay Mullvad lists as out of service.
hostname: String,
},
/// The relay's `public_key` did not decode to 32 bytes.
KeyNotThirtyTwoBytes {
/// The relay whose key could not be used.
hostname: String,
/// What the decoder said about it.
detail: String,
},
/// The chosen port is not one Mullvad's relays were measured listening on.
/// A refusal here rather than a warning, because unlike an address range
/// this fact was **measured**, not inferred, and a caller picking a port is
/// choosing rather than reporting what a config said.
PortNotAServedRange {
/// The port the caller asked for.
port: u16,
},
}
impl std::fmt::Display for PeerError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PeerError::RelayInactive { hostname } => write!(
f,
"relay {hostname} is listed as inactive — it answers nothing, so a tunnel to \
it sends handshake initiations into silence with no error anywhere"
),
PeerError::KeyNotThirtyTwoBytes { hostname, detail } => write!(
f,
"relay {hostname} has an unusable public key: {detail}. A key of the wrong \
length builds a peer the data plane accepts and that then fails every \
handshake, which looks exactly like a network fault"
),
PeerError::PortNotAServedRange { port } => write!(
f,
"port {port} is outside every range Mullvad's relays were measured listening \
on (measured {})",
facts::MEASURED_ON
),
}
}
}
/// Build the `[Peer]` for a Mullvad relay.
///
/// `port` must be one the relays actually serve — see [`facts::PORT_RANGES`],
/// where the **gaps** matter as much as the ranges.
///
/// `preshared_key` is `None` on every road this crate can offer today: the only
/// legitimate one is negotiated in-tunnel, and that needs HQC-256 (see
/// [`crate::pq`]). The parameter exists so a caller that has obtained one by a
/// road of its own can pass it, not because this crate can produce one.
pub fn peer_for_relay(
relay: &Relay,
port: u16,
preshared_key: Option<[u8; KEY_LEN]>,
) -> Result<PeerSpec, PeerError> {
if !relay.active {
return Err(PeerError::RelayInactive { hostname: relay.hostname.clone() });
}
if !facts::is_relay_port(port) {
return Err(PeerError::PortNotAServedRange { port });
}
let public_key = decode_key(&relay.public_key).map_err(|e| PeerError::KeyNotThirtyTwoBytes {
hostname: relay.hostname.clone(),
detail: e.0,
})?;
Ok(PeerSpec {
public_key,
preshared_key,
endpoint: Some(SocketAddrV4::new(relay.ipv4_addr_in, port)),
// Mullvad is an exit: everything goes down the tunnel. `0.0.0.0/0` as
// the data plane spells it — a `(network, prefix)` pair, because
// AllowedIPs here picks the peer for an outbound packet and installs no
// kernel route.
allowed_ips: vec![(Ipv4Addr::UNSPECIFIED, 0)],
// 25 s, the wg-quick convention for a peer behind NAT. A Mullvad client
// is always the one behind the NAT, so this is not optional in
// practice: without it the relay's return path closes and the tunnel
// goes one-way silently.
persistent_keepalive: Some(25),
})
}
/// Build the whole tunnel: this device's private key, the relay, and the
/// address the registration assigned.
///
/// The assigned address is **not** used to build the peer — it is the
/// `[Interface] Address`, which the consumer writes onto its own interface with
/// its own writer. It is taken here only so that the one function that produces
/// a tunnel can check it against the range Mullvad was observed to assign from,
/// and say so when it does not fit.
pub fn tunnel_for_relay(
private_key: [u8; KEY_LEN],
relay: &Relay,
port: u16,
preshared_key: Option<[u8; KEY_LEN]>,
) -> Result<TunnelSpec, PeerError> {
Ok(TunnelSpec {
private_key,
// None: a Mullvad client only ever dials out, so the kernel picking the
// source port is right, and pinning one would make two appliances
// behind the same NAT collide.
listen_port: None,
peers: vec![peer_for_relay(relay, port, preshared_key)?],
})
}
/// Render a tunnel **without its secrets**, for a log or a UI.
///
/// This exists because the type's own `Debug` cannot be trusted to. As
/// published on crates.io, `wg-appliance-core 0.1.1` derives `Debug` on
/// `TunnelSpec` and `PeerSpec`, so `{:?}` prints the private key and any
/// pre-shared key as a decimal byte list — measured 2026-09-05, and covered by
/// a tripwire test in this module. The fix is committed in edda and not
/// published.
///
/// So: anything in this workspace that wants to *say* what a tunnel is uses
/// this, and never `{:?}`. What it prints is the public half — the relay's
/// public key, its endpoint, the routing — which is exactly what identifies a
/// tunnel to an operator, and nothing that identifies it to an attacker.
pub fn redacted(tunnel: &TunnelSpec) -> String {
let peers = tunnel
.peers
.iter()
.map(|p| {
format!(
"peer(public_key={}, preshared_key={}, endpoint={}, allowed_ips={:?}, keepalive={:?})",
wg_appliance_core::keys::encode_key(&p.public_key),
if p.preshared_key.is_some() { "[redacted]" } else { "none" },
p.endpoint
.map(|e| e.to_string())
.unwrap_or_else(|| "learned".into()),
p.allowed_ips,
p.persistent_keepalive,
)
})
.collect::<Vec<_>>()
.join(", ");
format!(
"TunnelSpec(private_key=[redacted], public_key={}, listen_port={:?}, peers=[{peers}])",
wg_appliance_core::keys::encode_key(&wg_appliance_core::keys::public_from_private_bytes(
&tunnel.private_key
)),
tunnel.listen_port,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::relays;
/// A real relay, captured verbatim from `api.mullvad.net` on 2026-09-05.
fn se_sto() -> Relay {
let list = relays::parse(include_str!("../tests/fixtures/relays-2026-09-05.json"))
.expect("the captured relay list parses");
list.wireguard
.relays
.into_iter()
.find(|r| r.hostname == "se-sto-wg-001")
.expect("se-sto-wg-001 is in the fixture")
}
#[test]
fn a_real_relay_becomes_a_peer_the_data_plane_can_use() {
let relay = se_sto();
let peer = peer_for_relay(&relay, 51820, None).expect("builds");
assert_eq!(peer.public_key.len(), KEY_LEN);
assert_eq!(
wg_appliance_core::keys::encode_key(&peer.public_key),
relay.public_key,
"the key round-trips to exactly what Mullvad published"
);
assert_eq!(
peer.endpoint,
Some(SocketAddrV4::new("185.195.233.76".parse().unwrap(), 51820))
);
assert_eq!(peer.allowed_ips, vec![(Ipv4Addr::UNSPECIFIED, 0)]);
assert_eq!(peer.persistent_keepalive, Some(25));
assert_eq!(peer.preshared_key, None);
}
/// RED-WHEN-BROKEN — an inactive relay must be refused. It is listed, its
/// key is valid, and it answers nothing; a peer built from it is a tunnel
/// that fails with no error at all.
#[test]
fn an_inactive_relay_is_refused_by_name() {
let mut relay = se_sto();
relay.active = false;
assert_eq!(
peer_for_relay(&relay, 51820, None),
Err(PeerError::RelayInactive { hostname: "se-sto-wg-001".into() })
);
// The identity guard: the same relay, active, still builds.
relay.active = true;
assert!(peer_for_relay(&relay, 51820, None).is_ok());
}
/// RED-WHEN-BROKEN — a key of the wrong length must not become a peer. It
/// is well-formed base64 and the data plane accepts it; the handshake is
/// what fails, silently, later.
#[test]
fn a_key_that_is_not_thirty_two_bytes_is_refused() {
let mut relay = se_sto();
for bad in [
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", // valid base64, 24 bytes
"MkP/Jytkg51/Y/EostONjIN6YaFRpsAYiNKMX27/CA", // truncated
"not base64 at all",
"",
] {
relay.public_key = bad.into();
assert!(
matches!(
peer_for_relay(&relay, 51820, None),
Err(PeerError::KeyNotThirtyTwoBytes { .. })
),
"{bad:?} must be refused"
);
}
}
/// RED-WHEN-BROKEN — the gaps between the measured port ranges. A caller
/// that picks 51821 gets a refusal, not a peer nothing answers.
#[test]
fn a_port_in_a_gap_between_the_measured_ranges_is_refused() {
let relay = se_sto();
for gap in [52u16, 3999, 33434, 33564, 51821, 52000, 60001] {
assert_eq!(
peer_for_relay(&relay, gap, None),
Err(PeerError::PortNotAServedRange { port: gap }),
"{gap} is in a gap"
);
}
for served in [53u16, 123, 4000, 33433, 33565, 51820, 52001, 60000] {
assert!(peer_for_relay(&relay, served, None).is_ok(), "{served} is served");
}
}
/// A pre-shared key a caller obtained by a road of its own reaches the data
/// plane. This half is not decoration: a WireGuard handshake is 148 bytes
/// with or without a PSK, so a key dropped on the way to `Tunn::new`
/// produces a tunnel that fails every handshake with nothing in any log to
/// say why.
#[test]
fn a_preshared_key_reaches_the_data_plane() {
let relay = se_sto();
let psk = [7u8; KEY_LEN];
let peer = peer_for_relay(&relay, 51820, Some(psk)).expect("builds");
assert_eq!(peer.preshared_key, Some(psk));
// …and its absence is genuinely absent, or the assertion above is
// satisfied by a field that is always `Some`.
let without = peer_for_relay(&relay, 51820, None).expect("builds");
assert_eq!(without.preshared_key, None);
}
/// **The tripwire that fired, and what it found.**
///
/// This test used to assert the *opposite*: that `wg-appliance-core 0.1.1`
/// derives `Debug` on `PeerSpec` and `TunnelSpec` and therefore **prints
/// the appliance's private key and its pre-shared key as a decimal byte
/// list** through a single `{:?}`. Its own doc said: when the dependency is
/// bumped to a fixed version, this test fails, and whoever bumped it
/// deletes it and asserts the mask instead. That is what happened on
/// 2026-09-05, and the reason is worth recording because it is not the one
/// the tripwire predicted.
///
/// **Nothing was published.** What changed is that this crate moved into
/// tunnr's workspace and its `wg-appliance-core` edge became a **path**
/// dependency on edda's checkout rather than a registry one. edda's tree
/// carries the fix (`adc6abc`, "wg-appliance-core: the appliance had no
/// handshake budget, printed its own private key, and shipped a test
/// responder") and has never released it.
///
/// So: **two different crates answer to `wg-appliance-core 0.1.1`.** The
/// registry copy leaks; edda's path copy masks. Measured here, from the
/// rendered `Debug` of both types. Anything in this constellation resolving
/// the registry copy still has the leak, and a version number cannot tell
/// the two apart — which is the finding, and it belongs to whoever owns
/// edda's release cadence, not to this crate.
///
/// What this test asserts now is the property the appliance actually gets:
/// the mask is there, and the secret bytes are not in the rendering.
#[test]
fn the_resolved_wg_appliance_core_masks_secrets_in_its_debug() {
let relay = se_sto();
let peer = peer_for_relay(&relay, 51820, Some([7u8; KEY_LEN])).expect("builds");
let rendered = format!("{peer:?}");
assert!(
!rendered.contains("7, 7, 7"),
"the PSK bytes are in the Debug rendering — the resolved wg-appliance-core is \
the LEAKING copy (crates.io 0.1.1), not edda's masked one. Rendered: {rendered}"
);
assert!(
rendered.contains("[redacted]"),
"…and the mask is what replaced them, rather than the field having vanished: \
{rendered}"
);
// The rendering is a real one and the test is not passing on an empty
// string: the PUBLIC half is still printed, because that is not a
// secret and hiding it would make a spec undebuggable for nothing.
assert!(
rendered.contains(relay.public_key.as_str()),
"the public key is public and must still print: {rendered}"
);
let tunnel = tunnel_for_relay([9u8; KEY_LEN], &relay, 51820, None).expect("builds");
let rendered = format!("{tunnel:?}");
assert!(
!rendered.contains("9, 9, 9"),
"the PRIVATE KEY bytes are in the Debug rendering. Rendered: {rendered}"
);
assert!(rendered.contains("[redacted]"), "{rendered}");
}
/// RED-WHEN-BROKEN — what this crate prints instead. Whatever the upstream
/// `Debug` does, nothing in this workspace hands a secret to a log.
#[test]
fn this_crate_never_renders_a_secret_even_though_upstream_does() {
let relay = se_sto();
let tunnel = tunnel_for_relay([9u8; KEY_LEN], &relay, 51820, Some([7u8; KEY_LEN]))
.expect("builds");
let safe = redacted(&tunnel);
assert!(!safe.contains("9, 9, 9"), "the private key is not rendered: {safe}");
assert!(!safe.contains("7, 7, 7"), "the pre-shared key is not rendered: {safe}");
assert_eq!(safe.matches("[redacted]").count(), 2, "{safe}");
// The parts an operator actually needs ARE there — a redaction that
// erased everything would pass the two assertions above and be useless.
assert!(safe.contains("185.195.233.76:51820"), "{safe}");
assert!(
safe.contains(&wg_appliance_core::keys::encode_key(&tunnel.peers[0].public_key)),
"the relay's PUBLIC key is public data and is what identifies the peer: {safe}"
);
}
#[test]
fn a_tunnel_dials_out_and_pins_no_local_port() {
let relay = se_sto();
let tunnel = tunnel_for_relay([9u8; KEY_LEN], &relay, 51820, None).expect("builds");
assert_eq!(tunnel.listen_port, None, "two appliances behind one NAT must not collide");
assert_eq!(tunnel.peers.len(), 1);
}
}