vopono_core 1.0.2

Library code for running VPN connections in network namespaces
Documentation
use super::{NordVPN, ServiceCredentials};
use crate::config::providers::{UiClient, WireguardProvider};
use crate::network::wireguard_config::{
    WireguardConfig, WireguardEndpoint, WireguardInterface, WireguardPeer,
};
use anyhow::{Context, anyhow};
use ipnet::IpNet;
use log::info;
use reqwest::blocking::Client;
use serde::Deserialize;
use std::collections::HashSet;
use std::fs::create_dir_all;
use std::io::Write;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::str::FromStr;

const WIREGUARD_PORT: u16 = 51820;
const SERVER_PAGE_SIZE: usize = 1000;

#[derive(Deserialize)]
struct Server {
    id: u64,
    hostname: String,
    station: IpAddr,
    status: String,
    locations: Vec<Location>,
    technologies: Vec<Technology>,
}

#[derive(Deserialize)]
struct Location {
    country: Country,
}

#[derive(Deserialize)]
struct Country {
    code: String,
}

#[derive(Deserialize)]
struct Technology {
    identifier: String,
    metadata: Vec<Metadata>,
}

#[derive(Deserialize)]
struct Metadata {
    name: String,
    value: String,
}

fn fetch_servers(client: &Client) -> anyhow::Result<Vec<Server>> {
    let mut servers = Vec::new();
    let mut seen_ids = HashSet::new();
    let mut offset = 0;

    loop {
        let page: Vec<Server> = client
            .get("https://api.nordvpn.com/v1/servers")
            .query(&[
                ("limit", SERVER_PAGE_SIZE.to_string()),
                ("offset", offset.to_string()),
                (
                    "filters[servers_technologies][identifier]",
                    "wireguard_udp".to_string(),
                ),
            ])
            .send()
            .context("Failed to request NordVPN WireGuard servers")?
            .error_for_status()
            .context("NordVPN rejected the WireGuard server list request")?
            .json()
            .context("Failed to parse NordVPN WireGuard server list")?;

        let page_len = page.len();
        let new_servers = page
            .into_iter()
            .filter(|server| seen_ids.insert(server.id))
            .collect::<Vec<_>>();
        if page_len > 0 && new_servers.is_empty() {
            return Err(anyhow!(
                "NordVPN server list pagination did not return new servers"
            ));
        }
        servers.extend(new_servers);

        if page_len < SERVER_PAGE_SIZE {
            break;
        }
        offset += page_len;
    }

    Ok(servers)
}

fn public_key(server: &Server) -> Option<&str> {
    server
        .technologies
        .iter()
        .find(|technology| technology.identifier == "wireguard_udp")?
        .metadata
        .iter()
        .find(|metadata| metadata.name == "public_key")
        .map(|metadata| metadata.value.as_str())
}

fn config_for_server(
    server: &Server,
    private_key: &str,
    country_map: &std::collections::HashMap<&str, &str>,
) -> anyhow::Result<Option<(String, String)>> {
    if server.status != "online" {
        return Ok(None);
    }
    let Some(country_code) = server
        .locations
        .first()
        .map(|location| location.country.code.to_ascii_lowercase())
    else {
        return Ok(None);
    };
    let Some(public_key) = public_key(server) else {
        return Ok(None);
    };

    let country = country_map
        .get(country_code.as_str())
        .copied()
        .unwrap_or(country_code.as_str());
    let server_name = server
        .hostname
        .split('.')
        .next()
        .filter(|name| !name.is_empty())
        .unwrap_or(&server.hostname);

    let interface = WireguardInterface {
        private_key: private_key.to_string(),
        address: vec![IpNet::from_str("10.5.0.2/32")?],
        dns: Some(vec![
            IpAddr::V4(Ipv4Addr::new(103, 86, 96, 100)),
            IpAddr::V4(Ipv4Addr::new(103, 86, 99, 100)),
        ]),
        mtu: Some("1420".to_string()),
    };
    let peer = WireguardPeer {
        public_key: public_key.to_string(),
        allowed_ips: vec![IpNet::from_str("0.0.0.0/0")?],
        endpoint: WireguardEndpoint::IpWithPort(SocketAddr::new(server.station, WIREGUARD_PORT)),
        keepalive: Some("25".to_string()),
    };
    let config = WireguardConfig { interface, peer }.to_string();
    let filename = format!("{country}-{country_code}-{server_name}.conf");

    Ok(Some((filename, config)))
}

impl WireguardProvider for NordVPN {
    fn create_wireguard_config(&self, uiclient: &dyn UiClient) -> anyhow::Result<()> {
        let credentials: ServiceCredentials = self.service_credentials(uiclient)?;
        let private_key = credentials
            .nordlynx_private_key
            .as_deref()
            .context("NordVPN did not return a NordLynx private key")?;
        crate::util::wireguard::generate_public_key(private_key)
            .context("NordVPN returned an invalid NordLynx private key")?;

        let servers = fetch_servers(&Client::new())?;
        let country_map = crate::util::country_map::code_to_country_map();
        let configs = servers
            .iter()
            .filter_map(|server| config_for_server(server, private_key, &country_map).transpose())
            .collect::<anyhow::Result<Vec<_>>>()?;
        if configs.is_empty() {
            return Err(anyhow!(
                "NordVPN returned no usable WireGuard server configurations"
            ));
        }

        let wireguard_dir = self.wireguard_dir()?;
        create_dir_all(&wireguard_dir)?;
        crate::util::delete_all_files_in_dir(&wireguard_dir)?;
        for (filename, config) in &configs {
            let path = wireguard_dir.join(filename.to_ascii_lowercase());
            let mut file = crate::util::create_private_file(&path)?;
            file.write_all(config.as_bytes())?;
        }

        info!(
            "NordVPN WireGuard configs written to {} ({} servers)",
            wireguard_dir.display(),
            configs.len()
        );
        Ok(())
    }
}