hyper-connector-mullvad 0.0.1

A Hyper connector pooling tunnels from local Mullvad WireGuard configurations
Documentation
use std::{
    env,
    net::SocketAddr,
    path::{Path, PathBuf},
};

use base64::{Engine, engine::general_purpose::STANDARD};
use serde::Deserialize;
use wireguard_hyper_connector::{WgConfigFile, WireGuardConfig};

use crate::{Error, LocationFilter};

const SYSTEM_DEVICE_JSON: &str = "/etc/mullvad-vpn/device.json";
const RELAY_CACHE: &str = "/var/cache/mullvad-vpn/relays.json";

#[derive(Clone)]
pub(crate) struct Candidate {
    pub id: String,
    pub country: String,
    pub config: WireGuardConfig,
}

#[derive(Deserialize)]
struct DeviceState {
    logged_in: LoggedIn,
}

#[derive(Deserialize)]
struct LoggedIn {
    device: Device,
}

#[derive(Deserialize)]
struct Device {
    wg_data: WgData,
}

#[derive(Deserialize)]
struct WgData {
    private_key: String,
    addresses: Addresses,
}

#[derive(Deserialize)]
struct Addresses {
    ipv4_address: String,
}

#[derive(Deserialize)]
struct RelayList {
    wireguard: WireguardRelays,
}

#[derive(Deserialize)]
struct WireguardRelays {
    port_ranges: Vec<[u16; 2]>,
    relays: Vec<Relay>,
}

#[derive(Deserialize)]
struct Relay {
    hostname: String,
    active: bool,
    location: String,
    ipv4_addr_in: String,
    public_key: String,
}

pub(crate) async fn discover(
    config_dir: &Path,
    device_json: Option<&Path>,
    filter: Option<&LocationFilter>,
) -> Result<(Vec<Candidate>, usize), Error> {
    let (configs, invalid) = discover_configs(config_dir, filter).await;
    if !configs.is_empty() {
        return Ok((configs, invalid));
    }
    let (device_path, bytes) = read_device_json(device_json).await?;
    let device: DeviceState =
        serde_json::from_slice(&bytes).map_err(|source| Error::DeviceJsonParse {
            path: device_path,
            source,
        })?;
    let private_key = decode_key(
        &device.logged_in.device.wg_data.private_key,
        "device private key",
    )?;
    let tunnel_ip = device
        .logged_in
        .device
        .wg_data
        .addresses
        .ipv4_address
        .split('/')
        .next()
        .unwrap_or_default()
        .parse()
        .map_err(|_| Error::InvalidDeviceJson("invalid IPv4 tunnel address".into()))?;

    let relay_path = PathBuf::from(RELAY_CACHE);
    let relay_bytes = tokio::fs::read(&relay_path)
        .await
        .map_err(|source| Error::RelayCache {
            path: relay_path.clone(),
            source,
        })?;
    let relays: RelayList =
        serde_json::from_slice(&relay_bytes).map_err(|source| Error::RelayCacheParse {
            path: relay_path,
            source,
        })?;
    let port = preferred_port(&relays.wireguard.port_ranges)
        .ok_or_else(|| Error::InvalidRelayCache("no WireGuard port ranges".into()))?;

    let mut candidates = Vec::new();
    for relay in relays.wireguard.relays {
        let country = relay
            .location
            .split('-')
            .next()
            .unwrap_or_default()
            .to_ascii_lowercase();
        if !relay.active || filter.is_some_and(|value| !value.matches(&country)) {
            continue;
        }
        let ip = relay.ipv4_addr_in.parse().map_err(|_| {
            Error::InvalidRelayCache(format!("invalid endpoint for {}", relay.hostname))
        })?;
        candidates.push(Candidate {
            id: relay.hostname,
            country,
            config: WireGuardConfig {
                private_key,
                peer_public_key: decode_key(&relay.public_key, "relay public key")?,
                peer_endpoint: SocketAddr::new(ip, port),
                tunnel_ip,
                preshared_key: None,
                keepalive_seconds: Some(25),
                mtu: None,
            },
        });
    }
    if candidates.is_empty() {
        return Err(Error::NoConfigsMatchingFilter);
    }
    Ok((candidates, invalid))
}

async fn discover_configs(dir: &Path, filter: Option<&LocationFilter>) -> (Vec<Candidate>, usize) {
    let Ok(mut entries) = tokio::fs::read_dir(dir).await else {
        return (Vec::new(), 0);
    };
    let mut candidates = Vec::new();
    let mut invalid = 0;
    while let Ok(Some(entry)) = entries.next_entry().await {
        let path = entry.path();
        let Some((id, country)) = relay_from_config_path(&path) else {
            continue;
        };
        if filter.is_some_and(|value| !value.matches(&country)) {
            continue;
        }
        let config = match WgConfigFile::from_file(&path) {
            Ok(config) => match config.into_wireguard_config().await {
                Ok(config) => config,
                Err(_) => {
                    invalid += 1;
                    continue;
                }
            },
            Err(_) => {
                invalid += 1;
                continue;
            }
        };
        candidates.push(Candidate {
            id,
            country,
            config,
        });
    }
    (candidates, invalid)
}

fn relay_from_config_path(path: &Path) -> Option<(String, String)> {
    let name = path.file_name()?.to_str()?;
    let stem = name.strip_suffix(".conf")?;
    let relay = stem
        .strip_prefix("mullvad-")
        .unwrap_or(stem)
        .to_ascii_lowercase();
    let parts: Vec<_> = relay.split('-').collect();
    if parts.len() != 4
        || parts[0].len() != 2
        || parts[1].len() != 3
        || parts[2] != "wg"
        || !parts[3].bytes().all(|byte| byte.is_ascii_digit())
    {
        return None;
    }
    let country = parts[0].to_owned();
    Some((relay, country))
}

async fn read_device_json(explicit: Option<&Path>) -> Result<(PathBuf, Vec<u8>), Error> {
    if let Some(path) = explicit {
        return tokio::fs::read(path)
            .await
            .map(|bytes| (path.to_owned(), bytes))
            .map_err(|source| Error::DeviceJsonRead {
                path: path.to_owned(),
                source,
            });
    }
    let system = PathBuf::from(SYSTEM_DEVICE_JSON);
    if let Ok(bytes) = tokio::fs::read(&system).await {
        return Ok((system, bytes));
    }
    let user = env::var_os("HOME")
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from("~"))
        .join(".config/Mullvad VPN/device.json");
    if let Ok(bytes) = tokio::fs::read(&user).await {
        return Ok((user, bytes));
    }
    Err(Error::DeviceJsonNotFound { system, user })
}

fn decode_key(value: &str, label: &str) -> Result<[u8; 32], Error> {
    let bytes = STANDARD
        .decode(value)
        .map_err(|_| Error::InvalidDeviceJson(format!("invalid base64 {label}")))?;
    bytes
        .try_into()
        .map_err(|_| Error::InvalidDeviceJson(format!("{label} is not 32 bytes")))
}

fn preferred_port(ranges: &[[u16; 2]]) -> Option<u16> {
    ranges
        .iter()
        .find(|[start, end]| (*start..=*end).contains(&51820))
        .map(|_| 51820)
        .or_else(|| ranges.first().map(|[start, _]| *start))
}

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

    #[test]
    fn chooses_standard_wireguard_port() {
        assert_eq!(preferred_port(&[[53, 53], [40_000, 60_000]]), Some(51_820));
    }

    #[test]
    fn recognizes_downloaded_config_names() {
        assert_eq!(
            relay_from_config_path(Path::new("mullvad-se-sto-wg-201.conf")),
            Some(("se-sto-wg-201".into(), "se".into()))
        );
        assert!(relay_from_config_path(Path::new("unrelated.conf")).is_none());
    }
}