vopono_core 0.1.17

Library code for running VPN connections in network namespaces
Documentation
mod wireguard;

use super::{Provider, WireguardProvider};
use crate::config::vpn::Protocol;
use base64::Engine;
use reqwest::blocking::Client;
use serde::Deserialize;

#[derive(serde::Serialize)]
struct AccessTokenRequest<'a> {
    code: &'a str,
    code_verifier: &'a str,
}

#[derive(Deserialize, Debug)]
struct User {
    devices: Vec<Device>,
}

#[derive(Deserialize, Debug)]
struct Login {
    user: User,
    token: String,
}

#[derive(Deserialize, Debug, Clone)]
struct Device {
    name: String,
    pubkey: String,
    ipv4_address: ipnet::Ipv4Net,
    ipv6_address: ipnet::Ipv6Net,
}

#[allow(dead_code)]
#[derive(Deserialize, Debug)]
struct Error {
    errno: u32,
    error: String,
}

impl std::fmt::Display for Device {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        write!(f, "{}: {}", self.name, self.pubkey,)
    }
}

/// MozillaVPN is a wrapper for Wireguard using OAuth authentication with Mozilla services.
/// Supports Wireguard only.
///
/// Mozilla VPN uses Mullvad's infrastructure for VPN servers.
/// See: https://support.mozilla.org/en-US/kb/what-mozilla-vpn-and-how-does-it-work
///
/// API authentication is based on the MozWire project:
/// https://github.com/NilsIrl/MozWire
///
/// API Endpoints:
/// - Login: POST /api/v2/vpn/login/linux (with code_challenge)
/// - Verify: POST /api/v2/vpn/login/verify
/// - Account: GET /api/v1/vpn/account
/// - Devices: POST/DELETE /api/v1/vpn/device
/// - Server list: https://api.mullvad.net/www/relays/wireguard/ (Mullvad infrastructure)
pub struct MozillaVPN {}

impl Provider for MozillaVPN {
    fn alias(&self) -> String {
        "mozilla".to_string()
    }

    fn alias_2char(&self) -> String {
        "mz".to_string()
    }

    fn default_protocol(&self) -> Protocol {
        Protocol::Wireguard
    }
}

impl MozillaVPN {
    const V2_URL: &'static str = "https://vpn.mozilla.org/api/v2";

    fn base_url(&self) -> &'static str {
        "https://vpn.mozilla.org/api/v1"
    }

    /// Login with OAuth login flow.
    /// Opens a browser for authentication and listens for the callback.
    /// Adapted from MozWire: https://github.com/NilsIrl/MozWire
    fn get_login(&self, client: &Client) -> anyhow::Result<Login> {
        // no token given
        use base64::prelude::BASE64_URL_SAFE_NO_PAD;
        use rand::Rng;
        use sha2::Digest;
        let mut code_verifier_random = [0u8; 32];
        let mut rng = rand::rng();
        rng.fill_bytes(&mut code_verifier_random);
        let mut code_verifier = [0u8; 43];
        BASE64_URL_SAFE_NO_PAD.encode_slice(code_verifier_random, &mut code_verifier)?;
        let mut code_challenge = String::with_capacity(43);
        BASE64_URL_SAFE_NO_PAD
            .encode_string(sha2::Sha256::digest(code_verifier), &mut code_challenge);

        use tiny_http::{Method, Server};

        let server = Server::http("127.0.0.1:0").unwrap();

        let login_url = format!(
            "{}/vpn/login/linux?code_challenge_method=S256&code_challenge={}&port={}",
            Self::V2_URL,
            code_challenge,
            server
                .server_addr()
                .to_ip()
                .expect("Failed to get SocketAddr")
                .port()
        );

        eprint!("Please visit {login_url}");

        match webbrowser::open(&login_url) {
            Ok(_) => eprint!(" Link opened in browser."),
            Err(_) => eprint!(" Failed to open link in browser, please visit it manually."),
        }
        eprintln!();

        let code;
        let code_url_regex = regex::Regex::new(r"\A/\?code=([0-9a-f]{80})\z").unwrap();
        for request in server.incoming_requests() {
            if let Some(caps) = code_url_regex.captures(request.url())
                && *request.method() == Method::Get
            {
                code = caps.get(1).unwrap();
                let response = client
                    .post(format!("{}/vpn/login/verify", Self::V2_URL))
                    .header("User-Agent", "Why do you need a user agent???")
                    .json(&AccessTokenRequest {
                        code: code.as_str(),
                        code_verifier: std::str::from_utf8(&code_verifier).unwrap(),
                    })
                    .send()
                    .unwrap();
                return Ok(response.json::<Login>().unwrap());
            }
        }
        unreachable!("Server closed without receiving code")
    }
}

fn validate_hostname(hostname: &str) -> bool {
    !hostname.is_empty()
        && hostname
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || c == '-')
}

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

    #[test]
    fn test_validate_hostname_valid() {
        assert!(validate_hostname("my-device"));
        assert!(validate_hostname("device123"));
        assert!(validate_hostname("MyDevice"));
        assert!(validate_hostname("a"));
        assert!(validate_hostname("device-name-123"));
    }

    #[test]
    fn test_validate_hostname_invalid() {
        assert!(!validate_hostname("my_device")); // underscore not allowed
        assert!(!validate_hostname("my device")); // space not allowed
        assert!(!validate_hostname("device@home")); // @ not allowed
        assert!(!validate_hostname("device.local")); // dot not allowed
        assert!(!validate_hostname("déjà")); // non-ASCII not allowed
    }

    #[test]
    fn test_validate_hostname_empty() {
        assert!(!validate_hostname(""));
    }

    #[test]
    fn test_provider_alias() {
        let mozilla = MozillaVPN {};
        assert_eq!(mozilla.alias(), "mozilla");
        assert_eq!(mozilla.alias_2char(), "mz");
    }

    #[test]
    fn test_default_protocol() {
        let mozilla = MozillaVPN {};
        assert_eq!(mozilla.default_protocol(), Protocol::Wireguard);
    }
}