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());
}
}