Gunnar

nordisk/tunnr-mullvad

src/peers.rs

//! **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);
    }
}