vopono_core 1.0.1

Library code for running VPN connections in network namespaces
Documentation
use super::AirVPN;
use super::{ConfigurationChoice, OpenVpnProvider};
use crate::config::providers::UiClient;
use crate::util::delete_all_files_in_dir;
use anyhow::{Context, anyhow};
use log::debug;
use std::fmt::Display;
use std::fs::File;
use std::fs::create_dir_all;
use std::io::{Cursor, Read, Write};
use std::net::IpAddr;
use std::path::PathBuf;
use strum::IntoEnumIterator;
use strum_macros::{EnumIter, EnumString, FromRepr};
use zip::ZipArchive;

impl OpenVpnProvider for AirVPN {
    fn provider_dns(&self) -> Option<Vec<IpAddr>> {
        None
    }

    fn prompt_for_auth(&self, _uiclient: &dyn UiClient) -> anyhow::Result<(String, String)> {
        // AirVPN embeds the generated connection credentials in each profile;
        // the provider trait still requires this no-op authentication result.
        Ok(("unused".to_string(), "unused".to_string()))
    }

    fn auth_file_path(&self) -> anyhow::Result<Option<PathBuf>> {
        //NOTE: not required for AirVPN auth is inside ovpn file
        Ok(None)
    }

    fn create_openvpn_config(&self, uiclient: &dyn UiClient) -> anyhow::Result<()> {
        let config_choice = uiclient.get_configuration_choice(&ConfigType::default())?;
        let config_type = ConfigType::from_repr(config_choice)
            .ok_or_else(|| anyhow!("Invalid AirVPN OpenVPN configuration selection"))?;
        let client = super::http_client()?;

        let servers = super::fetch_servers(&client)?;
        let server_names = servers
            .iter()
            .map(|server| server.public_name.as_str())
            .collect::<Vec<_>>()
            .join(",");
        if server_names.is_empty() {
            anyhow::bail!("AirVPN returned no servers while generating OpenVPN configs");
        }

        let api_key = super::require_api_key(uiclient, "OpenVPN")?;
        let zipfile = client
            .get("https://airvpn.org/api/generator/")
            .query(&[
                ("protocols", config_type.protocol_selector()),
                ("download", "zip".to_string()),
                ("system", "linux".to_string()),
                // Keep the generated profile IPv4-only to match vopono's
                // default namespace behavior; advanced IP-layer choices are
                // intentionally not frontend selectors.
                ("iplayer_exit", "ipv4".to_string()),
                ("servers", server_names),
            ])
            .header("API-KEY", api_key)
            .send()?
            .error_for_status()
            .context("AirVPN OpenVPN config request failed")?;
        let mut zip = ZipArchive::new(Cursor::new(zipfile.bytes()?))
            .context("AirVPN returned an invalid OpenVPN archive")?;
        let openvpn_dir = self.openvpn_dir()?;
        create_dir_all(&openvpn_dir)?;
        delete_all_files_in_dir(&openvpn_dir)?;
        for i in 0..zip.len() {
            let mut file_contents: Vec<u8> = Vec::with_capacity(4096);
            let mut file = zip.by_index(i)?;
            file.read_to_end(&mut file_contents)?;

            let original_name = file.name().to_string();
            let filename = if let Some("ovpn") = file
                .enclosed_name()
                .as_ref()
                .and_then(|p| p.extension())
                .and_then(|x| x.to_str())
            {
                let filename = super::generator_filename(&original_name, "ovpn");
                debug!("Writing OpenVPN config: {filename}");
                filename
            } else {
                original_name
            };

            let mut outfile =
                File::create(openvpn_dir.join(filename.to_lowercase().replace(' ', "_")))?;
            outfile.write_all(file_contents.as_slice())?;
        }

        Ok(())
    }
}

#[derive(EnumIter, EnumString, FromRepr, PartialEq, Default, Copy, Clone, Debug)]
#[repr(usize)]
enum ConfigType {
    #[default]
    Udp443,
    Tcp443,
    Udp53,
    Udp80,
    Udp1194,
    Udp2018,
    Tcp53,
    Tcp80,
    Tcp1194,
    Tcp2018,
}

impl ConfigType {
    fn transport(&self) -> &'static str {
        match self {
            Self::Udp53 | Self::Udp80 | Self::Udp443 | Self::Udp1194 | Self::Udp2018 => "udp",
            Self::Tcp53 | Self::Tcp80 | Self::Tcp443 | Self::Tcp1194 | Self::Tcp2018 => "tcp",
        }
    }

    fn port(&self) -> u16 {
        match self {
            Self::Udp53 | Self::Tcp53 => 53,
            Self::Udp80 | Self::Tcp80 => 80,
            Self::Udp443 | Self::Tcp443 => 443,
            Self::Udp1194 | Self::Tcp1194 => 1194,
            Self::Udp2018 | Self::Tcp2018 => 2018,
        }
    }

    fn protocol_selector(&self) -> String {
        format!("openvpn_1_{}_{}", self.transport(), self.port())
    }
}

impl Display for ConfigType {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{} {}", self.transport().to_uppercase(), self.port())
    }
}

impl ConfigurationChoice for ConfigType {
    fn prompt(&self) -> String {
        "Please choose the set of OpenVPN configuration files you wish to install".to_string()
    }

    fn all_names(&self) -> Vec<String> {
        Self::iter().map(|x| format!("{x}")).collect()
    }
    fn all_descriptions(&self) -> Option<Vec<String>> {
        Some(Self::iter().filter_map(|x| x.description()).collect())
    }

    fn description(&self) -> Option<String> {
        Some(format!(
            "Protocol: {}, Port: {}, Entry IP: 1",
            self.transport().to_uppercase(),
            self.port()
        ))
    }
}

#[cfg(test)]
mod tests {
    use super::super::generator_filename;
    use super::ConfigType;
    use strum::IntoEnumIterator;

    #[test]
    fn direct_generator_modes_have_stable_selectors() {
        let selectors = ConfigType::iter()
            .map(|config| config.protocol_selector())
            .collect::<Vec<_>>();
        assert_eq!(
            selectors,
            vec![
                "openvpn_1_udp_443",
                "openvpn_1_tcp_443",
                "openvpn_1_udp_53",
                "openvpn_1_udp_80",
                "openvpn_1_udp_1194",
                "openvpn_1_udp_2018",
                "openvpn_1_tcp_53",
                "openvpn_1_tcp_80",
                "openvpn_1_tcp_1194",
                "openvpn_1_tcp_2018",
            ]
        );
    }

    #[test]
    fn generator_names_are_reduced_to_stable_config_ids() {
        assert_eq!(
            generator_filename("AirVPN_CH-Zurich_Achernar_UDP-443.ovpn", "ovpn"),
            "switzerland-ch-Achernar.ovpn"
        );
        assert_eq!(
            generator_filename("ca-Custom.ovpn", "ovpn"),
            "ca-Custom.ovpn"
        );
    }

    #[test]
    fn config_types_support_from_repr_and_from_str() {
        for (index, variant) in ConfigType::iter().enumerate() {
            assert_eq!(ConfigType::from_repr(index), Some(variant));
        }
        assert_eq!("Udp443".parse::<ConfigType>().unwrap(), ConfigType::Udp443);
        assert_eq!(
            "Tcp2018".parse::<ConfigType>().unwrap(),
            ConfigType::Tcp2018
        );
    }
}