From c578b7ac7c217ed17c7ef601632b98ab6285a97e Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Mon, 13 Apr 2026 21:42:23 -0400 Subject: [PATCH 1/2] feat(#301): add VpnDetails enum to VpnConnectionInfo --- nmrs/CHANGELOG.md | 7 ++ nmrs/src/api/models/vpn.rs | 33 +++++ nmrs/src/core/vpn.rs | 247 ++++++++++++++++++++++++++++++++++++- nmrs/src/lib.rs | 4 +- 4 files changed, 288 insertions(+), 3 deletions(-) diff --git a/nmrs/CHANGELOG.md b/nmrs/CHANGELOG.md index da898024..94931754 100644 --- a/nmrs/CHANGELOG.md +++ b/nmrs/CHANGELOG.md @@ -15,10 +15,17 @@ All notable changes to the `nmrs` crate will be documented in this file. - OpenVPN builder: compression, proxy, and `build_openvpn_connection()` ([#315](https://github.com/cachebag/nmrs/pull/315)) - `VpnConfiguration` to dispatch WireGuard vs OpenVPN; `connect_vpn` wired to the OpenVPN builder ([#322](https://github.com/cachebag/nmrs/pull/322)) - Support for specifying Bluetooth adapter in `BluetoothIdentity` ([#267](https://github.com/cachebag/nmrs/pull/267)) +- `OpenVpnBuilder`: fluent validated builder for `OpenVpnConfig` with auth-type-specific field requirements and 17 unit tests ([#326](https://github.com/cachebag/nmrs/pull/326)) +- Parse `auth-user-pass` directive in `.ovpn` files and automatically infer `OpenVpnAuthType` ([#340](https://github.com/cachebag/nmrs/pull/340)) +- OpenVPN input validation via `validate_openvpn_config`; `validate_vpn_credentials` now dispatches on VPN type ([#345](https://github.com/cachebag/nmrs/pull/345)) +- TLS hardening options for `OpenVpnConfig`: `tls-auth`, `tls-crypt`, `tls-crypt-v2`, `remote-cert-tls`, `verify-x509-name`, `crl-verify`, min/max TLS version, and TLS cipher ([#346](https://github.com/cachebag/nmrs/pull/346)) +- `NetworkManager::import_ovpn()` and `OpenVpnBuilder::from_ovpn_file()` / `from_ovpn_str()` for importing `.ovpn` profiles directly into NetworkManager ([#347](https://github.com/cachebag/nmrs/pull/347)) ### Fixed - Line-accurate source locations for `.ovpn` directives and blocks ([#318](https://github.com/cachebag/nmrs/pull/318)) - `key_direction` when nested under `tls_auth` and as a standalone directive ([#320](https://github.com/cachebag/nmrs/pull/320)) +- `vpn.data` and `vpn.secrets` now correctly serialized as `zvariant::Dict` on the D-Bus wire ([#337](https://github.com/cachebag/nmrs/pull/337)) +- `get_vpn_info` now deserializes `vpn.data` as `HashMap` and correctly populates `gateway` for OpenVPN connections ([#344](https://github.com/cachebag/nmrs/pull/344)) ## [2.3.0] - 2026-04-10 ### Added diff --git a/nmrs/src/api/models/vpn.rs b/nmrs/src/api/models/vpn.rs index 204e0daa..ed2dadea 100644 --- a/nmrs/src/api/models/vpn.rs +++ b/nmrs/src/api/models/vpn.rs @@ -127,6 +127,37 @@ pub struct VpnConnection { pub interface: Option, } +/// Protocol-specific details for an active VPN connection. +/// +/// Provides configuration details extracted from the NetworkManager connection +/// profile, varying by VPN type. +#[non_exhaustive] +#[derive(Debug, Clone)] +pub enum VpnDetails { + /// WireGuard-specific connection details. + WireGuard { + /// The local interface's public key. + public_key: Option, + /// The peer endpoint (e.g. "vpn.example.com:51820"). + endpoint: Option, + }, + /// OpenVPN-specific connection details. + OpenVpn { + /// Remote server address (e.g. "vpn.example.com:1194"). + remote: String, + /// Remote server port. + port: u16, + /// Transport protocol ("udp" or "tcp"). + protocol: String, + /// Data channel cipher (e.g. "AES-256-GCM"). + cipher: Option, + /// HMAC digest algorithm (e.g. "SHA256"). + auth: Option, + /// Compression mode if enabled (e.g. "lz4-v2"). + compression: Option, + }, +} + /// Detailed VPN connection information and statistics. /// /// Provides comprehensive information about an active VPN connection, @@ -164,4 +195,6 @@ pub struct VpnConnectionInfo { pub ip6_address: Option, /// DNS servers configured for this VPN. pub dns_servers: Vec, + /// Protocol-specific connection details, if available. + pub details: Option, } diff --git a/nmrs/src/core/vpn.rs b/nmrs/src/core/vpn.rs index 8a21d8df..8e1b0ee4 100644 --- a/nmrs/src/core/vpn.rs +++ b/nmrs/src/core/vpn.rs @@ -19,7 +19,7 @@ use zvariant::OwnedObjectPath; use crate::Result; use crate::api::models::{ ConnectionError, ConnectionOptions, DeviceState, TimeoutConfig, VpnConfig, VpnConnection, - VpnConnectionInfo, VpnCredentials, VpnType, + VpnConnectionInfo, VpnCredentials, VpnDetails, VpnType, }; use crate::builders::{build_openvpn_connection, build_wireguard_connection}; use crate::core::state_wait::wait_for_connection_activation; @@ -826,6 +826,11 @@ pub(crate) async fn get_vpn_info(conn: &Connection, name: &str) -> Result extract_wireguard_details(&settings_map), + VpnType::OpenVpn => extract_openvpn_details(&settings_map), + }; + return Ok(VpnConnectionInfo { name: id.to_string(), vpn_type, @@ -835,6 +840,7 @@ pub(crate) async fn get_vpn_info(conn: &Connection, name: &str) -> Result>>, + key: &str, +) -> Option { + let zvariant::Value::Dict(dict) = settings_map.get("vpn")?.get("data")? else { + return None; + }; + dict.iter().find_map(|(k, v)| match (k, v) { + (zvariant::Value::Str(k_str), zvariant::Value::Str(v_str)) if k_str.as_str() == key => { + Some(v_str.to_string()) + } + _ => None, + }) +} + +fn extract_openvpn_details( + settings_map: &HashMap>>, +) -> Option { + let remote_raw = extract_openvpn_data_value(settings_map, "remote")?; + + let (remote, port) = if let Some(idx) = remote_raw.rfind(':') { + let host = remote_raw[..idx].to_string(); + let port = remote_raw[idx + 1..].parse::().unwrap_or(1194); + (host, port) + } else { + (remote_raw, 1194) + }; + + let protocol = + if extract_openvpn_data_value(settings_map, "proto-tcp").as_deref() == Some("yes") { + "tcp".to_string() + } else { + "udp".to_string() + }; + + let cipher = extract_openvpn_data_value(settings_map, "cipher"); + let auth = extract_openvpn_data_value(settings_map, "auth"); + + let compression = extract_openvpn_data_value(settings_map, "compress") + .or_else(|| extract_openvpn_data_value(settings_map, "comp-lzo").map(|_| "lzo".into())); + + Some(VpnDetails::OpenVpn { + remote, + port, + protocol, + cipher, + auth, + compression, + }) +} + +fn extract_wireguard_details( + settings_map: &HashMap>>, +) -> Option { + let wg_sec = settings_map.get("wireguard")?; + + let public_key = wg_sec.get("public-key").and_then(|v| match v { + zvariant::Value::Str(s) => Some(s.to_string()), + _ => None, + }); + + let endpoint = wg_sec + .get("peers") + .and_then(|v| match v { + zvariant::Value::Str(s) => Some(s.as_str().to_string()), + _ => None, + }) + .and_then(|peers| { + let first = peers.split(',').next()?.trim().to_string(); + for tok in first.split_whitespace() { + if let Some(rest) = tok.strip_prefix("endpoint=") { + return Some(rest.to_string()); + } + } + None + }); + + Some(VpnDetails::WireGuard { + public_key, + endpoint, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -906,4 +996,159 @@ mod tests { let settings = HashMap::from([("vpn".to_string(), vpn_sec)]); assert_eq!(extract_openvpn_gateway(&settings), None); } + + #[test] + fn openvpn_details_full() { + let data = HashMap::from([ + ("remote".to_string(), "vpn.example.com:1194".to_string()), + ("proto-tcp".to_string(), "yes".to_string()), + ("cipher".to_string(), "AES-256-GCM".to_string()), + ("auth".to_string(), "SHA256".to_string()), + ("compress".to_string(), "lz4-v2".to_string()), + ]); + let settings = openvpn_settings_with_data(data); + let details = extract_openvpn_details(&settings).unwrap(); + match details { + VpnDetails::OpenVpn { + remote, + port, + protocol, + cipher, + auth, + compression, + } => { + assert_eq!(remote, "vpn.example.com"); + assert_eq!(port, 1194); + assert_eq!(protocol, "tcp"); + assert_eq!(cipher, Some("AES-256-GCM".into())); + assert_eq!(auth, Some("SHA256".into())); + assert_eq!(compression, Some("lz4-v2".into())); + } + _ => panic!("expected OpenVpn variant"), + } + } + + #[test] + fn openvpn_details_minimal() { + let data = HashMap::from([("remote".to_string(), "vpn.example.com:443".to_string())]); + let settings = openvpn_settings_with_data(data); + let details = extract_openvpn_details(&settings).unwrap(); + match details { + VpnDetails::OpenVpn { + remote, + port, + protocol, + cipher, + auth, + compression, + } => { + assert_eq!(remote, "vpn.example.com"); + assert_eq!(port, 443); + assert_eq!(protocol, "udp"); + assert!(cipher.is_none()); + assert!(auth.is_none()); + assert!(compression.is_none()); + } + _ => panic!("expected OpenVpn variant"), + } + } + + #[test] + fn openvpn_details_none_when_no_remote() { + let data = HashMap::from([("cipher".to_string(), "AES-256-GCM".to_string())]); + let settings = openvpn_settings_with_data(data); + assert!(extract_openvpn_details(&settings).is_none()); + } + + #[test] + fn openvpn_details_remote_without_port() { + let data = HashMap::from([("remote".to_string(), "vpn.example.com".to_string())]); + let settings = openvpn_settings_with_data(data); + let details = extract_openvpn_details(&settings).unwrap(); + match details { + VpnDetails::OpenVpn { remote, port, .. } => { + assert_eq!(remote, "vpn.example.com"); + assert_eq!(port, 1194); + } + _ => panic!("expected OpenVpn variant"), + } + } + + #[test] + fn openvpn_details_comp_lzo_fallback() { + let data = HashMap::from([ + ("remote".to_string(), "vpn.example.com:1194".to_string()), + ("comp-lzo".to_string(), "yes".to_string()), + ]); + let settings = openvpn_settings_with_data(data); + let details = extract_openvpn_details(&settings).unwrap(); + match details { + VpnDetails::OpenVpn { compression, .. } => { + assert_eq!(compression, Some("lzo".into())); + } + _ => panic!("expected OpenVpn variant"), + } + } + + fn wireguard_settings( + pairs: Vec<(&str, zvariant::Value<'static>)>, + ) -> HashMap>> { + let wg_sec: HashMap> = + pairs.into_iter().map(|(k, v)| (k.to_string(), v)).collect(); + HashMap::from([("wireguard".to_string(), wg_sec)]) + } + + #[test] + fn wireguard_details_full() { + let settings = wireguard_settings(vec![ + ( + "public-key", + zvariant::Value::Str("YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=".into()), + ), + ( + "peers", + zvariant::Value::Str("endpoint=vpn.example.com:51820 allowed-ips=0.0.0.0/0".into()), + ), + ]); + let details = extract_wireguard_details(&settings).unwrap(); + match details { + VpnDetails::WireGuard { + public_key, + endpoint, + } => { + assert_eq!( + public_key, + Some("YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=".into()) + ); + assert_eq!(endpoint, Some("vpn.example.com:51820".into())); + } + _ => panic!("expected WireGuard variant"), + } + } + + #[test] + fn wireguard_details_no_public_key() { + let settings = wireguard_settings(vec![( + "peers", + zvariant::Value::Str("endpoint=vpn.example.com:51820".into()), + )]); + let details = extract_wireguard_details(&settings).unwrap(); + match details { + VpnDetails::WireGuard { + public_key, + endpoint, + } => { + assert!(public_key.is_none()); + assert_eq!(endpoint, Some("vpn.example.com:51820".into())); + } + _ => panic!("expected WireGuard variant"), + } + } + + #[test] + fn wireguard_details_none_when_no_section() { + let settings: HashMap>> = + HashMap::from([("connection".to_string(), HashMap::new())]); + assert!(extract_wireguard_details(&settings).is_none()); + } } diff --git a/nmrs/src/lib.rs b/nmrs/src/lib.rs index 273f83ec..21cee3df 100644 --- a/nmrs/src/lib.rs +++ b/nmrs/src/lib.rs @@ -321,8 +321,8 @@ pub use api::models::{ ConnectionError, ConnectionOptions, ConnectionStateReason, Device, DeviceState, DeviceType, EapMethod, EapOptions, Network, NetworkInfo, OpenVpnAuthType, OpenVpnCompression, OpenVpnConfig, OpenVpnProxy, Phase2, StateReason, TimeoutConfig, VpnConfig, VpnConfiguration, - VpnConnection, VpnConnectionInfo, VpnCredentials, VpnType, WifiSecurity, WireGuardConfig, - WireGuardPeer, connection_state_reason_to_error, reason_to_error, + VpnConnection, VpnConnectionInfo, VpnCredentials, VpnDetails, VpnType, WifiSecurity, + WireGuardConfig, WireGuardPeer, connection_state_reason_to_error, reason_to_error, }; pub use api::network_manager::NetworkManager; From c95801e938c2de84b8edd497ef1c142938d9841b Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Mon, 13 Apr 2026 21:43:55 -0400 Subject: [PATCH 2/2] fix: handle ca/cert/key/tls-crypt file-path directives in ovpn parser --- nmrs/src/core/ovpn_parser/parser.rs | 40 +++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/nmrs/src/core/ovpn_parser/parser.rs b/nmrs/src/core/ovpn_parser/parser.rs index d0fdea92..2ec1d40f 100644 --- a/nmrs/src/core/ovpn_parser/parser.rs +++ b/nmrs/src/core/ovpn_parser/parser.rs @@ -405,6 +405,46 @@ pub fn parse_ovpn(content: &str) -> Result { b.proto = Some(value.clone()); } + "ca" => { + let path = args + .first() + .ok_or(OvpnParseError::MissingArgument { + key: key.clone(), + line, + })? + .clone(); + b.ca = Some(CertSource::File(path)); + } + "cert" => { + let path = args + .first() + .ok_or(OvpnParseError::MissingArgument { + key: key.clone(), + line, + })? + .clone(); + b.cert = Some(CertSource::File(path)); + } + "key" => { + let path = args + .first() + .ok_or(OvpnParseError::MissingArgument { + key: key.clone(), + line, + })? + .clone(); + b.key = Some(CertSource::File(path)); + } + "tls-crypt" => { + let path = args + .first() + .ok_or(OvpnParseError::MissingArgument { + key: key.clone(), + line, + })? + .clone(); + b.tls_crypt = Some(CertSource::File(path)); + } "tls-auth" => { // tls-auth [DIRECTION]