Gunnar

nordisk/tunnr-mullvad

src/audit.rs

//! **The Mullvad-shaped audit** — the checks that only make sense because the
//! config claims Mullvad, moved here from tunnr's appliance payload so there is
//! one writer of them.
//!
//! # What moved, and what deliberately did not
//!
//! Left in the config owner (tunnr's `wg-appliance`): the checks that are true
//! of *every* WireGuard config and of plain WireGuard with no provider at all —
//! a resolver no `AllowedIPs` covers is queried in clear over the underlay; an
//! `AllowedIPs` entry that does not parse routes nothing; a `Provider =` name
//! this build does not implement is refused rather than silently falling back
//! to a bare tunnel. None of those are Mullvad's opinion. They belong to
//! whoever owns the config surface.
//!
//! Moved here: everything whose *correct answer depends on Mullvad's product* —
//! which addresses their registration issues, which resolvers are theirs, which
//! ports their relays listen on, and what a pre-shared key in a config means
//! given that their flagship feature IS a pre-shared key.
//!
//! The dividing line is worth stating because it is not "which file was it in":
//! it is **whether the fact has a provider's name on it**. A fact with a
//! provider's name on it goes stale when that provider changes something, and
//! this crate is the place that says when it was last checked.
//!
//! # This audit never sees a secret
//!
//! [`MullvadPeer`] carries `has_preshared_key: bool`, not the key. The audit's
//! whole finding about a PSK is about the **road it travelled**, which is
//! answerable from a boolean; giving this crate the bytes would create a place
//! for a secret to be logged in exchange for nothing.

use std::net::{IpAddr, SocketAddr};

use crate::facts;

/// How serious a [`Finding`] is.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Severity {
    /// Say it on the log and in the UI; the boot continues. A misconfiguration
    /// whose blast radius is real but whose correct value is a product
    /// decision, or one established from an **inferred** fact.
    Warn,
    /// The config cannot mean what it says. Whoever is bringing a tunnel up
    /// must not bring it up and call it Mullvad.
    Refuse,
}

impl Severity {
    /// The short word a marker line prints.
    pub fn as_str(&self) -> &'static str {
        match self {
            Severity::Warn => "WARN",
            Severity::Refuse => "REFUSE",
        }
    }
}

/// One thing wrong with a config, given that it claims Mullvad.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Finding {
    /// How serious.
    pub severity: Severity,
    /// A short stable code, so a test asserts the **finding** rather than
    /// pattern-matching prose that will be reworded.
    pub code: &'static str,
    /// The human sentence, naming the offending value.
    pub detail: String,
}

impl Finding {
    fn warn(code: &'static str, detail: impl Into<String>) -> Finding {
        Finding { severity: Severity::Warn, code, detail: detail.into() }
    }
    fn refuse(code: &'static str, detail: impl Into<String>) -> Finding {
        Finding { severity: Severity::Refuse, code, detail: detail.into() }
    }
    /// The one-line marker a boot log prints for this finding.
    pub fn marker_line(&self) -> String {
        format!("[tunnr] mullvad: {} {} — {}", self.severity.as_str(), self.code, self.detail)
    }
}

/// One peer of a config, as much of it as a Mullvad audit needs.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MullvadPeer {
    /// The peer's `AllowedIPs`, as the CIDR strings the config carried.
    pub allowed_ips: Vec<String>,
    /// The `Endpoint`, when the config gave one.
    pub endpoint: Option<SocketAddr>,
    /// Whether the config carried a `PresharedKey`. **Not the key** — see the
    /// module docs.
    pub has_preshared_key: bool,
}

/// A config, as much of it as a Mullvad audit needs.
///
/// Built by whoever owns the config surface (tunnr's payload parses wg-quick
/// INI and fills this in), so this crate holds no parser for a format it does
/// not own and no opinion about one.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MullvadConfig {
    /// `[Interface] Address`, as the CIDR strings the config carried.
    pub addresses: Vec<String>,
    /// `[Interface] DNS`.
    pub dns: Vec<IpAddr>,
    /// The peers.
    pub peers: Vec<MullvadPeer>,
}

/// Audit a config that claims to be Mullvad.
///
/// Returns every [`Finding`], most severe first. An empty list means the config
/// is **coherent for Mullvad** — it does not mean the tunnel will work. The
/// underlay may be unreachable, the relay may be inactive, the account may have
/// expired; none of that is visible from a config file and this function never
/// claims it is.
pub fn audit(cfg: &MullvadConfig) -> Vec<Finding> {
    let mut out = Vec::new();

    // ── The address ────────────────────────────────────────────────────────
    //
    // Mullvad assigns one per registered device and the registration response
    // returns it already in CIDR form. Absent, there is no IP through the
    // tunnel at all — and that one IS a refusal, because it rests on a
    // documented fact rather than an inferred range.
    let mut saw_v4 = false;
    for cidr in &cfg.addresses {
        let Some((addr, _)) = cidr.split_once('/') else { continue };
        let Ok(ip) = addr.trim().parse::<IpAddr>() else { continue };
        if !matches!(ip, IpAddr::V4(_)) {
            continue;
        }
        saw_v4 = true;
        if !facts::is_plausible_assigned_address(ip) {
            // WARN and never REFUSE: the range is INFERRED (see
            // `facts::TUNNEL_NET_INFERRED`), and an inferred boundary must not
            // reject a config Mullvad itself issued.
            out.push(Finding::warn(
                "mullvad-address-not-assigned",
                format!(
                    "[Interface] Address {cidr} is outside the observed assigned range {} — \
                     an address that did not come from a device registration has no route \
                     back from the relay, so the tunnel will hand-shake and then carry \
                     nothing. The range is INFERRED, so this is a warning",
                    facts::TUNNEL_NET_INFERRED
                ),
            ));
        }
    }
    if !saw_v4 {
        out.push(Finding::refuse(
            "mullvad-no-address",
            "no IPv4 [Interface] Address — Mullvad assigns one per registered device (the \
             registration response returns it already in CIDR form) and without it there is \
             no IP through the tunnel",
        ));
    }

    // ── The exit ───────────────────────────────────────────────────────────
    //
    // Mullvad's product is an exit. A partial AllowedIPs silently sends
    // everything else in clear — legitimate as a deliberate split tunnel,
    // which is why it warns; but the whole point of buying the product is the
    // traffic you did not think about.
    let full_tunnel = cfg
        .peers
        .iter()
        .any(|p| p.allowed_ips.iter().any(|c| c.trim() == facts::FULL_TUNNEL_V4));
    if !full_tunnel {
        out.push(Finding::warn(
            "mullvad-not-full-tunnel",
            format!(
                "no peer routes {} — Mullvad is an exit provider, so every prefix not listed \
                 leaves this appliance in clear. A deliberate split tunnel is legitimate; a \
                 forgotten one is a leak",
                facts::FULL_TUNNEL_V4
            ),
        ));
    }

    // ── The resolver ───────────────────────────────────────────────────────
    //
    // Whoever owns the config already checks that a resolver is ROUTED into
    // the tunnel. This is the narrower, Mullvad-specific question: a resolver
    // that is in the tunnel but is not Mullvad's own is being reached THROUGH
    // Mullvad, which works, and which the operator may not have meant.
    if !cfg.dns.is_empty() && !cfg.dns.iter().any(|d| facts::is_resolver(*d)) {
        out.push(Finding::warn(
            "mullvad-foreign-dns",
            format!(
                "DNS names neither Mullvad's in-tunnel resolver {} nor a content-blocking \
                 variant in {} — resolution is being done by someone else, through the tunnel",
                facts::RESOLVER,
                facts::RESOLVER_BLOCKING_NET
            ),
        ));
    }

    // ── The endpoint port ──────────────────────────────────────────────────
    //
    // This one is new here, and it is the reason the port ranges are worth
    // carrying at all. Relays listen on five ranges with real gaps between
    // them; a port in a gap is not a relay port, and a Shadowsocks port is an
    // obfuscation endpoint that a bare WireGuard peer will hand-shake with
    // nothing on.
    for peer in &cfg.peers {
        let Some(endpoint) = peer.endpoint else { continue };
        let port = endpoint.port();
        if facts::is_shadowsocks_port(port) {
            out.push(Finding::warn(
                "mullvad-shadowsocks-endpoint",
                format!(
                    "Endpoint {endpoint} is on a Shadowsocks obfuscation port — a plain \
                     WireGuard peer pointed at it will send handshakes nobody answers. \
                     Shadowsocks needs a proxy this workspace does not carry"
                ),
            ));
        } else if !facts::is_relay_port(port) {
            out.push(Finding::warn(
                "mullvad-endpoint-port",
                format!(
                    "Endpoint {endpoint} uses port {port}, which is outside every port range \
                     Mullvad's relays were measured listening on ({}). Measured {}",
                    port_ranges_readable(),
                    facts::MEASURED_ON
                ),
            ));
        }
    }

    // ── The pre-shared key ─────────────────────────────────────────────────
    //
    // NOT refused. Mullvad's quantum-resistant tunnel IS a WireGuard PSK, and
    // refusing one would read as "this appliance does not support Mullvad's PQ
    // tunnels", which is the opposite of true. What is wrong is the ROAD: a
    // key that arrived in a config file arrived at rest, and the legitimate
    // road is negotiated in-tunnel (see `crate::pq`), where the key cannot
    // predate the device being up.
    for peer in &cfg.peers {
        if peer.has_preshared_key {
            out.push(Finding::warn(
                "mullvad-config-psk",
                "a [Peer] PresharedKey arrived IN THE CONFIG. The key itself is fine and the \
                 data plane uses it — the road is wrong: a secret in a config file is a \
                 secret at rest, and Mullvad's quantum-resistant PSK is derived inside an \
                 already-established tunnel and never exists at rest",
            ));
        }
    }

    out.sort_by(|a, b| b.severity.cmp(&a.severity));
    out
}

/// The measured port ranges, rendered for a message.
fn port_ranges_readable() -> String {
    facts::PORT_RANGES
        .iter()
        .map(|(lo, hi)| if lo == hi { format!("{lo}") } else { format!("{lo}-{hi}") })
        .collect::<Vec<_>>()
        .join(", ")
}

/// The worst severity in a list, or `None` when it is clean.
pub fn worst(findings: &[Finding]) -> Option<Severity> {
    findings.iter().map(|f| f.severity).max()
}

/// Whether these findings must stop a tunnel coming up and calling itself
/// Mullvad.
pub fn must_refuse(findings: &[Finding]) -> bool {
    worst(findings) == Some(Severity::Refuse)
}

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

    /// A config that is a correct Mullvad client — the clean baseline every
    /// test below perturbs.
    fn ok() -> MullvadConfig {
        MullvadConfig {
            addresses: vec!["10.65.12.34/32".into()],
            dns: vec![facts::RESOLVER],
            peers: vec![MullvadPeer {
                allowed_ips: vec!["0.0.0.0/0".into(), "::/0".into()],
                endpoint: Some("185.65.135.70:51820".parse().unwrap()),
                has_preshared_key: false,
            }],
        }
    }

    /// The identity guard for the whole module. If the clean case ever fires,
    /// the audit has become a thing that always fires, and every assertion
    /// below is worth nothing.
    #[test]
    fn a_correct_mullvad_config_produces_no_findings() {
        let found = audit(&ok());
        assert!(found.is_empty(), "clean config: {found:?}");
        assert_eq!(worst(&found), None);
        assert!(!must_refuse(&found));
    }

    /// RED-WHEN-BROKEN — the boundary of the inferred range, driven on both
    /// sides, plus a real registration address. Also: it must never refuse.
    #[test]
    fn an_address_outside_the_inferred_range_warns_and_never_refuses() {
        let mut cfg = ok();
        for outside in ["10.0.0.2/32", "10.128.0.1/32", "192.168.1.10/24"] {
            cfg.addresses = vec![outside.into()];
            let found = audit(&cfg);
            assert!(
                found.iter().any(|f| f.code == "mullvad-address-not-assigned"),
                "{outside} is outside the range: {found:?}"
            );
            assert!(!must_refuse(&found), "an INFERRED boundary must not refuse");
        }
        // Inside, including the last address of the /10 and a real assignment
        // measured from their API.
        for inside in ["10.64.0.2/32", "10.67.114.172/32", "10.127.255.254/32"] {
            cfg.addresses = vec![inside.into()];
            assert!(audit(&cfg).is_empty(), "{inside} is a legitimate assignment");
        }
    }

    /// No address at all IS a refusal — that rests on a documented fact
    /// (registration always returns one), not an inference. And when it fires,
    /// the *inferred* finding must not also fire: they would contradict.
    #[test]
    fn a_missing_address_refuses_while_an_odd_one_does_not() {
        let mut cfg = ok();
        cfg.addresses.clear();
        let found = audit(&cfg);
        assert!(found.iter().any(|f| f.code == "mullvad-no-address"));
        assert!(must_refuse(&found));
        assert!(!found.iter().any(|f| f.code == "mullvad-address-not-assigned"));

        // An IPv6-only Address is still "no IPv4 address": the check must read
        // the family and not merely count entries.
        cfg.addresses = vec!["fc00:bbbb:bbbb:bb01::5:72ac/128".into()];
        assert!(audit(&cfg).iter().any(|f| f.code == "mullvad-no-address"));
    }

    /// RED-WHEN-BROKEN — the PSK must warn and must NEVER refuse. Refusing
    /// would refuse Mullvad's flagship feature.
    #[test]
    fn a_config_preshared_key_warns_about_the_road_and_is_never_refused() {
        let mut cfg = ok();
        cfg.peers[0].has_preshared_key = true;
        let found = audit(&cfg);
        let f = found
            .iter()
            .find(|f| f.code == "mullvad-config-psk")
            .unwrap_or_else(|| panic!("{found:?}"));
        assert_eq!(f.severity, Severity::Warn);
        assert!(!must_refuse(&found));
        assert!(f.detail.contains("road is wrong"), "{}", f.detail);
        // The same config without the key is clean, so the finding is about
        // the key's arrival and nothing else in the config.
        cfg.peers[0].has_preshared_key = false;
        assert!(audit(&cfg).is_empty());
    }

    /// RED-WHEN-BROKEN — the content-blocking resolvers are Mullvad's own and
    /// must not be called foreign.
    #[test]
    fn a_content_blocking_resolver_is_not_foreign_but_someone_elses_is() {
        let mut cfg = ok();
        for own in ["10.64.0.1", "100.64.0.1", "100.64.0.7", "100.127.255.255"] {
            cfg.dns = vec![own.parse().unwrap()];
            assert!(
                audit(&cfg).is_empty(),
                "{own} is a Mullvad resolver: {:?}",
                audit(&cfg)
            );
        }
        for foreign in ["1.1.1.1", "8.8.8.8", "100.128.0.1", "100.63.255.255"] {
            cfg.dns = vec![foreign.parse().unwrap()];
            let found = audit(&cfg);
            assert!(
                found.iter().any(|f| f.code == "mullvad-foreign-dns"),
                "{foreign} is not Mullvad's: {found:?}"
            );
            assert!(!must_refuse(&found));
        }
        // No DNS at all is not a foreign resolver — it is no resolver, which
        // is the config owner's question, not this one's.
        cfg.dns.clear();
        assert!(!audit(&cfg).iter().any(|f| f.code == "mullvad-foreign-dns"));
    }

    /// RED-WHEN-BROKEN — the endpoint port check, and specifically its GAPS.
    /// This is the finding the measured port ranges pay for.
    #[test]
    fn an_endpoint_port_outside_every_measured_range_warns() {
        let mut cfg = ok();
        // Ports Mullvad really uses, including the two unusual ones chosen to
        // survive captive portals. None of these may fire.
        for good in [53u16, 123, 4000, 33433, 33565, 51820, 52001, 60000] {
            cfg.peers[0].endpoint = Some(format!("185.65.135.70:{good}").parse().unwrap());
            assert!(
                audit(&cfg).is_empty(),
                "port {good} is inside a measured range: {:?}",
                audit(&cfg)
            );
        }
        // Ports in the gaps between the ranges. A "between 53 and 60000" check
        // waves every one of these through.
        for bad in [52u16, 3999, 33434, 33564, 51821, 52000, 60001] {
            cfg.peers[0].endpoint = Some(format!("185.65.135.70:{bad}").parse().unwrap());
            let found = audit(&cfg);
            assert!(
                found.iter().any(|f| f.code == "mullvad-endpoint-port"),
                "port {bad} is in a gap: {found:?}"
            );
            assert!(!must_refuse(&found));
        }
    }

    /// A Shadowsocks port is reported as itself, not as a generic bad port —
    /// the fix is different (it needs a proxy), so the finding must be too.
    #[test]
    fn a_shadowsocks_endpoint_is_named_as_such() {
        let mut cfg = ok();
        cfg.peers[0].endpoint = Some("185.65.135.70:51920".parse().unwrap());
        let found = audit(&cfg);
        assert!(found.iter().any(|f| f.code == "mullvad-shadowsocks-endpoint"), "{found:?}");
        assert!(
            !found.iter().any(|f| f.code == "mullvad-endpoint-port"),
            "one finding, not two: {found:?}"
        );
    }

    #[test]
    fn a_split_tunnel_warns_but_does_not_refuse() {
        let mut cfg = ok();
        cfg.peers[0].allowed_ips = vec!["10.64.0.0/10".into()];
        let found = audit(&cfg);
        assert!(found.iter().any(|f| f.code == "mullvad-not-full-tunnel"), "{found:?}");
        assert_eq!(worst(&found), Some(Severity::Warn));
        assert!(!must_refuse(&found));
    }

    #[test]
    fn findings_come_back_worst_first_and_carry_a_marker_line() {
        let mut cfg = ok();
        cfg.addresses.clear(); // REFUSE
        cfg.peers[0].allowed_ips = vec!["10.0.0.0/8".into()]; // WARN
        let found = audit(&cfg);
        assert!(found.len() >= 2, "{found:?}");
        assert!(
            found.iter().any(|f| f.severity == Severity::Warn),
            "the list really does mix severities, or the ordering proves nothing: {found:?}"
        );
        assert_eq!(found[0].severity, Severity::Refuse, "worst first: {found:?}");
        let line = found[0].marker_line();
        assert!(line.starts_with("[tunnr] mullvad: REFUSE"), "{line}");
        assert!(line.contains(found[0].code), "{line}");
    }
}