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