diff --git a/nmrs/src/api/builders/connection_builder.rs b/nmrs/src/api/builders/connection_builder.rs new file mode 100644 index 00000000..5a660844 --- /dev/null +++ b/nmrs/src/api/builders/connection_builder.rs @@ -0,0 +1,656 @@ +//! Core connection builder for NetworkManager settings. +//! +//! This module provides a flexible builder API for constructing NetworkManager +//! connection settings dictionaries. The `ConnectionBuilder` handles common +//! sections like connection metadata, IPv4/IPv6 configuration, and allows +//! type-specific builders to add their own sections. +//! +//! # Design Philosophy +//! +//! The builder follows a "base + specialization" pattern: +//! - `ConnectionBuilder` handles common sections (connection, ipv4, ipv6) +//! - Type-specific builders (WifiConnectionBuilder, VpnBuilder, etc.) add +//! connection-type-specific sections and provide ergonomic APIs +//! +//! # Example +//! +//! ```rust +//! use nmrs::builders::ConnectionBuilder; +//! use std::net::Ipv4Addr; +//! +//! let settings = ConnectionBuilder::new("802-3-ethernet", "eth0") +//! .autoconnect(true) +//! .ipv4_auto() +//! .ipv6_auto() +//! .build(); +//! ``` + +use std::collections::HashMap; +use std::net::{Ipv4Addr, Ipv6Addr}; +use uuid::Uuid; +use zvariant::Value; + +use crate::api::models::ConnectionOptions; + +/// IP address configuration with CIDR prefix. +#[derive(Debug, Clone)] +pub struct IpConfig { + pub address: String, + pub prefix: u32, +} + +impl IpConfig { + /// Creates a new IP configuration. + pub fn new(address: impl Into, prefix: u32) -> Self { + Self { + address: address.into(), + prefix, + } + } +} + +/// Route configuration for static routing. +#[derive(Debug, Clone)] +pub struct Route { + pub dest: String, + pub prefix: u32, + pub next_hop: Option, + pub metric: Option, +} + +impl Route { + /// Creates a new route configuration. + pub fn new(dest: impl Into, prefix: u32) -> Self { + Self { + dest: dest.into(), + prefix, + next_hop: None, + metric: None, + } + } + + /// Sets the next hop gateway for this route. + pub fn next_hop(mut self, gateway: impl Into) -> Self { + self.next_hop = Some(gateway.into()); + self + } + + /// Sets the metric (priority) for this route. + pub fn metric(mut self, metric: u32) -> Self { + self.metric = Some(metric); + self + } +} + +/// Core connection settings builder. +/// +/// This builder constructs the base NetworkManager connection settings dictionary +/// that all connection types share. Type-specific builders wrap this to add +/// their own sections. +/// +/// # Sections Managed +/// +/// - `connection`: Metadata (type, id, uuid, autoconnect settings) +/// - `ipv4`: IPv4 configuration (auto/manual/disabled/etc) +/// - `ipv6`: IPv6 configuration (auto/manual/ignore/etc) +/// +/// # Usage Pattern +/// +/// This builder is typically wrapped by type-specific builders like +/// `WifiConnectionBuilder` or `EthernetConnectionBuilder`. However, it can +/// be used directly for advanced use cases: +/// +/// ```rust +/// use nmrs::builders::ConnectionBuilder; +/// +/// let settings = ConnectionBuilder::new("802-11-wireless", "MyNetwork") +/// .autoconnect(true) +/// .autoconnect_priority(10) +/// .ipv4_auto() +/// .ipv6_auto() +/// .build(); +/// ``` +pub struct ConnectionBuilder { + settings: HashMap<&'static str, HashMap<&'static str, Value<'static>>>, +} + +impl ConnectionBuilder { + /// Creates a new connection builder with the specified type and ID. + /// + /// # Arguments + /// + /// * `connection_type` - NetworkManager connection type (e.g., "802-11-wireless", + /// "802-3-ethernet", "wireguard", "bridge", "bond", "vlan") + /// * `id` - Human-readable connection identifier + /// + /// # Example + /// + /// ```rust + /// use nmrs::builders::ConnectionBuilder; + /// + /// let builder = ConnectionBuilder::new("802-11-wireless", "HomeNetwork"); + /// ``` + pub fn new(connection_type: &str, id: impl Into) -> Self { + let mut settings = HashMap::new(); + let mut connection = HashMap::new(); + + connection.insert("type", Value::from(connection_type.to_string())); + connection.insert("id", Value::from(id.into())); + connection.insert("uuid", Value::from(Uuid::new_v4().to_string())); + + settings.insert("connection", connection); + + Self { settings } + } + + /// Sets a specific UUID for the connection. + /// + /// By default, a random UUID is generated. Use this to specify a deterministic + /// UUID for testing or when recreating existing connections. + pub fn uuid(mut self, uuid: Uuid) -> Self { + if let Some(conn) = self.settings.get_mut("connection") { + conn.insert("uuid", Value::from(uuid.to_string())); + } + self + } + + /// Sets the network interface name for this connection. + /// + /// This restricts the connection to a specific interface (e.g., "wlan0", "eth0"). + pub fn interface_name(mut self, name: impl Into) -> Self { + if let Some(conn) = self.settings.get_mut("connection") { + conn.insert("interface-name", Value::from(name.into())); + } + self + } + + /// Enables or disables automatic connection on boot/availability. + pub fn autoconnect(mut self, enabled: bool) -> Self { + if let Some(conn) = self.settings.get_mut("connection") { + conn.insert("autoconnect", Value::from(enabled)); + } + self + } + + /// Sets the autoconnect priority (higher values are preferred). + /// + /// When multiple connections are available, NetworkManager connects to the + /// one with the highest priority. Default is 0. + pub fn autoconnect_priority(mut self, priority: i32) -> Self { + if let Some(conn) = self.settings.get_mut("connection") { + conn.insert("autoconnect-priority", Value::from(priority)); + } + self + } + + /// Sets the number of autoconnect retry attempts. + /// + /// After this many failed attempts, the connection won't auto-retry. + /// Default is -1 (unlimited retries). + pub fn autoconnect_retries(mut self, retries: i32) -> Self { + if let Some(conn) = self.settings.get_mut("connection") { + conn.insert("autoconnect-retries", Value::from(retries)); + } + self + } + + /// Applies multiple connection options at once. + /// + /// This is a convenience method to apply all fields from `ConnectionOptions`. + pub fn options(mut self, opts: &ConnectionOptions) -> Self { + if let Some(conn) = self.settings.get_mut("connection") { + conn.insert("autoconnect", Value::from(opts.autoconnect)); + + if let Some(priority) = opts.autoconnect_priority { + conn.insert("autoconnect-priority", Value::from(priority)); + } + + if let Some(retries) = opts.autoconnect_retries { + conn.insert("autoconnect-retries", Value::from(retries)); + } + } + self + } + + /// Configures IPv4 to use automatic configuration (DHCP). + pub fn ipv4_auto(mut self) -> Self { + let mut ipv4 = HashMap::new(); + ipv4.insert("method", Value::from("auto")); + self.settings.insert("ipv4", ipv4); + self + } + + /// Configures IPv4 with manual (static) addresses. + /// + /// # Example + /// + /// ```rust + /// use nmrs::builders::{ConnectionBuilder, IpConfig}; + /// + /// let settings = ConnectionBuilder::new("802-3-ethernet", "eth0") + /// .ipv4_manual(vec![ + /// IpConfig::new("192.168.1.100", 24), + /// ]) + /// .build(); + /// ``` + pub fn ipv4_manual(mut self, addresses: Vec) -> Self { + let mut ipv4 = HashMap::new(); + ipv4.insert("method", Value::from("manual")); + + // Convert to address-data format (array of dictionaries) + let address_data: Vec>> = addresses + .into_iter() + .map(|config| { + let mut addr_dict = HashMap::new(); + addr_dict.insert("address".to_string(), Value::from(config.address)); + addr_dict.insert("prefix".to_string(), Value::from(config.prefix)); + addr_dict + }) + .collect(); + + ipv4.insert("address-data", Value::from(address_data)); + self.settings.insert("ipv4", ipv4); + self + } + + /// Disables IPv4 for this connection. + pub fn ipv4_disabled(mut self) -> Self { + let mut ipv4 = HashMap::new(); + ipv4.insert("method", Value::from("disabled")); + self.settings.insert("ipv4", ipv4); + self + } + + /// Configures IPv4 to use link-local addressing (169.254.x.x). + pub fn ipv4_link_local(mut self) -> Self { + let mut ipv4 = HashMap::new(); + ipv4.insert("method", Value::from("link-local")); + self.settings.insert("ipv4", ipv4); + self + } + + /// Configures IPv4 for internet connection sharing. + /// + /// The connection will provide DHCP and NAT for other devices. + pub fn ipv4_shared(mut self) -> Self { + let mut ipv4 = HashMap::new(); + ipv4.insert("method", Value::from("shared")); + self.settings.insert("ipv4", ipv4); + self + } + + /// Sets IPv4 DNS servers. + /// + /// DNS servers are specified as integers (network byte order). + pub fn ipv4_dns(mut self, servers: Vec) -> Self { + let dns_u32: Vec = servers.into_iter().map(u32::from).collect(); + + if let Some(ipv4) = self.settings.get_mut("ipv4") { + ipv4.insert("dns", Value::from(dns_u32)); + } + self + } + + /// Sets the IPv4 gateway. + pub fn ipv4_gateway(mut self, gateway: Ipv4Addr) -> Self { + if let Some(ipv4) = self.settings.get_mut("ipv4") { + ipv4.insert("gateway", Value::from(gateway.to_string())); + } + self + } + + /// Adds IPv4 static routes. + pub fn ipv4_routes(mut self, routes: Vec) -> Self { + let route_data: Vec>> = routes + .into_iter() + .map(|route| { + let mut route_dict = HashMap::new(); + route_dict.insert("dest".to_string(), Value::from(route.dest)); + route_dict.insert("prefix".to_string(), Value::from(route.prefix)); + + if let Some(next_hop) = route.next_hop { + route_dict.insert("next-hop".to_string(), Value::from(next_hop)); + } + + if let Some(metric) = route.metric { + route_dict.insert("metric".to_string(), Value::from(metric)); + } + + route_dict + }) + .collect(); + + if let Some(ipv4) = self.settings.get_mut("ipv4") { + ipv4.insert("route-data", Value::from(route_data)); + } + self + } + + /// Configures IPv6 to use automatic configuration (SLAAC/DHCPv6). + pub fn ipv6_auto(mut self) -> Self { + let mut ipv6 = HashMap::new(); + ipv6.insert("method", Value::from("auto")); + self.settings.insert("ipv6", ipv6); + self + } + + /// Configures IPv6 with manual (static) addresses. + pub fn ipv6_manual(mut self, addresses: Vec) -> Self { + let mut ipv6 = HashMap::new(); + ipv6.insert("method", Value::from("manual")); + + let address_data: Vec>> = addresses + .into_iter() + .map(|config| { + let mut addr_dict = HashMap::new(); + addr_dict.insert("address".to_string(), Value::from(config.address)); + addr_dict.insert("prefix".to_string(), Value::from(config.prefix)); + addr_dict + }) + .collect(); + + ipv6.insert("address-data", Value::from(address_data)); + self.settings.insert("ipv6", ipv6); + self + } + + /// Disables IPv6 for this connection. + pub fn ipv6_ignore(mut self) -> Self { + let mut ipv6 = HashMap::new(); + ipv6.insert("method", Value::from("ignore")); + self.settings.insert("ipv6", ipv6); + self + } + + /// Configures IPv6 to use link-local addressing only. + pub fn ipv6_link_local(mut self) -> Self { + let mut ipv6 = HashMap::new(); + ipv6.insert("method", Value::from("link-local")); + self.settings.insert("ipv6", ipv6); + self + } + + /// Sets IPv6 DNS servers. + pub fn ipv6_dns(mut self, servers: Vec) -> Self { + let dns_strings: Vec = servers.into_iter().map(|s| s.to_string()).collect(); + + if let Some(ipv6) = self.settings.get_mut("ipv6") { + ipv6.insert("dns", Value::from(dns_strings)); + } + self + } + + /// Sets the IPv6 gateway. + pub fn ipv6_gateway(mut self, gateway: Ipv6Addr) -> Self { + if let Some(ipv6) = self.settings.get_mut("ipv6") { + ipv6.insert("gateway", Value::from(gateway.to_string())); + } + self + } + + /// Adds IPv6 static routes. + pub fn ipv6_routes(mut self, routes: Vec) -> Self { + let route_data: Vec>> = routes + .into_iter() + .map(|route| { + let mut route_dict = HashMap::new(); + route_dict.insert("dest".to_string(), Value::from(route.dest)); + route_dict.insert("prefix".to_string(), Value::from(route.prefix)); + + if let Some(next_hop) = route.next_hop { + route_dict.insert("next-hop".to_string(), Value::from(next_hop)); + } + + if let Some(metric) = route.metric { + route_dict.insert("metric".to_string(), Value::from(metric)); + } + + route_dict + }) + .collect(); + + if let Some(ipv6) = self.settings.get_mut("ipv6") { + ipv6.insert("route-data", Value::from(route_data)); + } + self + } + + /// Adds or replaces a complete settings section. + /// + /// This is useful for type-specific settings that don't have dedicated + /// builder methods. For example, adding "802-11-wireless" or "wireguard" + /// sections. + /// + /// # Example + /// + /// ```rust + /// use nmrs::builders::ConnectionBuilder; + /// use std::collections::HashMap; + /// use zvariant::Value; + /// + /// let mut bridge_section = HashMap::new(); + /// bridge_section.insert("stp", Value::from(true)); + /// + /// let settings = ConnectionBuilder::new("bridge", "br0") + /// .with_section("bridge", bridge_section) + /// .build(); + /// ``` + pub fn with_section( + mut self, + name: &'static str, + section: HashMap<&'static str, Value<'static>>, + ) -> Self { + self.settings.insert(name, section); + self + } + + /// Updates an existing section using a closure. + /// + /// This allows modifying a section after it's been created, which is useful + /// when a builder method creates a base section and you need to add extra fields. + /// + /// # Example + /// + /// ```rust + /// use nmrs::builders::ConnectionBuilder; + /// use zvariant::Value; + /// + /// let settings = ConnectionBuilder::new("802-3-ethernet", "eth0") + /// .ipv4_auto() + /// .update_section("ipv4", |ipv4| { + /// ipv4.insert("may-fail", Value::from(false)); + /// }) + /// .build(); + /// ``` + pub fn update_section(mut self, name: &'static str, f: F) -> Self + where + F: FnOnce(&mut HashMap<&'static str, Value<'static>>), + { + if let Some(section) = self.settings.get_mut(name) { + f(section); + } + self + } + + /// Builds and returns the final settings dictionary. + /// + /// This consumes the builder and returns the complete settings structure + /// ready to be passed to NetworkManager's D-Bus API. + pub fn build(self) -> HashMap<&'static str, HashMap<&'static str, Value<'static>>> { + self.settings + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn creates_basic_connection() { + let settings = ConnectionBuilder::new("802-11-wireless", "TestNetwork").build(); + + assert!(settings.contains_key("connection")); + let conn = settings.get("connection").unwrap(); + assert_eq!(conn.get("type"), Some(&Value::from("802-11-wireless"))); + assert_eq!(conn.get("id"), Some(&Value::from("TestNetwork"))); + assert!(conn.contains_key("uuid")); + } + + #[test] + fn sets_custom_uuid() { + let test_uuid = Uuid::new_v4(); + let settings = ConnectionBuilder::new("802-3-ethernet", "eth0") + .uuid(test_uuid) + .build(); + + let conn = settings.get("connection").unwrap(); + assert_eq!(conn.get("uuid"), Some(&Value::from(test_uuid.to_string()))); + } + + #[test] + fn sets_interface_name() { + let settings = ConnectionBuilder::new("802-3-ethernet", "MyConnection") + .interface_name("eth0") + .build(); + + let conn = settings.get("connection").unwrap(); + assert_eq!(conn.get("interface-name"), Some(&Value::from("eth0"))); + } + + #[test] + fn configures_autoconnect() { + let settings = ConnectionBuilder::new("802-11-wireless", "test") + .autoconnect(false) + .autoconnect_priority(10) + .autoconnect_retries(3) + .build(); + + let conn = settings.get("connection").unwrap(); + assert_eq!(conn.get("autoconnect"), Some(&Value::from(false))); + assert_eq!(conn.get("autoconnect-priority"), Some(&Value::from(10i32))); + assert_eq!(conn.get("autoconnect-retries"), Some(&Value::from(3i32))); + } + + #[test] + fn applies_connection_options() { + let opts = ConnectionOptions { + autoconnect: true, + autoconnect_priority: Some(5), + autoconnect_retries: Some(2), + }; + + let settings = ConnectionBuilder::new("802-3-ethernet", "eth0") + .options(&opts) + .build(); + + let conn = settings.get("connection").unwrap(); + assert_eq!(conn.get("autoconnect"), Some(&Value::from(true))); + assert_eq!(conn.get("autoconnect-priority"), Some(&Value::from(5i32))); + assert_eq!(conn.get("autoconnect-retries"), Some(&Value::from(2i32))); + } + + #[test] + fn configures_ipv4_auto() { + let settings = ConnectionBuilder::new("802-3-ethernet", "eth0") + .ipv4_auto() + .build(); + + let ipv4 = settings.get("ipv4").unwrap(); + assert_eq!(ipv4.get("method"), Some(&Value::from("auto"))); + } + + #[test] + fn configures_ipv4_manual() { + let settings = ConnectionBuilder::new("802-3-ethernet", "eth0") + .ipv4_manual(vec![IpConfig::new("192.168.1.100", 24)]) + .build(); + + let ipv4 = settings.get("ipv4").unwrap(); + assert_eq!(ipv4.get("method"), Some(&Value::from("manual"))); + assert!(ipv4.contains_key("address-data")); + } + + #[test] + fn configures_ipv4_disabled() { + let settings = ConnectionBuilder::new("802-3-ethernet", "eth0") + .ipv4_disabled() + .build(); + + let ipv4 = settings.get("ipv4").unwrap(); + assert_eq!(ipv4.get("method"), Some(&Value::from("disabled"))); + } + + #[test] + fn configures_ipv4_dns() { + let dns = vec!["8.8.8.8".parse().unwrap(), "1.1.1.1".parse().unwrap()]; + let settings = ConnectionBuilder::new("802-3-ethernet", "eth0") + .ipv4_auto() + .ipv4_dns(dns) + .build(); + + let ipv4 = settings.get("ipv4").unwrap(); + assert!(ipv4.contains_key("dns")); + } + + #[test] + fn configures_ipv6_auto() { + let settings = ConnectionBuilder::new("802-3-ethernet", "eth0") + .ipv6_auto() + .build(); + + let ipv6 = settings.get("ipv6").unwrap(); + assert_eq!(ipv6.get("method"), Some(&Value::from("auto"))); + } + + #[test] + fn configures_ipv6_ignore() { + let settings = ConnectionBuilder::new("802-3-ethernet", "eth0") + .ipv6_ignore() + .build(); + + let ipv6 = settings.get("ipv6").unwrap(); + assert_eq!(ipv6.get("method"), Some(&Value::from("ignore"))); + } + + #[test] + fn adds_custom_section() { + let mut bridge = HashMap::new(); + bridge.insert("stp", Value::from(true)); + + let settings = ConnectionBuilder::new("bridge", "br0") + .with_section("bridge", bridge) + .build(); + + assert!(settings.contains_key("bridge")); + let bridge_section = settings.get("bridge").unwrap(); + assert_eq!(bridge_section.get("stp"), Some(&Value::from(true))); + } + + #[test] + fn updates_existing_section() { + let settings = ConnectionBuilder::new("802-3-ethernet", "eth0") + .ipv4_auto() + .update_section("ipv4", |ipv4| { + ipv4.insert("may-fail", Value::from(false)); + }) + .build(); + + let ipv4 = settings.get("ipv4").unwrap(); + assert_eq!(ipv4.get("may-fail"), Some(&Value::from(false))); + } + + #[test] + fn configures_complete_static_ipv4() { + let settings = ConnectionBuilder::new("802-3-ethernet", "eth0") + .ipv4_manual(vec![IpConfig::new("192.168.1.100", 24)]) + .ipv4_gateway("192.168.1.1".parse().unwrap()) + .ipv4_dns(vec!["8.8.8.8".parse().unwrap()]) + .build(); + + let ipv4 = settings.get("ipv4").unwrap(); + assert_eq!(ipv4.get("method"), Some(&Value::from("manual"))); + assert!(ipv4.contains_key("address-data")); + assert!(ipv4.contains_key("gateway")); + assert!(ipv4.contains_key("dns")); + } +} diff --git a/nmrs/src/api/builders/mod.rs b/nmrs/src/api/builders/mod.rs index 5c18c357..5d658098 100644 --- a/nmrs/src/api/builders/mod.rs +++ b/nmrs/src/api/builders/mod.rs @@ -60,12 +60,20 @@ //! let vpn_settings = build_wireguard_connection(&creds, &opts).unwrap(); //! ``` //! -//! These settings can then be passed to NetworkManager’s +//! These settings can then be passed to NetworkManager's //! `AddConnection` or `AddAndActivateConnection` D-Bus methods. +pub mod connection_builder; pub mod vpn; pub mod wifi; +pub mod wifi_builder; +pub mod wireguard_builder; -// Re-export builder functions for convenience +// Re-export core builder types +pub use connection_builder::{ConnectionBuilder, IpConfig, Route}; +pub use wifi_builder::{WifiBand, WifiConnectionBuilder}; +pub use wireguard_builder::WireGuardBuilder; + +// Re-export builder functions for convenience (backward compatibility) pub use vpn::build_wireguard_connection; pub use wifi::{build_ethernet_connection, build_wifi_connection}; diff --git a/nmrs/src/api/builders/vpn.rs b/nmrs/src/api/builders/vpn.rs index 20a2f395..c10d1cc3 100644 --- a/nmrs/src/api/builders/vpn.rs +++ b/nmrs/src/api/builders/vpn.rs @@ -11,7 +11,34 @@ //! instead of using these builders directly. This module is intended for //! advanced use cases where you need low-level control over the connection settings. //! -//! # Example +//! # Connection Builder API +//! +//! Consider using the fluent builder API added in 1.3.0: +//! +//! ```rust +//! use nmrs::builders::WireGuardBuilder; +//! use nmrs::WireGuardPeer; +//! +//! let peer = WireGuardPeer { +//! public_key: "SUPER_LONG_KEY".into(), +//! gateway: "vpn.example.com:51820".into(), +//! allowed_ips: vec!["0.0.0.0/0".into()], +//! preshared_key: None, +//! persistent_keepalive: Some(25), +//! }; +//! +//! let settings = WireGuardBuilder::new("MyVPN") +//! .private_key("SUPER_LONG_KEY") +//! .address("10.0.0.2/24") +//! .add_peer(peer) +//! .dns(vec!["1.1.1.1".into()]) +//! .build() +//! .expect("Failed to build WireGuard connection"); +//! ``` +//! +//! # Legacy Function API +//! +//! The `build_wireguard_connection` function is maintained for backward compatibility: //! //! ```rust //! use nmrs::builders::build_wireguard_connection; @@ -22,11 +49,11 @@ //! name: "MyVPN".into(), //! gateway: "vpn.example.com:51820".into(), //! // Valid WireGuard private key (44 chars base64) -//! private_key: "YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=".into(), +//! private_key: "SUPER_LONG_KEY".into(), //! address: "10.0.0.2/24".into(), //! peers: vec![WireGuardPeer { //! // Valid WireGuard public key (44 chars base64) -//! public_key: "HIgo9xNzJMWLKAShlKl6/bUT1VI9Q0SDBXGtLXkPFXc=".into(), +//! public_key: "SUPER_LONG_KEY".into(), //! gateway: "vpn.example.com:51820".into(), //! allowed_ips: vec!["0.0.0.0/0".into()], //! preshared_key: None, @@ -48,151 +75,11 @@ //! ``` use std::collections::HashMap; -use std::net::Ipv4Addr; -use uuid::Uuid; use zvariant::Value; +use super::wireguard_builder::WireGuardBuilder; use crate::api::models::{ConnectionError, ConnectionOptions, VpnCredentials}; -/// Validates a WireGuard key (private or public). -/// -/// WireGuard keys are 32-byte values encoded in base64, resulting in 44 characters -/// (including padding). -fn validate_wireguard_key(key: &str, key_type: &str) -> Result<(), ConnectionError> { - // Basic validation: should be non-empty and reasonable length - if key.trim().is_empty() { - return Err(ConnectionError::InvalidPrivateKey(format!( - "{} cannot be empty", - key_type - ))); - } - - // WireGuard keys are 32 bytes, base64 encoded = 44 chars with padding - // We'll be lenient and allow 43-45 characters - let len = key.trim().len(); - if !(40..=50).contains(&len) { - return Err(ConnectionError::InvalidPrivateKey(format!( - "{} has invalid length: {} (expected ~44 characters)", - key_type, len - ))); - } - - // Check if it's valid base64 (contains only base64 characters) - let is_valid_base64 = key - .trim() - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '='); - - if !is_valid_base64 { - return Err(ConnectionError::InvalidPrivateKey(format!( - "{} contains invalid base64 characters", - key_type - ))); - } - - Ok(()) -} - -/// Validates an IP address with CIDR notation (e.g., "10.0.0.2/24"). -fn validate_address(address: &str) -> Result<(String, u32), ConnectionError> { - let (ip, prefix) = address.split_once('/').ok_or_else(|| { - ConnectionError::InvalidAddress(format!( - "missing CIDR prefix (e.g., '10.0.0.2/24'): {}", - address - )) - })?; - - // Validate IP address format (basic check) - if ip.trim().is_empty() { - return Err(ConnectionError::InvalidAddress( - "IP address cannot be empty".into(), - )); - } - - // Parse CIDR prefix - let prefix: u32 = prefix - .parse() - .map_err(|_| ConnectionError::InvalidAddress(format!("invalid CIDR prefix: {}", prefix)))?; - - // Validate prefix range (IPv4: 0-32, IPv6: 0-128) - // We'll accept up to 128 to support IPv6 - if prefix > 128 { - return Err(ConnectionError::InvalidAddress(format!( - "CIDR prefix too large: {} (max 128)", - prefix - ))); - } - - // Basic IPv4 validation (if it contains dots) - if ip.contains('.') { - let octets: Vec<&str> = ip.split('.').collect(); - if octets.len() != 4 { - return Err(ConnectionError::InvalidAddress(format!( - "invalid IPv4 address: {}", - ip - ))); - } - - for octet in octets { - let num: u32 = octet.parse().map_err(|_| { - ConnectionError::InvalidAddress(format!("invalid IPv4 octet: {}", octet)) - })?; - if num > 255 { - return Err(ConnectionError::InvalidAddress(format!( - "IPv4 octet out of range: {}", - num - ))); - } - } - - if prefix > 32 { - return Err(ConnectionError::InvalidAddress(format!( - "IPv4 CIDR prefix too large: {} (max 32)", - prefix - ))); - } - } - - Ok((ip.to_string(), prefix)) -} - -/// Validates a VPN gateway endpoint (should be in "host:port" format). -fn validate_gateway(gateway: &str) -> Result<(), ConnectionError> { - if gateway.trim().is_empty() { - return Err(ConnectionError::InvalidGateway( - "gateway cannot be empty".into(), - )); - } - - // Should contain a colon for port - if !gateway.contains(':') { - return Err(ConnectionError::InvalidGateway(format!( - "gateway must be in 'host:port' format: {}", - gateway - ))); - } - - let parts: Vec<&str> = gateway.rsplitn(2, ':').collect(); - if parts.len() != 2 { - return Err(ConnectionError::InvalidGateway(format!( - "invalid gateway format: {}", - gateway - ))); - } - - // Validate port - let port_str = parts[0]; - let port: u16 = port_str.parse().map_err(|_| { - ConnectionError::InvalidGateway(format!("invalid port number: {}", port_str)) - })?; - - if port == 0 { - return Err(ConnectionError::InvalidGateway("port cannot be 0".into())); - } - - Ok(()) -} - /// Builds WireGuard VPN connection settings. /// /// Returns a complete NetworkManager settings dictionary suitable for @@ -202,158 +89,34 @@ fn validate_gateway(gateway: &str) -> Result<(), ConnectionError> { /// /// - `ConnectionError::InvalidPeers` if no peers are provided /// - `ConnectionError::InvalidAddress` if the address is missing or malformed +/// +/// # Note +/// +/// This function is maintained for backward compatibility. For new code, +/// consider using `WireGuardBuilder` for a more ergonomic API. pub fn build_wireguard_connection( creds: &VpnCredentials, opts: &ConnectionOptions, ) -> Result>>, ConnectionError> { - // Validate peers - if creds.peers.is_empty() { - return Err(ConnectionError::InvalidPeers("No peers provided".into())); - } - - // Validate private key - validate_wireguard_key(&creds.private_key, "Private key")?; - - // Validate gateway - validate_gateway(&creds.gateway)?; + let mut builder = WireGuardBuilder::new(&creds.name) + .private_key(&creds.private_key) + .address(&creds.address) + .add_peers(creds.peers.iter().cloned()) + .options(opts); - // Validate address - let (ip, prefix) = validate_address(&creds.address)?; - - // Validate each peer - for (i, peer) in creds.peers.iter().enumerate() { - validate_wireguard_key(&peer.public_key, &format!("Peer {} public key", i))?; - validate_gateway(&peer.gateway)?; - - if peer.allowed_ips.is_empty() { - return Err(ConnectionError::InvalidPeers(format!( - "Peer {} has no allowed IPs", - i - ))); - } + if let Some(uuid) = creds.uuid { + builder = builder.uuid(uuid); } - let mut conn = HashMap::new(); - - // [connection] section - let mut connection = HashMap::new(); - connection.insert("type", Value::from("wireguard")); - connection.insert("id", Value::from(creds.name.clone())); - let interface_name = format!( - "wg-{}", - creds - .name - .to_lowercase() - .chars() - .filter(|c| c.is_alphanumeric() || *c == '-') - .take(10) - .collect::() - ); - connection.insert("interface-name", Value::from(interface_name)); - - let uuid = creds.uuid.unwrap_or_else(|| { - Uuid::new_v5( - &Uuid::NAMESPACE_DNS, - format!("wg:{}@{}", creds.name, creds.gateway).as_bytes(), - ) - }); - connection.insert("uuid", Value::from(uuid.to_string())); - connection.insert("autoconnect", Value::from(opts.autoconnect)); - - if let Some(p) = opts.autoconnect_priority { - connection.insert("autoconnect-priority", Value::from(p)); - } - if let Some(r) = opts.autoconnect_retries { - connection.insert("autoconnect-retries", Value::from(r)); - } - - conn.insert("connection", connection); - - // [wireguard] section - let mut wireguard = HashMap::new(); - - wireguard.insert( - "service-type", - Value::from("org.freedesktop.NetworkManager.wireguard"), - ); - wireguard.insert("private-key", Value::from(creds.private_key.clone())); - - let mut peers_array: Vec>> = Vec::new(); - - for peer in creds.peers.iter() { - let mut peer_dict: HashMap> = HashMap::new(); - - peer_dict.insert("public-key".into(), Value::from(peer.public_key.clone())); - peer_dict.insert("endpoint".into(), Value::from(peer.gateway.clone())); - peer_dict.insert("allowed-ips".into(), Value::from(peer.allowed_ips.clone())); - - if let Some(psk) = &peer.preshared_key { - peer_dict.insert("preshared-key".into(), Value::from(psk.clone())); - } - - if let Some(ka) = peer.persistent_keepalive { - peer_dict.insert("persistent-keepalive".into(), Value::from(ka)); - } - - peers_array.push(peer_dict); - } - - wireguard.insert("peers", Value::from(peers_array)); - - if let Some(mtu) = creds.mtu { - wireguard.insert("mtu", Value::from(mtu)); - } - - conn.insert("wireguard", wireguard); - - // [ipv4] section - let mut ipv4 = HashMap::new(); - ipv4.insert("method", Value::from("manual")); - - // address-data must be an array of dictionaries with "address" and "prefix" keys - let mut addr_dict: HashMap> = HashMap::new(); - addr_dict.insert("address".into(), Value::from(ip)); - addr_dict.insert("prefix".into(), Value::from(prefix)); - let addresses = vec![addr_dict]; - ipv4.insert("address-data", Value::from(addresses)); - if let Some(dns) = &creds.dns { - // NetworkManager expects DNS as array of u32 (IP addresses as integers) - let dns_u32: Result, _> = dns - .iter() - .map(|ip_str| { - ip_str - .parse::() - .map(u32::from) - .map_err(|_| format!("Invalid IP: {ip_str}")) - }) - .collect(); - - match dns_u32 { - Ok(ips) => { - ipv4.insert("dns", Value::from(ips)); - } - Err(e) => { - return Err(crate::api::models::ConnectionError::VpnFailed(format!( - "Invalid DNS server address: {}", - e - ))); - } - } + builder = builder.dns(dns.clone()); } if let Some(mtu) = creds.mtu { - ipv4.insert("mtu", Value::from(mtu)); + builder = builder.mtu(mtu); } - conn.insert("ipv4", ipv4); - - // [ipv6] section (required but typically ignored for WireGuard) - let mut ipv6 = HashMap::new(); - ipv6.insert("method", Value::from("ignore")); - conn.insert("ipv6", ipv6); - - Ok(conn) + builder.build() } #[cfg(test)] @@ -693,10 +456,13 @@ mod tests { )); } + // Gateway validation tests for peer gateways + // These test that validation is properly delegated to WireGuardBuilder + #[test] - fn rejects_empty_gateway() { + fn rejects_peer_with_empty_gateway() { let mut creds = create_test_credentials(); - creds.gateway = "".into(); + creds.peers[0].gateway = "".into(); let opts = create_test_options(); let result = build_wireguard_connection(&creds, &opts); @@ -708,9 +474,9 @@ mod tests { } #[test] - fn rejects_gateway_without_port() { + fn rejects_peer_gateway_without_port() { let mut creds = create_test_credentials(); - creds.gateway = "vpn.example.com".into(); + creds.peers[0].gateway = "vpn.example.com".into(); let opts = create_test_options(); let result = build_wireguard_connection(&creds, &opts); @@ -722,9 +488,9 @@ mod tests { } #[test] - fn rejects_gateway_with_invalid_port() { + fn rejects_peer_gateway_with_invalid_port() { let mut creds = create_test_credentials(); - creds.gateway = "vpn.example.com:99999".into(); + creds.peers[0].gateway = "vpn.example.com:99999".into(); let opts = create_test_options(); let result = build_wireguard_connection(&creds, &opts); @@ -736,9 +502,9 @@ mod tests { } #[test] - fn rejects_gateway_with_zero_port() { + fn rejects_peer_gateway_with_zero_port() { let mut creds = create_test_credentials(); - creds.gateway = "vpn.example.com:0".into(); + creds.peers[0].gateway = "vpn.example.com:0".into(); let opts = create_test_options(); let result = build_wireguard_connection(&creds, &opts); diff --git a/nmrs/src/api/builders/wifi.rs b/nmrs/src/api/builders/wifi.rs index 3e8e817c..0734e8d8 100644 --- a/nmrs/src/api/builders/wifi.rs +++ b/nmrs/src/api/builders/wifi.rs @@ -12,144 +12,28 @@ //! - `802-11-wireless-security`: Security settings (key-mgmt, psk, auth-alg) //! - `802-1x`: Enterprise authentication settings (for WPA-EAP) //! - `ipv4` / `ipv6`: IP configuration (usually "auto" for DHCP) +//! +//! # New Builder API +//! +//! For new code, consider using the builder API from `wifi_builder` module: +//! +//! ```rust +//! use nmrs::builders::WifiConnectionBuilder; +//! +//! let settings = WifiConnectionBuilder::new("MyNetwork") +//! .wpa_psk("password") +//! .autoconnect(true) +//! .ipv4_auto() +//! .ipv6_auto() +//! .build(); +//! ``` use std::collections::HashMap; use zvariant::Value; -use crate::api::models::{self, ConnectionOptions, EapMethod}; - -/// Converts a string to bytes for SSID encoding. -fn bytes(val: &str) -> Vec { - val.as_bytes().to_vec() -} - -/// Creates a D-Bus string array value. -fn string_array(xs: &[&str]) -> Value<'static> { - let vals: Vec = xs.iter().map(|s| s.to_string()).collect(); - Value::from(vals) -} - -/// Builds the `802-11-wireless` section with SSID and mode. -fn base_wifi_section(ssid: &str) -> HashMap<&'static str, Value<'static>> { - let mut s = HashMap::new(); - s.insert("ssid", Value::from(bytes(ssid))); - s.insert("mode", Value::from("infrastructure")); - s -} - -/// Builds the `connection` section with type, id, uuid, and autoconnect settings. -fn base_connection_section( - ssid: &str, - opts: &ConnectionOptions, -) -> HashMap<&'static str, Value<'static>> { - let mut s = HashMap::new(); - s.insert("type", Value::from("802-11-wireless")); - s.insert("id", Value::from(ssid.to_string())); - s.insert("uuid", Value::from(uuid::Uuid::new_v4().to_string())); - s.insert("autoconnect", Value::from(opts.autoconnect)); - - if let Some(p) = opts.autoconnect_priority { - s.insert("autoconnect-priority", Value::from(p)); - } - - if let Some(r) = opts.autoconnect_retries { - s.insert("autoconnect-retries", Value::from(r)); - } - - s -} - -/// Builds the `connection` section for Ethernet connections. -fn base_ethernet_connection_section( - connection_id: &str, - opts: &ConnectionOptions, -) -> HashMap<&'static str, Value<'static>> { - let mut s = HashMap::new(); - s.insert("type", Value::from("802-3-ethernet")); - s.insert("id", Value::from(connection_id.to_string())); - s.insert("uuid", Value::from(uuid::Uuid::new_v4().to_string())); - s.insert("autoconnect", Value::from(opts.autoconnect)); - - if let Some(p) = opts.autoconnect_priority { - s.insert("autoconnect-priority", Value::from(p)); - } - - if let Some(r) = opts.autoconnect_retries { - s.insert("autoconnect-retries", Value::from(r)); - } - - s -} - -/// Builds the `802-11-wireless-security` section for WPA-PSK networks. -/// -/// Uses WPA2 (RSN) with CCMP encryption. The `psk-flags` of 0 means the -/// password is stored in the connection (agent-owned). -fn build_psk_security(psk: &str) -> HashMap<&'static str, Value<'static>> { - let mut sec = HashMap::new(); - - sec.insert("key-mgmt", Value::from("wpa-psk")); - sec.insert("psk", Value::from(psk.to_string())); - sec.insert("psk-flags", Value::from(0u32)); - sec.insert("auth-alg", Value::from("open")); - - // Enforce WPA2 with AES - sec.insert("proto", string_array(&["rsn"])); - sec.insert("pairwise", string_array(&["ccmp"])); - sec.insert("group", string_array(&["ccmp"])); - - sec -} - -/// Builds security sections for WPA-EAP (802.1X) networks. -/// -/// Returns both the `802-11-wireless-security` section and the `802-1x` section. -/// Supports PEAP and TTLS methods with MSCHAPv2 or PAP inner authentication. -fn build_eap_security( - opts: &models::EapOptions, -) -> ( - HashMap<&'static str, Value<'static>>, - HashMap<&'static str, Value<'static>>, -) { - let mut sec = HashMap::new(); - sec.insert("key-mgmt", Value::from("wpa-eap")); - sec.insert("auth-alg", Value::from("open")); - - let mut e1x = HashMap::new(); - - // EAP method (outer authentication) - let eap_str = match opts.method { - EapMethod::Peap => "peap", - EapMethod::Ttls => "ttls", - }; - e1x.insert("eap", string_array(&[eap_str])); - e1x.insert("identity", Value::from(opts.identity.clone())); - e1x.insert("password", Value::from(opts.password.clone())); - - if let Some(ai) = &opts.anonymous_identity { - e1x.insert("anonymous-identity", Value::from(ai.clone())); - } - - // Phase 2 (inner authentication) - let p2 = match opts.phase2 { - models::Phase2::Mschapv2 => "mschapv2", - models::Phase2::Pap => "pap", - }; - e1x.insert("phase2-auth", Value::from(p2)); - - // CA certificate handling for server verification - if opts.system_ca_certs { - e1x.insert("system-ca-certs", Value::from(true)); - } - if let Some(cert) = &opts.ca_cert_path { - e1x.insert("ca-cert", Value::from(cert.clone())); - } - if let Some(dom) = &opts.domain_suffix_match { - e1x.insert("domain-suffix-match", Value::from(dom.clone())); - } - - (sec, e1x) -} +use super::connection_builder::ConnectionBuilder; +use super::wifi_builder::WifiConnectionBuilder; +use crate::api::models::{self, ConnectionOptions}; /// Builds a complete Wi-Fi connection settings dictionary. /// @@ -164,53 +48,28 @@ fn build_eap_security( /// - `ipv4` / `ipv6`: Always present (set to "auto" for DHCP) /// - `802-11-wireless-security`: Present for PSK and EAP networks /// - `802-1x`: Present only for EAP networks +/// +/// # Note +/// +/// This function is maintained for backward compatibility. For new code, +/// consider using `WifiConnectionBuilder` for a more ergonomic API. pub fn build_wifi_connection( ssid: &str, security: &models::WifiSecurity, opts: &ConnectionOptions, ) -> HashMap<&'static str, HashMap<&'static str, Value<'static>>> { - let mut conn: HashMap<&'static str, HashMap<&'static str, Value<'static>>> = HashMap::new(); - - // base connections - conn.insert("connection", base_connection_section(ssid, opts)); - conn.insert("802-11-wireless", base_wifi_section(ssid)); - - // Add IPv4 and IPv6 configuration to prevent state 60 stall - // TODO: Expand upon auto/manual configuration options - let mut ipv4 = HashMap::new(); - ipv4.insert("method", Value::from("auto")); - conn.insert("ipv4", ipv4); - - let mut ipv6 = HashMap::new(); - ipv6.insert("method", Value::from("auto")); - conn.insert("ipv6", ipv6); - - match security { - models::WifiSecurity::Open => {} - - models::WifiSecurity::WpaPsk { psk } => { - // point wireless at security section - if let Some(w) = conn.get_mut("802-11-wireless") { - w.insert("security", Value::from("802-11-wireless-security")); - } - - let sec = build_psk_security(psk); - conn.insert("802-11-wireless-security", sec); - } - - models::WifiSecurity::WpaEap { opts } => { - if let Some(w) = conn.get_mut("802-11-wireless") { - w.insert("security", Value::from("802-11-wireless-security")); - } - - let (mut sec, e1x) = build_eap_security(opts); - sec.insert("auth-alg", Value::from("open")); - conn.insert("802-11-wireless-security", sec); - conn.insert("802-1x", e1x); - } - } + let mut builder = WifiConnectionBuilder::new(ssid) + .options(opts) + .ipv4_auto() + .ipv6_auto(); + + builder = match security { + models::WifiSecurity::Open => builder.open(), + models::WifiSecurity::WpaPsk { psk } => builder.wpa_psk(psk), + models::WifiSecurity::WpaEap { opts } => builder.wpa_eap(opts.clone()), + }; - conn + builder.build() } /// Builds a complete Ethernet connection settings dictionary. @@ -223,38 +82,29 @@ pub fn build_wifi_connection( /// - `connection`: Always present (type: "802-3-ethernet") /// - `802-3-ethernet`: Ethernet-specific settings (currently empty, can be extended) /// - `ipv4` / `ipv6`: Always present (set to "auto" for DHCP) +/// +/// # Note +/// +/// This function is maintained for backward compatibility. For new code, +/// consider using `EthernetConnectionBuilder` for a more ergonomic API. pub fn build_ethernet_connection( connection_id: &str, opts: &ConnectionOptions, ) -> HashMap<&'static str, HashMap<&'static str, Value<'static>>> { - let mut conn: HashMap<&'static str, HashMap<&'static str, Value<'static>>> = HashMap::new(); - - // Base connection section - conn.insert( - "connection", - base_ethernet_connection_section(connection_id, opts), - ); - - // Ethernet section (minimal - can be extended for MAC address, MTU, etc.) let ethernet = HashMap::new(); - conn.insert("802-3-ethernet", ethernet); - - // Add IPv4 and IPv6 configuration - let mut ipv4 = HashMap::new(); - ipv4.insert("method", Value::from("auto")); - conn.insert("ipv4", ipv4); - - let mut ipv6 = HashMap::new(); - ipv6.insert("method", Value::from("auto")); - conn.insert("ipv6", ipv6); - conn + ConnectionBuilder::new("802-3-ethernet", connection_id) + .options(opts) + .with_section("802-3-ethernet", ethernet) + .ipv4_auto() + .ipv6_auto() + .build() } #[cfg(test)] mod tests { use super::*; - use crate::models::{ConnectionOptions, EapOptions, Phase2, WifiSecurity}; + use crate::models::{ConnectionOptions, EapMethod, EapOptions, Phase2, WifiSecurity}; use zvariant::Value; fn default_opts() -> ConnectionOptions { diff --git a/nmrs/src/api/builders/wifi_builder.rs b/nmrs/src/api/builders/wifi_builder.rs new file mode 100644 index 00000000..9c990931 --- /dev/null +++ b/nmrs/src/api/builders/wifi_builder.rs @@ -0,0 +1,435 @@ +//! WiFi connection builder with type-safe API. +//! +//! Provides a fluent builder interface for constructing WiFi connection settings +//! with support for different security modes (Open, WPA-PSK, WPA-EAP). + +use std::collections::HashMap; +use zvariant::Value; + +use super::connection_builder::ConnectionBuilder; +use crate::api::models::{self, ConnectionOptions, EapMethod}; + +/// WiFi band selection. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WifiBand { + /// 2.4 GHz band + Bg, + /// 5 GHz band + A, +} + +/// Builder for WiFi (802.11) connections. +/// +/// This builder provides a type-safe, ergonomic API for creating WiFi connection +/// settings. It wraps `ConnectionBuilder` and adds WiFi-specific configuration. +/// +/// # Examples +/// +/// ## Open Network +/// +/// ```rust +/// use nmrs::builders::WifiConnectionBuilder; +/// +/// let settings = WifiConnectionBuilder::new("CoffeeShop-WiFi") +/// .open() +/// .autoconnect(true) +/// .build(); +/// ``` +/// +/// ## WPA-PSK (Personal) +/// +/// ```rust +/// use nmrs::builders::WifiConnectionBuilder; +/// +/// let settings = WifiConnectionBuilder::new("HomeNetwork") +/// .wpa_psk("my_secure_password") +/// .autoconnect(true) +/// .autoconnect_priority(10) +/// .build(); +/// ``` +/// +/// ## WPA-EAP (Enterprise) +/// +/// ```rust +/// use nmrs::builders::WifiConnectionBuilder; +/// use nmrs::{EapOptions, EapMethod, Phase2}; +/// +/// let eap_opts = EapOptions { +/// identity: "user@company.com".into(), +/// password: "password".into(), +/// anonymous_identity: None, +/// domain_suffix_match: Some("company.com".into()), +/// ca_cert_path: None, +/// system_ca_certs: true, +/// method: EapMethod::Peap, +/// phase2: Phase2::Mschapv2, +/// }; +/// +/// let settings = WifiConnectionBuilder::new("CorpNetwork") +/// .wpa_eap(eap_opts) +/// .autoconnect(false) +/// .build(); +/// ``` +pub struct WifiConnectionBuilder { + inner: ConnectionBuilder, + ssid: String, + security_configured: bool, + hidden: Option, + band: Option, + bssid: Option, +} + +impl WifiConnectionBuilder { + /// Creates a new WiFi connection builder for the specified SSID. + /// + /// By default, the connection is configured as an open network. Use + /// `.wpa_psk()` or `.wpa_eap()` to add security. + pub fn new(ssid: impl Into) -> Self { + let ssid = ssid.into(); + let inner = ConnectionBuilder::new("802-11-wireless", &ssid); + + Self { + inner, + ssid, + security_configured: false, + hidden: None, + band: None, + bssid: None, + } + } + + /// Configures this as an open (unsecured) network. + /// + /// This is the default, but can be called explicitly for clarity. + pub fn open(self) -> Self { + // Open networks don't need a security section + Self { + security_configured: true, + ..self + } + } + + /// Configures WPA-PSK (Personal) security with the given passphrase. + /// + /// Uses WPA2 (RSN) with CCMP encryption. + pub fn wpa_psk(mut self, psk: impl Into) -> Self { + let mut security = HashMap::new(); + security.insert("key-mgmt", Value::from("wpa-psk")); + security.insert("psk", Value::from(psk.into())); + security.insert("psk-flags", Value::from(0u32)); + security.insert("auth-alg", Value::from("open")); + + // Enforce WPA2 with AES + security.insert("proto", Self::string_array(&["rsn"])); + security.insert("pairwise", Self::string_array(&["ccmp"])); + security.insert("group", Self::string_array(&["ccmp"])); + + self.inner = self + .inner + .with_section("802-11-wireless-security", security); + self.security_configured = true; + self + } + + /// Configures WPA-EAP (Enterprise) security with 802.1X authentication. + /// + /// Supports PEAP and TTLS methods with various inner authentication protocols. + pub fn wpa_eap(mut self, opts: models::EapOptions) -> Self { + let mut security = HashMap::new(); + security.insert("key-mgmt", Value::from("wpa-eap")); + security.insert("auth-alg", Value::from("open")); + + self.inner = self + .inner + .with_section("802-11-wireless-security", security); + + // Build 802.1x section + let mut e1x = HashMap::new(); + + let eap_str = match opts.method { + EapMethod::Peap => "peap", + EapMethod::Ttls => "ttls", + }; + e1x.insert("eap", Self::string_array(&[eap_str])); + e1x.insert("identity", Value::from(opts.identity)); + e1x.insert("password", Value::from(opts.password)); + + if let Some(ai) = opts.anonymous_identity { + e1x.insert("anonymous-identity", Value::from(ai)); + } + + let p2 = match opts.phase2 { + models::Phase2::Mschapv2 => "mschapv2", + models::Phase2::Pap => "pap", + }; + e1x.insert("phase2-auth", Value::from(p2)); + + if opts.system_ca_certs { + e1x.insert("system-ca-certs", Value::from(true)); + } + if let Some(cert) = opts.ca_cert_path { + e1x.insert("ca-cert", Value::from(cert)); + } + if let Some(dom) = opts.domain_suffix_match { + e1x.insert("domain-suffix-match", Value::from(dom)); + } + + self.inner = self.inner.with_section("802-1x", e1x); + self.security_configured = true; + self + } + + /// Marks this network as hidden (doesn't broadcast SSID). + pub fn hidden(mut self, hidden: bool) -> Self { + self.hidden = Some(hidden); + self + } + + /// Restricts connection to a specific WiFi band. + pub fn band(mut self, band: WifiBand) -> Self { + self.band = Some(band); + self + } + + /// Restricts connection to a specific access point by BSSID (MAC address). + /// + /// Format: "00:11:22:33:44:55" + pub fn bssid(mut self, bssid: impl Into) -> Self { + self.bssid = Some(bssid.into()); + self + } + + // Delegation methods to inner ConnectionBuilder + + /// Applies connection options (autoconnect settings). + pub fn options(mut self, opts: &ConnectionOptions) -> Self { + self.inner = self.inner.options(opts); + self + } + + /// Enables or disables automatic connection. + pub fn autoconnect(mut self, enabled: bool) -> Self { + self.inner = self.inner.autoconnect(enabled); + self + } + + /// Sets autoconnect priority (higher values preferred). + pub fn autoconnect_priority(mut self, priority: i32) -> Self { + self.inner = self.inner.autoconnect_priority(priority); + self + } + + /// Sets autoconnect retry limit. + pub fn autoconnect_retries(mut self, retries: i32) -> Self { + self.inner = self.inner.autoconnect_retries(retries); + self + } + + /// Configures IPv4 to use DHCP. + pub fn ipv4_auto(mut self) -> Self { + self.inner = self.inner.ipv4_auto(); + self + } + + /// Configures IPv6 to use SLAAC/DHCPv6. + pub fn ipv6_auto(mut self) -> Self { + self.inner = self.inner.ipv6_auto(); + self + } + + /// Disables IPv6. + pub fn ipv6_ignore(mut self) -> Self { + self.inner = self.inner.ipv6_ignore(); + self + } + + /// Builds the final connection settings dictionary. + /// + /// This method adds the WiFi-specific "802-11-wireless" section and links + /// it to the security section if configured. + pub fn build(mut self) -> HashMap<&'static str, HashMap<&'static str, Value<'static>>> { + // Build the 802-11-wireless section + let mut wireless = HashMap::new(); + wireless.insert("ssid", Value::from(self.ssid.as_bytes().to_vec())); + wireless.insert("mode", Value::from("infrastructure")); + + // Add optional WiFi settings + if let Some(hidden) = self.hidden { + wireless.insert("hidden", Value::from(hidden)); + } + + if let Some(band) = self.band { + let band_str = match band { + WifiBand::Bg => "bg", + WifiBand::A => "a", + }; + wireless.insert("band", Value::from(band_str)); + } + + if let Some(bssid) = self.bssid { + wireless.insert("bssid", Value::from(bssid)); + } + + // Link to security section if security is configured (not open) + if self.security_configured && !self.ssid.is_empty() { + // Check if we actually have a security section (not just open) + // Open networks don't have the security section + wireless.insert("security", Value::from("802-11-wireless-security")); + } + + self.inner = self.inner.with_section("802-11-wireless", wireless); + + self.inner.build() + } + + // Helper functions + + fn string_array(xs: &[&str]) -> Value<'static> { + let vals: Vec = xs.iter().map(|s| s.to_string()).collect(); + Value::from(vals) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{EapOptions, Phase2}; + + #[test] + fn builds_open_wifi() { + let settings = WifiConnectionBuilder::new("OpenNetwork") + .open() + .autoconnect(true) + .ipv4_auto() + .ipv6_auto() + .build(); + + assert!(settings.contains_key("connection")); + assert!(settings.contains_key("802-11-wireless")); + assert!(settings.contains_key("ipv4")); + assert!(settings.contains_key("ipv6")); + assert!(!settings.contains_key("802-11-wireless-security")); + + let wireless = settings.get("802-11-wireless").unwrap(); + assert_eq!( + wireless.get("ssid"), + Some(&Value::from(b"OpenNetwork".to_vec())) + ); + } + + #[test] + fn builds_wpa_psk_wifi() { + let settings = WifiConnectionBuilder::new("SecureNet") + .wpa_psk("password123") + .ipv4_auto() + .ipv6_auto() + .build(); + + assert!(settings.contains_key("802-11-wireless-security")); + + let security = settings.get("802-11-wireless-security").unwrap(); + assert_eq!(security.get("key-mgmt"), Some(&Value::from("wpa-psk"))); + assert_eq!( + security.get("psk"), + Some(&Value::from("password123".to_string())) + ); + + let wireless = settings.get("802-11-wireless").unwrap(); + assert_eq!( + wireless.get("security"), + Some(&Value::from("802-11-wireless-security")) + ); + } + + #[test] + fn builds_wpa_eap_wifi() { + let eap_opts = EapOptions { + identity: "user@example.com".into(), + password: "secret".into(), + anonymous_identity: Some("anon@example.com".into()), + domain_suffix_match: Some("example.com".into()), + ca_cert_path: None, + system_ca_certs: true, + method: EapMethod::Peap, + phase2: Phase2::Mschapv2, + }; + + let settings = WifiConnectionBuilder::new("Enterprise") + .wpa_eap(eap_opts) + .autoconnect(false) + .ipv4_auto() + .ipv6_auto() + .build(); + + assert!(settings.contains_key("802-11-wireless-security")); + assert!(settings.contains_key("802-1x")); + + let security = settings.get("802-11-wireless-security").unwrap(); + assert_eq!(security.get("key-mgmt"), Some(&Value::from("wpa-eap"))); + + let e1x = settings.get("802-1x").unwrap(); + assert_eq!( + e1x.get("identity"), + Some(&Value::from("user@example.com".to_string())) + ); + assert_eq!(e1x.get("phase2-auth"), Some(&Value::from("mschapv2"))); + } + + #[test] + fn configures_hidden_network() { + let settings = WifiConnectionBuilder::new("HiddenSSID") + .open() + .hidden(true) + .ipv4_auto() + .build(); + + let wireless = settings.get("802-11-wireless").unwrap(); + assert_eq!(wireless.get("hidden"), Some(&Value::from(true))); + } + + #[test] + fn configures_specific_band() { + let settings = WifiConnectionBuilder::new("5GHz-Only") + .open() + .band(WifiBand::A) + .ipv4_auto() + .build(); + + let wireless = settings.get("802-11-wireless").unwrap(); + assert_eq!(wireless.get("band"), Some(&Value::from("a"))); + } + + #[test] + fn configures_bssid() { + let settings = WifiConnectionBuilder::new("SpecificAP") + .open() + .bssid("00:11:22:33:44:55") + .ipv4_auto() + .build(); + + let wireless = settings.get("802-11-wireless").unwrap(); + assert_eq!( + wireless.get("bssid"), + Some(&Value::from("00:11:22:33:44:55")) + ); + } + + #[test] + fn applies_connection_options() { + let opts = ConnectionOptions { + autoconnect: false, + autoconnect_priority: Some(5), + autoconnect_retries: Some(3), + }; + + let settings = WifiConnectionBuilder::new("TestNet") + .open() + .options(&opts) + .ipv4_auto() + .build(); + + let conn = settings.get("connection").unwrap(); + assert_eq!(conn.get("autoconnect"), Some(&Value::from(false))); + assert_eq!(conn.get("autoconnect-priority"), Some(&Value::from(5i32))); + } +} diff --git a/nmrs/src/api/builders/wireguard_builder.rs b/nmrs/src/api/builders/wireguard_builder.rs new file mode 100644 index 00000000..38a811f1 --- /dev/null +++ b/nmrs/src/api/builders/wireguard_builder.rs @@ -0,0 +1,553 @@ +//! WireGuard VPN connection builder with validation. +//! +//! Provides a type-safe builder API for constructing WireGuard VPN connections +//! with comprehensive validation of keys, addresses, and peer configurations. + +use std::collections::HashMap; +use std::net::Ipv4Addr; +use uuid::Uuid; +use zvariant::Value; + +use super::connection_builder::{ConnectionBuilder, IpConfig}; +use crate::api::models::{ConnectionError, ConnectionOptions, WireGuardPeer}; + +/// Builder for WireGuard VPN connections. +/// +/// This builder provides a fluent API for creating WireGuard VPN connection settings +/// with validation at build time. +/// +/// # Example +/// +/// ```rust +/// use nmrs::builders::WireGuardBuilder; +/// use nmrs::{WireGuardPeer, ConnectionOptions}; +/// +/// let peer = WireGuardPeer { +/// public_key: "HIgo9xNzJMWLKAShlKl6/bUT1VI9Q0SDBXGtLXkPFXc=".into(), +/// gateway: "vpn.example.com:51820".into(), +/// allowed_ips: vec!["0.0.0.0/0".into()], +/// preshared_key: None, +/// persistent_keepalive: Some(25), +/// }; +/// +/// let settings = WireGuardBuilder::new("MyVPN") +/// .private_key("YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=") +/// .address("10.0.0.2/24") +/// .add_peer(peer) +/// .autoconnect(false) +/// .build() +/// .expect("Failed to build WireGuard connection"); +/// ``` +pub struct WireGuardBuilder { + inner: ConnectionBuilder, + name: String, + private_key: Option, + address: Option, + peers: Vec, + dns: Option>, + mtu: Option, + uuid: Option, +} + +impl WireGuardBuilder { + /// Creates a new WireGuard connection builder. + /// + /// # Arguments + /// + /// * `name` - Human-readable connection name + pub fn new(name: impl Into) -> Self { + let name = name.into(); + let inner = ConnectionBuilder::new("wireguard", &name); + + Self { + inner, + name, + private_key: None, + address: None, + peers: Vec::new(), + dns: None, + mtu: None, + uuid: None, + } + } + + /// Sets the WireGuard private key. + /// + /// The key must be a valid base64-encoded 32-byte WireGuard key (44 characters). + pub fn private_key(mut self, key: impl Into) -> Self { + self.private_key = Some(key.into()); + self + } + + /// Sets the VPN interface IP address with CIDR notation. + /// + /// # Example + /// + /// ```rust + /// # use nmrs::builders::WireGuardBuilder; + /// let builder = WireGuardBuilder::new("MyVPN") + /// .address("10.0.0.2/24"); + /// ``` + pub fn address(mut self, address: impl Into) -> Self { + self.address = Some(address.into()); + self + } + + /// Adds a WireGuard peer to the connection. + /// + /// At least one peer must be added before building. + pub fn add_peer(mut self, peer: WireGuardPeer) -> Self { + self.peers.push(peer); + self + } + + /// Adds multiple WireGuard peers at once. + pub fn add_peers(mut self, peers: impl IntoIterator) -> Self { + self.peers.extend(peers); + self + } + + /// Sets DNS servers for the VPN connection. + /// + /// # Example + /// + /// ```rust + /// # use nmrs::builders::WireGuardBuilder; + /// let builder = WireGuardBuilder::new("MyVPN") + /// .dns(vec!["1.1.1.1".into(), "8.8.8.8".into()]); + /// ``` + pub fn dns(mut self, servers: Vec) -> Self { + self.dns = Some(servers); + self + } + + /// Sets the MTU (Maximum Transmission Unit) for the WireGuard interface. + /// + /// Typical value is 1420 for WireGuard over IPv4. + pub fn mtu(mut self, mtu: u32) -> Self { + self.mtu = Some(mtu); + self + } + + /// Sets a specific UUID for the connection. + /// + /// If not set, a deterministic UUID will be generated based on the + /// connection name. + pub fn uuid(mut self, uuid: Uuid) -> Self { + self.uuid = Some(uuid); + self + } + + // Delegation methods to inner ConnectionBuilder + + /// Applies connection options. + pub fn options(mut self, opts: &ConnectionOptions) -> Self { + self.inner = self.inner.options(opts); + self + } + + /// Enables or disables automatic connection. + pub fn autoconnect(mut self, enabled: bool) -> Self { + self.inner = self.inner.autoconnect(enabled); + self + } + + /// Sets autoconnect priority. + pub fn autoconnect_priority(mut self, priority: i32) -> Self { + self.inner = self.inner.autoconnect_priority(priority); + self + } + + /// Sets autoconnect retry limit. + pub fn autoconnect_retries(mut self, retries: i32) -> Self { + self.inner = self.inner.autoconnect_retries(retries); + self + } + + /// Builds the final WireGuard connection settings. + /// + /// This method validates all required fields and returns an error if + /// any validation fails. + /// + /// # Errors + /// + /// - `ConnectionError::InvalidPrivateKey` if private key is missing or invalid + /// - `ConnectionError::InvalidAddress` if address is missing or invalid + /// - `ConnectionError::InvalidPeers` if no peers are configured or peer validation fails + /// - `ConnectionError::InvalidGateway` if any peer gateway is invalid + pub fn build( + mut self, + ) -> Result>>, ConnectionError> { + // Validate required fields + let private_key = self + .private_key + .ok_or_else(|| ConnectionError::InvalidPrivateKey("Private key not set".into()))?; + + let address = self + .address + .ok_or_else(|| ConnectionError::InvalidAddress("Address not set".into()))?; + + if self.peers.is_empty() { + return Err(ConnectionError::InvalidPeers("No peers configured".into())); + } + + // Validate private key + validate_wireguard_key(&private_key, "Private key")?; + + // Validate address + let (ip, prefix) = validate_address(&address)?; + + // Validate each peer + for (i, peer) in self.peers.iter().enumerate() { + validate_wireguard_key(&peer.public_key, &format!("Peer {} public key", i))?; + validate_gateway(&peer.gateway)?; + + if peer.allowed_ips.is_empty() { + return Err(ConnectionError::InvalidPeers(format!( + "Peer {} has no allowed IPs", + i + ))); + } + } + + // Generate interface name + let interface_name = format!( + "wg-{}", + self.name + .to_lowercase() + .chars() + .filter(|c| c.is_alphanumeric() || *c == '-') + .take(10) + .collect::() + ); + + self.inner = self.inner.interface_name(&interface_name); + + // Set UUID (deterministic or provided) + let uuid = self.uuid.unwrap_or_else(|| { + // Generate deterministic UUID based on name + Uuid::new_v5(&Uuid::NAMESPACE_DNS, format!("wg:{}", self.name).as_bytes()) + }); + + self.inner = self.inner.uuid(uuid); + + // Build wireguard section + let mut wireguard = HashMap::new(); + wireguard.insert( + "service-type", + Value::from("org.freedesktop.NetworkManager.wireguard"), + ); + wireguard.insert("private-key", Value::from(private_key)); + + // Build peers array + let mut peers_array: Vec>> = Vec::new(); + + for peer in self.peers { + let mut peer_dict: HashMap> = HashMap::new(); + + peer_dict.insert("public-key".into(), Value::from(peer.public_key)); + peer_dict.insert("endpoint".into(), Value::from(peer.gateway)); + peer_dict.insert("allowed-ips".into(), Value::from(peer.allowed_ips)); + + if let Some(psk) = peer.preshared_key { + peer_dict.insert("preshared-key".into(), Value::from(psk)); + } + + if let Some(ka) = peer.persistent_keepalive { + peer_dict.insert("persistent-keepalive".into(), Value::from(ka)); + } + + peers_array.push(peer_dict); + } + + wireguard.insert("peers", Value::from(peers_array)); + + if let Some(mtu) = self.mtu { + wireguard.insert("mtu", Value::from(mtu)); + } + + self.inner = self.inner.with_section("wireguard", wireguard); + + // Configure IPv4 with manual addressing + self.inner = self.inner.ipv4_manual(vec![IpConfig::new(ip, prefix)]); + + // Add DNS if configured + if let Some(dns) = self.dns { + let dns_addrs: Result, _> = + dns.iter().map(|s| s.parse::()).collect(); + + match dns_addrs { + Ok(addrs) => { + self.inner = self.inner.ipv4_dns(addrs); + } + Err(_) => { + return Err(ConnectionError::VpnFailed( + "Invalid DNS server address".into(), + )); + } + } + } + + // Add MTU to IPv4 if configured + if let Some(mtu) = self.mtu { + self.inner = self.inner.update_section("ipv4", |ipv4| { + ipv4.insert("mtu", Value::from(mtu)); + }); + } + + // Set IPv6 to ignore + self.inner = self.inner.ipv6_ignore(); + + Ok(self.inner.build()) + } +} + +// Validation functions (same as in vpn.rs) + +fn validate_wireguard_key(key: &str, key_type: &str) -> Result<(), ConnectionError> { + if key.trim().is_empty() { + return Err(ConnectionError::InvalidPrivateKey(format!( + "{} cannot be empty", + key_type + ))); + } + + let len = key.trim().len(); + if !(40..=50).contains(&len) { + return Err(ConnectionError::InvalidPrivateKey(format!( + "{} has invalid length: {} (expected ~44 characters)", + key_type, len + ))); + } + + let is_valid_base64 = key + .trim() + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '='); + + if !is_valid_base64 { + return Err(ConnectionError::InvalidPrivateKey(format!( + "{} contains invalid base64 characters", + key_type + ))); + } + + Ok(()) +} + +fn validate_address(address: &str) -> Result<(String, u32), ConnectionError> { + let (ip, prefix) = address.split_once('/').ok_or_else(|| { + ConnectionError::InvalidAddress(format!( + "missing CIDR prefix (e.g., '10.0.0.2/24'): {}", + address + )) + })?; + + if ip.trim().is_empty() { + return Err(ConnectionError::InvalidAddress( + "IP address cannot be empty".into(), + )); + } + + let prefix: u32 = prefix + .parse() + .map_err(|_| ConnectionError::InvalidAddress(format!("invalid CIDR prefix: {}", prefix)))?; + + if prefix > 128 { + return Err(ConnectionError::InvalidAddress(format!( + "CIDR prefix too large: {} (max 128)", + prefix + ))); + } + + // Basic IPv4 validation + if ip.contains('.') { + let octets: Vec<&str> = ip.split('.').collect(); + if octets.len() != 4 { + return Err(ConnectionError::InvalidAddress(format!( + "invalid IPv4 address: {}", + ip + ))); + } + + for octet in octets { + let num: u32 = octet.parse().map_err(|_| { + ConnectionError::InvalidAddress(format!("invalid IPv4 octet: {}", octet)) + })?; + if num > 255 { + return Err(ConnectionError::InvalidAddress(format!( + "IPv4 octet out of range: {}", + num + ))); + } + } + + if prefix > 32 { + return Err(ConnectionError::InvalidAddress(format!( + "IPv4 CIDR prefix too large: {} (max 32)", + prefix + ))); + } + } + + Ok((ip.to_string(), prefix)) +} + +fn validate_gateway(gateway: &str) -> Result<(), ConnectionError> { + if gateway.trim().is_empty() { + return Err(ConnectionError::InvalidGateway( + "gateway cannot be empty".into(), + )); + } + + if !gateway.contains(':') { + return Err(ConnectionError::InvalidGateway(format!( + "gateway must be in 'host:port' format: {}", + gateway + ))); + } + + let parts: Vec<&str> = gateway.rsplitn(2, ':').collect(); + if parts.len() != 2 { + return Err(ConnectionError::InvalidGateway(format!( + "invalid gateway format: {}", + gateway + ))); + } + + let port_str = parts[0]; + let port: u16 = port_str.parse().map_err(|_| { + ConnectionError::InvalidGateway(format!("invalid port number: {}", port_str)) + })?; + + if port == 0 { + return Err(ConnectionError::InvalidGateway("port cannot be 0".into())); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_test_peer() -> WireGuardPeer { + WireGuardPeer { + public_key: "HIgo9xNzJMWLKAShlKl6/bUT1VI9Q0SDBXGtLXkPFXc=".into(), + gateway: "vpn.example.com:51820".into(), + allowed_ips: vec!["0.0.0.0/0".into()], + preshared_key: None, + persistent_keepalive: Some(25), + } + } + + #[test] + fn builds_basic_wireguard_connection() { + let settings = WireGuardBuilder::new("TestVPN") + .private_key("YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=") + .address("10.0.0.2/24") + .add_peer(create_test_peer()) + .autoconnect(false) + .build() + .expect("Failed to build"); + + assert!(settings.contains_key("connection")); + assert!(settings.contains_key("wireguard")); + assert!(settings.contains_key("ipv4")); + assert!(settings.contains_key("ipv6")); + + let conn = settings.get("connection").unwrap(); + assert_eq!(conn.get("type"), Some(&Value::from("wireguard"))); + } + + #[test] + fn requires_private_key() { + let result = WireGuardBuilder::new("TestVPN") + .address("10.0.0.2/24") + .add_peer(create_test_peer()) + .build(); + + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ConnectionError::InvalidPrivateKey(_) + )); + } + + #[test] + fn requires_address() { + let result = WireGuardBuilder::new("TestVPN") + .private_key("YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=") + .add_peer(create_test_peer()) + .build(); + + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ConnectionError::InvalidAddress(_) + )); + } + + #[test] + fn requires_at_least_one_peer() { + let result = WireGuardBuilder::new("TestVPN") + .private_key("YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=") + .address("10.0.0.2/24") + .build(); + + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ConnectionError::InvalidPeers(_) + )); + } + + #[test] + fn adds_dns_servers() { + let settings = WireGuardBuilder::new("TestVPN") + .private_key("YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=") + .address("10.0.0.2/24") + .add_peer(create_test_peer()) + .dns(vec!["1.1.1.1".into(), "8.8.8.8".into()]) + .build() + .expect("Failed to build"); + + let ipv4 = settings.get("ipv4").unwrap(); + assert!(ipv4.contains_key("dns")); + } + + #[test] + fn sets_mtu() { + let settings = WireGuardBuilder::new("TestVPN") + .private_key("YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=") + .address("10.0.0.2/24") + .add_peer(create_test_peer()) + .mtu(1420) + .build() + .expect("Failed to build"); + + let wireguard = settings.get("wireguard").unwrap(); + assert_eq!(wireguard.get("mtu"), Some(&Value::from(1420u32))); + } + + #[test] + fn supports_multiple_peers() { + let peer1 = create_test_peer(); + let peer2 = WireGuardPeer { + public_key: "xScVkH3fUGUVRvGLFcjkx+GGD7cf5eBVyN3Gh4FLjmI=".into(), + gateway: "peer2.example.com:51821".into(), + allowed_ips: vec!["192.168.0.0/16".into()], + preshared_key: None, + persistent_keepalive: None, + }; + + let settings = WireGuardBuilder::new("TestVPN") + .private_key("YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=") + .address("10.0.0.2/24") + .add_peers(vec![peer1, peer2]) + .build() + .expect("Failed to build"); + + assert!(settings.contains_key("wireguard")); + } +}