Gunnar

nordisk/tunnr-mullvad

src/relays.rs

//! Parsing `GET /app/v1/relays` — the **public**, unauthenticated relay list.
//!
//! # Written from the response, not from their client
//!
//! Mullvad publishes no REST API reference. This parser was written by reading
//! a live 550 378-byte response (2026-09-05, HTTP 200, 556 WireGuard relays),
//! not by transcribing their app — see the repository's clean-room rule. The
//! shapes asserted in the tests are captured bytes from that response, checked
//! into `tests/fixtures/relays-2026-09-05.json` verbatim, and a companion test
//! re-reads the live endpoint so a silent schema change becomes a red test
//! rather than a wrong tunnel.
//!
//! # Deliberately lenient about fields, strict about the ones that matter
//!
//! `#[serde(default)]` on the optional halves and no `deny_unknown_fields`:
//! Mullvad add fields (`daita`, `quic`, `lwo`, `shadowsocks_extra_addr_in` are
//! all recent) and a parser that refuses an unfamiliar key turns *their*
//! feature launch into *our* outage. What is **not** optional is the four
//! fields a peer cannot be built without — `hostname`, `ipv4_addr_in`,
//! `public_key`, `active` — and a relay missing any of them fails the parse
//! loudly rather than yielding a peer that cannot work.

use std::net::{Ipv4Addr, Ipv6Addr};

use serde::Deserialize;

/// The whole relay-list document, of which this crate reads one section.
#[derive(Debug, Clone, Deserialize)]
pub struct RelayList {
    /// The WireGuard section. The `openvpn` and `bridge` sections are parsed as
    /// nothing on purpose: this workspace supports WireGuard and only
    /// WireGuard, and a type for OpenVPN would be a promise we do not keep.
    pub wireguard: WireguardSection,
    /// Location code (`se-sto`) → where it is. Kept because relay selection by
    /// country is the one thing an operator actually asks for.
    #[serde(default)]
    pub locations: std::collections::BTreeMap<String, Location>,
}

/// One entry of the `locations` map.
#[derive(Debug, Clone, Deserialize)]
pub struct Location {
    /// Country name as Mullvad spells it.
    pub country: String,
    /// City name as Mullvad spells it.
    pub city: String,
}

/// The `wireguard` section: the relays plus the network-wide facts.
#[derive(Debug, Clone, Deserialize)]
pub struct WireguardSection {
    /// Every WireGuard relay, active and inactive.
    pub relays: Vec<Relay>,
    /// The ports relays listen on, inclusive `[lo, hi]` pairs.
    #[serde(default)]
    pub port_ranges: Vec<(u16, u16)>,
    /// The Shadowsocks obfuscation ports, inclusive pairs.
    #[serde(default)]
    pub shadowsocks_port_ranges: Vec<(u16, u16)>,
    /// The in-tunnel v4 gateway, which is also the unfiltered resolver.
    pub ipv4_gateway: Ipv4Addr,
    /// The in-tunnel v6 gateway.
    pub ipv6_gateway: Ipv6Addr,
}

/// One WireGuard relay.
#[derive(Debug, Clone, Deserialize)]
pub struct Relay {
    /// `se-sto-wg-001`. Stable, and what an operator names in a config.
    pub hostname: String,
    /// The location code, a key into [`RelayList::locations`].
    pub location: String,
    /// Whether Mullvad currently considers this relay usable. **A false here
    /// is not cosmetic**: an inactive relay answers nothing, and selecting one
    /// produces a tunnel that hand-shakes with silence.
    pub active: bool,
    /// Whether Mullvad owns the hardware (as opposed to renting it).
    #[serde(default)]
    pub owned: bool,
    /// The hosting provider's name.
    #[serde(default)]
    pub provider: String,
    /// Whether the relay boots from signed RAM-only media ("System Transparency").
    #[serde(default)]
    pub stboot: bool,
    /// The relay's public IPv4 — the `Endpoint` address, without the port.
    pub ipv4_addr_in: Ipv4Addr,
    /// The relay's public IPv6, when it has one.
    #[serde(default)]
    pub ipv6_addr_in: Option<Ipv6Addr>,
    /// The relay's WireGuard public key, base64. This is the `[Peer] PublicKey`.
    pub public_key: String,
    /// Selection weight. Larger relays carry a larger share.
    #[serde(default)]
    pub weight: u64,
    /// Whether the relay supports DAITA (traffic-analysis defence). Out of
    /// scope for this workspace; parsed so its presence is visible rather than
    /// silently dropped.
    #[serde(default)]
    pub daita: bool,
}

impl Relay {
    /// Whether this relay is worth building a peer from: active, and with a
    /// key that is the right length once decoded.
    ///
    /// The key length check is the one that matters. A truncated or re-wrapped
    /// base64 key produces a peer the data plane accepts and that then fails
    /// every handshake, which looks exactly like a network problem.
    pub fn is_usable(&self) -> bool {
        self.active && decoded_key_len(&self.public_key) == Some(32)
    }
}

/// How many bytes this base64 string decodes to, or `None` if it is not
/// base64 at all.
///
/// Hand-rolled because the only question asked is the LENGTH, and pulling a
/// base64 crate into this workspace to count bytes would be a dependency that
/// buys nothing. The key itself is passed on as the string it arrived as; the
/// data plane owns decoding it, because the data plane is where a wrong key
/// has consequences.
fn decoded_key_len(s: &str) -> Option<usize> {
    let bytes = s.as_bytes();
    if bytes.is_empty() || bytes.len() % 4 != 0 {
        return None;
    }
    let mut pad = 0usize;
    for (i, &b) in bytes.iter().enumerate() {
        let is_b64 = b.is_ascii_alphanumeric() || b == b'+' || b == b'/';
        if b == b'=' {
            // Padding only in the last two positions, and only at the end.
            if i + 2 < bytes.len() {
                return None;
            }
            pad += 1;
        } else if !is_b64 || pad > 0 {
            return None;
        }
    }
    Some(bytes.len() / 4 * 3 - pad)
}

/// Parse a relay list out of the endpoint's JSON.
pub fn parse(json: &str) -> Result<RelayList, serde_json::Error> {
    serde_json::from_str(json)
}

impl RelayList {
    /// Every relay that could actually carry a tunnel.
    pub fn usable(&self) -> impl Iterator<Item = &Relay> {
        self.wireguard.relays.iter().filter(|r| r.is_usable())
    }

    /// Usable relays in one location code (`se-sto`).
    pub fn in_location<'a>(&'a self, code: &'a str) -> impl Iterator<Item = &'a Relay> {
        self.usable().filter(move |r| r.location == code)
    }

    /// Usable relays in one country, by the name Mullvad spells it with.
    pub fn in_country<'a>(&'a self, country: &'a str) -> impl Iterator<Item = &'a Relay> + 'a {
        self.usable().filter(move |r| {
            self.locations
                .get(&r.location)
                .is_some_and(|l| l.country.eq_ignore_ascii_case(country))
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_key_length_is_counted_and_a_wrong_one_is_rejected() {
        // A real Mullvad relay key, captured 2026-09-05.
        assert_eq!(
            decoded_key_len("MkP/Jytkg51/Y/EostONjIN6YaFRpsAYiNKMX27/CAY="),
            Some(32)
        );
        // RED-WHEN-BROKEN — every way a key goes subtly wrong.
        assert_eq!(decoded_key_len(""), None, "empty");
        assert_eq!(decoded_key_len("MkP/Jytkg51/Y/EostONjIN6YaFRpsAYiNKMX27/CA"), None);
        assert_eq!(
            decoded_key_len("MkP/Jytkg51/Y/EostONjIN6YaFRpsAYiNKMX27/CAY"),
            None,
            "the padding stripped: 43 chars is not a multiple of 4"
        );
        assert_eq!(
            decoded_key_len("MkP Jytkg51/Y/EostONjIN6YaFRpsAYiNKMX27/CAY="),
            None,
            "a space where a character should be"
        );
        assert_eq!(
            decoded_key_len("MkP=Jytkg51/Y/EostONjIN6YaFRpsAYiNKMX27/CAY="),
            None,
            "padding in the middle"
        );
        // A 24-byte key is well-formed base64 and the WRONG LENGTH — this is
        // the case a "does it parse as base64" check waves through.
        assert_eq!(decoded_key_len("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"), Some(24));
    }
}