diff --git a/nmrs/src/api/builders/openvpn_builder.rs b/nmrs/src/api/builders/openvpn_builder.rs index dd275832..0077c4e0 100644 --- a/nmrs/src/api/builders/openvpn_builder.rs +++ b/nmrs/src/api/builders/openvpn_builder.rs @@ -9,11 +9,15 @@ //! struct. Use [`super::vpn::build_openvpn_connection`] to convert it into //! NetworkManager connection settings. +use std::path::Path; + use uuid::Uuid; use crate::api::models::{ ConnectionError, OpenVpnAuthType, OpenVpnCompression, OpenVpnConfig, OpenVpnProxy, }; +use crate::core::ovpn_parser::parser::{self, CertSource, OvpnFile}; +use crate::util::cert_store::store_inline_cert; use crate::util::validation::validate_connection_name; /// Builder for OpenVPN connections. @@ -41,6 +45,7 @@ use crate::util::validation::validate_connection_name; /// .build() /// .expect("Failed to build OpenVPN config"); /// ``` +#[derive(Debug)] pub struct OpenVpnBuilder { name: String, remote: Option, @@ -108,6 +113,150 @@ impl OpenVpnBuilder { } } + /// Creates a builder pre-populated from a `.ovpn` file on disk. + /// + /// Reads the file, parses it, extracts inline certificates (persisting them + /// via the cert store), and pre-populates the builder. The connection name + /// defaults to the file stem (e.g. `"corp"` for `corp.ovpn`). + /// + /// The caller can override any field before calling [`build()`](Self::build). + /// + /// # Errors + /// + /// - `ConnectionError::VpnFailed` if the file cannot be read + /// - `ConnectionError::ParseError` if the `.ovpn` content is malformed + /// - `ConnectionError::InvalidGateway` if no `remote` directive is found + pub fn from_ovpn_file(path: impl AsRef) -> Result { + let path = path.as_ref(); + let content = std::fs::read_to_string(path).map_err(|e| { + ConnectionError::VpnFailed(format!("failed to read {}: {e}", path.display())) + })?; + let name = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("openvpn") + .to_string(); + Self::from_ovpn_str(&content, name) + } + + /// Creates a builder pre-populated from `.ovpn` file content. + /// + /// Parses the content, extracts inline certificates (persisting them via + /// the cert store under `name`), and pre-populates the builder. + /// + /// The caller can override any field before calling [`build()`](Self::build). + /// + /// # Errors + /// + /// - `ConnectionError::ParseError` if the content is malformed + /// - `ConnectionError::InvalidGateway` if no `remote` directive is found + /// - `ConnectionError::VpnFailed` if inline cert storage fails + pub fn from_ovpn_str(content: &str, name: impl Into) -> Result { + let name = name.into(); + let ovpn = parser::parse_ovpn(content)?; + Self::from_parsed(ovpn, name) + } + + /// Populates a builder from a parsed `OvpnFile`, resolving inline certs. + fn from_parsed(f: OvpnFile, name: String) -> Result { + use crate::core::ovpn_parser::parser::{AllowCompress, Compress}; + + let first_remote = f + .remotes + .into_iter() + .next() + .ok_or_else(|| ConnectionError::InvalidGateway("no remote in .ovpn file".into()))?; + + let tcp = first_remote + .proto + .as_deref() + .map(|p: &str| p.starts_with("tcp")) + .unwrap_or_else(|| { + f.proto + .as_deref() + .map(|p: &str| p.starts_with("tcp")) + .unwrap_or(false) + }); + + let compression = match (f.compress, f.allow_compress) { + (Some(Compress::Algorithm(ref s)), _) => Some(match s.as_str() { + "lz4" => OpenVpnCompression::Lz4, + "lz4-v2" => OpenVpnCompression::Lz4V2, + _ => OpenVpnCompression::Yes, + }), + (Some(Compress::Stub | Compress::StubV2), _) => Some(OpenVpnCompression::No), + (None, Some(AllowCompress::No)) => Some(OpenVpnCompression::No), + _ => None, + }; + + let resolve_cert = + |src: CertSource, cert_type: &str, conn: &str| -> Result { + match src { + CertSource::File(p) => Ok(p), + CertSource::Inline(pem) => { + let path = store_inline_cert(conn, cert_type, &pem)?; + Ok(path.to_string_lossy().into_owned()) + } + } + }; + + let ca_cert = f.ca.map(|s| resolve_cert(s, "ca", &name)).transpose()?; + let client_cert = f.cert.map(|s| resolve_cert(s, "cert", &name)).transpose()?; + let client_key = f.key.map(|s| resolve_cert(s, "key", &name)).transpose()?; + + let has_client_cert_pair = client_cert.is_some() && client_key.is_some(); + let auth_type = match (f.auth_user_pass, has_client_cert_pair) { + (true, true) => Some(OpenVpnAuthType::PasswordTls), + (true, false) => Some(OpenVpnAuthType::Password), + (false, true) => Some(OpenVpnAuthType::Tls), + (false, false) => None, + }; + + let (tls_auth_key, tls_auth_direction) = match f.tls_auth { + Some(ta) => { + let path = resolve_cert(ta.source, "ta", &name)?; + (Some(path), ta.key_direction) + } + None => (None, None), + }; + + let tls_crypt = f + .tls_crypt + .map(|s| resolve_cert(s, "tls-crypt", &name)) + .transpose()?; + + Ok(Self { + name, + remote: Some(first_remote.host), + port: first_remote.port, + tcp, + auth_type, + auth: f.auth, + cipher: f.cipher, + dns: None, + mtu: None, + uuid: None, + ca_cert, + client_cert, + client_key, + key_password: None, + username: None, + password: None, + compression, + proxy: None, + tls_auth_key, + tls_auth_direction, + tls_crypt, + tls_crypt_v2: None, + tls_version_min: None, + tls_version_max: None, + tls_cipher: None, + remote_cert_tls: None, + verify_x509_name: None, + crl_verify: None, + }) + } + /// Sets the remote server hostname or IP address. #[must_use] pub fn remote(mut self, remote: impl Into) -> Self { @@ -629,4 +778,200 @@ mod tests { .build(); assert!(matches!(result.unwrap_err(), ConnectionError::VpnFailed(_))); } + + // --- from_ovpn_str tests --- + + use std::sync::Mutex; + + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + fn with_fake_xdg(f: impl FnOnce() -> R) -> R { + let _g = ENV_LOCK.lock().unwrap(); + let base = std::env::temp_dir().join(format!("nmrs-ovpn-test-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&base).unwrap(); + unsafe { std::env::set_var("XDG_DATA_HOME", &base) }; + let out = f(); + unsafe { std::env::remove_var("XDG_DATA_HOME") }; + let _ = std::fs::remove_dir_all(&base); + out + } + + #[test] + fn from_ovpn_str_basic_tls_file_certs() { + let ovpn = "\ +remote vpn.example.com 1194 udp +ca /etc/openvpn/ca.crt +cert /etc/openvpn/client.crt +key /etc/openvpn/client.key +"; + let builder = OpenVpnBuilder::from_ovpn_str(ovpn, "test-tls").unwrap(); + let config = builder.build().unwrap(); + assert_eq!(config.remote, "vpn.example.com"); + assert_eq!(config.port, 1194); + assert!(!config.tcp); + assert_eq!(config.auth_type, Some(OpenVpnAuthType::Tls)); + assert_eq!(config.ca_cert, Some("/etc/openvpn/ca.crt".into())); + assert_eq!(config.client_cert, Some("/etc/openvpn/client.crt".into())); + assert_eq!(config.client_key, Some("/etc/openvpn/client.key".into())); + } + + #[test] + fn from_ovpn_str_password_auth() { + let ovpn = "remote vpn.example.com 443 tcp\nauth-user-pass\n"; + let builder = OpenVpnBuilder::from_ovpn_str(ovpn, "test-pw") + .unwrap() + .username("user"); + let config = builder.build().unwrap(); + assert_eq!(config.auth_type, Some(OpenVpnAuthType::Password)); + assert!(config.tcp); + assert_eq!(config.port, 443); + } + + #[test] + fn from_ovpn_str_inline_certs_stored() { + with_fake_xdg(|| { + let ovpn = "\ +remote vpn.example.com 1194 + +-----BEGIN CERTIFICATE----- +FAKECA +-----END CERTIFICATE----- + + +-----BEGIN CERTIFICATE----- +FAKECERT +-----END CERTIFICATE----- + + +-----BEGIN PRIVATE KEY----- +FAKEKEY +-----END PRIVATE KEY----- + +"; + let builder = OpenVpnBuilder::from_ovpn_str(ovpn, "inline-test").unwrap(); + let config = builder.build().unwrap(); + assert_eq!(config.auth_type, Some(OpenVpnAuthType::Tls)); + + let ca = config.ca_cert.unwrap(); + assert!( + std::path::Path::new(&ca).exists(), + "CA cert should be written to disk: {ca}" + ); + assert!(config.client_cert.is_some()); + assert!(config.client_key.is_some()); + }); + } + + #[test] + fn from_ovpn_str_tls_auth_with_direction() { + let ovpn = "\ +remote vpn.example.com 1194 +ca /etc/openvpn/ca.crt +cert /etc/openvpn/client.crt +key /etc/openvpn/client.key +tls-auth /etc/openvpn/ta.key 1 +"; + let builder = OpenVpnBuilder::from_ovpn_str(ovpn, "test-ta").unwrap(); + let config = builder.build().unwrap(); + assert_eq!(config.tls_auth_key, Some("/etc/openvpn/ta.key".into())); + assert_eq!(config.tls_auth_direction, Some(1)); + } + + #[test] + fn from_ovpn_str_inline_tls_auth() { + with_fake_xdg(|| { + let ovpn = "\ +remote vpn.example.com 1194 +ca /etc/openvpn/ca.crt +cert /etc/openvpn/client.crt +key /etc/openvpn/client.key +key-direction 0 + +-----BEGIN OpenVPN Static key V1----- +FAKEKEY +-----END OpenVPN Static key V1----- + +"; + let builder = OpenVpnBuilder::from_ovpn_str(ovpn, "inline-ta").unwrap(); + let config = builder.build().unwrap(); + assert!(config.tls_auth_key.is_some()); + assert_eq!(config.tls_auth_direction, Some(0)); + }); + } + + #[test] + fn from_ovpn_str_compression_lz4() { + let ovpn = "\ +remote vpn.example.com 1194 +ca /etc/openvpn/ca.crt +cert /etc/openvpn/client.crt +key /etc/openvpn/client.key +compress lz4-v2 +"; + let builder = OpenVpnBuilder::from_ovpn_str(ovpn, "test-comp").unwrap(); + let config = builder.build().unwrap(); + assert_eq!(config.compression, Some(OpenVpnCompression::Lz4V2)); + } + + #[test] + fn from_ovpn_str_cipher_and_auth() { + let ovpn = "\ +remote vpn.example.com 1194 +ca /etc/openvpn/ca.crt +cert /etc/openvpn/client.crt +key /etc/openvpn/client.key +cipher AES-256-GCM +auth SHA256 +"; + let builder = OpenVpnBuilder::from_ovpn_str(ovpn, "test-cipher").unwrap(); + let config = builder.build().unwrap(); + assert_eq!(config.cipher, Some("AES-256-GCM".into())); + assert_eq!(config.auth, Some("SHA256".into())); + } + + #[test] + fn from_ovpn_str_caller_can_override() { + let ovpn = "\ +remote vpn.example.com 1194 +ca /etc/openvpn/ca.crt +cert /etc/openvpn/client.crt +key /etc/openvpn/client.key +"; + let config = OpenVpnBuilder::from_ovpn_str(ovpn, "test-override") + .unwrap() + .port(443) + .tcp(true) + .dns(vec!["1.1.1.1".into()]) + .build() + .unwrap(); + assert_eq!(config.port, 443); + assert!(config.tcp); + assert!(config.dns.is_some()); + } + + #[test] + fn from_ovpn_str_no_remote_fails() { + let ovpn = "cipher AES-256-GCM\n"; + let result = OpenVpnBuilder::from_ovpn_str(ovpn, "test-fail"); + assert!(matches!( + result.unwrap_err(), + ConnectionError::InvalidGateway(_) + )); + } + + #[test] + fn from_ovpn_file_reads_and_parses() { + let dir = std::env::temp_dir().join(format!("nmrs-ovpn-file-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("corp.ovpn"); + std::fs::write(&path, "remote vpn.corp.com 1194\nauth-user-pass\n").unwrap(); + + let builder = OpenVpnBuilder::from_ovpn_file(&path).unwrap(); + assert_eq!(builder.name, "corp"); + let config = builder.username("user").build().unwrap(); + assert_eq!(config.remote, "vpn.corp.com"); + assert_eq!(config.auth_type, Some(OpenVpnAuthType::Password)); + + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/nmrs/src/api/network_manager.rs b/nmrs/src/api/network_manager.rs index 9cab055e..3bd6472a 100644 --- a/nmrs/src/api/network_manager.rs +++ b/nmrs/src/api/network_manager.rs @@ -315,6 +315,55 @@ impl NetworkManager { connect_vpn(&self.conn, config.into(), Some(self.timeout_config)).await } + /// Imports a `.ovpn` file and activates the OpenVPN connection. + /// + /// Parses the file, persists any inline certificates, builds the + /// connection profile, and activates it through NetworkManager. + /// + /// # Arguments + /// + /// * `path` — Path to the `.ovpn` configuration file + /// * `username` — Optional username for password-based authentication + /// * `password` — Optional password for password-based authentication + /// + /// # Example + /// + /// ```no_run + /// use nmrs::NetworkManager; + /// + /// # async fn example() -> nmrs::Result<()> { + /// let nm = NetworkManager::new().await?; + /// nm.import_ovpn("corp.ovpn", Some("user"), Some("secret")).await?; + /// # Ok(()) + /// # } + /// ``` + /// + /// # Errors + /// + /// Returns an error if: + /// - The file cannot be read or parsed + /// - Inline certificate storage fails + /// - The configuration is incomplete (e.g. TLS auth without certs) + /// - The VPN connection fails to activate + pub async fn import_ovpn( + &self, + path: impl AsRef, + username: Option<&str>, + password: Option<&str>, + ) -> Result<()> { + use crate::builders::OpenVpnBuilder; + + let mut builder = OpenVpnBuilder::from_ovpn_file(path)?; + if let Some(u) = username { + builder = builder.username(u); + } + if let Some(p) = password { + builder = builder.password(p); + } + let config = builder.build()?; + self.connect_vpn(config).await + } + /// Disconnects from an active VPN connection by name. /// /// Searches through active connections for a VPN matching the given name. diff --git a/nmrs/src/util/cert_store.rs b/nmrs/src/util/cert_store.rs index 695f4035..4f07bb2a 100644 --- a/nmrs/src/util/cert_store.rs +++ b/nmrs/src/util/cert_store.rs @@ -151,8 +151,9 @@ fn filename_for(cert_type: &str) -> Result<&'static str, ConnectionError> { "cert" => Ok("cert.pem"), "key" => Ok("key.pem"), "ta" => Ok("ta.key"), + "tls-crypt" => Ok("tls-crypt.key"), _ => Err(ConnectionError::InvalidAddress(format!( - "unknown cert_type {cert_type:?} (expected ca, cert, key, ta)" + "unknown cert_type {cert_type:?} (expected ca, cert, key, ta, tls-crypt)" ))), } } diff --git a/nmrs/tests/integration_test.rs b/nmrs/tests/integration_test.rs index 329f1939..7ba99bc0 100644 --- a/nmrs/tests/integration_test.rs +++ b/nmrs/tests/integration_test.rs @@ -1,6 +1,6 @@ use nmrs::{ - ConnectionError, DeviceState, DeviceType, NetworkManager, StateReason, VpnType, WifiSecurity, - WireGuardConfig, WireGuardPeer, reason_to_error, + ConnectionError, DeviceState, DeviceType, NetworkManager, OpenVpnAuthType, StateReason, + VpnType, WifiSecurity, WireGuardConfig, WireGuardPeer, reason_to_error, }; use std::time::Duration; use tokio::time::sleep; @@ -1246,3 +1246,92 @@ fn test_bluetooth_network_role_from_u32() { BluetoothNetworkRole::PanU )); } + +// --- OpenVPN import tests --- + +/// Test that OpenVpnBuilder::from_ovpn_str produces correct settings for a +/// full TLS config, and that build_openvpn_connection serializes them. +#[test] +fn test_ovpn_import_tls_roundtrip() { + use nmrs::ConnectionOptions; + use nmrs::builders::{OpenVpnBuilder, build_openvpn_connection}; + + let ovpn = "\ +remote vpn.example.com 1194 udp +ca /etc/openvpn/ca.crt +cert /etc/openvpn/client.crt +key /etc/openvpn/client.key +cipher AES-256-GCM +auth SHA256 +tls-auth /etc/openvpn/ta.key 1 +"; + let config = OpenVpnBuilder::from_ovpn_str(ovpn, "roundtrip-test") + .unwrap() + .build() + .unwrap(); + + assert_eq!(config.remote, "vpn.example.com"); + assert_eq!(config.port, 1194); + assert_eq!(config.auth_type, Some(OpenVpnAuthType::Tls)); + assert_eq!(config.cipher, Some("AES-256-GCM".into())); + assert_eq!(config.auth, Some("SHA256".into())); + assert_eq!(config.tls_auth_key, Some("/etc/openvpn/ta.key".into())); + assert_eq!(config.tls_auth_direction, Some(1)); + + let opts = ConnectionOptions::new(false); + let settings = build_openvpn_connection(&config, &opts).unwrap(); + assert!(settings.contains_key("connection")); + assert!(settings.contains_key("vpn")); +} + +/// Test that from_ovpn_str infers password+TLS auth when both +/// auth-user-pass and cert/key are present. +#[test] +fn test_ovpn_import_password_tls() { + use nmrs::builders::OpenVpnBuilder; + + let ovpn = "\ +remote vpn.example.com 443 tcp +auth-user-pass +ca /etc/openvpn/ca.crt +cert /etc/openvpn/client.crt +key /etc/openvpn/client.key +"; + let config = OpenVpnBuilder::from_ovpn_str(ovpn, "pw-tls-test") + .unwrap() + .username("user") + .build() + .unwrap(); + + assert_eq!(config.auth_type, Some(OpenVpnAuthType::PasswordTls)); + assert!(config.tcp); + assert_eq!(config.port, 443); +} + +/// Test that the caller can override parsed settings before build. +#[test] +fn test_ovpn_import_override() { + use nmrs::builders::OpenVpnBuilder; + + let ovpn = "\ +remote vpn.example.com 1194 +ca /etc/openvpn/ca.crt +cert /etc/openvpn/client.crt +key /etc/openvpn/client.key +"; + let config = OpenVpnBuilder::from_ovpn_str(ovpn, "override-test") + .unwrap() + .port(443) + .tcp(true) + .dns(vec!["1.1.1.1".into()]) + .mtu(1400) + .remote_cert_tls("server") + .build() + .unwrap(); + + assert_eq!(config.port, 443); + assert!(config.tcp); + assert_eq!(config.dns, Some(vec!["1.1.1.1".into()])); + assert_eq!(config.mtu, Some(1400)); + assert_eq!(config.remote_cert_tls, Some("server".into())); +}