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