Gunnar

nordisk/tunnr-mullvad

src/facts.rs

//! **What is true about Mullvad's network, and how each fact was established.**
//!
//! Every constant here carries its provenance in one of three grades, because
//! the grade decides what the fact is allowed to *do*:
//!
//! * **DOCUMENTED** — Mullvad says it in their own help pages. May drive a
//!   refusal.
//! * **MEASURED** — read out of a live response from `api.mullvad.net`, with
//!   the date. May drive a refusal when the response is unambiguous about it.
//! * **INFERRED** — deduced from measurements, with no published reference.
//!   **May only ever drive a warning.** An inferred boundary that refuses a
//!   config is a boundary that will one day reject something Mullvad itself
//!   issued, and the operator will have no way to tell our guess from their
//!   product.
//!
//! That grading is not decoration. It is the difference between a check that
//! protects an operator and one that fights them.
//!
//! # The measurement
//!
//! `GET https://api.mullvad.net/app/v1/relays`, unauthenticated, 2026-09-05:
//! HTTP 200, 550 378 bytes, **556 WireGuard relays**. No account, no key, no
//! request body — the relay list is public, which is why this crate can assert
//! against it without credentials.
//!
//! # What is NOT here, on purpose
//!
//! No account number, no device token, no API client. Provisioning is a
//! host-side exchange (see [`crate::device`]) and this crate parses its
//! *shapes* without ever performing it — so nothing in this crate can leak a
//! credential, because nothing in it has ever held one.

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

use crate::net::V4Net;

/// The date every MEASURED constant in this module was read at.
///
/// A figure from a live network with no date on it is a figure nobody can
/// re-check, so the date is a constant the tests print rather than a comment.
pub const MEASURED_ON: &str = "2026-09-05";

/// The unauthenticated relay-list endpoint. Public: no account, no token.
pub const RELAY_LIST_URL: &str = "https://api.mullvad.net/app/v1/relays";

/// **MEASURED** `2026-09-05` — `wireguard.ipv4_gateway` in the relay list.
/// Also **DOCUMENTED**: "the WireGuard servers run an unfiltered DNS on the
/// internal IP 10.64.0.1"
/// (<https://mullvad.net/en/help/running-wireguard-router>).
pub const IPV4_GATEWAY: Ipv4Addr = Ipv4Addr::new(10, 64, 0, 1);

/// **MEASURED** `2026-09-05` — `wireguard.ipv6_gateway` in the relay list.
pub const IPV6_GATEWAY: Ipv6Addr = Ipv6Addr::new(0xfc00, 0xbbbb, 0xbbbb, 0xbb01, 0, 0, 0, 1);

/// Mullvad's plain, unfiltered in-tunnel resolver — the gateway itself.
pub const RESOLVER: IpAddr = IpAddr::V4(IPV4_GATEWAY);

/// **MEASURED** `2026-09-05` — the range Mullvad's **content-blocking**
/// resolvers live in. `100.64.0.1` and up select ad / tracker / malware /
/// adult / gambling / social-media blocking combinations.
///
/// This is the correction that a live probe forced on the first version of the
/// check: a resolver test that knew only [`RESOLVER`] flagged every
/// privacy-conscious operator's perfectly correct config as foreign. It is
/// also **not** the carrier-grade-NAT use of `100.64.0.0/10` (RFC 6598) — it is
/// Mullvad reusing that space inside their own tunnel, which is why an address
/// in it is legitimate *here* and would be suspicious anywhere else.
pub const RESOLVER_BLOCKING_NET: V4Net = V4Net::new(100, 64, 0, 0, 10);

/// **INFERRED** — the range device registrations assign tunnel addresses out
/// of, deduced from [`IPV4_GATEWAY`] plus observed assignments
/// (`10.67.114.172/32` among them). Mullvad publishes no REST API reference and
/// no statement of this range.
///
/// **Therefore: warning only, never a refusal.** See the module docs.
pub const TUNNEL_NET_INFERRED: V4Net = V4Net::new(10, 64, 0, 0, 10);

/// **MEASURED** `2026-09-05` — `wireguard.port_ranges`, inclusive on both ends.
///
/// Five ranges, and the shape of them is the finding: **nothing pins 51820.**
/// A check that refused any endpoint port but the WireGuard default would
/// reject a relay reachable on 53 or 123 (deliberately chosen to survive
/// captive portals and DPI that only lets DNS and NTP out), and would also
/// reject most of the 4000–33433 span.
///
/// The **gaps** are as load-bearing as the ranges. `33434..=33564` is the
/// traceroute probe range and `51821..=52000` is left for
/// [`SHADOWSOCKS_PORT_RANGES`] and headroom; an endpoint in either is not a
/// Mullvad relay port, and a check written as "between 53 and 60000" would
/// wave both through.
pub const PORT_RANGES: &[(u16, u16)] = &[
    (53, 53),
    (123, 123),
    (4000, 33433),
    (33565, 51820),
    (52001, 60000),
];

/// **MEASURED** `2026-09-05` — `wireguard.shadowsocks_port_ranges`. Listed
/// because an endpoint here is a *Shadowsocks* obfuscation port, not a plain
/// WireGuard one: a config pointing a bare WireGuard peer at it will hand-shake
/// with nothing.
pub const SHADOWSOCKS_PORT_RANGES: &[(u16, u16)] = &[(51900, 51949)];

/// **MEASURED** `2026-09-05` — the number of WireGuard relays the list carried.
/// Not a constraint on anything; recorded so a future reading can say whether
/// the network grew or the parse broke.
pub const RELAY_COUNT_MEASURED: usize = 556;

/// The full-tunnel `AllowedIPs` a Mullvad exit implies, v4.
pub const FULL_TUNNEL_V4: &str = "0.0.0.0/0";
/// The full-tunnel `AllowedIPs` a Mullvad exit implies, v6.
pub const FULL_TUNNEL_V6: &str = "::/0";

/// Is `ip` one of Mullvad's own in-tunnel resolvers — the plain one, or any
/// content-blocking variant?
pub fn is_resolver(ip: IpAddr) -> bool {
    ip == RESOLVER || RESOLVER_BLOCKING_NET.contains(ip)
}

/// Is `port` inside a range Mullvad's WireGuard relays actually listen on?
pub fn is_relay_port(port: u16) -> bool {
    PORT_RANGES.iter().any(|&(lo, hi)| port >= lo && port <= hi)
}

/// Is `port` a Shadowsocks obfuscation port rather than a plain WireGuard one?
pub fn is_shadowsocks_port(port: u16) -> bool {
    SHADOWSOCKS_PORT_RANGES
        .iter()
        .any(|&(lo, hi)| port >= lo && port <= hi)
}

/// Could `addr` have come from a Mullvad device registration?
///
/// **INFERRED** — see [`TUNNEL_NET_INFERRED`]. A `false` here is a warning's
/// worth of evidence and never a refusal's.
pub fn is_plausible_assigned_address(addr: IpAddr) -> bool {
    TUNNEL_NET_INFERRED.contains(addr)
}

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

    /// RED-WHEN-BROKEN — the correction the live probe forced. Mullvad's
    /// content-blocking resolvers are `100.64.0.x`, NOT `10.64.0.x`, and a
    /// check that knew only the plain resolver called every ad-blocking
    /// operator's config foreign.
    #[test]
    fn every_mullvad_resolver_is_recognised_and_the_boundary_is_exact() {
        for own in [
            "10.64.0.1",       // the plain, unfiltered one
            "100.64.0.1",      // ad blocking
            "100.64.0.7",      // ad + tracker + malware
            "100.127.255.255", // the last address of the /10
            "100.64.0.0",      // the first
        ] {
            assert!(
                is_resolver(own.parse().unwrap()),
                "{own} is one of Mullvad's own resolvers"
            );
        }
        // One address on each side of the /10, and two resolvers that are
        // somebody else's. Without these the test above passes for a function
        // that returns `true`.
        for foreign in [
            "100.63.255.255",
            "100.128.0.1",
            "1.1.1.1",
            "8.8.8.8",
            "10.64.0.2",
            "10.65.0.1",
        ] {
            assert!(
                !is_resolver(foreign.parse().unwrap()),
                "{foreign} is not a Mullvad resolver"
            );
        }
    }

    /// RED-WHEN-BROKEN — the finding that nothing may pin 51820. Both the
    /// unusual ports Mullvad really uses and the gaps between the ranges are
    /// asserted; a "between 53 and 60000" check passes the first set and fails
    /// the second.
    #[test]
    fn the_relay_port_ranges_are_five_and_their_gaps_are_real() {
        assert_eq!(PORT_RANGES.len(), 5);
        for open in [53u16, 123, 4000, 20000, 33433, 33565, 51820, 52001, 60000] {
            assert!(is_relay_port(open), "{open} is inside a measured range");
        }
        for closed in [0u16, 52, 54, 122, 124, 3999, 33434, 33564, 51821, 52000, 60001, 65535] {
            assert!(
                !is_relay_port(closed),
                "{closed} is in a GAP between the measured ranges, not a relay port"
            );
        }
        // 51820 — the WireGuard default — is legitimate, but only because it
        // is the last port of the fourth range, not because it is special.
        assert!(is_relay_port(51820));
        assert!(!is_relay_port(51821));
    }

    #[test]
    fn a_shadowsocks_port_is_named_as_such_and_is_not_a_plain_relay_port() {
        for ss in [51900u16, 51925, 51949] {
            assert!(is_shadowsocks_port(ss));
            assert!(
                !is_relay_port(ss),
                "{ss} is obfuscation, not plain WireGuard — the two sets must not overlap"
            );
        }
        assert!(!is_shadowsocks_port(51899));
        assert!(!is_shadowsocks_port(51950));
        assert!(!is_shadowsocks_port(51820));
    }

    /// The measured port ranges and the Shadowsocks ranges must not overlap,
    /// or `is_relay_port` and `is_shadowsocks_port` would both answer yes and
    /// neither would mean anything.
    #[test]
    fn the_two_port_sets_are_disjoint() {
        for &(lo, hi) in SHADOWSOCKS_PORT_RANGES {
            for p in lo..=hi {
                assert!(!is_relay_port(p), "{p} is in both sets");
            }
        }
    }

    /// The inferred range does its job on a real assignment and on the
    /// gateway, and — the half that matters — says no to an address Mullvad
    /// would never issue.
    #[test]
    fn the_inferred_assignment_range_covers_a_real_registration() {
        assert!(is_plausible_assigned_address(IpAddr::V4(IPV4_GATEWAY)));
        assert!(is_plausible_assigned_address("10.67.114.172".parse().unwrap()));
        assert!(is_plausible_assigned_address("10.65.12.34".parse().unwrap()));
        assert!(!is_plausible_assigned_address("10.0.0.2".parse().unwrap()));
        assert!(!is_plausible_assigned_address("10.128.0.1".parse().unwrap()));
        assert!(!is_plausible_assigned_address("192.168.1.10".parse().unwrap()));
    }

    /// The gateway is inside the range it gates, and the resolver is the
    /// gateway. Two facts that are obvious right up until someone edits one
    /// constant and not the other.
    #[test]
    fn the_gateway_the_resolver_and_the_range_agree_with_each_other() {
        assert_eq!(RESOLVER, IpAddr::V4(IPV4_GATEWAY));
        assert!(TUNNEL_NET_INFERRED.contains(IpAddr::V4(IPV4_GATEWAY)));
        assert!(is_resolver(RESOLVER));
        assert_eq!(IPV4_GATEWAY.to_string(), "10.64.0.1");
        assert_eq!(IPV6_GATEWAY.to_string(), "fc00:bbbb:bbbb:bb01::1");
    }
}