From e844f629a898b998900792237196c3cd09e87e8c Mon Sep 17 00:00:00 2001 From: stoutes <31317041+stoutes@users.noreply.github.com> Date: Tue, 31 Mar 2026 22:06:31 -0500 Subject: [PATCH 1/5] feature(vpn): add OpenVPN compression and proxy support with build_openvpn_connection --- nmrs/src/api/builders/vpn.rs | 321 ++++++++++++++++++++++++++++++++++- nmrs/src/api/models/vpn.rs | 190 +++++++++++++++++++++ 2 files changed, 504 insertions(+), 7 deletions(-) diff --git a/nmrs/src/api/builders/vpn.rs b/nmrs/src/api/builders/vpn.rs index 8cda4508..01efd2aa 100644 --- a/nmrs/src/api/builders/vpn.rs +++ b/nmrs/src/api/builders/vpn.rs @@ -67,7 +67,10 @@ use std::collections::HashMap; use zvariant::Value; use super::wireguard_builder::WireGuardBuilder; -use crate::api::models::{ConnectionError, ConnectionOptions, VpnCredentials}; +use crate::api::models::{ + ConnectionError, ConnectionOptions, OpenVpnCompression, OpenVpnConfig, OpenVpnProxy, + VpnCredentials, +}; /// Builds WireGuard VPN connection settings. /// @@ -108,10 +111,132 @@ pub fn build_wireguard_connection( builder.build() } +/// Builds OpenVPN connection settings for NetworkManager. +/// +/// Returns a settings dictionary suitable for `AddAndActivateConnection`. +/// OpenVPN uses the NM VPN plugin model: `connection.type = "vpn"` with +/// `vpn.service-type = "org.freedesktop.NetworkManager.openvpn"`. +/// All config lives in the flat `vpn.data` dict. +/// +/// # Errors +/// +/// - `ConnectionError::InvalidGateway` if `remote` is empty +/// - `ConnectionError::InvalidAddress` if a proxy port is zero +pub fn build_openvpn_connection( + config: &OpenVpnConfig, + opts: &ConnectionOptions, +) -> Result>>, ConnectionError> { + if config.remote.is_empty() { + return Err(ConnectionError::InvalidGateway( + "OpenVPN remote must not be empty".into(), + )); + } + + let uuid = config.uuid.unwrap_or_else(uuid::Uuid::new_v4).to_string(); + + let mut connection: HashMap<&'static str, Value<'static>> = HashMap::new(); + connection.insert("type", Value::from("vpn")); + connection.insert("id", Value::from(config.name.clone())); + connection.insert("uuid", Value::from(uuid)); + connection.insert("autoconnect", Value::from(opts.autoconnect)); + if let Some(p) = opts.autoconnect_priority { + connection.insert("autoconnect-priority", Value::from(p)); + } + + let mut vpn_data: Vec<(String, String)> = Vec::new(); + + let remote = match config.port { + Some(port) => format!("{}:{}", config.remote, port), + None => config.remote.clone(), + }; + vpn_data.push(("remote".into(), remote)); + vpn_data.push(("ca".into(), config.ca.clone())); + vpn_data.push(("cert".into(), config.cert.clone())); + vpn_data.push(("key".into(), config.key.clone())); + vpn_data.push(("connection-type".into(), "tls".into())); + + if let Some(ref compression) = config.compression { + #[allow(deprecated)] + match compression { + OpenVpnCompression::No => { + vpn_data.push(("compress".into(), "no".into())); + } + OpenVpnCompression::Lzo => { + vpn_data.push(("comp-lzo".into(), "yes".into())); + } + OpenVpnCompression::Lz4 => { + vpn_data.push(("compress".into(), "lz4".into())); + } + OpenVpnCompression::Lz4V2 => { + vpn_data.push(("compress".into(), "lz4-v2".into())); + } + OpenVpnCompression::Yes => { + vpn_data.push(("compress".into(), "yes".into())); + } + } + } + + if let Some(ref proxy) = config.proxy { + match proxy { + OpenVpnProxy::Http { server, port, username, password, retry } => { + if *port == 0 { + return Err(ConnectionError::InvalidAddress( + "proxy port must not be zero".into(), + )); + } + vpn_data.push(("proxy-type".into(), "http".into())); + vpn_data.push(("proxy-server".into(), server.clone())); + vpn_data.push(("proxy-port".into(), port.to_string())); + vpn_data.push(("proxy-retry".into(), if *retry { "yes" } else { "no" }.into())); + if let Some(u) = username { + vpn_data.push(("http-proxy-username".into(), u.clone())); + } + if let Some(p) = password { + vpn_data.push(("http-proxy-password".into(), p.clone())); + } + } + OpenVpnProxy::Socks { server, port, retry } => { + if *port == 0 { + return Err(ConnectionError::InvalidAddress( + "proxy port must not be zero".into(), + )); + } + vpn_data.push(("proxy-type".into(), "socks".into())); + vpn_data.push(("proxy-server".into(), server.clone())); + vpn_data.push(("proxy-port".into(), port.to_string())); + vpn_data.push(("proxy-retry".into(), if *retry { "yes" } else { "no" }.into())); + } + } + } + + let data_value = Value::from(vpn_data); + + let mut vpn: HashMap<&'static str, Value<'static>> = HashMap::new(); + vpn.insert("service-type", Value::from("org.freedesktop.NetworkManager.openvpn")); + vpn.insert("data", data_value); + + let mut ipv4: HashMap<&'static str, Value<'static>> = HashMap::new(); + ipv4.insert("method", Value::from("auto")); + if let Some(dns) = &config.dns { + let dns_array: Vec = dns.iter().map(|s| Value::from(s.clone())).collect(); + ipv4.insert("dns", Value::from(dns_array)); + } + + let mut ipv6: HashMap<&'static str, Value<'static>> = HashMap::new(); + ipv6.insert("method", Value::from("ignore")); + + let mut settings = HashMap::new(); + settings.insert("connection", connection); + settings.insert("vpn", vpn); + settings.insert("ipv4", ipv4); + settings.insert("ipv6", ipv6); + + Ok(settings) +} #[cfg(test)] mod tests { use super::*; - use crate::api::models::{VpnType, WireGuardPeer}; + use crate::api::models::{VpnType, WireGuardPeer, OpenVpnConfig, OpenVpnCompression, OpenVpnProxy}; fn create_test_credentials() -> VpnCredentials { let peer = WireGuardPeer::new( @@ -119,7 +244,7 @@ mod tests { "vpn.example.com:51820", vec!["0.0.0.0/0".into()], ) - .with_persistent_keepalive(25); + .with_persistent_keepalive(25); VpnCredentials::new( VpnType::WireGuard, @@ -129,8 +254,8 @@ mod tests { "10.0.0.2/24", vec![peer], ) - .with_dns(vec!["1.1.1.1".into(), "8.8.8.8".into()]) - .with_mtu(1420) + .with_dns(vec!["1.1.1.1".into(), "8.8.8.8".into()]) + .with_mtu(1420) } fn create_test_options() -> ConnectionOptions { @@ -266,7 +391,7 @@ mod tests { "peer2.example.com:51821", vec!["192.168.0.0/16".into()], ) - .with_preshared_key("PSKABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklm="); + .with_preshared_key("PSKABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklm="); creds.peers.push(extra_peer); let opts = create_test_options(); @@ -598,4 +723,186 @@ mod tests { assert!(result.is_ok(), "Should accept valid gateway: {}", gateway); } } -} + + + // --- OpenVPN tests --- + fn create_openvpn_config() -> OpenVpnConfig { + OpenVpnConfig::new( + "TestOpenVPN", + "vpn.example.com", + "/etc/openvpn/ca.crt", + "/etc/openvpn/client.crt", + "/etc/openvpn/client.key", + ) + } + + #[test] + fn builds_openvpn_connection() { + let config = create_openvpn_config(); + let opts = create_test_options(); + let result = build_openvpn_connection(&config, &opts); + assert!(result.is_ok()); + let settings = result.unwrap(); + assert!(settings.contains_key("connection")); + assert!(settings.contains_key("vpn")); + assert!(settings.contains_key("ipv4")); + assert!(settings.contains_key("ipv6")); + } + + #[test] + fn openvpn_connection_type_is_vpn() { + let config = create_openvpn_config(); + let opts = create_test_options(); + let settings = build_openvpn_connection(&config, &opts).unwrap(); + let conn = settings.get("connection").unwrap(); + assert_eq!(conn.get("type").unwrap(), &Value::from("vpn")); + } + + #[test] + fn openvpn_service_type_is_correct() { + let config = create_openvpn_config(); + let opts = create_test_options(); + let settings = build_openvpn_connection(&config, &opts).unwrap(); + let vpn = settings.get("vpn").unwrap(); + assert_eq!( + vpn.get("service-type").unwrap(), + &Value::from("org.freedesktop.NetworkManager.openvpn") + ); + } + + #[test] + fn openvpn_rejects_empty_remote() { + let mut config = create_openvpn_config(); + config.remote = "".into(); + let opts = create_test_options(); + let result = build_openvpn_connection(&config, &opts); + assert!(matches!(result.unwrap_err(), ConnectionError::InvalidGateway(_))); + } + + #[test] + fn openvpn_compression_no() { + let config = create_openvpn_config() + .with_compression(OpenVpnCompression::No); + let opts = create_test_options(); + let settings = build_openvpn_connection(&config, &opts).unwrap(); + let vpn = settings.get("vpn").unwrap(); + // vpn.data is packed — just assert the section exists and no error + assert!(vpn.contains_key("data")); + } + #[allow(deprecated)] + #[test] + fn openvpn_compression_lzo() { + let config = create_openvpn_config() + .with_compression(OpenVpnCompression::Lzo); + let opts = create_test_options(); + assert!(build_openvpn_connection(&config, &opts).is_ok()); + } + + #[test] + fn openvpn_compression_lz4() { + let config = create_openvpn_config() + .with_compression(OpenVpnCompression::Lz4); + let opts = create_test_options(); + assert!(build_openvpn_connection(&config, &opts).is_ok()); + } + + #[test] + fn openvpn_compression_lz4v2() { + let config = create_openvpn_config() + .with_compression(OpenVpnCompression::Lz4V2); + let opts = create_test_options(); + assert!(build_openvpn_connection(&config, &opts).is_ok()); + } + + #[test] + fn openvpn_compression_yes() { + let config = create_openvpn_config() + .with_compression(OpenVpnCompression::Yes); + let opts = create_test_options(); + assert!(build_openvpn_connection(&config, &opts).is_ok()); + } + + #[test] + fn openvpn_http_proxy() { + let config = create_openvpn_config().with_proxy(OpenVpnProxy::Http { + server: "proxy.example.com".into(), + port: 8080, + username: Some("user".into()), + password: Some("pass".into()), + retry: true, + }); + let opts = create_test_options(); + assert!(build_openvpn_connection(&config, &opts).is_ok()); + } + + #[test] + fn openvpn_http_proxy_no_credentials() { + let config = create_openvpn_config().with_proxy(OpenVpnProxy::Http { + server: "proxy.example.com".into(), + port: 3128, + username: None, + password: None, + retry: false, + }); + let opts = create_test_options(); + assert!(build_openvpn_connection(&config, &opts).is_ok()); + } + + #[test] + fn openvpn_socks_proxy() { + let config = create_openvpn_config().with_proxy(OpenVpnProxy::Socks { + server: "socks.example.com".into(), + port: 1080, + retry: false, + }); + let opts = create_test_options(); + assert!(build_openvpn_connection(&config, &opts).is_ok()); + } + + #[test] + fn openvpn_proxy_rejects_zero_port_http() { + let config = create_openvpn_config().with_proxy(OpenVpnProxy::Http { + server: "proxy.example.com".into(), + port: 0, + username: None, + password: None, + retry: false, + }); + let opts = create_test_options(); + assert!(matches!( + build_openvpn_connection(&config, &opts).unwrap_err(), + ConnectionError::InvalidAddress(_) + )); + } + + #[test] + fn openvpn_proxy_rejects_zero_port_socks() { + let config = create_openvpn_config().with_proxy(OpenVpnProxy::Socks { + server: "socks.example.com".into(), + port: 0, + retry: false, + }); + let opts = create_test_options(); + assert!(matches!( + build_openvpn_connection(&config, &opts).unwrap_err(), + ConnectionError::InvalidAddress(_) + )); + } + + #[test] + fn openvpn_with_port() { + let config = create_openvpn_config().with_port(1194); + let opts = create_test_options(); + assert!(build_openvpn_connection(&config, &opts).is_ok()); + } + + #[test] + fn openvpn_with_dns() { + let config = create_openvpn_config() + .with_dns(vec!["1.1.1.1".into(), "8.8.8.8".into()]); + let opts = create_test_options(); + let settings = build_openvpn_connection(&config, &opts).unwrap(); + let ipv4 = settings.get("ipv4").unwrap(); + assert!(ipv4.contains_key("dns")); + } +} \ No newline at end of file diff --git a/nmrs/src/api/models/vpn.rs b/nmrs/src/api/models/vpn.rs index 4859d677..955d4f3e 100644 --- a/nmrs/src/api/models/vpn.rs +++ b/nmrs/src/api/models/vpn.rs @@ -73,6 +73,196 @@ pub struct VpnCredentials { pub uuid: Option, } +/// Compression algorithm for OpenVPN connections. +/// +/// Maps to the NM `compress` and `comp-lzo` keys in the `vpn.data` dict. +/// +/// # Security Warning +/// +/// Compression is generally discouraged due to the VORACLE vulnerability, +/// where compression oracles can be exploited to recover plaintext from +/// encrypted tunnels. OpenVPN 2.5+ defaults to `--allow-compression no`. +/// Prefer [`No`](OpenVpnCompression::No) unless you have a specific need +/// and understand the risk. See . +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OpenVpnCompression { + /// Disable compression explicitly. Recommended default. + /// + /// Maps to `compress no` in the NM `vpn.data` dict. + No, + + /// LZO compression. + /// + /// Maps to `comp-lzo yes` in the NM `vpn.data` dict. + /// + /// # Security Warning + /// + /// Subject to the VORACLE vulnerability. See [`OpenVpnCompression`] docs. + /// + /// # Deprecation + /// + /// `comp-lzo` is deprecated upstream in OpenVPN in favour of the newer + /// `compress` directive. Use [`Lz4V2`](OpenVpnCompression::Lz4V2) if + /// you need compression, or [`No`](OpenVpnCompression::No) to disable it. + #[deprecated(note = "comp-lzo is deprecated upstream. Use Lz4V2 or No instead.")] + Lzo, + + /// LZ4 compression. + /// + /// Maps to `compress lz4` in the NM `vpn.data` dict. + /// + /// # Security Warning + /// + /// Subject to the VORACLE vulnerability. See [`OpenVpnCompression`] docs. + Lz4, + + /// LZ4 v2 compression. + /// + /// Maps to `compress lz4-v2` in the NM `vpn.data` dict. + /// + /// # Security Warning + /// + /// Subject to the VORACLE vulnerability. See [`OpenVpnCompression`] docs. + Lz4V2, + + /// Adaptive compression — algorithm negotiated at runtime. + /// + /// Maps to `compress yes` in the NM `vpn.data` dict. + /// + /// # Security Warning + /// + /// Subject to the VORACLE vulnerability. See [`OpenVpnCompression`] docs. + Yes, +} + +/// Proxy configuration for OpenVPN connections. +/// +/// Maps to the NM `proxy-type`, `proxy-server`, `proxy-port`, +/// `proxy-retry`, `http-proxy-username`, and `http-proxy-password` keys. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OpenVpnProxy { + /// HTTP proxy. + Http { + server: String, + port: u16, + username: Option, + password: Option, + retry: bool, + }, + /// SOCKS proxy. + Socks { + server: String, + port: u16, + retry: bool, + }, +} + +/// OpenVPN connection configuration. +/// +/// Stores the necessary information to configure an OpenVPN connection +/// through NetworkManager's VPN plugin model. +/// +/// # Example +/// +/// ```rust +/// use nmrs::{OpenVpnConfig, OpenVpnCompression}; +/// +/// let config = OpenVpnConfig::new( +/// "CorpVPN", +/// "vpn.example.com", +/// "/etc/openvpn/ca.crt", +/// "/etc/openvpn/client.crt", +/// "/etc/openvpn/client.key", +/// ) +/// .with_compression(OpenVpnCompression::Lz4V2) +/// .with_dns(vec!["1.1.1.1".into()]); +/// ``` +#[non_exhaustive] +#[derive(Debug, Clone)] +pub struct OpenVpnConfig { + /// Unique name for the connection profile. + pub name: String, + /// VPN server hostname or IP address. + pub remote: String, + /// Path to the CA certificate file. + pub ca: String, + /// Path to the client certificate file. + pub cert: String, + /// Path to the client private key file. + pub key: String, + /// Optional port (defaults to 1194 if not set). + pub port: Option, + /// Optional compression setting. + pub compression: Option, + /// Optional proxy configuration. + pub proxy: Option, + /// Optional DNS servers to use when connected. + pub dns: Option>, + /// Optional UUID for the connection (auto-generated if not provided). + pub uuid: Option, +} + +impl OpenVpnConfig { + /// Creates a new `OpenVpnConfig` with the required fields. + pub fn new( + name: impl Into, + remote: impl Into, + ca: impl Into, + cert: impl Into, + key: impl Into, + ) -> Self { + Self { + name: name.into(), + remote: remote.into(), + ca: ca.into(), + cert: cert.into(), + key: key.into(), + port: None, + compression: None, + proxy: None, + dns: None, + uuid: None, + } + } + + /// Sets the server port. + #[must_use] + pub fn with_port(mut self, port: u16) -> Self { + self.port = Some(port); + self + } + + /// Sets the compression algorithm. + #[must_use] + pub fn with_compression(mut self, compression: OpenVpnCompression) -> Self { + self.compression = Some(compression); + self + } + + /// Sets the proxy configuration. + #[must_use] + pub fn with_proxy(mut self, proxy: OpenVpnProxy) -> Self { + self.proxy = Some(proxy); + self + } + + /// Sets the DNS servers to use when connected. + #[must_use] + pub fn with_dns(mut self, dns: Vec) -> Self { + self.dns = Some(dns); + self + } + + /// Sets the UUID for the connection. + #[must_use] + pub fn with_uuid(mut self, uuid: uuid::Uuid) -> Self { + self.uuid = Some(uuid); + self + } +} + impl VpnCredentials { /// Creates new `VpnCredentials` with the required fields. /// From 59efc32eff39cbaa784addfdb29d356159109f0c Mon Sep 17 00:00:00 2001 From: stoutes <31317041+stoutes@users.noreply.github.com> Date: Tue, 31 Mar 2026 22:13:15 -0500 Subject: [PATCH 2/5] feature(lib): added OpenVpnCompression, OpenVpnConfig, OpenVpnProxy imports to crate root --- nmrs/src/lib.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nmrs/src/lib.rs b/nmrs/src/lib.rs index c71f78fa..be92f5a3 100644 --- a/nmrs/src/lib.rs +++ b/nmrs/src/lib.rs @@ -318,9 +318,9 @@ pub mod models { pub use api::models::{ ActiveConnectionState, BluetoothDevice, BluetoothIdentity, BluetoothNetworkRole, ConnectionError, ConnectionOptions, ConnectionStateReason, Device, DeviceState, DeviceType, - EapMethod, EapOptions, Network, NetworkInfo, Phase2, StateReason, TimeoutConfig, VpnConnection, - VpnConnectionInfo, VpnCredentials, VpnType, WifiSecurity, WireGuardPeer, - connection_state_reason_to_error, reason_to_error, + EapMethod, EapOptions, Network, NetworkInfo, OpenVpnCompression, OpenVpnConfig, OpenVpnProxy, + Phase2, StateReason, TimeoutConfig, VpnConnection, VpnConnectionInfo, VpnCredentials, VpnType, + WifiSecurity, WireGuardPeer, connection_state_reason_to_error, reason_to_error, }; pub use api::network_manager::NetworkManager; From 58050fd543da6437cf6c0715044a6bc7b26f4904 Mon Sep 17 00:00:00 2001 From: stoutes <31317041+stoutes@users.noreply.github.com> Date: Tue, 31 Mar 2026 22:36:27 -0500 Subject: [PATCH 3/5] feature(lib): updated doc comment for #Note section in build_openvpn_connection --- nmrs/src/api/builders/vpn.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/nmrs/src/api/builders/vpn.rs b/nmrs/src/api/builders/vpn.rs index 01efd2aa..249c58f3 100644 --- a/nmrs/src/api/builders/vpn.rs +++ b/nmrs/src/api/builders/vpn.rs @@ -118,6 +118,11 @@ pub fn build_wireguard_connection( /// `vpn.service-type = "org.freedesktop.NetworkManager.openvpn"`. /// All config lives in the flat `vpn.data` dict. /// +/// # Note +/// `vpn.data` is serialized as `Value::from(Vec<(String, String)>)`. If NM +/// rejects the connection with a type error, this may need to be replaced with +/// `zvariant::Dict` to produce the exact `a{ss}` signature the OpenVPN plugin +/// expects. Cannot be verified without a live NM + OpenVPN profile. /// # Errors /// /// - `ConnectionError::InvalidGateway` if `remote` is empty From 80cdb38b75d9f5366d9f853a7809dc421cd3e87b Mon Sep 17 00:00:00 2001 From: stoutes <31317041+stoutes@users.noreply.github.com> Date: Tue, 31 Mar 2026 22:37:52 -0500 Subject: [PATCH 4/5] feature(lib): updated formatting with cargo fmt --- nmrs/src/api/builders/vpn.rs | 79 ++++++++++++++++++++++-------------- 1 file changed, 48 insertions(+), 31 deletions(-) diff --git a/nmrs/src/api/builders/vpn.rs b/nmrs/src/api/builders/vpn.rs index 249c58f3..257f0968 100644 --- a/nmrs/src/api/builders/vpn.rs +++ b/nmrs/src/api/builders/vpn.rs @@ -183,7 +183,13 @@ pub fn build_openvpn_connection( if let Some(ref proxy) = config.proxy { match proxy { - OpenVpnProxy::Http { server, port, username, password, retry } => { + OpenVpnProxy::Http { + server, + port, + username, + password, + retry, + } => { if *port == 0 { return Err(ConnectionError::InvalidAddress( "proxy port must not be zero".into(), @@ -192,7 +198,10 @@ pub fn build_openvpn_connection( vpn_data.push(("proxy-type".into(), "http".into())); vpn_data.push(("proxy-server".into(), server.clone())); vpn_data.push(("proxy-port".into(), port.to_string())); - vpn_data.push(("proxy-retry".into(), if *retry { "yes" } else { "no" }.into())); + vpn_data.push(( + "proxy-retry".into(), + if *retry { "yes" } else { "no" }.into(), + )); if let Some(u) = username { vpn_data.push(("http-proxy-username".into(), u.clone())); } @@ -200,7 +209,11 @@ pub fn build_openvpn_connection( vpn_data.push(("http-proxy-password".into(), p.clone())); } } - OpenVpnProxy::Socks { server, port, retry } => { + OpenVpnProxy::Socks { + server, + port, + retry, + } => { if *port == 0 { return Err(ConnectionError::InvalidAddress( "proxy port must not be zero".into(), @@ -209,7 +222,10 @@ pub fn build_openvpn_connection( vpn_data.push(("proxy-type".into(), "socks".into())); vpn_data.push(("proxy-server".into(), server.clone())); vpn_data.push(("proxy-port".into(), port.to_string())); - vpn_data.push(("proxy-retry".into(), if *retry { "yes" } else { "no" }.into())); + vpn_data.push(( + "proxy-retry".into(), + if *retry { "yes" } else { "no" }.into(), + )); } } } @@ -217,7 +233,10 @@ pub fn build_openvpn_connection( let data_value = Value::from(vpn_data); let mut vpn: HashMap<&'static str, Value<'static>> = HashMap::new(); - vpn.insert("service-type", Value::from("org.freedesktop.NetworkManager.openvpn")); + vpn.insert( + "service-type", + Value::from("org.freedesktop.NetworkManager.openvpn"), + ); vpn.insert("data", data_value); let mut ipv4: HashMap<&'static str, Value<'static>> = HashMap::new(); @@ -241,7 +260,9 @@ pub fn build_openvpn_connection( #[cfg(test)] mod tests { use super::*; - use crate::api::models::{VpnType, WireGuardPeer, OpenVpnConfig, OpenVpnCompression, OpenVpnProxy}; + use crate::api::models::{ + OpenVpnCompression, OpenVpnConfig, OpenVpnProxy, VpnType, WireGuardPeer, + }; fn create_test_credentials() -> VpnCredentials { let peer = WireGuardPeer::new( @@ -249,7 +270,7 @@ mod tests { "vpn.example.com:51820", vec!["0.0.0.0/0".into()], ) - .with_persistent_keepalive(25); + .with_persistent_keepalive(25); VpnCredentials::new( VpnType::WireGuard, @@ -259,8 +280,8 @@ mod tests { "10.0.0.2/24", vec![peer], ) - .with_dns(vec!["1.1.1.1".into(), "8.8.8.8".into()]) - .with_mtu(1420) + .with_dns(vec!["1.1.1.1".into(), "8.8.8.8".into()]) + .with_mtu(1420) } fn create_test_options() -> ConnectionOptions { @@ -396,7 +417,7 @@ mod tests { "peer2.example.com:51821", vec!["192.168.0.0/16".into()], ) - .with_preshared_key("PSKABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklm="); + .with_preshared_key("PSKABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklm="); creds.peers.push(extra_peer); let opts = create_test_options(); @@ -729,7 +750,6 @@ mod tests { } } - // --- OpenVPN tests --- fn create_openvpn_config() -> OpenVpnConfig { OpenVpnConfig::new( @@ -781,13 +801,15 @@ mod tests { config.remote = "".into(); let opts = create_test_options(); let result = build_openvpn_connection(&config, &opts); - assert!(matches!(result.unwrap_err(), ConnectionError::InvalidGateway(_))); + assert!(matches!( + result.unwrap_err(), + ConnectionError::InvalidGateway(_) + )); } #[test] fn openvpn_compression_no() { - let config = create_openvpn_config() - .with_compression(OpenVpnCompression::No); + let config = create_openvpn_config().with_compression(OpenVpnCompression::No); let opts = create_test_options(); let settings = build_openvpn_connection(&config, &opts).unwrap(); let vpn = settings.get("vpn").unwrap(); @@ -797,32 +819,28 @@ mod tests { #[allow(deprecated)] #[test] fn openvpn_compression_lzo() { - let config = create_openvpn_config() - .with_compression(OpenVpnCompression::Lzo); + let config = create_openvpn_config().with_compression(OpenVpnCompression::Lzo); let opts = create_test_options(); assert!(build_openvpn_connection(&config, &opts).is_ok()); } #[test] fn openvpn_compression_lz4() { - let config = create_openvpn_config() - .with_compression(OpenVpnCompression::Lz4); + let config = create_openvpn_config().with_compression(OpenVpnCompression::Lz4); let opts = create_test_options(); assert!(build_openvpn_connection(&config, &opts).is_ok()); } #[test] fn openvpn_compression_lz4v2() { - let config = create_openvpn_config() - .with_compression(OpenVpnCompression::Lz4V2); + let config = create_openvpn_config().with_compression(OpenVpnCompression::Lz4V2); let opts = create_test_options(); assert!(build_openvpn_connection(&config, &opts).is_ok()); } #[test] fn openvpn_compression_yes() { - let config = create_openvpn_config() - .with_compression(OpenVpnCompression::Yes); + let config = create_openvpn_config().with_compression(OpenVpnCompression::Yes); let opts = create_test_options(); assert!(build_openvpn_connection(&config, &opts).is_ok()); } @@ -875,9 +893,9 @@ mod tests { }); let opts = create_test_options(); assert!(matches!( - build_openvpn_connection(&config, &opts).unwrap_err(), - ConnectionError::InvalidAddress(_) - )); + build_openvpn_connection(&config, &opts).unwrap_err(), + ConnectionError::InvalidAddress(_) + )); } #[test] @@ -889,9 +907,9 @@ mod tests { }); let opts = create_test_options(); assert!(matches!( - build_openvpn_connection(&config, &opts).unwrap_err(), - ConnectionError::InvalidAddress(_) - )); + build_openvpn_connection(&config, &opts).unwrap_err(), + ConnectionError::InvalidAddress(_) + )); } #[test] @@ -903,11 +921,10 @@ mod tests { #[test] fn openvpn_with_dns() { - let config = create_openvpn_config() - .with_dns(vec!["1.1.1.1".into(), "8.8.8.8".into()]); + let config = create_openvpn_config().with_dns(vec!["1.1.1.1".into(), "8.8.8.8".into()]); let opts = create_test_options(); let settings = build_openvpn_connection(&config, &opts).unwrap(); let ipv4 = settings.get("ipv4").unwrap(); assert!(ipv4.contains_key("dns")); } -} \ No newline at end of file +} From 5524b1d5a05267297f0e5c1015d8d0ea85b181b9 Mon Sep 17 00:00:00 2001 From: stoutes <31317041+stoutes@users.noreply.github.com> Date: Tue, 31 Mar 2026 22:45:13 -0500 Subject: [PATCH 5/5] feature(lib): updated openvpn_connection #note section --- nmrs/src/api/builders/vpn.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/nmrs/src/api/builders/vpn.rs b/nmrs/src/api/builders/vpn.rs index 257f0968..45f9f50e 100644 --- a/nmrs/src/api/builders/vpn.rs +++ b/nmrs/src/api/builders/vpn.rs @@ -119,10 +119,12 @@ pub fn build_wireguard_connection( /// All config lives in the flat `vpn.data` dict. /// /// # Note -/// `vpn.data` is serialized as `Value::from(Vec<(String, String)>)`. If NM -/// rejects the connection with a type error, this may need to be replaced with -/// `zvariant::Dict` to produce the exact `a{ss}` signature the OpenVPN plugin -/// expects. Cannot be verified without a live NM + OpenVPN profile. +/// +/// Per the [NM VPN settings spec](https://networkmanager.dev/docs/api/latest/settings-vpn.html), +/// `vpn.data` must be a `dict of string to string` (`a{ss}` in D-Bus type notation). +/// `Value::from(Vec<(String, String)>)` may not produce this exact signature — +/// if NM rejects the connection at runtime, replace with `zvariant::Dict`. +/// /// # Errors /// /// - `ConnectionError::InvalidGateway` if `remote` is empty