Gunnar

nordisk/tunnr-mullvad

tests/relay_list_wire.rs

//! **The relay-list parser against Mullvad's actual bytes.**
//!
//! Two halves, and the split between them is the honest part:
//!
//! * `captured_*` — `tests/fixtures/relays-2026-09-05.json`, three relay
//!   objects lifted **verbatim** out of a 550 378-byte `GET
//!   https://api.mullvad.net/app/v1/relays` on 2026-09-05, with the real
//!   `port_ranges`, `shadowsocks_port_ranges` and both gateways. Runs offline,
//!   for ever, and pins the shape we were written against.
//! * `live_api_mullvad_net_*` — the same parse against the endpoint, now.
//!   Requires the network **and fails when it cannot reach it**, because a
//!   test that skips itself when the wire is unavailable reports green having
//!   proven nothing about a stranger's implementation, which is this crate's
//!   entire job.
//!
//! Set `TUNNR_NO_NETWORK=1` to turn the live half off. It does not go quiet:
//! it fails with a message saying the proof was declined, so an offline run is
//! visibly an offline run rather than a pass.
//!
//! The endpoint is public and read-only: no account, no key, no request body,
//! nothing sent but a GET.

use tunnr_mullvad::{facts, relays};

const CAPTURED: &str = include_str!("fixtures/relays-2026-09-05.json");

#[test]
fn captured_bytes_parse_into_relays_with_every_field_we_rely_on() {
    let list = relays::parse(CAPTURED).expect("the captured response parses");
    assert_eq!(list.wireguard.relays.len(), 3, "the fixture holds three relays");

    let sto = list
        .wireguard
        .relays
        .iter()
        .find(|r| r.hostname == "se-sto-wg-001")
        .expect("se-sto-wg-001");
    // Every field a peer is built from, asserted against the captured bytes.
    assert_eq!(sto.location, "se-sto");
    assert!(sto.active);
    assert!(sto.owned);
    assert_eq!(sto.provider, "31173");
    assert!(sto.stboot);
    assert_eq!(sto.ipv4_addr_in.to_string(), "185.195.233.76");
    assert_eq!(sto.public_key, "MkP/Jytkg51/Y/EostONjIN6YaFRpsAYiNKMX27/CAY=");
    assert!(sto.is_usable());

    // A relay with `daita: true` in the bytes — the recently added field that
    // a strict parser would have choked on.
    let tia = list
        .wireguard
        .relays
        .iter()
        .find(|r| r.hostname == "al-tia-wg-001")
        .expect("al-tia-wg-001");
    assert!(tia.daita, "the captured bytes say daita:true");
    assert_eq!(tia.provider, "iRegister");
    assert!(!tia.owned, "rented, not owned — the two are different fields");

    // The location table resolves, which is what relay selection by country
    // depends on.
    assert_eq!(list.locations["se-sto"].city, "Stockholm");
    assert_eq!(list.locations["se-sto"].country, "Sweden");
    assert_eq!(list.in_country("Sweden").count(), 2);
    assert_eq!(list.in_country("sweden").count(), 2, "case-insensitive");
    assert_eq!(list.in_country("Norway").count(), 0);
    assert_eq!(list.in_location("se-sto").count(), 1);
}

/// The constants in [`facts`] are only worth anything if they still agree with
/// the bytes they were read from. This is the test that catches a hand-edit.
#[test]
fn captured_bytes_agree_with_every_constant_read_from_them() {
    let list = relays::parse(CAPTURED).expect("parses");
    assert_eq!(list.wireguard.ipv4_gateway, facts::IPV4_GATEWAY);
    assert_eq!(list.wireguard.ipv6_gateway, facts::IPV6_GATEWAY);
    assert_eq!(list.wireguard.port_ranges, facts::PORT_RANGES);
    assert_eq!(
        list.wireguard.shadowsocks_port_ranges,
        facts::SHADOWSOCKS_PORT_RANGES
    );
    // …and the derived predicates agree with the parsed ranges, not merely
    // with the constants — which is a different assertion and the one that
    // would catch `is_relay_port` being written against the wrong list.
    for &(lo, hi) in &list.wireguard.port_ranges {
        assert!(facts::is_relay_port(lo), "{lo} is served");
        assert!(facts::is_relay_port(hi), "{hi} is served");
    }
}

/// RED-WHEN-BROKEN — the parser must refuse a relay missing a field a peer
/// cannot be built without, rather than yielding one with a default in it.
/// A relay with an empty key or `0.0.0.0` would build a peer that fails every
/// handshake and looks like a network fault.
#[test]
fn a_relay_missing_a_load_bearing_field_fails_the_parse() {
    for missing in ["public_key", "ipv4_addr_in", "hostname", "active"] {
        // Remove exactly one required field from the captured bytes.
        let broken = strip_field(CAPTURED, missing);
        assert_ne!(broken, CAPTURED, "the {missing} field was actually removed");
        assert!(
            relays::parse(&broken).is_err(),
            "a relay with no {missing} must fail the parse, not default"
        );
    }
    // The identity guard: unmodified bytes still parse, or every assertion
    // above is satisfied by a parser that rejects everything.
    assert!(relays::parse(CAPTURED).is_ok());
}

/// An *unknown* field, by contrast, must be tolerated: Mullvad add fields, and
/// a parser that refuses one turns their feature launch into our outage.
#[test]
fn an_unknown_field_does_not_break_the_parse() {
    let with_new_field = CAPTURED.replacen(
        r#""hostname":"se-sto-wg-001""#,
        r#""some_future_feature":{"enabled":true},"hostname":"se-sto-wg-001""#,
        1,
    );
    assert_ne!(with_new_field, CAPTURED);
    let list = relays::parse(&with_new_field).expect("an unfamiliar key is not an error");
    assert_eq!(list.wireguard.relays.len(), 3);
}

/// Remove the first `"<field>":<value>` pair from one relay object, keeping the
/// JSON otherwise well formed.
fn strip_field(json: &str, field: &str) -> String {
    let needle = format!("\"{field}\":");
    let Some(start) = json.find(&needle) else {
        return json.to_string();
    };
    // Scan to the next top-level comma inside this object.
    let bytes = json.as_bytes();
    let mut i = start + needle.len();
    let mut depth = 0i32;
    let mut in_string = false;
    while i < bytes.len() {
        match bytes[i] {
            b'"' if !(i > 0 && bytes[i - 1] == b'\\') => in_string = !in_string,
            b'[' | b'{' if !in_string => depth += 1,
            b']' | b'}' if !in_string => {
                if depth == 0 {
                    break;
                }
                depth -= 1;
            }
            b',' if !in_string && depth == 0 => {
                i += 1;
                break;
            }
            _ => {}
        }
        i += 1;
    }
    format!("{}{}", &json[..start], &json[i..])
}

// ── the live half ───────────────────────────────────────────────────────────

/// **The live endpoint, now.** The captured fixture proves we parse what
/// Mullvad sent in September; this proves we parse what Mullvad is sending.
///
/// Fails rather than skips when the network is unavailable — see the module
/// docs. `TUNNR_NO_NETWORK=1` turns it off, loudly.
#[test]
fn live_api_mullvad_net_relay_list_parses_and_still_has_the_shape_we_rely_on() {
    if std::env::var_os("TUNNR_NO_NETWORK").is_some() {
        panic!(
            "TUNNR_NO_NETWORK is set, so the only test in this crate that talks to \
             Mullvad did not run. Nothing here has been proven against their live \
             implementation on this run — that is what this failure says, and it says it \
             instead of going quiet"
        );
    }

    let body = match fetch(facts::RELAY_LIST_URL) {
        Ok(b) => b,
        Err(e) => panic!(
            "could not reach {} ({e}). This test asserts interop with a stranger's \
             implementation; unreachable means unproven, not fine. Set \
             TUNNR_NO_NETWORK=1 to state deliberately that this run has no network",
            facts::RELAY_LIST_URL
        ),
    };

    // Real bytes really arrived. The fetch is fast enough (≈0.15 s from oden
    // on 2026-09-05, against 0.184 s for the same GET through curl) that
    // "surely it did not have time" is a reasonable doubt, so the doubt is
    // answered by an assertion instead of a stopwatch: an empty or truncated
    // body cannot satisfy this.
    assert!(
        body.len() > 100_000,
        "the live response was only {} bytes — 550 378 were measured on {}; a short body \
         means a proxy, an error page, or a truncated read, not a proof",
        body.len(),
        facts::MEASURED_ON
    );

    let list = relays::parse(&body).unwrap_or_else(|e| {
        panic!(
            "the LIVE relay list no longer parses ({e}) — Mullvad changed the response and \
             a peer built from our understanding of it would be wrong. First 400 bytes: {}",
            &body[..body.len().min(400)]
        )
    });

    // The facts this crate ships, re-read from the live response.
    assert_eq!(
        list.wireguard.ipv4_gateway,
        facts::IPV4_GATEWAY,
        "the in-tunnel gateway moved since {}",
        facts::MEASURED_ON
    );
    assert_eq!(
        list.wireguard.ipv6_gateway,
        facts::IPV6_GATEWAY,
        "the v6 gateway moved since {}",
        facts::MEASURED_ON
    );
    assert_eq!(
        list.wireguard.port_ranges,
        facts::PORT_RANGES,
        "the relay port ranges changed since {} — `facts::PORT_RANGES` is now wrong, and \
         a config audit built on it will flag correct endpoints",
        facts::MEASURED_ON
    );
    assert_eq!(
        list.wireguard.shadowsocks_port_ranges,
        facts::SHADOWSOCKS_PORT_RANGES,
        "the Shadowsocks port ranges changed since {}",
        facts::MEASURED_ON
    );

    // The list is a real list, not an empty success. An endpoint that answered
    // `{"wireguard":{"relays":[]}}` would satisfy every assertion above.
    let usable = list.usable().count();
    assert!(
        usable > 100,
        "only {usable} usable relays — {} were measured on {}; either the response is \
         truncated or our `is_usable` has started rejecting real relays",
        facts::RELAY_COUNT_MEASURED,
        facts::MEASURED_ON
    );

    // Every live relay must survive the conversion to a peer, or the seam is
    // wrong for some shape the fixture does not contain.
    for relay in list.usable() {
        let peer = tunnr_mullvad::peer_for_relay(relay, 51820, None)
            .unwrap_or_else(|e| panic!("live relay {} does not convert: {e}", relay.hostname));
        assert_eq!(
            wg_appliance_core::keys::encode_key(&peer.public_key),
            relay.public_key
        );
    }
}

/// A minimal GET. `ureq` is a **dev**-dependency: the TLS it brings links C,
/// which is fine in a test binary and is exactly why this crate's own
/// transport belongs to the caller.
fn fetch(url: &str) -> Result<String, String> {
    let mut response = ureq::get(url)
        .header("User-Agent", "tunnr-mullvad interop test (codeberg.org/nordisk/tunnr)")
        .call()
        .map_err(|e| e.to_string())?;
    if response.status() != 200 {
        return Err(format!("HTTP {}", response.status()));
    }
    response
        .body_mut()
        .with_config()
        .limit(16 * 1024 * 1024)
        .read_to_string()
        .map_err(|e| e.to_string())
}