use crate::join::Peer;
use crate::seed::{MullvadSeed, TunnelKind};
use crate::tunnel::{Identity, Tunnel, TunnelError, TunnelStatus};
use crate::wireguard::{WireGuard, MTU};
use std::net::Ipv4Addr;
use tunnr_mullvad::device::{RegisterDevice, RegisteredDevice};
use tunnr_mullvad::relays::{self, Relay, RelayList};
use wg_appliance_core::{addr, dataplane, encode_key, generate_keypair, TunDevice, TunnelHandle, TunnelSpec, KEY_LEN};
pub const API: &str = "https://api.mullvad.net";
pub const TOKEN_PATH: &str = "/auth/v1/token";
pub const DEVICES_PATH: &str = "/accounts/v1/devices";
pub const PUBKEY_IN_USE: &str = "PUBKEY_IN_USE";
pub const EXIT_IFACE: &str = "wg1";
const MAX_BODY: u64 = 32 << 20;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ApiError {
pub status: Option<u16>,
pub body: String,
}
impl std::fmt::Display for ApiError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.status {
Some(s) => write!(f, "HTTP {s}: {}", self.body.trim()),
None => write!(f, "{}", self.body.trim()),
}
}
}
pub trait MullvadApi {
fn relay_list(&self) -> Result<String, ApiError>;
fn access_token(&self, account: &str) -> Result<String, ApiError>;
fn register(&self, token: &str, req: &RegisterDevice) -> Result<String, ApiError>;
fn devices(&self, token: &str) -> Result<String, ApiError>;
}
pub struct HttpApi {
agent: ureq::Agent,
base: String,
}
impl Default for HttpApi {
fn default() -> Self {
HttpApi::new(API)
}
}
impl HttpApi {
pub fn new(base: &str) -> HttpApi {
let agent = ureq::Agent::config_builder()
.http_status_as_error(false)
.timeout_global(Some(std::time::Duration::from_secs(30)))
.build()
.new_agent();
HttpApi { agent, base: base.trim_end_matches('/').to_string() }
}
fn done(r: Result<ureq::http::Response<ureq::Body>, ureq::Error>) -> Result<String, ApiError> {
let mut r = r.map_err(|e| ApiError { status: None, body: format!("could not be reached: {e}") })?;
let status = r.status().as_u16();
let body = r.body_mut().with_config().limit(MAX_BODY).read_to_string().map_err(|e| ApiError { status: Some(status), body: format!("reading the body: {e}") })?;
if (200..300).contains(&status) {
Ok(body)
} else {
Err(ApiError { status: Some(status), body })
}
}
fn post_json(&self, path: &str, bearer: Option<&str>, body: String) -> Result<String, ApiError> {
let mut req = self.agent.post(&format!("{}{path}", self.base)).header("content-type", "application/json");
if let Some(t) = bearer {
req = req.header("authorization", &format!("Bearer {t}"));
}
HttpApi::done(req.send(body))
}
}
impl MullvadApi for HttpApi {
fn relay_list(&self) -> Result<String, ApiError> {
HttpApi::done(self.agent.get(tunnr_mullvad::facts::RELAY_LIST_URL).call())
}
fn access_token(&self, account: &str) -> Result<String, ApiError> {
let body = serde_json::json!({ "account_number": account }).to_string();
self.post_json(TOKEN_PATH, None, body)
}
fn register(&self, token: &str, req: &RegisterDevice) -> Result<String, ApiError> {
let body = serde_json::to_string(req).map_err(|e| ApiError { status: None, body: e.to_string() })?;
self.post_json(DEVICES_PATH, Some(token), body)
}
fn devices(&self, token: &str) -> Result<String, ApiError> {
HttpApi::done(self.agent.get(&format!("{}{DEVICES_PATH}", self.base)).header("authorization", &format!("Bearer {token}")).call())
}
}
pub struct ExitPlan {
pub relay: String,
pub address: (Ipv4Addr, u8),
pub device: String,
pub spec: TunnelSpec,
}
impl std::fmt::Debug for ExitPlan {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "ExitPlan {{ relay: {}, address: {}/{}, device: {}, spec: {} }}", self.relay, self.address.0, self.address.1, self.device, tunnr_mullvad::peers::redacted(&self.spec))
}
}
pub fn choose_relay<'a>(list: &'a RelayList, location: &str) -> Result<&'a Relay, TunnelError> {
let country = format!("{location}-");
list.usable()
.filter(|r| location.is_empty() || r.location == location || (!location.contains('-') && r.location.starts_with(&country)))
.max_by(|a, b| a.weight.cmp(&b.weight).then_with(|| b.hostname.cmp(&a.hostname)))
.ok_or_else(|| TunnelError::NotConfigured { kind: TunnelKind::Mullvad, what: format!("no usable Mullvad relay in `{}` ({} relays listed)", if location.is_empty() { "any location" } else { location }, list.wireguard.relays.len()) })
}
pub struct Mullvad {
mesh: WireGuard,
exit_private: [u8; KEY_LEN],
exit_public: String,
seed: MullvadSeed,
account: Option<String>,
api: Box<dyn MullvadApi>,
planned: Option<(String, (Ipv4Addr, u8), String)>,
exit: Option<TunnelHandle>,
}
impl Mullvad {
pub fn new(mesh: WireGuard, exit_key: Option<[u8; KEY_LEN]>, seed: MullvadSeed, account: Option<String>) -> Mullvad {
Mullvad::with_api(mesh, exit_key, seed, account, Box::new(HttpApi::default()))
}
pub fn with_api(mesh: WireGuard, exit_key: Option<[u8; KEY_LEN]>, seed: MullvadSeed, account: Option<String>, api: Box<dyn MullvadApi>) -> Mullvad {
let exit_private = exit_key.unwrap_or_else(|| generate_keypair().0);
let exit_public = encode_key(&wg_appliance_core::keys::public_from_private_bytes(&exit_private));
let account = account.map(|a| a.chars().filter(|c| !c.is_whitespace()).collect::<String>()).filter(|a| !a.is_empty());
Mullvad { mesh, exit_private, exit_public, seed, account, api, planned: None, exit: None }
}
pub fn exit_private_key(&self) -> &[u8; KEY_LEN] {
&self.exit_private
}
pub fn exit_public_key(&self) -> &str {
&self.exit_public
}
pub fn mesh(&self) -> &WireGuard {
&self.mesh
}
pub fn plan_exit(&mut self) -> Result<ExitPlan, TunnelError> {
let fail = |what: &str, e: ApiError| TunnelError::Failed(format!("mullvad: {what}: {e}"));
let account = self.account.clone().ok_or_else(|| TunnelError::NotConfigured {
kind: TunnelKind::Mullvad,
what: format!("no Mullvad account number: the door feeds it as `{}` (never on the medium)", crate::seed::FEED_MULLVAD_ACCOUNT),
})?;
let list = relays::parse(&self.api.relay_list().map_err(|e| fail("the relay list", e))?).map_err(|e| TunnelError::Failed(format!("mullvad: the relay list is not the shape tunnr-mullvad reads: {e}")))?;
let relay = choose_relay(&list, &self.seed.location)?;
let (device, address) = match &self.planned {
Some((_, address, device)) => (device.clone(), *address),
None => {
let token_body = self.api.access_token(&account).map_err(|e| fail("the account", e))?;
let token = serde_json::from_str::<serde_json::Value>(&token_body)
.ok()
.and_then(|v| v.get("access_token").and_then(|t| t.as_str()).map(str::to_string))
.ok_or_else(|| TunnelError::Failed("mullvad: the token answer has no `access_token`".into()))?;
let req = RegisterDevice::new(self.exit_public.clone());
let dev: RegisteredDevice = match self.api.register(&token, &req) {
Ok(body) => serde_json::from_str(&body).map_err(|e| TunnelError::Failed(format!("mullvad: the registration answer: {e}")))?,
Err(e) if e.body.contains(PUBKEY_IN_USE) => {
let body = self.api.devices(&token).map_err(|e| fail("listing the account's devices", e))?;
let all: Vec<RegisteredDevice> = serde_json::from_str(&body).map_err(|e| TunnelError::Failed(format!("mullvad: the device list: {e}")))?;
all.into_iter().find(|d| d.echoes(&req)).ok_or_else(|| TunnelError::Failed("mullvad: the key is `in use` and no device of this account has it — another account holds it".into()))?
}
Err(e) => return Err(fail("registering this box's key", e)),
};
let address = dev.assigned_ipv4_for(&req).map_err(|e| TunnelError::Failed(format!("mullvad: {e}")))?;
self.planned = Some((relay.hostname.clone(), address, dev.name.clone()));
(dev.name, address)
}
};
let spec = tunnr_mullvad::peers::tunnel_for_relay(self.exit_private, relay, self.seed.port, None).map_err(|e| TunnelError::Failed(format!("mullvad: {e}")))?;
Ok(ExitPlan { relay: relay.hostname.clone(), address, device, spec })
}
fn raise_exit(&mut self, plan: &ExitPlan) -> Result<(), TunnelError> {
let f = |e: std::io::Error| TunnelError::Failed(format!("{EXIT_IFACE}: {e} (CAP_NET_ADMIN?)"));
let tun = TunDevice::create(EXIT_IFACE, MTU).map_err(f)?;
let handle = dataplane::spawn(tun, &plan.spec).map_err(f)?;
addr::assign_ipv4(EXIT_IFACE, plan.address.0, plan.address.1).map_err(f)?;
addr::link_up(EXIT_IFACE).map_err(f)?;
self.exit = Some(handle);
Ok(())
}
}
impl Tunnel for Mullvad {
fn kind(&self) -> TunnelKind {
TunnelKind::Mullvad
}
fn public_key(&self) -> String {
self.mesh.public_key().to_string()
}
fn up(&mut self, me: &Identity, peers: &[Peer]) -> Result<(), TunnelError> {
self.mesh.up(me, peers)?;
if self.exit.is_none() {
let plan = self.plan_exit()?;
self.raise_exit(&plan)?;
}
Ok(())
}
fn add_peer(&mut self, peer: &Peer) -> Result<(), TunnelError> {
self.mesh.add_peer(peer)
}
fn status(&self) -> TunnelStatus {
let mesh = self.mesh.status();
let exit = match (&self.exit, &self.planned) {
(Some(h), Some((relay, (a, p), _))) => format!("exit {EXIT_IFACE} via {relay} as {a}/{p}, {}; the default route is the caller's", if h.handshaken() { "handshaken" } else { "no handshake yet" }),
(None, Some((relay, _, _))) => format!("exit registered via {relay}, not raised"),
_ => format!("exit not up ({})", if self.account.is_some() { "account fed" } else { "no account fed" }),
};
TunnelStatus { kind: TunnelKind::Mullvad, up: mesh.up && self.exit.is_some(), reachable: mesh.reachable, detail: format!("{} · {exit}", mesh.detail) }
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
use std::rc::Rc;
fn key() -> String {
encode_key(&generate_keypair().1)
}
fn list(relays: &[(&str, &str, bool, u64)]) -> String {
let rs: Vec<serde_json::Value> = relays
.iter()
.enumerate()
.map(|(i, (host, loc, active, weight))| serde_json::json!({ "hostname": host, "location": loc, "active": active, "ipv4_addr_in": format!("185.195.233.{}", 70 + i), "public_key": key(), "weight": weight }))
.collect();
serde_json::json!({ "locations": { "se-sto": { "country": "Sweden", "city": "Stockholm" }, "se-got": { "country": "Sweden", "city": "Gothenburg" }, "de-fra": { "country": "Germany", "city": "Frankfurt" } }, "wireguard": { "ipv4_gateway": "10.64.0.1", "ipv6_gateway": "fc00:bbbb:bbbb:bb01::1", "relays": rs } }).to_string()
}
struct Fake {
relays: String,
in_use: bool,
registered: Rc<Cell<u32>>,
echo_other_key: bool,
kept: Option<String>,
}
impl MullvadApi for Fake {
fn relay_list(&self) -> Result<String, ApiError> {
Ok(self.relays.clone())
}
fn access_token(&self, account: &str) -> Result<String, ApiError> {
if account == "1234123412341234" {
Ok(r#"{"access_token":"mva_T","expiry":"2026-09-25T00:00:00Z"}"#.into())
} else {
Err(ApiError { status: Some(400), body: r#"{"code":"INVALID_ACCOUNT"}"#.into() })
}
}
fn register(&self, token: &str, req: &RegisterDevice) -> Result<String, ApiError> {
assert_eq!(token, "mva_T");
self.registered.set(self.registered.get() + 1);
if self.in_use {
return Err(ApiError { status: Some(400), body: format!(r#"{{"code":"{PUBKEY_IN_USE}"}}"#) });
}
let pubkey = if self.echo_other_key { key() } else { req.pubkey.clone() };
Ok(serde_json::json!({ "id": "d1", "name": "cuddly-lemming", "pubkey": pubkey, "ipv4_address": "10.67.114.172/32" }).to_string())
}
fn devices(&self, _token: &str) -> Result<String, ApiError> {
let kept = self.kept.clone().unwrap_or_else(key);
Ok(serde_json::json!([{ "id": "d0", "name": "other", "pubkey": key(), "ipv4_address": "10.64.0.9/32" }, { "id": "d1", "name": "kept-key", "pubkey": kept, "ipv4_address": "10.64.0.10/32" }]).to_string())
}
}
fn fake(in_use: bool) -> (Box<Fake>, Rc<Cell<u32>>) {
let n = Rc::new(Cell::new(0));
(Box::new(Fake { relays: list(&[("se-sto-wg-001", "se-sto", true, 100), ("se-sto-wg-002", "se-sto", true, 300), ("se-got-wg-001", "se-got", true, 900), ("de-fra-wg-001", "de-fra", false, 5000)]), in_use, registered: n.clone(), echo_other_key: false, kept: None }), n)
}
fn driver(location: &str, account: Option<&str>, api: Box<Fake>) -> Mullvad {
Mullvad::with_api(WireGuard::mint(None), None, MullvadSeed { location: location.into(), port: 51820 }, account.map(str::to_string), api)
}
#[test]
fn shape_only_a_box_registers_its_exit_key_once_and_plans_the_heaviest_relay_in_its_location() {
let (api, n) = fake(false);
let mut m = driver("se-sto", Some("1234 1234 1234 1234"), api);
let plan = m.plan_exit().unwrap();
assert_eq!(plan.relay, "se-sto-wg-002", "the heaviest usable relay in se-sto");
assert_eq!(plan.address, ("10.67.114.172".parse().unwrap(), 32));
assert_eq!(plan.device, "cuddly-lemming");
assert_eq!(plan.spec.peers.len(), 1);
assert_eq!(plan.spec.peers[0].allowed_ips, vec![(Ipv4Addr::UNSPECIFIED, 0)], "an exit takes everything");
assert_eq!(plan.spec.peers[0].endpoint.unwrap().port(), 51820);
assert_eq!(&plan.spec.private_key, m.exit_private_key(), "the EXIT key, not the mesh key");
assert_ne!(m.exit_public_key(), m.mesh().public_key(), "two identities: the estate's and Mullvad's");
assert_eq!(Tunnel::public_key(&m), m.mesh().public_key(), "the box joins with its MESH key");
assert!(!format!("{plan:?}").contains(&format!("{:?}", plan.spec.private_key)), "the plan never prints the private key");
m.plan_exit().unwrap();
assert_eq!(n.get(), 1, "planned again (a failed raise): no second device");
}
#[test]
fn shape_only_a_country_code_picks_across_its_cities_and_an_inactive_relay_is_never_chosen() {
let (api, _) = fake(false);
let l = relays::parse(&api.relays).unwrap();
assert_eq!(choose_relay(&l, "se").unwrap().hostname, "se-got-wg-001");
assert_eq!(choose_relay(&l, "").unwrap().hostname, "se-got-wg-001", "the inactive de-fra relay is heavier and skipped");
let e = choose_relay(&l, "de").unwrap_err().to_string();
assert!(e.starts_with("REFUSED [tunnel-mullvad-unconfigured] no usable Mullvad relay in `de`"), "{e}");
assert!(choose_relay(&l, "s").is_err(), "a country code is a whole prefix, not a letter");
}
#[test]
fn shape_only_a_kept_key_already_registered_is_found_among_the_accounts_devices() {
let (mut api, n) = fake(true);
let kept = generate_keypair().0;
api.kept = Some(encode_key(&wg_appliance_core::keys::public_from_private_bytes(&kept)));
let mut m = Mullvad::with_api(WireGuard::mint(None), Some(kept), MullvadSeed { location: "se-sto".into(), port: 51820 }, Some("1234123412341234".into()), api);
let plan = m.plan_exit().unwrap();
assert_eq!((plan.device.as_str(), plan.address), ("kept-key", ("10.64.0.10".parse().unwrap(), 32)));
assert_eq!(n.get(), 1);
let mut m = driver("se-sto", Some("1234123412341234"), fake(true).0);
let e = m.plan_exit().unwrap_err().to_string();
assert!(e.contains("another account holds it"), "{e}");
}
#[test]
fn shape_only_no_account_a_bad_account_and_an_answer_for_another_key_are_refused_by_name() {
let mut m = driver("se-sto", None, fake(false).0);
let e = m.plan_exit().unwrap_err().to_string();
assert!(e.starts_with("REFUSED [tunnel-mullvad-unconfigured]") && e.contains("mullvad_account"), "{e}");
let me = m.mesh().identity(crate::OVERLAY_BOX_A);
assert!(m.up(&me, &[]).is_err());
assert!(!m.status().up && m.status().detail.contains("no account fed"), "{}", m.status().detail);
let mut m = driver("se-sto", Some("0000"), fake(false).0);
let e = m.plan_exit().unwrap_err().to_string();
assert!(e.starts_with("REFUSED [tunnel-failed] mullvad: the account: HTTP 400") && !e.contains("0000"), "{e}");
let (mut api, _) = fake(false);
api.echo_other_key = true;
let mut m = driver("se-sto", Some("1234123412341234"), api);
let e = m.plan_exit().unwrap_err().to_string();
assert!(e.contains("different device"), "{e}");
}
#[test]
fn driver_for_builds_mullvad_from_the_seed_and_the_fed_account() {
let (mut seed, _) = crate::seed::tests::a_seed(crate::seed::Kind::Gunnar);
seed.tunnel = TunnelKind::Mullvad;
seed.mullvad = Some(MullvadSeed { location: "se-sto".into(), port: 51820 });
let d = crate::tunnel::driver_for(&seed, &Default::default()).unwrap();
assert_eq!(d.kind(), TunnelKind::Mullvad);
assert!(!d.public_key().is_empty(), "the mesh key, known before up");
assert!(d.status().detail.contains("no account fed"));
}
#[test]
#[ignore = "network: GET api.mullvad.net/app/v1/relays"]
fn live_api_mullvad_net_relay_list_through_our_client_yields_a_relay_in_sweden() {
let body = HttpApi::default().relay_list().expect("reached api.mullvad.net");
let l = relays::parse(&body).expect("parses");
let r = choose_relay(&l, "se").expect("a usable Swedish relay");
assert!(r.hostname.starts_with("se-"), "{}", r.hostname);
tunnr_mullvad::peers::tunnel_for_relay(generate_keypair().0, r, 51820, None).expect("a spec for the data plane");
}
}