From 69463ed4613abfb88b8d98f2d2aad06dd30d7bc0 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Mon, 15 Dec 2025 11:43:58 -0500 Subject: [PATCH 01/11] refactor: modularize codebase and enhance documentation - Restructure into api/, core/, dbus/, monitoring/ modules - Add comprehensive examples to all public types - Deprecate `wifi_builders` in favor of `builders::wifi` - Prepare architecture for VPN support --- nmrs/src/api/builders/mod.rs | 45 +++ .../builders/wifi.rs} | 3 +- nmrs/src/api/mod.rs | 7 + nmrs/src/{ => api}/models.rs | 324 +++++++++++++++++- nmrs/src/{ => api}/network_manager.rs | 105 +++++- nmrs/src/{ => core}/connection.rs | 16 +- nmrs/src/{ => core}/connection_settings.rs | 0 nmrs/src/{ => core}/device.rs | 8 +- nmrs/src/core/mod.rs | 10 + nmrs/src/{ => core}/scan.rs | 8 +- nmrs/src/{ => core}/state_wait.rs | 6 +- nmrs/src/{proxies => dbus}/access_point.rs | 0 .../{proxies => dbus}/active_connection.rs | 0 nmrs/src/{proxies => dbus}/device.rs | 0 nmrs/src/{proxies => dbus}/main_nm.rs | 0 nmrs/src/dbus/mod.rs | 16 + nmrs/src/{proxies => dbus}/wired.rs | 0 nmrs/src/{proxies => dbus}/wireless.rs | 0 nmrs/src/lib.rs | 310 ++++++++++++++--- .../device.rs} | 4 +- .../{network_info.rs => monitoring/info.rs} | 9 +- nmrs/src/monitoring/mod.rs | 8 + .../network.rs} | 6 +- nmrs/src/proxies/mod.rs | 35 -- nmrs/src/{ => types}/constants.rs | 0 nmrs/src/types/mod.rs | 5 + nmrs/src/util/mod.rs | 5 + nmrs/src/{ => util}/utils.rs | 4 +- 28 files changed, 810 insertions(+), 124 deletions(-) create mode 100644 nmrs/src/api/builders/mod.rs rename nmrs/src/{wifi_builders.rs => api/builders/wifi.rs} (99%) create mode 100644 nmrs/src/api/mod.rs rename nmrs/src/{ => api}/models.rs (78%) rename nmrs/src/{ => api}/network_manager.rs (69%) rename nmrs/src/{ => core}/connection.rs (97%) rename nmrs/src/{ => core}/connection_settings.rs (100%) rename nmrs/src/{ => core}/device.rs (94%) create mode 100644 nmrs/src/core/mod.rs rename nmrs/src/{ => core}/scan.rs (93%) rename nmrs/src/{ => core}/state_wait.rs (98%) rename nmrs/src/{proxies => dbus}/access_point.rs (100%) rename nmrs/src/{proxies => dbus}/active_connection.rs (100%) rename nmrs/src/{proxies => dbus}/device.rs (100%) rename nmrs/src/{proxies => dbus}/main_nm.rs (100%) create mode 100644 nmrs/src/dbus/mod.rs rename nmrs/src/{proxies => dbus}/wired.rs (100%) rename nmrs/src/{proxies => dbus}/wireless.rs (100%) rename nmrs/src/{device_monitor.rs => monitoring/device.rs} (97%) rename nmrs/src/{network_info.rs => monitoring/info.rs} (96%) create mode 100644 nmrs/src/monitoring/mod.rs rename nmrs/src/{network_monitor.rs => monitoring/network.rs} (94%) delete mode 100644 nmrs/src/proxies/mod.rs rename nmrs/src/{ => types}/constants.rs (100%) create mode 100644 nmrs/src/types/mod.rs create mode 100644 nmrs/src/util/mod.rs rename nmrs/src/{ => util}/utils.rs (97%) diff --git a/nmrs/src/api/builders/mod.rs b/nmrs/src/api/builders/mod.rs new file mode 100644 index 00000000..a88ea089 --- /dev/null +++ b/nmrs/src/api/builders/mod.rs @@ -0,0 +1,45 @@ +//! Connection builders for different network types. +//! +//! This module provides functions to construct NetworkManager connection settings +//! dictionaries for various connection types. These settings are used with +//! NetworkManager's D-Bus API to create and activate connections. +//! +//! # Available Builders +//! +//! - [`wifi`] - WiFi connection builders (WPA-PSK, WPA-EAP, Open) +//! - Ethernet builders (via [`build_ethernet_connection`]) +//! - VPN builders (coming in future releases) +//! +//! # When to Use These +//! +//! Most users should use the high-level [`NetworkManager`](crate::NetworkManager) API +//! instead of calling these builders directly. These are exposed for advanced use cases +//! where you need fine-grained control over connection settings. +//! +//! # Examples +//! +//! ```rust +//! use nmrs::builders::{build_wifi_connection, build_ethernet_connection}; +//! use nmrs::{WifiSecurity, ConnectionOptions}; +//! +//! let opts = ConnectionOptions { +//! autoconnect: true, +//! autoconnect_priority: Some(10), +//! autoconnect_retries: Some(3), +//! }; +//! +//! // Build WiFi connection settings +//! let wifi_settings = build_wifi_connection( +//! "MyNetwork", +//! &WifiSecurity::WpaPsk { psk: "password".into() }, +//! &opts +//! ); +//! +//! // Build Ethernet connection settings +//! let eth_settings = build_ethernet_connection("eth0", &opts); +//! ``` + +pub mod wifi; + +// Re-export builder functions for convenience +pub use wifi::{build_ethernet_connection, build_wifi_connection}; diff --git a/nmrs/src/wifi_builders.rs b/nmrs/src/api/builders/wifi.rs similarity index 99% rename from nmrs/src/wifi_builders.rs rename to nmrs/src/api/builders/wifi.rs index 5bd19efa..3e8e817c 100644 --- a/nmrs/src/wifi_builders.rs +++ b/nmrs/src/api/builders/wifi.rs @@ -13,11 +13,10 @@ //! - `802-1x`: Enterprise authentication settings (for WPA-EAP) //! - `ipv4` / `ipv6`: IP configuration (usually "auto" for DHCP) -use models::ConnectionOptions; use std::collections::HashMap; use zvariant::Value; -use crate::models::{self, EapMethod}; +use crate::api::models::{self, ConnectionOptions, EapMethod}; /// Converts a string to bytes for SSID encoding. fn bytes(val: &str) -> Vec { diff --git a/nmrs/src/api/mod.rs b/nmrs/src/api/mod.rs new file mode 100644 index 00000000..86bb8a0b --- /dev/null +++ b/nmrs/src/api/mod.rs @@ -0,0 +1,7 @@ +//! Public API module. +//! +//! This module contains the high-level user-facing API for the `nmrs` crate. + +pub mod builders; +pub mod models; +pub mod network_manager; diff --git a/nmrs/src/models.rs b/nmrs/src/api/models.rs similarity index 78% rename from nmrs/src/models.rs rename to nmrs/src/api/models.rs index 8046373c..08f93f71 100644 --- a/nmrs/src/models.rs +++ b/nmrs/src/api/models.rs @@ -221,42 +221,155 @@ pub enum StateReason { } /// Represents a Wi-Fi network discovered during a scan. +/// +/// This struct contains information about a WiFi network that was discovered +/// by NetworkManager during a scan operation. +/// +/// # Examples +/// +/// ```no_run +/// use nmrs::NetworkManager; +/// +/// # async fn example() -> nmrs::Result<()> { +/// let nm = NetworkManager::new().await?; +/// +/// // Scan for networks +/// nm.scan_networks().await?; +/// let networks = nm.list_networks().await?; +/// +/// for net in networks { +/// println!("SSID: {}", net.ssid); +/// println!(" Signal: {}%", net.strength.unwrap_or(0)); +/// println!(" Secured: {}", net.secured); +/// +/// if let Some(freq) = net.frequency { +/// let band = if freq > 5000 { "5GHz" } else { "2.4GHz" }; +/// println!(" Band: {}", band); +/// } +/// } +/// # Ok(()) +/// # } +/// ``` #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Network { + /// Device interface name (e.g., "wlan0") pub device: String, + /// Network SSID (name) pub ssid: String, + /// Access point MAC address (BSSID) pub bssid: Option, + /// Signal strength (0-100) pub strength: Option, + /// Frequency in MHz (e.g., 2437 for channel 6) pub frequency: Option, + /// Whether the network requires authentication pub secured: bool, + /// Whether the network uses WPA-PSK authentication pub is_psk: bool, + /// Whether the network uses WPA-EAP (Enterprise) authentication pub is_eap: bool, } -/// Detailed information about a connected Wi-Fi network. +/// Detailed information about a Wi-Fi network. +/// +/// Contains comprehensive information about a WiFi network, including +/// connection status, signal quality, and technical details. +/// +/// # Examples +/// +/// ```no_run +/// use nmrs::NetworkManager; +/// +/// # async fn example() -> nmrs::Result<()> { +/// let nm = NetworkManager::new().await?; +/// let networks = nm.list_networks().await?; +/// +/// if let Some(network) = networks.first() { +/// let info = nm.show_details(network).await?; +/// +/// println!("Network: {}", info.ssid); +/// println!("Signal: {} {}", info.strength, info.bars); +/// println!("Security: {}", info.security); +/// println!("Status: {}", info.status); +/// +/// if let Some(rate) = info.rate_mbps { +/// println!("Speed: {} Mbps", rate); +/// } +/// } +/// # Ok(()) +/// # } +/// ``` #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NetworkInfo { + /// Network SSID (name) pub ssid: String, + /// Access point MAC address (BSSID) pub bssid: String, + /// Signal strength (0-100) pub strength: u8, + /// Frequency in MHz pub freq: Option, + /// WiFi channel number pub channel: Option, + /// Operating mode (e.g., "infrastructure") pub mode: String, + /// Connection speed in Mbps pub rate_mbps: Option, + /// Visual signal strength representation (e.g., "▂▄▆█") pub bars: String, + /// Security type description pub security: String, + /// Connection status pub status: String, } /// Represents a network device managed by NetworkManager. +/// +/// A device can be a WiFi adapter, Ethernet interface, or other network hardware. +/// +/// # Examples +/// +/// ```no_run +/// use nmrs::NetworkManager; +/// +/// # async fn example() -> nmrs::Result<()> { +/// let nm = NetworkManager::new().await?; +/// let devices = nm.list_devices().await?; +/// +/// for device in devices { +/// println!("Interface: {}", device.interface); +/// println!(" Type: {}", device.device_type); +/// println!(" State: {}", device.state); +/// println!(" MAC: {}", device.identity.current_mac); +/// +/// if device.is_wireless() { +/// println!(" This is a WiFi device"); +/// } else if device.is_wired() { +/// println!(" This is an Ethernet device"); +/// } +/// +/// if let Some(driver) = &device.driver { +/// println!(" Driver: {}", driver); +/// } +/// } +/// # Ok(()) +/// # } +/// ``` #[derive(Debug, Clone)] pub struct Device { + /// D-Bus object path pub path: String, + /// Interface name (e.g., "wlan0", "eth0") pub interface: String, + /// Device hardware identity (MAC addresses) pub identity: DeviceIdentity, + /// Type of device (WiFi, Ethernet, etc.) pub device_type: DeviceType, + /// Current device state pub state: DeviceState, + /// Whether NetworkManager manages this device pub managed: Option, + /// Kernel driver name pub driver: Option, } @@ -279,30 +392,176 @@ pub enum Phase2 { Pap, } -/// EAP options for WPA-EAP Wi-Fi connections. +/// EAP options for WPA-EAP (Enterprise) Wi-Fi connections. +/// +/// Configuration for 802.1X authentication, commonly used in corporate +/// and educational networks. +/// +/// # Examples +/// +/// ## PEAP with MSCHAPv2 (Common Corporate Setup) +/// +/// ```rust +/// use nmrs::{EapOptions, EapMethod, Phase2}; +/// +/// let opts = EapOptions { +/// identity: "employee@company.com".into(), +/// password: "my_password".into(), +/// anonymous_identity: Some("anonymous@company.com".into()), +/// domain_suffix_match: Some("company.com".into()), +/// ca_cert_path: None, +/// system_ca_certs: true, // Use system certificate store +/// method: EapMethod::Peap, +/// phase2: Phase2::Mschapv2, +/// }; +/// ``` +/// +/// ## TTLS with PAP (Alternative Setup) +/// +/// ```rust +/// use nmrs::{EapOptions, EapMethod, Phase2}; +/// +/// let opts = EapOptions { +/// identity: "student@university.edu".into(), +/// password: "password".into(), +/// anonymous_identity: None, +/// domain_suffix_match: None, +/// ca_cert_path: Some("file:///etc/ssl/certs/university-ca.pem".into()), +/// system_ca_certs: false, +/// method: EapMethod::Ttls, +/// phase2: Phase2::Pap, +/// }; +/// ``` pub struct EapOptions { + /// User identity (usually email or username) pub identity: String, + /// Password for authentication pub password: String, + /// Anonymous outer identity (for privacy) pub anonymous_identity: Option, + /// Domain to match against server certificate pub domain_suffix_match: Option, + /// Path to CA certificate file (file:// URL) pub ca_cert_path: Option, + /// Use system CA certificate store pub system_ca_certs: bool, + /// EAP method (PEAP or TTLS) pub method: EapMethod, + /// Phase 2 inner authentication method pub phase2: Phase2, } /// Connection options for saved NetworkManager connections. +/// +/// Controls how NetworkManager handles saved connection profiles, +/// including automatic connection behavior. +/// +/// # Examples +/// +/// ```rust +/// use nmrs::ConnectionOptions; +/// +/// // Basic auto-connect +/// let opts = ConnectionOptions { +/// autoconnect: true, +/// autoconnect_priority: None, +/// autoconnect_retries: None, +/// }; +/// +/// // High-priority connection with retry limit +/// let opts_priority = ConnectionOptions { +/// autoconnect: true, +/// autoconnect_priority: Some(10), // Higher = more preferred +/// autoconnect_retries: Some(3), // Retry up to 3 times +/// }; +/// +/// // Manual connection only +/// let opts_manual = ConnectionOptions { +/// autoconnect: false, +/// autoconnect_priority: None, +/// autoconnect_retries: None, +/// }; +/// ``` pub struct ConnectionOptions { + /// Whether to automatically connect when available pub autoconnect: bool, + /// Priority for auto-connection (higher = more preferred) pub autoconnect_priority: Option, + /// Maximum number of auto-connect retry attempts pub autoconnect_retries: Option, } /// Wi-Fi connection security types. +/// +/// Represents the authentication method for connecting to a WiFi network. +/// +/// # Variants +/// +/// - [`Open`](WifiSecurity::Open) - No authentication required (open network) +/// - [`WpaPsk`](WifiSecurity::WpaPsk) - WPA/WPA2/WPA3 Personal (password-based) +/// - [`WpaEap`](WifiSecurity::WpaEap) - WPA/WPA2 Enterprise (802.1X authentication) +/// +/// # Examples +/// +/// ## Open Network +/// +/// ```rust +/// use nmrs::WifiSecurity; +/// +/// let security = WifiSecurity::Open; +/// ``` +/// +/// ## Password-Protected Network +/// +/// ```no_run +/// use nmrs::{NetworkManager, WifiSecurity}; +/// +/// # async fn example() -> nmrs::Result<()> { +/// let nm = NetworkManager::new().await?; +/// +/// nm.connect("HomeWiFi", WifiSecurity::WpaPsk { +/// psk: "my_secure_password".into() +/// }).await?; +/// # Ok(()) +/// # } +/// ``` +/// +/// ## Enterprise Network (WPA-EAP) +/// +/// ```no_run +/// use nmrs::{NetworkManager, WifiSecurity, EapOptions, EapMethod, Phase2}; +/// +/// # async fn example() -> nmrs::Result<()> { +/// let nm = NetworkManager::new().await?; +/// +/// nm.connect("CorpWiFi", WifiSecurity::WpaEap { +/// 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, +/// } +/// }).await?; +/// # Ok(()) +/// # } +/// ``` pub enum WifiSecurity { + /// Open network (no authentication) Open, - WpaPsk { psk: String }, - WpaEap { opts: EapOptions }, + /// WPA-PSK (password-based authentication) + WpaPsk { + /// Pre-shared key (password) + psk: String, + }, + /// WPA-EAP (Enterprise authentication via 802.1X) + WpaEap { + /// EAP configuration options + opts: EapOptions, + }, } /// NetworkManager device types. @@ -340,6 +599,63 @@ impl Device { } /// Errors that can occur during network operations. +/// +/// This enum provides specific error types for different failure modes, +/// making it easy to handle errors appropriately in your application. +/// +/// # Examples +/// +/// ## Basic Error Handling +/// +/// ```no_run +/// use nmrs::{NetworkManager, WifiSecurity, ConnectionError}; +/// +/// # async fn example() -> nmrs::Result<()> { +/// let nm = NetworkManager::new().await?; +/// +/// match nm.connect("MyNetwork", WifiSecurity::WpaPsk { +/// psk: "password".into() +/// }).await { +/// Ok(_) => println!("Connected!"), +/// Err(ConnectionError::AuthFailed) => { +/// eprintln!("Wrong password"); +/// } +/// Err(ConnectionError::NotFound) => { +/// eprintln!("Network not in range"); +/// } +/// Err(ConnectionError::Timeout) => { +/// eprintln!("Connection timed out"); +/// } +/// Err(e) => eprintln!("Error: {}", e), +/// } +/// # Ok(()) +/// # } +/// ``` +/// +/// ## Retry Logic +/// +/// ```no_run +/// use nmrs::{NetworkManager, WifiSecurity, ConnectionError}; +/// +/// # async fn example() -> nmrs::Result<()> { +/// let nm = NetworkManager::new().await?; +/// +/// for attempt in 1..=3 { +/// match nm.connect("MyNetwork", WifiSecurity::Open).await { +/// Ok(_) => { +/// println!("Connected on attempt {}", attempt); +/// break; +/// } +/// Err(ConnectionError::Timeout) if attempt < 3 => { +/// println!("Timeout, retrying..."); +/// continue; +/// } +/// Err(e) => return Err(e), +/// } +/// } +/// # Ok(()) +/// # } +/// ``` #[derive(Debug, Error)] pub enum ConnectionError { /// A D-Bus communication error occurred. diff --git a/nmrs/src/network_manager.rs b/nmrs/src/api/network_manager.rs similarity index 69% rename from nmrs/src/network_manager.rs rename to nmrs/src/api/network_manager.rs index 60501ae9..8a51931b 100644 --- a/nmrs/src/network_manager.rs +++ b/nmrs/src/api/network_manager.rs @@ -1,19 +1,104 @@ use zbus::Connection; use crate::Result; -use crate::connection::{connect, connect_wired, forget}; -use crate::connection_settings::{get_saved_connection_path, has_saved_connection}; -use crate::device::{list_devices, set_wifi_enabled, wait_for_wifi_ready, wifi_enabled}; -use crate::device_monitor; -use crate::models::{Device, Network, NetworkInfo, WifiSecurity}; -use crate::network_info::{current_connection_info, current_ssid, show_details}; -use crate::network_monitor; -use crate::scan::{list_networks, scan_networks}; +use crate::api::models::{Device, Network, NetworkInfo, WifiSecurity}; +use crate::core::connection::{connect, connect_wired, forget}; +use crate::core::connection_settings::{get_saved_connection_path, has_saved_connection}; +use crate::core::device::{list_devices, set_wifi_enabled, wait_for_wifi_ready, wifi_enabled}; +use crate::core::scan::{list_networks, scan_networks}; +use crate::monitoring::device as device_monitor; +use crate::monitoring::info::{current_connection_info, current_ssid, show_details}; +use crate::monitoring::network as network_monitor; /// High-level interface to NetworkManager over D-Bus. /// -/// Provides methods for listing devices, scanning networks, connecting, -/// and managing saved connections. +/// This is the main entry point for managing network connections on Linux systems. +/// It provides a safe, async Rust API over NetworkManager's D-Bus interface. +/// +/// # Creating an Instance +/// +/// ```no_run +/// use nmrs::NetworkManager; +/// +/// # async fn example() -> nmrs::Result<()> { +/// let nm = NetworkManager::new().await?; +/// # Ok(()) +/// # } +/// ``` +/// +/// # Capabilities +/// +/// - **Device Management**: List devices, enable/disable WiFi +/// - **Network Scanning**: Discover available WiFi networks +/// - **Connection Management**: Connect to WiFi, Ethernet networks +/// - **Profile Management**: Save, retrieve, and delete connection profiles +/// - **Real-Time Monitoring**: Subscribe to network and device state changes +/// +/// # Examples +/// +/// ## Basic WiFi Connection +/// +/// ```no_run +/// use nmrs::{NetworkManager, WifiSecurity}; +/// +/// # async fn example() -> nmrs::Result<()> { +/// let nm = NetworkManager::new().await?; +/// +/// // Scan and list networks +/// let networks = nm.list_networks().await?; +/// for net in &networks { +/// println!("{}: {}%", net.ssid, net.strength.unwrap_or(0)); +/// } +/// +/// // Connect to a network +/// nm.connect("MyNetwork", WifiSecurity::WpaPsk { +/// psk: "password".into() +/// }).await?; +/// # Ok(()) +/// # } +/// ``` +/// +/// ## Device Management +/// +/// ```no_run +/// use nmrs::NetworkManager; +/// +/// # async fn example() -> nmrs::Result<()> { +/// let nm = NetworkManager::new().await?; +/// +/// // List all network devices +/// let devices = nm.list_devices().await?; +/// +/// // Control WiFi +/// nm.set_wifi_enabled(false).await?; // Disable WiFi +/// nm.set_wifi_enabled(true).await?; // Enable WiFi +/// # Ok(()) +/// # } +/// ``` +/// +/// ## Connection Profiles +/// +/// ```no_run +/// use nmrs::NetworkManager; +/// +/// # async fn example() -> nmrs::Result<()> { +/// let nm = NetworkManager::new().await?; +/// +/// // Check for saved connection +/// if nm.has_saved_connection("MyNetwork").await? { +/// println!("Connection profile exists"); +/// +/// // Delete it +/// nm.forget("MyNetwork").await?; +/// } +/// # Ok(()) +/// # } +/// ``` +/// +/// # Thread Safety +/// +/// `NetworkManager` is `Clone` and can be safely shared across async tasks. +/// Each clone shares the same underlying D-Bus connection. #[derive(Clone)] pub struct NetworkManager { conn: Connection, diff --git a/nmrs/src/connection.rs b/nmrs/src/core/connection.rs similarity index 97% rename from nmrs/src/connection.rs rename to nmrs/src/core/connection.rs index 038063d9..35282002 100644 --- a/nmrs/src/connection.rs +++ b/nmrs/src/core/connection.rs @@ -5,14 +5,14 @@ use zbus::Connection; use zvariant::OwnedObjectPath; use crate::Result; -use crate::connection_settings::{delete_connection, get_saved_connection_path}; -use crate::constants::{device_state, device_type, timeouts}; -use crate::models::{ConnectionError, ConnectionOptions, WifiSecurity}; -use crate::network_info::current_ssid; -use crate::proxies::{NMAccessPointProxy, NMDeviceProxy, NMProxy, NMWirelessProxy}; -use crate::state_wait::{wait_for_connection_activation, wait_for_device_disconnect}; -use crate::utils::decode_ssid_or_empty; -use crate::wifi_builders::{build_ethernet_connection, build_wifi_connection}; +use crate::api::builders::wifi::{build_ethernet_connection, build_wifi_connection}; +use crate::api::models::{ConnectionError, ConnectionOptions, WifiSecurity}; +use crate::core::connection_settings::{delete_connection, get_saved_connection_path}; +use crate::core::state_wait::{wait_for_connection_activation, wait_for_device_disconnect}; +use crate::dbus::{NMAccessPointProxy, NMDeviceProxy, NMProxy, NMWirelessProxy}; +use crate::monitoring::info::current_ssid; +use crate::types::constants::{device_state, device_type, timeouts}; +use crate::util::utils::decode_ssid_or_empty; /// Decision on whether to reuse a saved connection or create a fresh one. enum SavedDecision { diff --git a/nmrs/src/connection_settings.rs b/nmrs/src/core/connection_settings.rs similarity index 100% rename from nmrs/src/connection_settings.rs rename to nmrs/src/core/connection_settings.rs diff --git a/nmrs/src/device.rs b/nmrs/src/core/device.rs similarity index 94% rename from nmrs/src/device.rs rename to nmrs/src/core/device.rs index 8d5dfa1d..9e18f00e 100644 --- a/nmrs/src/device.rs +++ b/nmrs/src/core/device.rs @@ -8,10 +8,10 @@ use log::debug; use zbus::Connection; use crate::Result; -use crate::constants::device_type; -use crate::models::{ConnectionError, Device, DeviceIdentity, DeviceState}; -use crate::proxies::{NMDeviceProxy, NMProxy}; -use crate::state_wait::wait_for_wifi_device_ready; +use crate::api::models::{ConnectionError, Device, DeviceIdentity, DeviceState}; +use crate::core::state_wait::wait_for_wifi_device_ready; +use crate::dbus::{NMDeviceProxy, NMProxy}; +use crate::types::constants::device_type; /// Lists all network devices managed by NetworkManager. /// diff --git a/nmrs/src/core/mod.rs b/nmrs/src/core/mod.rs new file mode 100644 index 00000000..5093e22f --- /dev/null +++ b/nmrs/src/core/mod.rs @@ -0,0 +1,10 @@ +//! Core internal logic for connection management. +//! +//! This module contains the internal implementation details for managing +//! network connections, devices, scanning, and state monitoring. + +pub(crate) mod connection; +pub(crate) mod connection_settings; +pub(crate) mod device; +pub(crate) mod scan; +pub(crate) mod state_wait; diff --git a/nmrs/src/scan.rs b/nmrs/src/core/scan.rs similarity index 93% rename from nmrs/src/scan.rs rename to nmrs/src/core/scan.rs index 29153d0b..eaa1c5ad 100644 --- a/nmrs/src/scan.rs +++ b/nmrs/src/core/scan.rs @@ -7,10 +7,10 @@ use std::collections::HashMap; use zbus::Connection; use crate::Result; -use crate::constants::{device_type, security_flags}; -use crate::models::Network; -use crate::proxies::{NMDeviceProxy, NMProxy, NMWirelessProxy}; -use crate::utils::{decode_ssid_or_hidden, for_each_access_point}; +use crate::api::models::Network; +use crate::dbus::{NMDeviceProxy, NMProxy, NMWirelessProxy}; +use crate::types::constants::{device_type, security_flags}; +use crate::util::utils::{decode_ssid_or_hidden, for_each_access_point}; /// Triggers a Wi-Fi scan on all wireless devices. /// diff --git a/nmrs/src/state_wait.rs b/nmrs/src/core/state_wait.rs similarity index 98% rename from nmrs/src/state_wait.rs rename to nmrs/src/core/state_wait.rs index d9af1c65..05ac8678 100644 --- a/nmrs/src/state_wait.rs +++ b/nmrs/src/core/state_wait.rs @@ -26,11 +26,11 @@ use std::time::Duration; use zbus::Connection; use crate::Result; -use crate::constants::{device_state, timeouts}; -use crate::models::{ +use crate::api::models::{ ActiveConnectionState, ConnectionError, ConnectionStateReason, connection_state_reason_to_error, }; -use crate::proxies::{NMActiveConnectionProxy, NMDeviceProxy}; +use crate::dbus::{NMActiveConnectionProxy, NMDeviceProxy}; +use crate::types::constants::{device_state, timeouts}; /// Default timeout for connection activation (30 seconds). const CONNECTION_TIMEOUT: Duration = Duration::from_secs(30); diff --git a/nmrs/src/proxies/access_point.rs b/nmrs/src/dbus/access_point.rs similarity index 100% rename from nmrs/src/proxies/access_point.rs rename to nmrs/src/dbus/access_point.rs diff --git a/nmrs/src/proxies/active_connection.rs b/nmrs/src/dbus/active_connection.rs similarity index 100% rename from nmrs/src/proxies/active_connection.rs rename to nmrs/src/dbus/active_connection.rs diff --git a/nmrs/src/proxies/device.rs b/nmrs/src/dbus/device.rs similarity index 100% rename from nmrs/src/proxies/device.rs rename to nmrs/src/dbus/device.rs diff --git a/nmrs/src/proxies/main_nm.rs b/nmrs/src/dbus/main_nm.rs similarity index 100% rename from nmrs/src/proxies/main_nm.rs rename to nmrs/src/dbus/main_nm.rs diff --git a/nmrs/src/dbus/mod.rs b/nmrs/src/dbus/mod.rs new file mode 100644 index 00000000..3cfe0b15 --- /dev/null +++ b/nmrs/src/dbus/mod.rs @@ -0,0 +1,16 @@ +//! D-Bus proxy interfaces for NetworkManager. +//! +//! This module contains low-level D-Bus proxy definitions for communicating +//! with NetworkManager over the system bus. + +mod access_point; +mod active_connection; +mod device; +mod main_nm; +mod wireless; + +pub(crate) use access_point::NMAccessPointProxy; +pub(crate) use active_connection::NMActiveConnectionProxy; +pub(crate) use device::NMDeviceProxy; +pub(crate) use main_nm::NMProxy; +pub(crate) use wireless::NMWirelessProxy; diff --git a/nmrs/src/proxies/wired.rs b/nmrs/src/dbus/wired.rs similarity index 100% rename from nmrs/src/proxies/wired.rs rename to nmrs/src/dbus/wired.rs diff --git a/nmrs/src/proxies/wireless.rs b/nmrs/src/dbus/wireless.rs similarity index 100% rename from nmrs/src/proxies/wireless.rs rename to nmrs/src/dbus/wireless.rs diff --git a/nmrs/src/lib.rs b/nmrs/src/lib.rs index 0743c882..16f4cfd3 100644 --- a/nmrs/src/lib.rs +++ b/nmrs/src/lib.rs @@ -1,13 +1,9 @@ -//! A Rust library for managing Wi-Fi connections via NetworkManager. +//! A Rust library for managing network connections via NetworkManager. //! -//! This crate provides a high-level async API for common Wi-Fi operations: +//! This crate provides a high-level async API for NetworkManager over D-Bus, +//! enabling easy management of WiFi, Ethernet, and (future) VPN connections on Linux. //! -//! - Listing network devices and visible networks -//! - Connecting to open, WPA-PSK, and WPA-EAP networks -//! - Managing saved connection profiles -//! - Enabling/disabling Wi-Fi -//! -//! # Example +//! # Quick Start //! //! ```no_run //! use nmrs::{NetworkManager, WifiSecurity}; @@ -18,69 +14,297 @@ //! // List visible networks //! let networks = nm.list_networks().await?; //! for net in &networks { -//! println!("{} ({}%)", net.ssid, net.strength.unwrap_or(0)); +//! println!("{} - Signal: {}%", net.ssid, net.strength.unwrap_or(0)); //! } //! //! // Connect to a network //! nm.connect("MyNetwork", WifiSecurity::WpaPsk { //! psk: "password123".into() //! }).await?; +//! +//! // Check current connection +//! if let Some(ssid) = nm.current_ssid().await { +//! println!("Connected to: {}", ssid); +//! } +//! # Ok(()) +//! # } +//! ``` +//! +//! # Core Concepts +//! +//! ## NetworkManager +//! +//! The main entry point is [`NetworkManager`], which provides methods for: +//! - Listing and managing network devices +//! - Scanning for available WiFi networks +//! - Connecting to networks (WiFi, Ethernet) +//! - Managing saved connection profiles +//! - Real-time monitoring of network changes +//! +//! ## Models +//! +//! The [`models`] module contains all types, enums, and errors: +//! - [`Device`] - Represents a network device (WiFi, Ethernet, etc.) +//! - [`Network`] - Represents a discovered WiFi network +//! - [`WifiSecurity`] - Security types (Open, WPA-PSK, WPA-EAP) +//! - [`ConnectionError`] - Comprehensive error types +//! +//! ## Connection Builders +//! +//! The [`builders`] module provides functions to construct connection settings +//! for different network types. These are typically used internally but exposed +//! for advanced use cases. +//! +//! # Examples +//! +//! ## Connecting to Different Network Types +//! +//! ```no_run +//! use nmrs::{NetworkManager, WifiSecurity, EapOptions, EapMethod, Phase2}; +//! +//! # async fn example() -> nmrs::Result<()> { +//! let nm = NetworkManager::new().await?; +//! +//! // Open network +//! nm.connect("OpenWiFi", WifiSecurity::Open).await?; +//! +//! // WPA-PSK (password-protected) +//! nm.connect("HomeWiFi", WifiSecurity::WpaPsk { +//! psk: "my_password".into() +//! }).await?; +//! +//! // WPA-EAP (Enterprise) +//! nm.connect("CorpWiFi", WifiSecurity::WpaEap { +//! 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, +//! } +//! }).await?; +//! +//! // Ethernet (auto-connects when cable is plugged in) +//! nm.connect_wired().await?; +//! # Ok(()) +//! # } +//! ``` +//! +//! ## Error Handling +//! +//! All operations return [`Result`], which is an alias for `Result`. +//! The [`ConnectionError`] type provides specific variants for different failure modes: +//! +//! ```no_run +//! use nmrs::{NetworkManager, WifiSecurity, ConnectionError}; +//! +//! # async fn example() -> nmrs::Result<()> { +//! let nm = NetworkManager::new().await?; +//! +//! match nm.connect("MyNetwork", WifiSecurity::WpaPsk { +//! psk: "wrong_password".into() +//! }).await { +//! Ok(_) => println!("Connected successfully"), +//! Err(ConnectionError::AuthFailed) => { +//! eprintln!("Wrong password!"); +//! } +//! Err(ConnectionError::NotFound) => { +//! eprintln!("Network not found or out of range"); +//! } +//! Err(ConnectionError::Timeout) => { +//! eprintln!("Connection timed out"); +//! } +//! Err(ConnectionError::DhcpFailed) => { +//! eprintln!("Failed to obtain IP address"); +//! } +//! Err(e) => eprintln!("Error: {}", e), +//! } +//! # Ok(()) +//! # } +//! ``` +//! +//! ## Device Management +//! +//! ```no_run +//! use nmrs::NetworkManager; +//! +//! # async fn example() -> nmrs::Result<()> { +//! let nm = NetworkManager::new().await?; +//! +//! // List all devices +//! let devices = nm.list_devices().await?; +//! for device in devices { +//! println!("{}: {} ({})", +//! device.interface, +//! device.device_type, +//! device.state +//! ); +//! } +//! +//! // Enable/disable WiFi +//! nm.set_wifi_enabled(false).await?; +//! nm.set_wifi_enabled(true).await?; //! # Ok(()) //! # } //! ``` //! -//! # Error Handling +//! ## Real-Time Monitoring +//! +//! Monitor network and device changes in real-time using D-Bus signals: +//! +//! ```ignore +//! use nmrs::NetworkManager; //! -//! All operations return `Result`. The error type provides -//! specific variants for common failures like authentication errors, timeouts, -//! and missing devices. +//! # async fn example() -> nmrs::Result<()> { +//! let nm = NetworkManager::new().await?; +//! +//! // Monitor network changes (new networks, signal changes, etc.) +//! nm.monitor_network_changes(|| { +//! println!("Networks changed! Refresh your UI."); +//! }).await?; +//! +//! // Monitor device state changes (cable plugged in, device activated, etc.) +//! nm.monitor_device_changes(|| { +//! println!("Device state changed!"); +//! }).await?; +//! # Ok(()) +//! # } +//! ``` //! -//! # Signal-Based State Monitoring +//! # Architecture //! //! This crate uses D-Bus signals for efficient state monitoring instead of polling. -//! When connecting to a network, the library subscribes to NetworkManager's -//! `StateChanged` signals to detect connection success or failure immediately, -//! rather than polling device state in a loop. This provides: +//! When connecting to a network, it subscribes to NetworkManager's `StateChanged` +//! signals to detect connection success or failure immediately. This provides: //! -//! - Faster response times (immediate notification vs polling delay) -//! - Lower CPU usage (no spinning loops) -//! - Better error messages with specific failure reasons +//! - **Faster response times** - Immediate notification vs polling delay +//! - **Lower CPU usage** - No spinning loops +//! - **Better error messages** - Specific failure reasons from NetworkManager //! //! # Logging //! -//! This crate uses the [`log`](https://docs.rs/log) facade for logging. To see -//! log output, add a logging implementation like `env_logger`. For example: - +//! This crate uses the [`log`](https://docs.rs/log) facade. To see log output, +//! add a logging implementation like `env_logger`: +//! //! ```no_run,ignore //! env_logger::init(); -//! // ... //! ``` +//! +//! # Feature Flags +//! +//! This crate currently has no optional features. All functionality is enabled by default. +//! +//! # Platform Support +//! +//! This crate is Linux-only and requires: +//! - NetworkManager running and accessible via D-Bus +//! - Appropriate permissions to manage network connections + +// Internal modules (not exposed in public API) +mod api; +mod core; +mod dbus; +mod monitoring; +mod types; +mod util; + +// ============================================================================ +// Public API +// ============================================================================ + +/// Connection builders for WiFi, Ethernet, and VPN connections. +/// +/// This module provides functions to construct NetworkManager connection settings +/// dictionaries. These are primarily used internally but exposed for advanced use cases. +/// +/// # Examples +/// +/// ```rust +/// use nmrs::builders::build_wifi_connection; +/// use nmrs::{WifiSecurity, ConnectionOptions}; +/// +/// let opts = ConnectionOptions { +/// autoconnect: true, +/// autoconnect_priority: None, +/// autoconnect_retries: None, +/// }; +/// +/// let settings = build_wifi_connection( +/// "MyNetwork", +/// &WifiSecurity::Open, +/// &opts +/// ); +/// ``` +pub mod builders { + pub use crate::api::builders::*; +} -// Internal implementation modules -mod connection; -mod connection_settings; -mod constants; -mod device; -mod device_monitor; -mod network_info; -mod network_monitor; -mod proxies; -mod scan; -mod state_wait; -mod utils; +/// Types, enums, and errors for NetworkManager operations. +/// +/// This module contains all the public types used throughout the crate: +/// +/// # Core Types +/// - [`NetworkManager`] - Main API entry point +/// - [`Device`] - Network device representation +/// - [`Network`] - WiFi network representation +/// - [`NetworkInfo`] - Detailed network information +/// +/// # Configuration +/// - [`WifiSecurity`] - WiFi security types (Open, WPA-PSK, WPA-EAP) +/// - [`EapOptions`] - Enterprise authentication options +/// - [`ConnectionOptions`] - Connection settings (autoconnect, priority, etc.) +/// +/// # Enums +/// - [`DeviceType`] - Device types (Ethernet, WiFi, etc.) +/// - [`DeviceState`] - Device states (Disconnected, Activated, etc.) +/// - [`EapMethod`] - EAP authentication methods +/// - [`Phase2`] - Phase 2 authentication for EAP +/// +/// # Errors +/// - [`ConnectionError`] - Comprehensive error type for all operations +/// - [`StateReason`] - Device state change reasons +/// - [`ConnectionStateReason`] - Connection state change reasons +/// +/// # Helper Functions +/// - [`reason_to_error`] - Convert device state reason to error +/// - [`connection_state_reason_to_error`] - Convert connection state reason to error +pub mod models { + pub use crate::api::models::*; +} -// Public API modules -pub mod models; -pub mod network_manager; -pub mod wifi_builders; +// Deprecated: Use `builders::wifi` instead +#[deprecated( + since = "0.6.0", + note = "Use `builders::wifi` module instead. This alias will be removed in 1.0.0" +)] +pub mod wifi_builders { + pub use crate::api::builders::wifi::*; +} -// Re-exported public API -pub use models::{ +// Re-export commonly used types at crate root for convenience +pub use api::models::{ ActiveConnectionState, ConnectionError, ConnectionOptions, ConnectionStateReason, Device, DeviceState, DeviceType, EapMethod, EapOptions, Network, NetworkInfo, Phase2, StateReason, WifiSecurity, connection_state_reason_to_error, reason_to_error, }; -pub use network_manager::NetworkManager; +pub use api::network_manager::NetworkManager; /// A specialized `Result` type for network operations. +/// +/// This is an alias for `Result` and is used throughout +/// the crate for all fallible operations. +/// +/// # Examples +/// +/// ```rust +/// use nmrs::Result; +/// +/// async fn connect_to_wifi() -> Result<()> { +/// // Your code here +/// Ok(()) +/// } +/// ``` pub type Result = std::result::Result; diff --git a/nmrs/src/device_monitor.rs b/nmrs/src/monitoring/device.rs similarity index 97% rename from nmrs/src/device_monitor.rs rename to nmrs/src/monitoring/device.rs index 104dc15d..3c7f4eec 100644 --- a/nmrs/src/device_monitor.rs +++ b/nmrs/src/monitoring/device.rs @@ -10,8 +10,8 @@ use std::pin::Pin; use zbus::Connection; use crate::Result; -use crate::models::ConnectionError; -use crate::proxies::{NMDeviceProxy, NMProxy}; +use crate::api::models::ConnectionError; +use crate::dbus::{NMDeviceProxy, NMProxy}; /// Monitors device state changes on all network devices. /// diff --git a/nmrs/src/network_info.rs b/nmrs/src/monitoring/info.rs similarity index 96% rename from nmrs/src/network_info.rs rename to nmrs/src/monitoring/info.rs index b93dcfb9..b3d6c2f5 100644 --- a/nmrs/src/network_info.rs +++ b/nmrs/src/monitoring/info.rs @@ -6,11 +6,12 @@ use zbus::Connection; use crate::Result; -use crate::constants::{device_type, rate, security_flags}; -use crate::models::{ConnectionError, Network, NetworkInfo}; -use crate::proxies::{NMAccessPointProxy, NMDeviceProxy, NMProxy, NMWirelessProxy}; +use crate::api::models::{ConnectionError, Network, NetworkInfo}; +#[allow(unused_imports)] // Used within try_log! macro +use crate::dbus::{NMAccessPointProxy, NMDeviceProxy, NMProxy, NMWirelessProxy}; use crate::try_log; -use crate::utils::{ +use crate::types::constants::{device_type, rate, security_flags}; +use crate::util::utils::{ bars_from_strength, channel_from_freq, decode_ssid_or_empty, for_each_access_point, mode_to_string, strength_or_zero, }; diff --git a/nmrs/src/monitoring/mod.rs b/nmrs/src/monitoring/mod.rs new file mode 100644 index 00000000..fb2a8ffa --- /dev/null +++ b/nmrs/src/monitoring/mod.rs @@ -0,0 +1,8 @@ +//! Real-time monitoring of network and device changes. +//! +//! This module provides functions for monitoring network state changes, +//! device state changes, and retrieving current connection information. + +pub(crate) mod device; +pub(crate) mod info; +pub(crate) mod network; diff --git a/nmrs/src/network_monitor.rs b/nmrs/src/monitoring/network.rs similarity index 94% rename from nmrs/src/network_monitor.rs rename to nmrs/src/monitoring/network.rs index c856f9a5..1ea35ec6 100644 --- a/nmrs/src/network_monitor.rs +++ b/nmrs/src/monitoring/network.rs @@ -9,9 +9,9 @@ use std::pin::Pin; use zbus::Connection; use crate::Result; -use crate::constants::device_type; -use crate::models::ConnectionError; -use crate::proxies::{NMDeviceProxy, NMProxy, NMWirelessProxy}; +use crate::api::models::ConnectionError; +use crate::dbus::{NMDeviceProxy, NMProxy, NMWirelessProxy}; +use crate::types::constants::device_type; /// Monitors access point changes on all Wi-Fi devices. /// diff --git a/nmrs/src/proxies/mod.rs b/nmrs/src/proxies/mod.rs deleted file mode 100644 index bba96534..00000000 --- a/nmrs/src/proxies/mod.rs +++ /dev/null @@ -1,35 +0,0 @@ -//! D-Bus proxy traits for NetworkManager interfaces. -//! -//! These traits define the NetworkManager D-Bus API surface used by this crate. -//! The `zbus::proxy` macro generates proxy implementations that handle -//! D-Bus communication automatically. -//! -//! # NetworkManager D-Bus Structure -//! -//! - `/org/freedesktop/NetworkManager` - Main NM object -//! - `/org/freedesktop/NetworkManager/Devices/*` - Device objects -//! - `/org/freedesktop/NetworkManager/AccessPoint/*` - Access point objects -//! - `/org/freedesktop/NetworkManager/ActiveConnection/*` - Active connection objects -//! - `/org/freedesktop/NetworkManager/Settings` - Connection settings -//! -//! # Signal-based State Monitoring -//! -//! This crate uses D-Bus signals for efficient state monitoring instead of polling: -//! - `NMDevice::StateChanged` - Emitted when device state changes -//! - `NMActiveConnection::StateChanged` - Emitted when connection activation state changes -//! -//! Use the generated `receive_device_state_changed()` and `receive_activation_state_changed()` -//! methods to get signal streams. - -mod access_point; -mod active_connection; -mod device; -mod main_nm; -mod wired; -mod wireless; - -pub use access_point::NMAccessPointProxy; -pub use active_connection::NMActiveConnectionProxy; -pub use device::NMDeviceProxy; -pub use main_nm::NMProxy; -pub use wireless::NMWirelessProxy; diff --git a/nmrs/src/constants.rs b/nmrs/src/types/constants.rs similarity index 100% rename from nmrs/src/constants.rs rename to nmrs/src/types/constants.rs diff --git a/nmrs/src/types/mod.rs b/nmrs/src/types/mod.rs new file mode 100644 index 00000000..08731cf7 --- /dev/null +++ b/nmrs/src/types/mod.rs @@ -0,0 +1,5 @@ +//! Type definitions and constants. +//! +//! This module contains NetworkManager constants and type definitions. + +pub(crate) mod constants; diff --git a/nmrs/src/util/mod.rs b/nmrs/src/util/mod.rs new file mode 100644 index 00000000..31be0e70 --- /dev/null +++ b/nmrs/src/util/mod.rs @@ -0,0 +1,5 @@ +//! Utility functions. +//! +//! This module contains helper functions used throughout the crate. + +pub(crate) mod utils; diff --git a/nmrs/src/utils.rs b/nmrs/src/util/utils.rs similarity index 97% rename from nmrs/src/utils.rs rename to nmrs/src/util/utils.rs index 070b6bc0..a105addc 100644 --- a/nmrs/src/utils.rs +++ b/nmrs/src/util/utils.rs @@ -9,8 +9,8 @@ use std::str; use zbus::Connection; use crate::Result; -use crate::constants::{device_type, frequency, signal_strength, wifi_mode}; -use crate::proxies::{NMAccessPointProxy, NMDeviceProxy, NMProxy, NMWirelessProxy}; +use crate::dbus::{NMAccessPointProxy, NMDeviceProxy, NMProxy, NMWirelessProxy}; +use crate::types::constants::{device_type, frequency, signal_strength, wifi_mode}; /// Converts a Wi-Fi frequency in MHz to a channel number. /// From 3b762782911794f763a16477aa70522573353b70 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Mon, 15 Dec 2025 11:57:27 -0500 Subject: [PATCH 02/11] chore: update `.gitignore` --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index de4579c2..1bea1a18 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,7 @@ nmrs-aur/*.tar.gz.* RELEASE_NOTES.md __pycache__/ *.pyc -vendor/ \ No newline at end of file +vendor/ + +# Internal design documents +docs/VPN_IMPLEMENTATION.md \ No newline at end of file From 152901a261626bad7e6b168406338d33042b6d8f Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Mon, 15 Dec 2025 14:30:03 -0500 Subject: [PATCH 03/11] feat(#92): scaffold WireGuard VPN connection settings --- Cargo.lock | 7 ++ nmrs/Cargo.toml | 2 +- nmrs/src/api/builders/mod.rs | 2 + nmrs/src/api/builders/vpn.rs | 135 +++++++++++++++++++++++++++++++++++ nmrs/src/api/models.rs | 81 ++++++++++++++++++--- nmrs/src/core/connection.rs | 27 +++---- nmrs/src/core/state_wait.rs | 2 +- 7 files changed, 229 insertions(+), 27 deletions(-) create mode 100644 nmrs/src/api/builders/vpn.rs diff --git a/Cargo.lock b/Cargo.lock index 863bbff5..43df2666 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1252,6 +1252,12 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + [[package]] name = "signal-hook-registry" version = "1.4.6" @@ -1513,6 +1519,7 @@ dependencies = [ "getrandom 0.3.3", "js-sys", "serde_core", + "sha1_smol", "wasm-bindgen", ] diff --git a/nmrs/Cargo.toml b/nmrs/Cargo.toml index 7b65839f..473a54bf 100644 --- a/nmrs/Cargo.toml +++ b/nmrs/Cargo.toml @@ -15,7 +15,7 @@ zbus = "5.12.0" zvariant = "5.8.0" serde = { version = "1.0.228", features = ["derive"] } thiserror = "2.0.17" -uuid = { version = "1.19.0", features = ["v4"] } +uuid = { version = "1.19.0", features = ["v4", "v5"] } futures = "0.3.31" futures-timer = "3.0.3" diff --git a/nmrs/src/api/builders/mod.rs b/nmrs/src/api/builders/mod.rs index a88ea089..2c63b671 100644 --- a/nmrs/src/api/builders/mod.rs +++ b/nmrs/src/api/builders/mod.rs @@ -39,7 +39,9 @@ //! let eth_settings = build_ethernet_connection("eth0", &opts); //! ``` +pub mod vpn; pub mod wifi; // Re-export builder functions for convenience +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 new file mode 100644 index 00000000..e0d5bb99 --- /dev/null +++ b/nmrs/src/api/builders/vpn.rs @@ -0,0 +1,135 @@ +//! VPN connection settings builders. +//! +//! Currently supports building settings for WireGuard VPN connections. +//! The resulting dictionary can be passed directly to +//! `AddAndActivateConnection` on the NetworkManager D-Bus API. +//! +//! Most users should call [`NetworkManager::connect_vpn`][crate::NetworkManager::connect_vpn] +//! instead of using these builders directly. This module is intended for +//! advanced use cases where you need low-level control over the settings. + +use std::collections::HashMap; +use uuid::Uuid; +use zvariant::Value; + +use crate::api::models::{ConnectionError, ConnectionOptions, VpnCredentials}; + +/// Builds WireGuard VPN connection settings. +/// +/// Returns a complete NetworkManager settings dictionary suitable for +/// `AddAndActivateConnection`. +/// +/// # Errors +/// +/// - `ConnectionError::InvalidPeers` if no peers are provided +/// - `ConnectionError::InvalidAddress` if the address is missing or malformed +pub fn build_wireguard_connection( + creds: &VpnCredentials, + opts: &ConnectionOptions, +) -> Result>>, ConnectionError> { + if creds.peers.is_empty() { + return Err(ConnectionError::InvalidPeers("No peers provided".into())); + } + + let mut conn = HashMap::new(); + + // [connection] section + let mut connection = HashMap::new(); + connection.insert("type", Value::from("vpn")); + connection.insert("id", Value::from(creds.name.clone())); + + 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); + + // [vpn] section + let mut vpn = HashMap::new(); + vpn.insert( + "service-type", + Value::from("org.freedesktop.NetworkManager.wireguard"), + ); + + // WireGuard-specific data + let mut data: HashMap> = HashMap::new(); + data.insert("private-key".into(), Value::from(creds.private_key.clone())); + + for (i, peer) in creds.peers.iter().enumerate() { + let prefix = format!("peer.{i}."); + data.insert( + format!("{prefix}public-key"), + Value::from(peer.public_key.clone()), + ); + data.insert( + format!("{prefix}endpoint"), + Value::from(peer.gateway.clone()), + ); + data.insert( + format!("{prefix}allowed-ips"), + Value::from(peer.allowed_ips.join(",")), + ); + + if let Some(psk) = &peer.preshared_key { + data.insert(format!("{prefix}preshared-key"), Value::from(psk.clone())); + } + + if let Some(ka) = peer.persistent_keepalive { + data.insert(format!("{prefix}persistent-keepalive"), Value::from(ka)); + } + } + + vpn.insert("data", Value::from(data)); + conn.insert("vpn", vpn); + + // [ipv4] section + let mut ipv4 = HashMap::new(); + ipv4.insert("method", Value::from("manual")); + + // Parse address (example: "10.0.0.2/24") + let (ip, prefix) = creds + .address + .split_once('/') + .ok_or_else(|| ConnectionError::InvalidAddress("missing address".into()))?; + + let prefix: u32 = prefix + .parse() + .map_err(|_| ConnectionError::InvalidAddress("invalid address".into()))?; + + let addresses = vec![vec![ + Value::from(ip.to_string()), + Value::from(prefix), + Value::from("0.0.0.0"), + ]]; + ipv4.insert("address-data", Value::from(addresses)); + + if let Some(dns) = &creds.dns { + let dns_vec: Vec = dns.to_vec(); + ipv4.insert("dns", Value::from(dns_vec)); + } + + if let Some(mtu) = creds.mtu { + ipv4.insert("mtu", Value::from(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) +} diff --git a/nmrs/src/api/models.rs b/nmrs/src/api/models.rs index 08f93f71..2eeb4d81 100644 --- a/nmrs/src/api/models.rs +++ b/nmrs/src/api/models.rs @@ -1,6 +1,7 @@ use serde::{Deserialize, Serialize}; use std::fmt::{Display, Formatter}; use thiserror::Error; +use uuid::Uuid; /// NetworkManager active connection state. /// @@ -156,7 +157,7 @@ pub fn connection_state_reason_to_error(code: u32) -> ConnectionError { ConnectionStateReason::IpConfigInvalid => ConnectionError::DhcpFailed, // All other failures - _ => ConnectionError::ConnectionFailed(reason), + _ => ConnectionError::ActivationFailed(reason), } } @@ -381,12 +382,14 @@ pub struct DeviceIdentity { } /// EAP (Extensible Authentication Protocol) method options for Wi-Fi connections. +#[derive(Debug, Clone, PartialEq, Eq)] pub enum EapMethod { Peap, // PEAPv0/EAP-MSCHAPv2 Ttls, // EAP-TTLS } /// Phase 2 authentication methods for EAP connections. +#[derive(Debug, Clone, PartialEq, Eq)] pub enum Phase2 { Mschapv2, Pap, @@ -432,6 +435,7 @@ pub enum Phase2 { /// phase2: Phase2::Pap, /// }; /// ``` +#[derive(Debug, Clone, PartialEq, Eq)] pub struct EapOptions { /// User identity (usually email or username) pub identity: String, @@ -482,6 +486,7 @@ pub struct EapOptions { /// autoconnect_retries: None, /// }; /// ``` +#[derive(Debug, Clone)] pub struct ConnectionOptions { /// Whether to automatically connect when available pub autoconnect: bool, @@ -549,6 +554,7 @@ pub struct ConnectionOptions { /// # Ok(()) /// # } /// ``` +#[derive(Debug, Clone, PartialEq, Eq)] pub enum WifiSecurity { /// Open network (no authentication) Open, @@ -564,6 +570,50 @@ pub enum WifiSecurity { }, } +/// VPN Connection type +/// +/// Currently only WireGuard is supported. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum VpnType { + WireGuard, +} + +/// VPN Credentials for establishing a VPN connection +/// +/// Stores the necessary information to configure and connect to a VPN. +#[derive(Debug, Clone)] +pub struct VpnCredentials { + pub vpn_type: VpnType, + pub name: String, + pub gateway: String, + pub private_key: String, + pub address: String, + pub peers: Vec, + pub dns: Option>, + pub mtu: Option, + pub uuid: Option, +} + +#[derive(Debug, Clone)] +pub struct WireGuardPeer { + pub public_key: String, + pub gateway: String, // endpoint host:port + pub allowed_ips: Vec, // CIDRs + pub preshared_key: Option, + pub persistent_keepalive: Option, +} + +/// Active VPN Connection Information +/// +/// Represents an active VPN connection managed by NetworkManager. +#[derive(Debug, Clone)] +pub struct VpnConnection { + pub name: String, + pub vpn_type: VpnType, + pub state: DeviceState, + pub interface: Option, +} + /// NetworkManager device types. #[derive(Debug, Clone, PartialEq)] pub enum DeviceType { @@ -708,15 +758,27 @@ pub enum ConnectionError { /// A general connection failure with a device state reason code. #[error("connection failed: {0}")] - Failed(StateReason), + DeviceFailed(StateReason), /// A connection activation failure with a connection state reason. #[error("connection activation failed: {0}")] - ConnectionFailed(ConnectionStateReason), + ActivationFailed(ConnectionStateReason), /// Invalid UTF-8 encountered in SSID. #[error("invalid UTF-8 in SSID: {0}")] InvalidUtf8(#[from] std::str::Utf8Error), + + /// No VPN connection found + #[error("no VPN connection found")] + NoVpnConnection, + + /// Invalid address + #[error("invalid address")] + InvalidAddress(String), + + /// Invalid peer configuration + #[error("invalid peer configuration")] + InvalidPeers(String), } /// NetworkManager device state reason codes. @@ -863,7 +925,7 @@ pub fn reason_to_error(code: u32) -> ConnectionError { StateReason::SsidNotFound => ConnectionError::NotFound, // All other failures - _ => ConnectionError::Failed(reason), + _ => ConnectionError::DeviceFailed(reason), } } @@ -1114,7 +1176,7 @@ mod tests { fn reason_to_error_generic_failure() { // User disconnected maps to generic Failed match reason_to_error(2) { - ConnectionError::Failed(reason) => { + ConnectionError::DeviceFailed(reason) => { assert_eq!(reason, StateReason::UserDisconnected); } _ => panic!("expected ConnectionError::Failed"), @@ -1145,7 +1207,10 @@ mod tests { "connection stuck in state: config" ); assert_eq!( - format!("{}", ConnectionError::Failed(StateReason::CarrierChanged)), + format!( + "{}", + ConnectionError::DeviceFailed(StateReason::CarrierChanged) + ), "connection failed: carrier changed" ); } @@ -1293,7 +1358,7 @@ mod tests { fn connection_state_reason_to_error_generic() { // Other reasons map to ConnectionFailed match connection_state_reason_to_error(2) { - ConnectionError::ConnectionFailed(reason) => { + ConnectionError::ActivationFailed(reason) => { assert_eq!(reason, ConnectionStateReason::UserDisconnected); } _ => panic!("expected ConnectionError::ConnectionFailed"), @@ -1305,7 +1370,7 @@ mod tests { assert_eq!( format!( "{}", - ConnectionError::ConnectionFailed(ConnectionStateReason::NoSecrets) + ConnectionError::ActivationFailed(ConnectionStateReason::NoSecrets) ), "connection activation failed: no secrets (password) provided" ); diff --git a/nmrs/src/core/connection.rs b/nmrs/src/core/connection.rs index 35282002..06f745e8 100644 --- a/nmrs/src/core/connection.rs +++ b/nmrs/src/core/connection.rs @@ -610,24 +610,17 @@ fn decide_saved_connection( saved: Option, creds: &WifiSecurity, ) -> Result { - if let Some(path) = saved { - if creds.is_psk() - && let WifiSecurity::WpaPsk { psk } = creds - { - if psk.trim().is_empty() { - return Ok(SavedDecision::UseSaved(path)); - } - return Ok(SavedDecision::RebuildFresh); + match saved { + Some(_) if matches!(creds, WifiSecurity::WpaPsk { psk } if !psk.trim().is_empty()) => { + Ok(SavedDecision::RebuildFresh) } - return Ok(SavedDecision::UseSaved(path)); - } - if creds.is_psk() - && let WifiSecurity::WpaPsk { psk } = creds - && psk.trim().is_empty() - { - return Err(ConnectionError::NoSavedConnection); - } + Some(path) => Ok(SavedDecision::UseSaved(path)), + + None if matches!(creds, WifiSecurity::WpaPsk { psk } if psk.trim().is_empty()) => { + Err(ConnectionError::NoSavedConnection) + } - Ok(SavedDecision::RebuildFresh) + None => Ok(SavedDecision::RebuildFresh), + } } diff --git a/nmrs/src/core/state_wait.rs b/nmrs/src/core/state_wait.rs index 05ac8678..d7802d47 100644 --- a/nmrs/src/core/state_wait.rs +++ b/nmrs/src/core/state_wait.rs @@ -68,7 +68,7 @@ pub(crate) async fn wait_for_connection_activation( } ActiveConnectionState::Deactivated => { warn!("Connection already deactivated"); - return Err(ConnectionError::ConnectionFailed( + return Err(ConnectionError::ActivationFailed( ConnectionStateReason::Unknown, )); } From 2a9c69ed84e8fefc6a463613925ae76265c914c5 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Mon, 15 Dec 2025 15:38:01 -0500 Subject: [PATCH 04/11] feat(#92): WireGuard VPN support + tests --- nmrs/src/api/builders/mod.rs | 2 +- nmrs/src/api/builders/vpn.rs | 714 +++++++++++++++++++++++++++++++- nmrs/src/api/models.rs | 150 ++++++- nmrs/src/api/network_manager.rs | 154 +++++++ nmrs/src/core/mod.rs | 1 + nmrs/src/core/vpn.rs | 542 ++++++++++++++++++++++++ nmrs/src/lib.rs | 53 ++- nmrs/tests/integration_test.rs | 237 ++++++++++- 8 files changed, 1804 insertions(+), 49 deletions(-) create mode 100644 nmrs/src/core/vpn.rs diff --git a/nmrs/src/api/builders/mod.rs b/nmrs/src/api/builders/mod.rs index 2c63b671..40a867a4 100644 --- a/nmrs/src/api/builders/mod.rs +++ b/nmrs/src/api/builders/mod.rs @@ -7,8 +7,8 @@ //! # Available Builders //! //! - [`wifi`] - WiFi connection builders (WPA-PSK, WPA-EAP, Open) +//! - [`vpn`] - VPN connection builders (WireGuard) //! - Ethernet builders (via [`build_ethernet_connection`]) -//! - VPN builders (coming in future releases) //! //! # When to Use These //! diff --git a/nmrs/src/api/builders/vpn.rs b/nmrs/src/api/builders/vpn.rs index e0d5bb99..d308ded4 100644 --- a/nmrs/src/api/builders/vpn.rs +++ b/nmrs/src/api/builders/vpn.rs @@ -1,12 +1,51 @@ //! VPN connection settings builders. //! -//! Currently supports building settings for WireGuard VPN connections. -//! The resulting dictionary can be passed directly to -//! `AddAndActivateConnection` on the NetworkManager D-Bus API. +//! This module provides functions to build NetworkManager settings dictionaries +//! for VPN connections. Currently supports: +//! +//! - **WireGuard** - Modern, high-performance VPN protocol +//! +//! # Usage //! //! Most users should call [`NetworkManager::connect_vpn`][crate::NetworkManager::connect_vpn] //! instead of using these builders directly. This module is intended for -//! advanced use cases where you need low-level control over the settings. +//! advanced use cases where you need low-level control over the connection settings. +//! +//! # Example +//! +//! ```rust +//! use nmrs::builders::build_wireguard_connection; +//! use nmrs::{VpnCredentials, VpnType, WireGuardPeer, ConnectionOptions}; +//! +//! let creds = VpnCredentials { +//! vpn_type: VpnType::WireGuard, +//! name: "MyVPN".into(), +//! gateway: "vpn.example.com:51820".into(), +//! // Valid WireGuard private key (44 chars base64) +//! private_key: "YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=".into(), +//! address: "10.0.0.2/24".into(), +//! peers: vec![WireGuardPeer { +//! // Valid WireGuard public key (44 chars base64) +//! 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), +//! }], +//! dns: Some(vec!["1.1.1.1".into()]), +//! mtu: None, +//! uuid: None, +//! }; +//! +//! let opts = ConnectionOptions { +//! autoconnect: false, +//! autoconnect_priority: None, +//! autoconnect_retries: None, +//! }; +//! +//! let settings = build_wireguard_connection(&creds, &opts).unwrap(); +//! // Pass settings to NetworkManager's AddAndActivateConnection +//! ``` use std::collections::HashMap; use uuid::Uuid; @@ -14,6 +53,145 @@ use zvariant::Value; 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 @@ -27,10 +205,33 @@ 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)?; + + // 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 + ))); + } + } + let mut conn = HashMap::new(); // [connection] section @@ -98,18 +299,9 @@ pub fn build_wireguard_connection( let mut ipv4 = HashMap::new(); ipv4.insert("method", Value::from("manual")); - // Parse address (example: "10.0.0.2/24") - let (ip, prefix) = creds - .address - .split_once('/') - .ok_or_else(|| ConnectionError::InvalidAddress("missing address".into()))?; - - let prefix: u32 = prefix - .parse() - .map_err(|_| ConnectionError::InvalidAddress("invalid address".into()))?; - + // Use already validated address let addresses = vec![vec![ - Value::from(ip.to_string()), + Value::from(ip), Value::from(prefix), Value::from("0.0.0.0"), ]]; @@ -133,3 +325,495 @@ pub fn build_wireguard_connection( Ok(conn) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::models::{VpnType, WireGuardPeer}; + + fn create_test_credentials() -> VpnCredentials { + VpnCredentials { + vpn_type: VpnType::WireGuard, + name: "TestVPN".into(), + gateway: "vpn.example.com:51820".into(), + private_key: "YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=".into(), + address: "10.0.0.2/24".into(), + peers: vec![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), + }], + dns: Some(vec!["1.1.1.1".into(), "8.8.8.8".into()]), + mtu: Some(1420), + uuid: None, + } + } + + fn create_test_options() -> ConnectionOptions { + ConnectionOptions { + autoconnect: false, + autoconnect_priority: None, + autoconnect_retries: None, + } + } + + #[test] + fn builds_wireguard_connection() { + let creds = create_test_credentials(); + let opts = create_test_options(); + + let settings = build_wireguard_connection(&creds, &opts); + assert!(settings.is_ok()); + + let settings = settings.unwrap(); + assert!(settings.contains_key("connection")); + assert!(settings.contains_key("vpn")); + assert!(settings.contains_key("ipv4")); + assert!(settings.contains_key("ipv6")); + } + + #[test] + fn connection_section_has_correct_type() { + let creds = create_test_credentials(); + let opts = create_test_options(); + + let settings = build_wireguard_connection(&creds, &opts).unwrap(); + let connection = settings.get("connection").unwrap(); + + let conn_type = connection.get("type").unwrap(); + assert_eq!(conn_type, &Value::from("vpn")); + + let id = connection.get("id").unwrap(); + assert_eq!(id, &Value::from("TestVPN")); + } + + #[test] + fn vpn_section_has_wireguard_service_type() { + let creds = create_test_credentials(); + let opts = create_test_options(); + + let settings = build_wireguard_connection(&creds, &opts).unwrap(); + let vpn = settings.get("vpn").unwrap(); + + let service_type = vpn.get("service-type").unwrap(); + assert_eq!( + service_type, + &Value::from("org.freedesktop.NetworkManager.wireguard") + ); + } + + #[test] + fn ipv4_section_is_manual() { + let creds = create_test_credentials(); + let opts = create_test_options(); + + let settings = build_wireguard_connection(&creds, &opts).unwrap(); + let ipv4 = settings.get("ipv4").unwrap(); + + let method = ipv4.get("method").unwrap(); + assert_eq!(method, &Value::from("manual")); + } + + #[test] + fn ipv6_section_is_ignored() { + let creds = create_test_credentials(); + let opts = create_test_options(); + + let settings = build_wireguard_connection(&creds, &opts).unwrap(); + let ipv6 = settings.get("ipv6").unwrap(); + + let method = ipv6.get("method").unwrap(); + assert_eq!(method, &Value::from("ignore")); + } + + #[test] + fn rejects_empty_peers() { + let mut creds = create_test_credentials(); + creds.peers = vec![]; + let opts = create_test_options(); + + let result = build_wireguard_connection(&creds, &opts); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ConnectionError::InvalidPeers(_) + )); + } + + #[test] + fn rejects_invalid_address_format() { + let mut creds = create_test_credentials(); + creds.address = "invalid".into(); + let opts = create_test_options(); + + let result = build_wireguard_connection(&creds, &opts); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ConnectionError::InvalidAddress(_) + )); + } + + #[test] + fn rejects_address_without_cidr() { + let mut creds = create_test_credentials(); + creds.address = "10.0.0.2".into(); + let opts = create_test_options(); + + let result = build_wireguard_connection(&creds, &opts); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ConnectionError::InvalidAddress(_) + )); + } + + #[test] + fn accepts_ipv6_address() { + let mut creds = create_test_credentials(); + creds.address = "fd00::2/64".into(); + let opts = create_test_options(); + + let result = build_wireguard_connection(&creds, &opts); + assert!(result.is_ok()); + } + + #[test] + fn handles_multiple_peers() { + let mut creds = create_test_credentials(); + creds.peers.push(WireGuardPeer { + public_key: "xScVkH3fUGUVRvGLFcjkx+GGD7cf5eBVyN3Gh4FLjmI=".into(), + gateway: "peer2.example.com:51821".into(), + allowed_ips: vec!["192.168.0.0/16".into()], + preshared_key: Some("PSKABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklm=".into()), + persistent_keepalive: None, + }); + let opts = create_test_options(); + + let result = build_wireguard_connection(&creds, &opts); + assert!(result.is_ok()); + } + + #[test] + fn handles_optional_dns() { + let mut creds = create_test_credentials(); + creds.dns = None; + let opts = create_test_options(); + + let result = build_wireguard_connection(&creds, &opts); + assert!(result.is_ok()); + } + + #[test] + fn handles_optional_mtu() { + let mut creds = create_test_credentials(); + creds.mtu = None; + let opts = create_test_options(); + + let result = build_wireguard_connection(&creds, &opts); + assert!(result.is_ok()); + } + + #[test] + fn includes_dns_when_provided() { + let creds = create_test_credentials(); + let opts = create_test_options(); + + let settings = build_wireguard_connection(&creds, &opts).unwrap(); + let ipv4 = settings.get("ipv4").unwrap(); + + assert!(ipv4.contains_key("dns")); + } + + #[test] + fn includes_mtu_when_provided() { + let creds = create_test_credentials(); + let opts = create_test_options(); + + let settings = build_wireguard_connection(&creds, &opts).unwrap(); + let ipv4 = settings.get("ipv4").unwrap(); + + assert!(ipv4.contains_key("mtu")); + } + + #[test] + fn respects_autoconnect_option() { + let creds = create_test_credentials(); + let mut opts = create_test_options(); + opts.autoconnect = true; + + let settings = build_wireguard_connection(&creds, &opts).unwrap(); + let connection = settings.get("connection").unwrap(); + + let autoconnect = connection.get("autoconnect").unwrap(); + assert_eq!(autoconnect, &Value::from(true)); + } + + #[test] + fn includes_autoconnect_priority_when_provided() { + let creds = create_test_credentials(); + let mut opts = create_test_options(); + opts.autoconnect_priority = Some(10); + + let settings = build_wireguard_connection(&creds, &opts).unwrap(); + let connection = settings.get("connection").unwrap(); + + assert!(connection.contains_key("autoconnect-priority")); + } + + #[test] + fn generates_uuid_when_not_provided() { + let creds = create_test_credentials(); + let opts = create_test_options(); + + let settings = build_wireguard_connection(&creds, &opts).unwrap(); + let connection = settings.get("connection").unwrap(); + + assert!(connection.contains_key("uuid")); + } + + #[test] + fn uses_provided_uuid() { + let mut creds = create_test_credentials(); + let test_uuid = uuid::Uuid::new_v4(); + creds.uuid = Some(test_uuid); + let opts = create_test_options(); + + let settings = build_wireguard_connection(&creds, &opts).unwrap(); + let connection = settings.get("connection").unwrap(); + + let uuid = connection.get("uuid").unwrap(); + assert_eq!(uuid, &Value::from(test_uuid.to_string())); + } + + #[test] + fn peer_with_preshared_key() { + let mut creds = create_test_credentials(); + creds.peers[0].preshared_key = Some("PSKABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklm=".into()); + let opts = create_test_options(); + + let result = build_wireguard_connection(&creds, &opts); + assert!(result.is_ok()); + } + + #[test] + fn peer_without_keepalive() { + let mut creds = create_test_credentials(); + creds.peers[0].persistent_keepalive = None; + let opts = create_test_options(); + + let result = build_wireguard_connection(&creds, &opts); + assert!(result.is_ok()); + } + + #[test] + fn multiple_allowed_ips_for_peer() { + let mut creds = create_test_credentials(); + creds.peers[0].allowed_ips = + vec!["0.0.0.0/0".into(), "::/0".into(), "192.168.1.0/24".into()]; + let opts = create_test_options(); + + let result = build_wireguard_connection(&creds, &opts); + assert!(result.is_ok()); + } + + // Validation tests + + #[test] + fn rejects_empty_private_key() { + let mut creds = create_test_credentials(); + creds.private_key = "".into(); + let opts = create_test_options(); + + let result = build_wireguard_connection(&creds, &opts); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ConnectionError::InvalidPrivateKey(_) + )); + } + + #[test] + fn rejects_short_private_key() { + let mut creds = create_test_credentials(); + creds.private_key = "tooshort".into(); + let opts = create_test_options(); + + let result = build_wireguard_connection(&creds, &opts); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ConnectionError::InvalidPrivateKey(_) + )); + } + + #[test] + fn rejects_invalid_private_key_characters() { + let mut creds = create_test_credentials(); + creds.private_key = "this is not base64 encoded!!!!!!!!!!!!!!!!!!".into(); + let opts = create_test_options(); + + let result = build_wireguard_connection(&creds, &opts); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ConnectionError::InvalidPrivateKey(_) + )); + } + + #[test] + fn rejects_empty_gateway() { + let mut creds = create_test_credentials(); + creds.gateway = "".into(); + let opts = create_test_options(); + + let result = build_wireguard_connection(&creds, &opts); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ConnectionError::InvalidGateway(_) + )); + } + + #[test] + fn rejects_gateway_without_port() { + let mut creds = create_test_credentials(); + creds.gateway = "vpn.example.com".into(); + let opts = create_test_options(); + + let result = build_wireguard_connection(&creds, &opts); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ConnectionError::InvalidGateway(_) + )); + } + + #[test] + fn rejects_gateway_with_invalid_port() { + let mut creds = create_test_credentials(); + creds.gateway = "vpn.example.com:99999".into(); + let opts = create_test_options(); + + let result = build_wireguard_connection(&creds, &opts); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ConnectionError::InvalidGateway(_) + )); + } + + #[test] + fn rejects_gateway_with_zero_port() { + let mut creds = create_test_credentials(); + creds.gateway = "vpn.example.com:0".into(); + let opts = create_test_options(); + + let result = build_wireguard_connection(&creds, &opts); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ConnectionError::InvalidGateway(_) + )); + } + + #[test] + fn rejects_invalid_ipv4_address() { + let mut creds = create_test_credentials(); + creds.address = "999.999.999.999/24".into(); + let opts = create_test_options(); + + let result = build_wireguard_connection(&creds, &opts); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ConnectionError::InvalidAddress(_) + )); + } + + #[test] + fn rejects_ipv4_with_invalid_prefix() { + let mut creds = create_test_credentials(); + creds.address = "10.0.0.2/999".into(); + let opts = create_test_options(); + + let result = build_wireguard_connection(&creds, &opts); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ConnectionError::InvalidAddress(_) + )); + } + + #[test] + fn rejects_peer_with_empty_allowed_ips() { + let mut creds = create_test_credentials(); + creds.peers[0].allowed_ips = vec![]; + let opts = create_test_options(); + + let result = build_wireguard_connection(&creds, &opts); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ConnectionError::InvalidPeers(_) + )); + } + + #[test] + fn rejects_peer_with_invalid_public_key() { + let mut creds = create_test_credentials(); + creds.peers[0].public_key = "invalid!@#$key".into(); + let opts = create_test_options(); + + let result = build_wireguard_connection(&creds, &opts); + assert!(result.is_err()); + // Should get InvalidPrivateKey error (we use same validation for both) + assert!(matches!( + result.unwrap_err(), + ConnectionError::InvalidPrivateKey(_) + )); + } + + #[test] + fn accepts_valid_ipv4_addresses() { + let test_cases = vec![ + "10.0.0.2/24", + "192.168.1.100/32", + "172.16.0.1/16", + "1.1.1.1/8", + ]; + + for address in test_cases { + let mut creds = create_test_credentials(); + creds.address = address.into(); + let opts = create_test_options(); + + let result = build_wireguard_connection(&creds, &opts); + assert!( + result.is_ok(), + "Should accept valid IPv4 address: {}", + address + ); + } + } + + #[test] + fn accepts_standard_wireguard_ports() { + let test_cases = vec![ + "vpn.example.com:51820", + "192.168.1.1:51821", + "test.local:12345", + ]; + + for gateway in test_cases { + let mut creds = create_test_credentials(); + creds.gateway = gateway.into(); + let opts = create_test_options(); + + let result = build_wireguard_connection(&creds, &opts); + assert!(result.is_ok(), "Should accept valid gateway: {}", gateway); + } + } +} diff --git a/nmrs/src/api/models.rs b/nmrs/src/api/models.rs index 2eeb4d81..980742db 100644 --- a/nmrs/src/api/models.rs +++ b/nmrs/src/api/models.rs @@ -578,9 +578,46 @@ pub enum VpnType { WireGuard, } -/// VPN Credentials for establishing a VPN connection +/// VPN Credentials for establishing a VPN connection. /// /// Stores the necessary information to configure and connect to a VPN. +/// Currently supports WireGuard VPN connections. +/// +/// # Fields +/// +/// - `vpn_type`: The type of VPN (currently only WireGuard) +/// - `name`: Unique identifier for the connection +/// - `gateway`: VPN gateway endpoint (e.g., "vpn.example.com:51820") +/// - `private_key`: Client's WireGuard private key +/// - `address`: Client's IP address with CIDR notation (e.g., "10.0.0.2/24") +/// - `peers`: List of WireGuard peers to connect to +/// - `dns`: Optional DNS servers to use (e.g., ["1.1.1.1", "8.8.8.8"]) +/// - `mtu`: Optional Maximum Transmission Unit +/// - `uuid`: Optional UUID for the connection (auto-generated if not provided) +/// +/// # Example +/// +/// ```rust +/// use nmrs::{VpnCredentials, VpnType, WireGuardPeer}; +/// +/// let creds = VpnCredentials { +/// vpn_type: VpnType::WireGuard, +/// name: "HomeVPN".into(), +/// gateway: "vpn.home.com:51820".into(), +/// private_key: "aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789=".into(), +/// address: "10.0.0.2/24".into(), +/// peers: vec![WireGuardPeer { +/// public_key: "server_public_key".into(), +/// gateway: "vpn.home.com:51820".into(), +/// allowed_ips: vec!["0.0.0.0/0".into()], +/// preshared_key: None, +/// persistent_keepalive: Some(25), +/// }], +/// dns: Some(vec!["1.1.1.1".into()]), +/// mtu: None, +/// uuid: None, +/// }; +/// ``` #[derive(Debug, Clone)] pub struct VpnCredentials { pub vpn_type: VpnType, @@ -594,18 +631,64 @@ pub struct VpnCredentials { pub uuid: Option, } +/// WireGuard peer configuration. +/// +/// Represents a single WireGuard peer (server) to connect to. +/// +/// # Fields +/// +/// - `public_key`: The peer's WireGuard public key +/// - `gateway`: Peer endpoint in "host:port" format (e.g., "vpn.example.com:51820") +/// - `allowed_ips`: List of IP ranges allowed through this peer (e.g., ["0.0.0.0/0"]) +/// - `preshared_key`: Optional pre-shared key for additional security +/// - `persistent_keepalive`: Optional keepalive interval in seconds (e.g., 25) +/// +/// # Example +/// +/// ```rust +/// use nmrs::WireGuardPeer; +/// +/// let peer = WireGuardPeer { +/// public_key: "aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789=".into(), +/// gateway: "vpn.example.com:51820".into(), +/// allowed_ips: vec!["0.0.0.0/0".into(), "::/0".into()], +/// preshared_key: None, +/// persistent_keepalive: Some(25), +/// }; +/// ``` #[derive(Debug, Clone)] pub struct WireGuardPeer { pub public_key: String, - pub gateway: String, // endpoint host:port - pub allowed_ips: Vec, // CIDRs + pub gateway: String, + pub allowed_ips: Vec, pub preshared_key: Option, pub persistent_keepalive: Option, } -/// Active VPN Connection Information +/// VPN Connection information. +/// +/// Represents a VPN connection managed by NetworkManager, including both +/// saved and active connections. +/// +/// # Fields +/// +/// - `name`: The connection name/identifier +/// - `vpn_type`: The type of VPN (WireGuard, etc.) +/// - `state`: Current connection state (for active connections) +/// - `interface`: Network interface name (e.g., "wg0") when active +/// +/// # Example /// -/// Represents an active VPN connection managed by NetworkManager. +/// ```rust +/// use nmrs::{VpnConnection, VpnType, DeviceState}; +/// +/// let vpn = VpnConnection { +/// name: "WorkVPN".into(), +/// vpn_type: VpnType::WireGuard, +/// state: DeviceState::Activated, +/// interface: Some("wg0".into()), +/// }; +/// ``` #[derive(Debug, Clone)] pub struct VpnConnection { pub name: String, @@ -614,6 +697,39 @@ pub struct VpnConnection { pub interface: Option, } +/// Detailed VPN connection information and statistics. +/// +/// Provides comprehensive information about an active VPN connection, +/// including IP configuration and connection details. +/// +/// # Example +/// +/// ```rust +/// use nmrs::{VpnConnectionInfo, VpnType, DeviceState}; +/// +/// let info = VpnConnectionInfo { +/// name: "WorkVPN".into(), +/// vpn_type: VpnType::WireGuard, +/// state: DeviceState::Activated, +/// interface: Some("wg0".into()), +/// gateway: Some("vpn.example.com:51820".into()), +/// ip4_address: Some("10.0.0.2/24".into()), +/// ip6_address: None, +/// dns_servers: vec!["1.1.1.1".into()], +/// }; +/// ``` +#[derive(Debug, Clone)] +pub struct VpnConnectionInfo { + pub name: String, + pub vpn_type: VpnType, + pub state: DeviceState, + pub interface: Option, + pub gateway: Option, + pub ip4_address: Option, + pub ip6_address: Option, + pub dns_servers: Vec, +} + /// NetworkManager device types. #[derive(Debug, Clone, PartialEq)] pub enum DeviceType { @@ -772,13 +888,29 @@ pub enum ConnectionError { #[error("no VPN connection found")] NoVpnConnection, - /// Invalid address - #[error("invalid address")] + /// Invalid IP address or CIDR notation + #[error("invalid address: {0}")] InvalidAddress(String), - /// Invalid peer configuration - #[error("invalid peer configuration")] + /// Invalid VPN peer configuration + #[error("invalid peer configuration: {0}")] InvalidPeers(String), + + /// Invalid WireGuard private key format + #[error("invalid WireGuard private key: {0}")] + InvalidPrivateKey(String), + + /// Invalid WireGuard public key format + #[error("invalid WireGuard public key: {0}")] + InvalidPublicKey(String), + + /// Invalid VPN gateway format (should be host:port) + #[error("invalid VPN gateway: {0}")] + InvalidGateway(String), + + /// VPN connection failed + #[error("VPN connection failed: {0}")] + VpnFailed(String), } /// NetworkManager device state reason codes. diff --git a/nmrs/src/api/network_manager.rs b/nmrs/src/api/network_manager.rs index 8a51931b..4dfd491a 100644 --- a/nmrs/src/api/network_manager.rs +++ b/nmrs/src/api/network_manager.rs @@ -6,6 +6,8 @@ use crate::core::connection::{connect, connect_wired, forget}; use crate::core::connection_settings::{get_saved_connection_path, has_saved_connection}; use crate::core::device::{list_devices, set_wifi_enabled, wait_for_wifi_ready, wifi_enabled}; use crate::core::scan::{list_networks, scan_networks}; +use crate::core::vpn::{connect_vpn, disconnect_vpn, get_vpn_info, list_vpn_connections}; +use crate::models::{VpnConnection, VpnConnectionInfo, VpnCredentials}; use crate::monitoring::device as device_monitor; use crate::monitoring::info::{current_connection_info, current_ssid, show_details}; use crate::monitoring::network as network_monitor; @@ -157,6 +159,158 @@ impl NetworkManager { connect_wired(&self.conn).await } + /// Connects to a VPN using the provided credentials. + /// + /// Currently supports WireGuard VPN connections. The function checks for an + /// existing saved VPN connection by name. If found, it activates the saved + /// connection. If not found, it creates a new VPN connection with the provided + /// credentials. + /// + /// # Example + /// + /// ```no_run + /// use nmrs::{NetworkManager, VpnCredentials, VpnType, WireGuardPeer}; + /// + /// # async fn example() -> nmrs::Result<()> { + /// let nm = NetworkManager::new().await?; + /// + /// let creds = VpnCredentials { + /// vpn_type: VpnType::WireGuard, + /// name: "MyVPN".into(), + /// gateway: "vpn.example.com:51820".into(), + /// private_key: "your_private_key".into(), + /// address: "10.0.0.2/24".into(), + /// peers: vec![WireGuardPeer { + /// public_key: "peer_public_key".into(), + /// gateway: "vpn.example.com:51820".into(), + /// allowed_ips: vec!["0.0.0.0/0".into()], + /// preshared_key: None, + /// persistent_keepalive: Some(25), + /// }], + /// dns: Some(vec!["1.1.1.1".into()]), + /// mtu: None, + /// uuid: None, + /// }; + /// + /// nm.connect_vpn(creds).await?; + /// # Ok(()) + /// # } + /// ``` + /// + /// # Errors + /// + /// Returns an error if: + /// - NetworkManager is not running or accessible + /// - The credentials are invalid or incomplete + /// - The VPN connection fails to activate + pub async fn connect_vpn(&self, creds: VpnCredentials) -> Result<()> { + connect_vpn(&self.conn, creds).await + } + + /// Disconnects from an active VPN connection by name. + /// + /// Searches through active connections for a VPN matching the given name. + /// If found, deactivates the connection. If not found or already disconnected, + /// returns success. + /// + /// # Example + /// + /// ```no_run + /// use nmrs::NetworkManager; + /// + /// # async fn example() -> nmrs::Result<()> { + /// let nm = NetworkManager::new().await?; + /// nm.disconnect_vpn("MyVPN").await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn disconnect_vpn(&self, name: &str) -> Result<()> { + disconnect_vpn(&self.conn, name).await + } + + /// Lists all saved VPN connections. + /// + /// Returns a list of all VPN connection profiles saved in NetworkManager, + /// including their name, type, and current state. Only VPN connections with + /// recognized types (currently WireGuard) are returned. + /// + /// # Example + /// + /// ```no_run + /// use nmrs::NetworkManager; + /// + /// # async fn example() -> nmrs::Result<()> { + /// let nm = NetworkManager::new().await?; + /// let vpns = nm.list_vpn_connections().await?; + /// + /// for vpn in vpns { + /// println!("{}: {:?}", vpn.name, vpn.vpn_type); + /// } + /// # Ok(()) + /// # } + /// ``` + pub async fn list_vpn_connections(&self) -> Result> { + list_vpn_connections(&self.conn).await + } + + /// Forgets (deletes) a saved VPN connection by name. + /// + /// Searches through saved connections for a VPN matching the given name. + /// If found, deletes the connection profile. If currently connected, the + /// VPN will be disconnected first before deletion. + /// + /// # Example + /// + /// ```no_run + /// use nmrs::NetworkManager; + /// + /// # async fn example() -> nmrs::Result<()> { + /// let nm = NetworkManager::new().await?; + /// nm.forget_vpn("MyVPN").await?; + /// # Ok(()) + /// # } + /// ``` + /// + /// # Errors + /// + /// Returns `ConnectionError::NoSavedConnection` if no VPN with the given + /// name is found. + pub async fn forget_vpn(&self, name: &str) -> Result<()> { + crate::core::vpn::forget_vpn(&self.conn, name).await + } + + /// Gets detailed information about an active VPN connection. + /// + /// Retrieves comprehensive information about a VPN connection, including + /// IP configuration, DNS servers, gateway, interface, and connection state. + /// The VPN must be actively connected to retrieve this information. + /// + /// # Example + /// + /// ```no_run + /// use nmrs::NetworkManager; + /// + /// # async fn example() -> nmrs::Result<()> { + /// let nm = NetworkManager::new().await?; + /// let info = nm.get_vpn_info("MyVPN").await?; + /// + /// println!("VPN: {}", info.name); + /// println!("Interface: {:?}", info.interface); + /// println!("IP Address: {:?}", info.ip4_address); + /// println!("DNS Servers: {:?}", info.dns_servers); + /// println!("State: {:?}", info.state); + /// # Ok(()) + /// # } + /// ``` + /// + /// # Errors + /// + /// Returns `ConnectionError::NoVpnConnection` if the VPN is not found + /// or not currently active. + pub async fn get_vpn_info(&self, name: &str) -> Result { + get_vpn_info(&self.conn, name).await + } + /// Returns whether Wi-Fi is currently enabled. pub async fn wifi_enabled(&self) -> Result { wifi_enabled(&self.conn).await diff --git a/nmrs/src/core/mod.rs b/nmrs/src/core/mod.rs index 5093e22f..70853d3d 100644 --- a/nmrs/src/core/mod.rs +++ b/nmrs/src/core/mod.rs @@ -8,3 +8,4 @@ pub(crate) mod connection_settings; pub(crate) mod device; pub(crate) mod scan; pub(crate) mod state_wait; +pub(crate) mod vpn; diff --git a/nmrs/src/core/vpn.rs b/nmrs/src/core/vpn.rs new file mode 100644 index 00000000..6454dbc9 --- /dev/null +++ b/nmrs/src/core/vpn.rs @@ -0,0 +1,542 @@ +//! Core VPN connection management logic. +//! +//! This module contains internal implementation for managing VPN connections +//! through NetworkManager, including connecting, disconnecting, listing, and +//! deleting VPN profiles. +//! +//! Currently supports: +//! - WireGuard VPN connections +//! +//! These functions are not part of the public API and should be accessed +//! through the [`NetworkManager`][crate::NetworkManager] interface. + +use log::{debug, info}; +use std::collections::HashMap; +use zbus::Connection; +use zvariant::OwnedObjectPath; + +use crate::Result; +use crate::api::models::{ + ConnectionOptions, DeviceState, VpnConnection, VpnConnectionInfo, VpnCredentials, VpnType, +}; +use crate::builders::build_wireguard_connection; +use crate::core::state_wait::wait_for_connection_activation; +use crate::dbus::NMProxy; + +/// Connects to a VPN using WireGuard. +/// +/// This function checks for an existing saved VPN connection by name. +/// If found, it activates the saved connection. If not found, it creates +/// a new WireGuard VPN connection using the provided credentials. +/// The function waits for the connection to reach the activated state +/// before returning. +/// +/// VPN connections do not have a specific device or access point, +/// so empty object paths are used for those parameters. +pub(crate) async fn connect_vpn(conn: &Connection, creds: VpnCredentials) -> Result<()> { + debug!("Connecting to VPN: {}", creds.name); + + let nm = NMProxy::new(conn).await?; + + // Check saved connections + let saved = + crate::core::connection_settings::get_saved_connection_path(conn, &creds.name).await?; + + // VPNs do not have a device path or specific_object + // So we use an empty path for both instead + let d_path = OwnedObjectPath::try_from("/").unwrap(); + let specific_object = OwnedObjectPath::try_from("/").unwrap(); + + let active_conn = if let Some(saved_path) = saved { + debug!("Activated existent VPN connection"); + nm.activate_connection(saved_path, d_path, specific_object) + .await? + } else { + debug!("Creating new VPN connection"); + let opts = ConnectionOptions { + autoconnect: false, + autoconnect_priority: None, + autoconnect_retries: None, + }; + + let settings = build_wireguard_connection(&creds, &opts); + let (_, active_conn) = nm + .add_and_activate_connection(settings?, d_path, specific_object) + .await?; + active_conn + }; + + wait_for_connection_activation(conn, &active_conn).await?; + + info!("Successfully connected to VPN: {}", creds.name); + Ok(()) +} + +/// Disconnects from a VPN by name. +/// +/// Searches through active connections for a VPN matching the given name. +/// If found, deactivates the connection. If not found, assumes already +/// disconnected and returns success. +pub(crate) async fn disconnect_vpn(conn: &Connection, name: &str) -> Result<()> { + debug!("Disconnecting VPN: {name}"); + + let nm = NMProxy::new(conn).await?; + let active_conns = match nm.active_connections().await { + Ok(conns) => conns, + Err(e) => { + debug!("Failed to get active connections: {}", e); + // If we can't get active connections, assume VPN is not active + info!("Disconnected VPN: {name} (could not verify active state)"); + return Ok(()); + } + }; + + for ac_path in active_conns { + let ac_proxy_result = zbus::proxy::Builder::new(conn) + .destination("org.freedesktop.NetworkManager") + .map(|b| b.path(ac_path.clone())) + .and_then(|r| r) + .map(|b| b.interface("org.freedesktop.NetworkManager.Connection.Active")) + .and_then(|r| r); + + let ac_proxy: zbus::Proxy<'_> = match ac_proxy_result { + Ok(builder) => match builder.build().await { + Ok(proxy) => proxy, + Err(_) => continue, + }, + Err(_) => continue, + }; + + let conn_path = match ac_proxy.call_method("Connection", &()).await { + Ok(msg) => match msg.body().deserialize::() { + Ok(path) => path, + Err(_) => continue, + }, + Err(_) => continue, + }; + + let cproxy_result = zbus::proxy::Builder::new(conn) + .destination("org.freedesktop.NetworkManager") + .map(|b| b.path(conn_path.clone())) + .and_then(|r| r) + .map(|b| b.interface("org.freedesktop.NetworkManager.Settings.Connection")) + .and_then(|r| r); + + let cproxy: zbus::Proxy<'_> = match cproxy_result { + Ok(builder) => match builder.build().await { + Ok(proxy) => proxy, + Err(_) => continue, + }, + Err(_) => continue, + }; + + let msg = match cproxy.call_method("GetSettings", &()).await { + Ok(msg) => msg, + Err(_) => continue, + }; + + let body = msg.body(); + let settings_map: HashMap> = + match body.deserialize() { + Ok(map) => map, + Err(_) => continue, + }; + + if let Some(conn_sec) = settings_map.get("connection") + && let Some(zvariant::Value::Str(id)) = conn_sec.get("id") + && id.as_str() == name + { + debug!("Found active VPN connection, deactivating: {name}"); + let _ = nm.deactivate_connection(ac_path).await; // Ignore errors on deactivation + info!("Successfully disconnected VPN: {name}"); + return Ok(()); + } + } + + info!("Disconnected VPN: {name} (not active)"); + Ok(()) +} + +/// Lists all saved VPN connections with their current state. +/// +/// Queries NetworkManager's saved connection settings and returns a list of +/// all VPN connections, including their name, type, current state, and interface. +/// Only returns VPN connections with recognized VPN types (currently WireGuard). +/// +/// For active VPN connections, this function populates the `state` and `interface` +/// fields by querying active connections. +pub(crate) async fn list_vpn_connections(conn: &Connection) -> Result> { + let nm = NMProxy::new(conn).await?; + + let settings: zbus::Proxy<'_> = zbus::proxy::Builder::new(conn) + .destination("org.freedesktop.NetworkManager")? + .path("/org/freedesktop/NetworkManager/Settings")? + .interface("org.freedesktop.NetworkManager.Settings")? + .build() + .await?; + + let list_reply = settings.call_method("ListConnections", &()).await?; + let body = list_reply.body(); + let saved_conns: Vec = body.deserialize()?; + + // Get active connections to populate state/interface + let active_conns = nm.active_connections().await?; + let mut active_vpn_map: HashMap)> = HashMap::new(); + + for ac_path in active_conns { + let ac_proxy: zbus::Proxy<'_> = zbus::proxy::Builder::new(conn) + .destination("org.freedesktop.NetworkManager")? + .path(ac_path.clone())? + .interface("org.freedesktop.NetworkManager.Connection.Active")? + .build() + .await?; + + // Get the connection path + if let Ok(conn_msg) = ac_proxy.call_method("Connection", &()).await + && let Ok(conn_path) = conn_msg.body().deserialize::() + { + // Get connection settings to find the name + let cproxy: zbus::Proxy<'_> = zbus::proxy::Builder::new(conn) + .destination("org.freedesktop.NetworkManager")? + .path(conn_path)? + .interface("org.freedesktop.NetworkManager.Settings.Connection")? + .build() + .await?; + + if let Ok(msg) = cproxy.call_method("GetSettings", &()).await + && let Ok(settings_map) = msg + .body() + .deserialize::>>() + && let Some(conn_sec) = settings_map.get("connection") + && let Some(zvariant::Value::Str(id)) = conn_sec.get("id") + && let Some(zvariant::Value::Str(conn_type)) = conn_sec.get("type") + && conn_type.as_str() == "vpn" + { + // Get state + let state = if let Ok(state_val) = ac_proxy.get_property::("State").await { + DeviceState::from(state_val) + } else { + DeviceState::Other(0) + }; + + // Get devices (which includes interface info) + let interface = if let Ok(dev_paths) = ac_proxy + .get_property::>("Devices") + .await + { + if let Some(dev_path) = dev_paths.first() { + // Get device interface name + match zbus::proxy::Builder::::new(conn) + .destination("org.freedesktop.NetworkManager")? + .path(dev_path.clone())? + .interface("org.freedesktop.NetworkManager.Device")? + .build() + .await + { + Ok(dev_proxy) => { + dev_proxy.get_property::("Interface").await.ok() + } + Err(_) => None, + } + } else { + None + } + } else { + None + }; + + active_vpn_map.insert(id.to_string(), (state, interface)); + } + } + } + + let mut vpn_conns = Vec::new(); + + for cpath in saved_conns { + let cproxy: zbus::Proxy<'_> = zbus::proxy::Builder::new(conn) + .destination("org.freedesktop.NetworkManager")? + .path(cpath.clone())? + .interface("org.freedesktop.NetworkManager.Settings.Connection")? + .build() + .await?; + + let msg = cproxy.call_method("GetSettings", &()).await?; + let body = msg.body(); + let settings_map: HashMap> = body.deserialize()?; + + if let Some(conn_sec) = settings_map.get("connection") + && let Some(zvariant::Value::Str(id)) = conn_sec.get("id") + && let Some(zvariant::Value::Str(conn_type)) = conn_sec.get("type") + && conn_type.as_str() == "vpn" + { + // Extract VPN service-type and convert to VpnType enum + let vpn_type = settings_map + .get("vpn") + .and_then(|vpn_sec| vpn_sec.get("service-type")) + .and_then(|v| match v { + zvariant::Value::Str(s) => { + // Match against known service types + match s.as_str() { + "org.freedesktop.NetworkManager.wireguard" => Some(VpnType::WireGuard), + _ => None, // Unknown VPN types are skipped for now + } + } + _ => None, + }); + + // Only add VPN connections with recognized types + if let Some(vpn_type) = vpn_type { + let name = id.to_string(); + let (state, interface) = active_vpn_map + .get(&name) + .cloned() + .unwrap_or((DeviceState::Other(0), None)); + + vpn_conns.push(VpnConnection { + name, + vpn_type, + interface, + state, + }); + } + } + } + + Ok(vpn_conns) +} + +/// Forgets (deletes) a saved VPN connection by name. +/// +/// Searches through saved connections for a VPN matching the given name. +/// If found, deletes the connection profile. If not found, returns +/// `NoSavedConnection` error. If currently connected, the VPN will be +/// disconnected first before deletion. +pub(crate) async fn forget_vpn(conn: &Connection, name: &str) -> Result<()> { + debug!("Starting forget operation for VPN: {name}"); + + // First, disconnect if currently active + let _ = disconnect_vpn(conn, name).await; + + let settings: zbus::Proxy<'_> = zbus::proxy::Builder::new(conn) + .destination("org.freedesktop.NetworkManager")? + .path("/org/freedesktop/NetworkManager/Settings")? + .interface("org.freedesktop.NetworkManager.Settings")? + .build() + .await?; + + let list_reply = settings.call_method("ListConnections", &()).await?; + let body = list_reply.body(); + let conns: Vec = body.deserialize()?; + + for cpath in conns { + let cproxy: zbus::Proxy<'_> = zbus::proxy::Builder::new(conn) + .destination("org.freedesktop.NetworkManager")? + .path(cpath.clone())? + .interface("org.freedesktop.NetworkManager.Settings.Connection")? + .build() + .await?; + + if let Ok(msg) = cproxy.call_method("GetSettings", &()).await { + let body = msg.body(); + let settings_map: HashMap> = + body.deserialize()?; + + if let Some(conn_sec) = settings_map.get("connection") + && let Some(zvariant::Value::Str(id)) = conn_sec.get("id") + && let Some(zvariant::Value::Str(conn_type)) = conn_sec.get("type") + && conn_type.as_str() == "vpn" + && id.as_str() == name + { + debug!("Found VPN connection, deleting: {name}"); + cproxy.call_method("Delete", &()).await?; + info!("Successfully deleted VPN connection: {name}"); + return Ok(()); + } + } + } + + debug!("No saved VPN connection found for '{name}'"); + Err(crate::api::models::ConnectionError::NoSavedConnection) +} + +/// Gets detailed information about a VPN connection. +/// +/// Queries NetworkManager for comprehensive information about a VPN connection, +/// including IP configuration, DNS servers, and connection state. The VPN must +/// be in the active connections list to retrieve full details. +/// +/// # Arguments +/// +/// * `conn` - D-Bus connection +/// * `name` - The name of the VPN connection +/// +/// # Returns +/// +/// Returns `VpnConnectionInfo` with detailed connection information, or an +/// error if the VPN is not found or not active. +pub(crate) async fn get_vpn_info(conn: &Connection, name: &str) -> Result { + let nm = NMProxy::new(conn).await?; + let active_conns = nm.active_connections().await?; + + for ac_path in active_conns { + let ac_proxy: zbus::Proxy<'_> = zbus::proxy::Builder::new(conn) + .destination("org.freedesktop.NetworkManager")? + .path(ac_path.clone())? + .interface("org.freedesktop.NetworkManager.Connection.Active")? + .build() + .await?; + + // Get the connection path + let conn_msg = ac_proxy.call_method("Connection", &()).await?; + let conn_path: OwnedObjectPath = conn_msg.body().deserialize()?; + + let cproxy: zbus::Proxy<'_> = zbus::proxy::Builder::new(conn) + .destination("org.freedesktop.NetworkManager")? + .path(conn_path)? + .interface("org.freedesktop.NetworkManager.Settings.Connection")? + .build() + .await?; + + let msg = cproxy.call_method("GetSettings", &()).await?; + let body = msg.body(); + let settings_map: HashMap> = body.deserialize()?; + + if let Some(conn_sec) = settings_map.get("connection") + && let Some(zvariant::Value::Str(id)) = conn_sec.get("id") + && let Some(zvariant::Value::Str(conn_type)) = conn_sec.get("type") + && conn_type.as_str() == "vpn" + && id.as_str() == name + { + // Found the VPN connection, get details + let vpn_type = settings_map + .get("vpn") + .and_then(|vpn_sec| vpn_sec.get("service-type")) + .and_then(|v| match v { + zvariant::Value::Str(s) => match s.as_str() { + "org.freedesktop.NetworkManager.wireguard" => Some(VpnType::WireGuard), + _ => None, + }, + _ => None, + }) + .ok_or_else(|| crate::api::models::ConnectionError::NoVpnConnection)?; + + // Get state + let state_val: u32 = ac_proxy.get_property("State").await?; + let state = DeviceState::from(state_val); + + // Get interface + let dev_paths: Vec = ac_proxy.get_property("Devices").await?; + let interface = if let Some(dev_path) = dev_paths.first() { + let dev_proxy: zbus::Proxy<'_> = zbus::proxy::Builder::new(conn) + .destination("org.freedesktop.NetworkManager")? + .path(dev_path.clone())? + .interface("org.freedesktop.NetworkManager.Device")? + .build() + .await?; + + Some(dev_proxy.get_property::("Interface").await?) + } else { + None + }; + + // Get gateway from VPN settings + let gateway = settings_map + .get("vpn") + .and_then(|vpn_sec| vpn_sec.get("data")) + .and_then(|data| match data { + zvariant::Value::Dict(dict) => { + // Try to find gateway/endpoint in the data + for entry in dict.iter() { + let (key_val, value_val) = entry; + if let zvariant::Value::Str(key) = key_val + && (key.as_str().contains("endpoint") + || key.as_str().contains("gateway")) + && let zvariant::Value::Str(val) = value_val + { + return Some(val.as_str().to_string()); + } + } + None + } + _ => None, + }); + + // Get IP4 configuration + let ip4_path: OwnedObjectPath = ac_proxy.get_property("Ip4Config").await?; + let (ip4_address, dns_servers) = if ip4_path.as_str() != "/" { + let ip4_proxy: zbus::Proxy<'_> = zbus::proxy::Builder::new(conn) + .destination("org.freedesktop.NetworkManager")? + .path(ip4_path)? + .interface("org.freedesktop.NetworkManager.IP4Config")? + .build() + .await?; + + // Get address data + let ip4_address = if let Ok(addr_array) = ip4_proxy + .get_property::>>("AddressData") + .await + { + addr_array.first().and_then(|addr_map| { + let address = addr_map.get("address").and_then(|v| match v { + zvariant::Value::Str(s) => Some(s.as_str().to_string()), + _ => None, + })?; + let prefix = addr_map.get("prefix").and_then(|v| match v { + zvariant::Value::U32(p) => Some(p), + _ => None, + })?; + Some(format!("{}/{}", address, prefix)) + }) + } else { + None + }; + + // Get DNS servers + let dns_servers = if let Ok(dns_array) = + ip4_proxy.get_property::>("Nameservers").await + { + dns_array + .iter() + .map(|ip| { + format!( + "{}.{}.{}.{}", + ip & 0xFF, + (ip >> 8) & 0xFF, + (ip >> 16) & 0xFF, + (ip >> 24) & 0xFF + ) + }) + .collect() + } else { + vec![] + }; + + (ip4_address, dns_servers) + } else { + (None, vec![]) + }; + + // Get IP6 configuration (similar to IP4, but simpler for now) + let ip6_path: OwnedObjectPath = ac_proxy.get_property("Ip6Config").await?; + let ip6_address = if ip6_path.as_str() != "/" { + // TODO: Implement IPv6 address parsing + None + } else { + None + }; + + return Ok(VpnConnectionInfo { + name: id.to_string(), + vpn_type, + state, + interface, + gateway, + ip4_address, + ip6_address, + dns_servers, + }); + } + } + + Err(crate::api::models::ConnectionError::NoVpnConnection) +} diff --git a/nmrs/src/lib.rs b/nmrs/src/lib.rs index 16f4cfd3..9dddfbac 100644 --- a/nmrs/src/lib.rs +++ b/nmrs/src/lib.rs @@ -5,6 +5,8 @@ //! //! # Quick Start //! +//! ## WiFi Connection +//! //! ```no_run //! use nmrs::{NetworkManager, WifiSecurity}; //! @@ -30,6 +32,48 @@ //! # } //! ``` //! +//! ## VPN Connection (WireGuard) +//! +//! ```no_run +//! use nmrs::{NetworkManager, VpnCredentials, VpnType, WireGuardPeer}; +//! +//! # async fn example() -> nmrs::Result<()> { +//! let nm = NetworkManager::new().await?; +//! +//! // Configure WireGuard VPN +//! let creds = VpnCredentials { +//! vpn_type: VpnType::WireGuard, +//! name: "MyVPN".into(), +//! gateway: "vpn.example.com:51820".into(), +//! private_key: "your_private_key".into(), +//! address: "10.0.0.2/24".into(), +//! peers: vec![WireGuardPeer { +//! public_key: "peer_public_key".into(), +//! gateway: "vpn.example.com:51820".into(), +//! allowed_ips: vec!["0.0.0.0/0".into()], +//! preshared_key: None, +//! persistent_keepalive: Some(25), +//! }], +//! dns: Some(vec!["1.1.1.1".into(), "8.8.8.8".into()]), +//! mtu: None, +//! uuid: None, +//! }; +//! +//! // Connect to VPN +//! nm.connect_vpn(creds).await?; +//! +//! // List VPN connections +//! let vpns = nm.list_vpn_connections().await?; +//! for vpn in vpns { +//! println!("{}: {:?} - {:?}", vpn.name, vpn.vpn_type, vpn.state); +//! } +//! +//! // Disconnect +//! nm.disconnect_vpn("MyVPN").await?; +//! # Ok(()) +//! # } +//! ``` +//! //! # Core Concepts //! //! ## NetworkManager @@ -37,7 +81,7 @@ //! The main entry point is [`NetworkManager`], which provides methods for: //! - Listing and managing network devices //! - Scanning for available WiFi networks -//! - Connecting to networks (WiFi, Ethernet) +//! - Connecting to networks (WiFi, Ethernet, VPN) //! - Managing saved connection profiles //! - Real-time monitoring of network changes //! @@ -47,6 +91,10 @@ //! - [`Device`] - Represents a network device (WiFi, Ethernet, etc.) //! - [`Network`] - Represents a discovered WiFi network //! - [`WifiSecurity`] - Security types (Open, WPA-PSK, WPA-EAP) +//! - [`VpnCredentials`] - VPN connection credentials +//! - [`VpnType`] - Supported VPN types (WireGuard, etc.) +//! - [`VpnConnection`] - Active VPN connection information +//! - [`WireGuardPeer`] - WireGuard peer configuration //! - [`ConnectionError`] - Comprehensive error types //! //! ## Connection Builders @@ -288,7 +336,8 @@ pub mod wifi_builders { pub use api::models::{ ActiveConnectionState, ConnectionError, ConnectionOptions, ConnectionStateReason, Device, DeviceState, DeviceType, EapMethod, EapOptions, Network, NetworkInfo, Phase2, StateReason, - WifiSecurity, connection_state_reason_to_error, reason_to_error, + VpnConnection, VpnConnectionInfo, VpnCredentials, VpnType, WifiSecurity, WireGuardPeer, + connection_state_reason_to_error, reason_to_error, }; pub use api::network_manager::NetworkManager; diff --git a/nmrs/tests/integration_test.rs b/nmrs/tests/integration_test.rs index 16f7848a..c520f81d 100644 --- a/nmrs/tests/integration_test.rs +++ b/nmrs/tests/integration_test.rs @@ -1,6 +1,6 @@ use nmrs::{ - ConnectionError, DeviceState, DeviceType, NetworkManager, StateReason, WifiSecurity, - reason_to_error, + ConnectionError, DeviceState, DeviceType, NetworkManager, StateReason, VpnCredentials, VpnType, + WifiSecurity, WireGuardPeer, reason_to_error, }; use std::time::Duration; use tokio::time::sleep; @@ -57,7 +57,6 @@ macro_rules! require_ethernet { }; } -/// Test NetworkManager initialization #[tokio::test] async fn test_networkmanager_initialization() { require_networkmanager!(); @@ -77,10 +76,8 @@ async fn test_list_devices() { .expect("Failed to create NetworkManager"); let devices = nm.list_devices().await.expect("Failed to list devices"); - // Should have at least one device (usually loopback) assert!(!devices.is_empty(), "Expected at least one device"); - // Verify device structure for device in &devices { assert!(!device.path.is_empty(), "Device path should not be empty"); assert!( @@ -105,30 +102,35 @@ async fn test_wifi_enabled_get_set() { .await .expect("Failed to get WiFi enabled state"); - // Toggle WiFi - nm.set_wifi_enabled(!initial_state) - .await - .expect("Failed to set WiFi enabled"); - - // Wait a bit for the change to take effect - sleep(Duration::from_millis(500)).await; - - // Verify the state changed - let new_state = nm - .wifi_enabled() - .await - .expect("Failed to get WiFi enabled state after toggle"); - assert_eq!(new_state, !initial_state, "WiFi state should have changed"); + match nm.set_wifi_enabled(!initial_state).await { + Ok(_) => { + sleep(Duration::from_millis(500)).await; + + let new_state = nm + .wifi_enabled() + .await + .expect("Failed to get WiFi enabled state after toggle"); + + if new_state != !initial_state { + eprintln!( + "Warning: WiFi state didn't change (may lack permissions). Initial: {}, New: {}", + initial_state, new_state + ); + return; + } + } + Err(e) => { + eprintln!("Failed to toggle WiFi (may lack permissions): {}", e); + return; + } + } - // Restore original state nm.set_wifi_enabled(initial_state) .await .expect("Failed to restore WiFi enabled state"); - // Wait a bit for the change to take effect sleep(Duration::from_millis(500)).await; - // Verify restored let restored_state = nm .wifi_enabled() .await @@ -837,3 +839,194 @@ async fn test_connect_wired() { } } } + +/// Helper to create test VPN credentials +fn create_test_vpn_creds(name: &str) -> VpnCredentials { + VpnCredentials { + vpn_type: VpnType::WireGuard, + name: name.into(), + gateway: "test.example.com:51820".into(), + private_key: "YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=".into(), + address: "10.100.0.2/24".into(), + peers: vec![WireGuardPeer { + public_key: "HIgo9xNzJMWLKAShlKl6/bUT1VI9Q0SDBXGtLXkPFXc=".into(), + gateway: "test.example.com:51820".into(), + allowed_ips: vec!["0.0.0.0/0".into(), "::/0".into()], + preshared_key: None, + persistent_keepalive: Some(25), + }], + dns: Some(vec!["1.1.1.1".into(), "8.8.8.8".into()]), + mtu: Some(1420), + uuid: None, + } +} + +/// Test listing VPN connections +#[tokio::test] +async fn test_list_vpn_connections() { + require_networkmanager!(); + + let nm = NetworkManager::new() + .await + .expect("Failed to create NetworkManager"); + + // List VPN connections (should not fail even if empty) + let result = nm.list_vpn_connections().await; + assert!(result.is_ok(), "Should be able to list VPN connections"); + + let vpns = result.unwrap(); + eprintln!("Found {} VPN connection(s)", vpns.len()); + + // Verify structure of any VPN connections found + for vpn in &vpns { + assert!(!vpn.name.is_empty(), "VPN name should not be empty"); + eprintln!("VPN: {} ({:?})", vpn.name, vpn.vpn_type); + } +} + +/// Test VPN connection lifecycle (does not actually connect) +#[tokio::test] +async fn test_vpn_lifecycle_dry_run() { + require_networkmanager!(); + + let nm = NetworkManager::new() + .await + .expect("Failed to create NetworkManager"); + + // Note: This test does NOT actually connect to a VPN + // It only tests the API structure and error handling + + // Create test credentials + let creds = create_test_vpn_creds("test_vpn_lifecycle"); + + // Attempt to connect (will likely fail as test server doesn't exist) + let result = nm.connect_vpn(creds).await; + + match result { + Ok(_) => { + eprintln!("VPN connection succeeded (unexpected in test)"); + // Clean up + let _ = nm.disconnect_vpn("test_vpn_lifecycle").await; + let _ = nm.forget_vpn("test_vpn_lifecycle").await; + } + Err(e) => { + eprintln!("VPN connection failed as expected: {}", e); + // This is expected since we're using fake credentials + } + } +} + +/// Test VPN disconnection with non-existent VPN +#[tokio::test] +async fn test_disconnect_nonexistent_vpn() { + require_networkmanager!(); + + let nm = NetworkManager::new() + .await + .expect("Failed to create NetworkManager"); + + // Disconnecting a non-existent VPN should succeed (idempotent) + let result = nm.disconnect_vpn("nonexistent_vpn_connection_12345").await; + assert!( + result.is_ok(), + "Disconnecting non-existent VPN should succeed" + ); +} + +/// Test forgetting non-existent VPN +#[tokio::test] +async fn test_forget_nonexistent_vpn() { + require_networkmanager!(); + + let nm = NetworkManager::new() + .await + .expect("Failed to create NetworkManager"); + + // Forgetting a non-existent VPN should fail + let result = nm.forget_vpn("nonexistent_vpn_connection_12345").await; + assert!( + result.is_err(), + "Forgetting non-existent VPN should return error" + ); + + match result { + Err(ConnectionError::NoSavedConnection) => { + eprintln!("Correct error: NoSavedConnection"); + } + Err(e) => { + panic!("Unexpected error type: {}", e); + } + Ok(_) => { + panic!("Should have failed"); + } + } +} + +/// Test getting info for non-existent VPN +#[tokio::test] +async fn test_get_nonexistent_vpn_info() { + require_networkmanager!(); + + let nm = NetworkManager::new() + .await + .expect("Failed to create NetworkManager"); + + // Getting info for non-existent/inactive VPN should fail + let result = nm.get_vpn_info("nonexistent_vpn_connection_12345").await; + assert!( + result.is_err(), + "Getting info for non-existent VPN should return error" + ); + + match result { + Err(ConnectionError::NoVpnConnection) => { + eprintln!("Correct error: NoVpnConnection"); + } + Err(e) => { + eprintln!("Error (acceptable): {}", e); + } + Ok(_) => { + panic!("Should have failed"); + } + } +} + +/// Test VPN type enum +#[tokio::test] +async fn test_vpn_type() { + // Verify VPN types are properly defined + let wg = VpnType::WireGuard; + assert_eq!(format!("{:?}", wg), "WireGuard"); +} + +/// Test WireGuard peer structure +#[tokio::test] +async fn test_wireguard_peer_structure() { + let peer = WireGuardPeer { + public_key: "test_key".into(), + gateway: "test.example.com:51820".into(), + allowed_ips: vec!["0.0.0.0/0".into()], + preshared_key: Some("psk".into()), + persistent_keepalive: Some(25), + }; + + assert_eq!(peer.public_key, "test_key"); + assert_eq!(peer.gateway, "test.example.com:51820"); + assert_eq!(peer.allowed_ips.len(), 1); + assert_eq!(peer.preshared_key, Some("psk".into())); + assert_eq!(peer.persistent_keepalive, Some(25)); +} + +/// Test VPN credentials structure +#[tokio::test] +async fn test_vpn_credentials_structure() { + let creds = create_test_vpn_creds("test_credentials"); + + assert_eq!(creds.name, "test_credentials"); + assert_eq!(creds.vpn_type, VpnType::WireGuard); + assert_eq!(creds.peers.len(), 1); + assert_eq!(creds.address, "10.100.0.2/24"); + assert!(creds.dns.is_some()); + assert_eq!(creds.dns.as_ref().unwrap().len(), 2); + assert_eq!(creds.mtu, Some(1420)); +} From c25b99df8944258a22aeaf36207bb467b00270ff Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Mon, 15 Dec 2025 16:09:21 -0500 Subject: [PATCH 05/11] chore: update `nmrs` README + example files --- Cargo.lock | 2 +- nmrs-aur | 2 +- nmrs/Cargo.toml | 2 +- nmrs/README.md | 200 +++++++++++++++++++++++++++++++---- nmrs/examples/vpn_connect.rs | 35 ++++++ nmrs/examples/wifi_scan.rs | 17 +++ 6 files changed, 232 insertions(+), 26 deletions(-) create mode 100644 nmrs/examples/vpn_connect.rs create mode 100644 nmrs/examples/wifi_scan.rs diff --git a/Cargo.lock b/Cargo.lock index 43df2666..ce95e569 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -957,7 +957,7 @@ dependencies = [ [[package]] name = "nmrs" -version = "0.5.0" +version = "1.0.0" dependencies = [ "futures", "futures-timer", diff --git a/nmrs-aur b/nmrs-aur index 1e81b65d..93a3e1cf 160000 --- a/nmrs-aur +++ b/nmrs-aur @@ -1 +1 @@ -Subproject commit 1e81b65d39b800e5d5a72accea36d8400642b2fd +Subproject commit 93a3e1cf219b9179c4593bff2a239e9ebfd0e503 diff --git a/nmrs/Cargo.toml b/nmrs/Cargo.toml index 473a54bf..ad025311 100644 --- a/nmrs/Cargo.toml +++ b/nmrs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nmrs" -version = "0.5.0" +version = "1.0.0" edition = "2024" description = "A Rust library for NetworkManager over D-Bus" license = "MIT" diff --git a/nmrs/README.md b/nmrs/README.md index cd482f06..d4688e9b 100644 --- a/nmrs/README.md +++ b/nmrs/README.md @@ -6,28 +6,32 @@ Rust bindings for NetworkManager via D-Bus. -## Overview +## Why? `nmrs` provides a high-level, async API for managing Wi-Fi connections on Linux systems. It abstracts the complexity of D-Bus communication with NetworkManager, offering typed error handling and an ergonomic interface. ## Features -- **Network Operations**: Connect to WPA-PSK, WPA-EAP, and open networks -- **Discovery**: Scan for and list available access points with signal strength -- **Profile Management**: Query, create, and delete saved connection profiles -- **Status Queries**: Get current connection state, SSID, and detailed network information -- **Typed Errors**: Structured error types mapping NetworkManager state reason codes -- **Fully Async**: Built on `tokio` with `async/await` support +- **WiFi Management**: Connect to WPA-PSK, WPA-EAP, and open networks +- **VPN Support**: WireGuard VPN connections with full configuration +- **Ethernet**: Wired network connection management +- **Network Discovery**: Scan and list available access points with signal strength +- **Profile Management**: Create, query, and delete saved connection profiles +- **Real-Time Monitoring**: Signal-based network and device state change notifications +- **Typed Errors**: Structured error types with specific failure reasons +- **Fully Async**: Built on `zbus` with async/await throughout ## Installation ```toml [dependencies] -nmrs = "0.4" +nmrs = "1.0.0" ``` ## Quick Start +### WiFi Connection + ```rust use nmrs::{NetworkManager, WifiSecurity}; @@ -37,53 +41,203 @@ async fn main() -> nmrs::Result<()> { // List networks let networks = nm.list_networks().await?; - for net in networks { - println!("{} ({}%)", net.ssid, net.strength.unwrap_or(0)); + for net in &networks { + println!("{} - Signal: {}%", net.ssid, net.strength.unwrap_or(0)); } - // Connect + // Connect to WPA-PSK network nm.connect("MyNetwork", WifiSecurity::WpaPsk { psk: "password".into() }).await?; + // Check current connection + if let Some(ssid) = nm.current_ssid().await { + println!("Connected to: {}", ssid); + } + + Ok(()) +} +``` + +### WireGuard VPN + +```rust +use nmrs::{NetworkManager, VpnCredentials, VpnType, WireGuardPeer}; + +#[tokio::main] +async fn main() -> nmrs::Result<()> { + let nm = NetworkManager::new().await?; + + let creds = VpnCredentials { + vpn_type: VpnType::WireGuard, + name: "WorkVPN".into(), + gateway: "vpn.example.com:51820".into(), + private_key: "your_private_key_here".into(), + address: "10.0.0.2/24".into(), + peers: vec![WireGuardPeer { + public_key: "server_public_key".into(), + gateway: "vpn.example.com:51820".into(), + allowed_ips: vec!["0.0.0.0/0".into()], + preshared_key: None, + persistent_keepalive: Some(25), + }], + dns: Some(vec!["1.1.1.1".into()]), + mtu: None, + uuid: None, + }; + + // Connect to VPN + nm.connect_vpn(creds).await?; + + // Get connection details + let info = nm.get_vpn_info("WorkVPN").await?; + println!("VPN IP: {:?}", info.ip4_address); + + // Disconnect + nm.disconnect_vpn("WorkVPN").await?; + + Ok(()) +} +``` + +### WPA-Enterprise (EAP) + +```rust +use nmrs::{NetworkManager, WifiSecurity, EapOptions, EapMethod, Phase2}; + +#[tokio::main] +async fn main() -> nmrs::Result<()> { + let nm = NetworkManager::new().await?; + + nm.connect("CorpNetwork", WifiSecurity::WpaEap { + 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, + } + }).await?; + + Ok(()) +} +``` + +### Device Management + +```rust +use nmrs::NetworkManager; + +#[tokio::main] +async fn main() -> nmrs::Result<()> { + let nm = NetworkManager::new().await?; + + // List all network devices + let devices = nm.list_devices().await?; + for device in devices { + println!("{}: {} ({})", device.interface, device.device_type, device.state); + } + + // Control WiFi radio + nm.set_wifi_enabled(false).await?; + nm.set_wifi_enabled(true).await?; + + Ok(()) +} +``` + +### Real-Time Monitoring + +```rust +use nmrs::NetworkManager; + +#[tokio::main] +async fn main() -> nmrs::Result<()> { + let nm = NetworkManager::new().await?; + + // Monitor network changes + nm.monitor_network_changes(|| { + println!("Network list changed"); + }).await?; + Ok(()) } ``` ## Error Handling -All operations return `Result` with specific error variants: +All operations return `Result` with specific variants: ```rust -use nmrs::ConnectionError; +use nmrs::{NetworkManager, WifiSecurity, ConnectionError}; -match nm.connect(ssid, creds).await { +match nm.connect("MyNetwork", WifiSecurity::WpaPsk { + psk: "wrong".into() +}).await { Ok(_) => println!("Connected"), - Err(ConnectionError::AuthFailed) => eprintln!("Wrong password"), + Err(ConnectionError::AuthFailed) => eprintln!("Authentication failed"), Err(ConnectionError::NotFound) => eprintln!("Network not in range"), Err(ConnectionError::Timeout) => eprintln!("Connection timed out"), + Err(ConnectionError::DhcpFailed) => eprintln!("Failed to obtain IP address"), Err(e) => eprintln!("Error: {}", e), } ``` +## Async Runtime Support -## Logging +`nmrs` is **runtime-agnostic** and works with any async runtime: -This crate uses the [`log`](https://docs.rs/log) facade. Enable logging with: +- **Tokio** +- **async-std** +- **smol** +- Any runtime supporting standard Rust `async/await` -```rust -env_logger::init(); -``` +All examples use Tokio, but you can use your preferred runtime: -Then run with `RUST_LOG=nmrs=debug` to see detailed logs. +**With Tokio:** +#[tokio::main] +async fn main() -> nmrs::Result<()> { + let nm = nmrs::NetworkManager::new().await?; + // ... + Ok(()) +}**With async-std:** +#[async_std::main] +async fn main() -> nmrs::Result<()> { + let nm = nmrs::NetworkManager::new().await?; + // ... + Ok(()) +}**With smol:** +fn main() -> nmrs::Result<()> { + smol::block_on(async { + let nm = nmrs::NetworkManager::new().await?; + // ... + Ok(()) + }) +} + +`nmrs` uses `zbus` for D-Bus communication, which launches a background thread to handle D-Bus message processing. This design ensures compatibility across all async runtimes without requiring manual executor management. ## Documentation -Full API documentation is available at [docs.rs/nmrs](https://docs.rs/nmrs). +Complete API documentation: [docs.rs/nmrs](https://docs.rs/nmrs) ## Requirements -- Linux with NetworkManager +- Linux with NetworkManager (1.0+) - D-Bus system bus access +- Appropriate permissions for network management + +## Logging + +Enable logging via the `log` crate: + +```rust +env_logger::init(); +``` + +Set `RUST_LOG=nmrs=debug` for detailed logs. ## License diff --git a/nmrs/examples/vpn_connect.rs b/nmrs/examples/vpn_connect.rs new file mode 100644 index 00000000..c400fbf5 --- /dev/null +++ b/nmrs/examples/vpn_connect.rs @@ -0,0 +1,35 @@ +use nmrs::{NetworkManager, VpnCredentials, VpnType, WireGuardPeer}; + +#[tokio::main] +async fn main() -> nmrs::Result<()> { + + let nm = NetworkManager::new().await?; + + let creds = VpnCredentials { + vpn_type: VpnType::WireGuard, + name: "ExampleVPN".into(), + gateway: "vpn.example.com:51820".into(), + private_key: std::env::var("WG_PRIVATE_KEY") + .expect("Set WG_PRIVATE_KEY env var"), + address: "10.0.0.2/24".into(), + peers: vec![WireGuardPeer { + public_key: std::env::var("WG_PUBLIC_KEY") + .expect("Set WG_PUBLIC_KEY env var"), + gateway: "vpn.example.com:51820".into(), + allowed_ips: vec!["0.0.0.0/0".into()], + preshared_key: None, + persistent_keepalive: Some(25), + }], + dns: Some(vec!["1.1.1.1".into()]), + mtu: None, + uuid: None, + }; + + println!("Connecting to VPN..."); + nm.connect_vpn(creds).await?; + + let info = nm.get_vpn_info("ExampleVPN").await?; + println!("Connected! IP: {:?}", info.ip4_address); + + Ok(()) +} \ No newline at end of file diff --git a/nmrs/examples/wifi_scan.rs b/nmrs/examples/wifi_scan.rs new file mode 100644 index 00000000..8019cad2 --- /dev/null +++ b/nmrs/examples/wifi_scan.rs @@ -0,0 +1,17 @@ +use nmrs::NetworkManager; + +#[tokio::main] +async fn main() -> nmrs::Result<()> { + + let nm = NetworkManager::new().await?; + + println!("Scanning for WiFi networks..."); + nm.scan_networks().await?; + + let networks = nm.list_networks().await?; + for net in networks { + println!("{:30} {}%", net.ssid, net.strength.unwrap_or(0)); + } + + Ok(()) +} \ No newline at end of file From acb17219d0afa87ee7550b0bd5d8cef05d83ce10 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Mon, 15 Dec 2025 16:16:04 -0500 Subject: [PATCH 06/11] chore: update CHANGELOG and README --- .gitignore | 5 +---- CHANGELOG.md | 1 + README.md | 4 +++- nmrs/README.md | 13 +++++++++++-- package.nix | 2 +- 5 files changed, 17 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index 1bea1a18..de4579c2 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,4 @@ nmrs-aur/*.tar.gz.* RELEASE_NOTES.md __pycache__/ *.pyc -vendor/ - -# Internal design documents -docs/VPN_IMPLEMENTATION.md \ No newline at end of file +vendor/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index a0ace2e1..38d30f44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog ## [Unreleased] +- Core: Full WireGuard VPN support ([#92](https://github.com/cachebag/nmrs/issues/92)) ## [0.5.0-beta] - 2025-12-15 ### Changed diff --git a/README.md b/README.md index 958ead9d..a9a1f7c6 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,9 @@ async fn main() -> nmrs::Result<()> { Ok(()) } ``` -#

GUI Application

+#

nmrs-gui

+ +![Version](https://img.shields.io/badge/nmrs--gui-0.5.0--beta-orange?style=flat-square) This repository also includes `nmrs-gui`, a Wayland-compatible NetworkManager frontend built with GTK4. diff --git a/nmrs/README.md b/nmrs/README.md index d4688e9b..48bf2c59 100644 --- a/nmrs/README.md +++ b/nmrs/README.md @@ -197,18 +197,26 @@ match nm.connect("MyNetwork", WifiSecurity::WpaPsk { All examples use Tokio, but you can use your preferred runtime: **With Tokio:** +```rust #[tokio::main] async fn main() -> nmrs::Result<()> { let nm = nmrs::NetworkManager::new().await?; // ... Ok(()) -}**With async-std:** +} +``` +**With async-std:** +```rust #[async_std::main] async fn main() -> nmrs::Result<()> { let nm = nmrs::NetworkManager::new().await?; // ... Ok(()) -}**With smol:** +} +``` + +**With smol:** +```rust fn main() -> nmrs::Result<()> { smol::block_on(async { let nm = nmrs::NetworkManager::new().await?; @@ -216,6 +224,7 @@ fn main() -> nmrs::Result<()> { Ok(()) }) } +``` `nmrs` uses `zbus` for D-Bus communication, which launches a background thread to handle D-Bus message processing. This design ensures compatibility across all async runtimes without requiring manual executor management. diff --git a/package.nix b/package.nix index 1b443bb6..308d6eb3 100644 --- a/package.nix +++ b/package.nix @@ -19,7 +19,7 @@ rustPlatform.buildRustPackage { src = ./.; - cargoHash = "sha256-g5lG4sRLTTKbBegwxWXM7ghVkmWufuJ5200Uvb8JVD8="; + cargoHash = "sha256-Gq1nuvZvMv69eTSKk7752fyhpuzlyULYFJv1tWnr/cw="; nativeBuildInputs = [ pkg-config From 7e81b7b5ebc4af6742d868a4e69cc913196e9a26 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Mon, 15 Dec 2025 17:09:48 -0500 Subject: [PATCH 07/11] chore: cleanup CHANGLEOG, update MSRV, refactor code to match MSRV --- CHANGELOG.md | 17 +- Cargo.toml | 21 +- nmrs-gui/Cargo.toml | 15 +- nmrs-gui/src/lib.rs | 2 +- nmrs-gui/src/ui/connect.rs | 33 +- nmrs-gui/src/ui/header.rs | 12 +- nmrs-gui/src/ui/mod.rs | 28 +- nmrs-gui/src/ui/network_page.rs | 14 +- nmrs-gui/src/ui/networks.rs | 4 +- nmrs-gui/src/ui/wired_devices.rs | 4 +- nmrs/Cargo.toml | 31 +- nmrs/examples/vpn_connect.rs | 17 +- nmrs/examples/wifi_scan.rs | 9 +- nmrs/src/api/models.rs | 8 +- nmrs/src/api/network_manager.rs | 4 +- nmrs/src/core/connection.rs | 87 ++--- nmrs/src/core/connection_settings.rs | 11 +- nmrs/src/core/device.rs | 2 +- nmrs/src/core/scan.rs | 2 +- nmrs/src/core/state_wait.rs | 6 +- nmrs/src/core/vpn.rs | 496 ++++++++++++++------------- nmrs/src/dbus/access_point.rs | 2 +- nmrs/src/dbus/active_connection.rs | 2 +- nmrs/src/dbus/device.rs | 2 +- nmrs/src/dbus/wireless.rs | 2 +- nmrs/src/lib.rs | 19 +- nmrs/src/monitoring/device.rs | 9 +- nmrs/src/monitoring/info.rs | 60 ++-- nmrs/src/monitoring/network.rs | 2 +- nmrs/src/util/utils.rs | 2 +- nmrs/tests/integration_test.rs | 4 +- 31 files changed, 489 insertions(+), 438 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38d30f44..ed24d5bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -119,19 +119,10 @@ - EAP connections default to no certificates (advanced certificate management coming in future releases) - VPN connections planned for near future -[0.3.0-beta]: https://github.com/cachebag/nmrs/compare/v0.2.0-beta -[0...v0.3.0-beta -[0.4.0-beta]: https://github.com/cachebag/nmrs/compare/v0.3.0-beta -[0.4.0-beta]: https://github.com/cachebag/nmrs/compare/v0.3.0-beta -[0...v0.4.0-beta -[0.5.0-beta]: https://github.com/cachebag/nmrs/compare/v0.3.0-beta -[0...v0.5.0-beta -[unreleased]: https://github...v0.4.0-beta -[0.4.0-beta]: https://github.com/cachebag/nmrs/compare/v0.3.0-beta -[0...v0.4.0-beta -[0.5.0-beta]: https://github.com/cachebag/nmrs/compare/v0.3.0-beta -[0...v0.5.0-beta -[unreleased]: https://github.com/cachebag/nmrs/compare/v0.5.0-beta...HEAD +[Unreleased]: https://github.com/cachebag/nmrs/compare/v0.5.0-beta...HEAD +[0.5.0-beta]: https://github.com/cachebag/nmrs/compare/v0.4.0-beta...v0.5.0-beta +[0.4.0-beta]: https://github.com/cachebag/nmrs/compare/v0.3.0-beta...v0.4.0-beta +[0.3.0-beta]: https://github.com/cachebag/nmrs/compare/v0.2.0-beta...v0.3.0-beta [0.2.0-beta]: https://github.com/cachebag/nmrs/compare/v0.1.1-beta...v0.2.0-beta [0.1.1-beta]: https://github.com/cachebag/nmrs/compare/v0.1.0-beta...v0.1.1-beta [0.1.0-beta]: https://github.com/cachebag/nmrs/releases/tag/v0.1.0-beta diff --git a/Cargo.toml b/Cargo.toml index b1579ca6..eb6c6fbc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,4 +4,23 @@ members = [ "nmrs-gui" ] -resolver = "3" +resolver = "2" + +[workspace.package] +edition = "2021" +license = "MIT" +repository = "https://github.com/cachebag/nmrs" + +[workspace.dependencies] +# Core dependencies +zbus = "5.12.0" +zvariant = "5.8.0" +log = "0.4.29" +serde = { version = "1.0.228", features = ["derive"] } +thiserror = "2.0.17" +uuid = { version = "1.19.0", features = ["v4", "v5"] } +futures = "0.3.31" +futures-timer = "3.0.3" + +# Dev dependencies +tokio = { version = "1.48.0", features = ["rt-multi-thread", "macros", "sync", "time"] } \ No newline at end of file diff --git a/nmrs-gui/Cargo.toml b/nmrs-gui/Cargo.toml index 32856c1c..98495858 100644 --- a/nmrs-gui/Cargo.toml +++ b/nmrs-gui/Cargo.toml @@ -1,14 +1,21 @@ [package] name = "nmrs-gui" version = "0.5.0" -edition = "2024" +authors = ["Akrm Al-Hakimi "] +edition.workspace = true +rust-version = "1.85.1" +description = "GTK4 GUI for managing NetworkManager connections" +license.workspace = true +repository.workspace = true +keywords = ["networkmanager", "gui", "gtk", "linux"] +categories = ["gui"] [dependencies] -log = "0.4.29" -nmrs = { path = "../nmrs" } -tokio = { version = "1.48.0", features = ["full"] } +nmrs = { path = "../nmrs", version = "1.0.0" } gtk = { version = "0.10.3", package = "gtk4" } glib = "0.21.5" +tokio = { version = "1.48.0", features = ["full"] } +log = "0.4.29" dirs = "6.0.0" fs2 = "0.4.3" anyhow = "1.0.100" diff --git a/nmrs-gui/src/lib.rs b/nmrs-gui/src/lib.rs index ed6c5d4c..ac78f004 100644 --- a/nmrs-gui/src/lib.rs +++ b/nmrs-gui/src/lib.rs @@ -5,8 +5,8 @@ pub mod theme_config; pub mod ui; use clap::{ArgAction, Parser}; -use gtk::Application; use gtk::prelude::*; +use gtk::Application; use crate::file_lock::acquire_app_lock; use crate::style::load_css; diff --git a/nmrs-gui/src/ui/connect.rs b/nmrs-gui/src/ui/connect.rs index 0b046733..4248c677 100644 --- a/nmrs-gui/src/ui/connect.rs +++ b/nmrs-gui/src/ui/connect.rs @@ -1,12 +1,12 @@ use glib::Propagation; use gtk::{ - ApplicationWindow, Box as GtkBox, Button, CheckButton, Dialog, Entry, EventControllerKey, - FileChooserAction, FileChooserDialog, Label, Orientation, ResponseType, prelude::*, + prelude::*, ApplicationWindow, Box as GtkBox, Button, CheckButton, Dialog, Entry, + EventControllerKey, FileChooserAction, FileChooserDialog, Label, Orientation, ResponseType, }; use log::{debug, error}; use nmrs::{ - NetworkManager, models::{EapMethod, EapOptions, Phase2, WifiSecurity}, + NetworkManager, }; use std::rc::Rc; @@ -21,11 +21,11 @@ pub fn connect_modal( let parent_weak = parent.downgrade(); glib::MainContext::default().spawn_local(async move { - if let Some(current) = nm.current_ssid().await - && current == ssid_owned - { - debug!("Already connected to {current}, skipping modal"); - return; + if let Some(current) = nm.current_ssid().await { + if current == ssid_owned { + debug!("Already connected to {current}, skipping modal"); + return; + } } if let Some(parent) = parent_weak.upgrade() { @@ -127,14 +127,15 @@ fn draw_connect_modal( let cert_entry = cert_entry_for_browse.clone(); file_dialog.connect_response(move |dialog, response| { - if response == ResponseType::Accept - && let Some(file) = dialog.file() - && let Some(path) = file.path() - { - cert_entry - .as_ref() - .unwrap() - .set_text(&path.to_string_lossy()); + if response == ResponseType::Accept { + if let Some(file) = dialog.file() { + if let Some(path) = file.path() { + cert_entry + .as_ref() + .unwrap() + .set_text(&path.to_string_lossy()); + } + } } dialog.close(); }); diff --git a/nmrs-gui/src/ui/header.rs b/nmrs-gui/src/ui/header.rs index a9fcb88e..28b6dd9f 100644 --- a/nmrs-gui/src/ui/header.rs +++ b/nmrs-gui/src/ui/header.rs @@ -1,7 +1,7 @@ use glib::clone; -use gtk::STYLE_PROVIDER_PRIORITY_USER; use gtk::prelude::*; -use gtk::{Align, Box as GtkBox, HeaderBar, Label, ListBox, Orientation, Switch, glib}; +use gtk::STYLE_PROVIDER_PRIORITY_USER; +use gtk::{glib, Align, Box as GtkBox, HeaderBar, Label, ListBox, Orientation, Switch}; use std::cell::Cell; use std::collections::HashSet; use std::rc::Rc; @@ -65,10 +65,10 @@ pub fn build_header( let names: Vec<&str> = THEMES.iter().map(|t| t.name).collect(); let dropdown = gtk::DropDown::from_strings(&names); - if let Some(saved) = crate::theme_config::load_theme() - && let Some(idx) = THEMES.iter().position(|t| t.key == saved.as_str()) - { - dropdown.set_selected(idx as u32); + if let Some(saved) = crate::theme_config::load_theme() { + if let Some(idx) = THEMES.iter().position(|t| t.key == saved.as_str()) { + dropdown.set_selected(idx as u32); + } } dropdown.set_valign(gtk::Align::Center); diff --git a/nmrs-gui/src/ui/mod.rs b/nmrs-gui/src/ui/mod.rs index 8001f079..43132ded 100644 --- a/nmrs-gui/src/ui/mod.rs +++ b/nmrs-gui/src/ui/mod.rs @@ -7,8 +7,8 @@ pub mod wired_page; use gtk::prelude::*; use gtk::{ - Application, ApplicationWindow, Box as GtkBox, Label, Orientation, - STYLE_PROVIDER_PRIORITY_USER, ScrolledWindow, Spinner, Stack, + Application, ApplicationWindow, Box as GtkBox, Label, Orientation, ScrolledWindow, Spinner, + Stack, STYLE_PROVIDER_PRIORITY_USER, }; use std::cell::Cell; use std::rc::Rc; @@ -32,20 +32,20 @@ pub fn build_ui(app: &Application) { win.set_title(Some("")); win.set_default_size(100, 600); - if let Some(key) = crate::theme_config::load_theme() - && let Some(theme) = THEMES.iter().find(|t| t.key == key.as_str()) - { - let provider = gtk::CssProvider::new(); - provider.load_from_data(theme.css); + if let Some(key) = crate::theme_config::load_theme() { + if let Some(theme) = THEMES.iter().find(|t| t.key == key.as_str()) { + let provider = gtk::CssProvider::new(); + provider.load_from_data(theme.css); - let display = gtk::prelude::RootExt::display(&win); - gtk::style_context_add_provider_for_display( - &display, - &provider, - STYLE_PROVIDER_PRIORITY_USER, - ); + let display = gtk::prelude::RootExt::display(&win); + gtk::style_context_add_provider_for_display( + &display, + &provider, + STYLE_PROVIDER_PRIORITY_USER, + ); - win.add_css_class("dark-theme"); + win.add_css_class("dark-theme"); + } } let vbox = GtkBox::new(Orientation::Vertical, 0); diff --git a/nmrs-gui/src/ui/network_page.rs b/nmrs-gui/src/ui/network_page.rs index e750c375..86a61146 100644 --- a/nmrs-gui/src/ui/network_page.rs +++ b/nmrs-gui/src/ui/network_page.rs @@ -1,8 +1,8 @@ use glib::clone; use gtk::prelude::*; use gtk::{Align, Box, Button, Image, Label, Orientation}; -use nmrs::NetworkManager; use nmrs::models::NetworkInfo; +use nmrs::NetworkManager; use std::cell::RefCell; use std::rc::Rc; @@ -75,12 +75,12 @@ impl NetworkPage { let on_success = on_success_clone.clone(); glib::MainContext::default().spawn_local(async move { - if let Ok(nm) = NetworkManager::new().await - && nm.forget(&ssid).await.is_ok() - { - stack.set_visible_child_name("networks"); - if let Some(callback) = on_success.borrow().as_ref() { - callback(); + if let Ok(nm) = NetworkManager::new().await { + if nm.forget(&ssid).await.is_ok() { + stack.set_visible_child_name("networks"); + if let Some(callback) = on_success.borrow().as_ref() { + callback(); + } } } }); diff --git a/nmrs-gui/src/ui/networks.rs b/nmrs-gui/src/ui/networks.rs index 8de7f5a3..f0437cb1 100644 --- a/nmrs-gui/src/ui/networks.rs +++ b/nmrs-gui/src/ui/networks.rs @@ -1,10 +1,10 @@ use anyhow::Result; +use gtk::prelude::*; use gtk::Align; use gtk::GestureClick; -use gtk::prelude::*; use gtk::{Box, Image, Label, ListBox, ListBoxRow, Orientation}; use nmrs::models::WifiSecurity; -use nmrs::{NetworkManager, models}; +use nmrs::{models, NetworkManager}; use std::rc::Rc; use crate::ui::connect; diff --git a/nmrs-gui/src/ui/wired_devices.rs b/nmrs-gui/src/ui/wired_devices.rs index 302519fa..3170566b 100644 --- a/nmrs-gui/src/ui/wired_devices.rs +++ b/nmrs-gui/src/ui/wired_devices.rs @@ -1,8 +1,8 @@ +use gtk::prelude::*; use gtk::Align; use gtk::GestureClick; -use gtk::prelude::*; use gtk::{Box, Image, Label, ListBox, ListBoxRow, Orientation}; -use nmrs::{NetworkManager, models}; +use nmrs::{models, NetworkManager}; use std::rc::Rc; use crate::ui::wired_page::WiredPage; diff --git a/nmrs/Cargo.toml b/nmrs/Cargo.toml index ad025311..cacae4d3 100644 --- a/nmrs/Cargo.toml +++ b/nmrs/Cargo.toml @@ -1,23 +1,30 @@ [package] name = "nmrs" version = "1.0.0" -edition = "2024" +authors = ["Akrm Al-Hakimi "] +edition.workspace = true +rust-version = "1.78.0" description = "A Rust library for NetworkManager over D-Bus" -license = "MIT" -repository = "https://github.com/cachebag/nmrs" +license.workspace = true +repository.workspace = true documentation = "https://docs.rs/nmrs" keywords = ["networkmanager", "dbus", "wifi", "linux", "networking"] categories = ["api-bindings", "asynchronous"] +readme = "README.md" [dependencies] -log = "0.4.29" -zbus = "5.12.0" -zvariant = "5.8.0" -serde = { version = "1.0.228", features = ["derive"] } -thiserror = "2.0.17" -uuid = { version = "1.19.0", features = ["v4", "v5"] } -futures = "0.3.31" -futures-timer = "3.0.3" +log.workspace = true +zbus.workspace = true +zvariant.workspace = true +serde.workspace = true +thiserror.workspace = true +uuid.workspace = true +futures.workspace = true +futures-timer.workspace = true [dev-dependencies] -tokio = { version = "1.48.0", features = ["rt-multi-thread", "macros", "sync", "time"] } +tokio.workspace = true + +[package.metadata.docs.rs] +all-features = true +targets = ["x86_64-unknown-linux-gnu"] diff --git a/nmrs/examples/vpn_connect.rs b/nmrs/examples/vpn_connect.rs index c400fbf5..65f30b6c 100644 --- a/nmrs/examples/vpn_connect.rs +++ b/nmrs/examples/vpn_connect.rs @@ -2,19 +2,16 @@ use nmrs::{NetworkManager, VpnCredentials, VpnType, WireGuardPeer}; #[tokio::main] async fn main() -> nmrs::Result<()> { - let nm = NetworkManager::new().await?; - + let creds = VpnCredentials { vpn_type: VpnType::WireGuard, name: "ExampleVPN".into(), gateway: "vpn.example.com:51820".into(), - private_key: std::env::var("WG_PRIVATE_KEY") - .expect("Set WG_PRIVATE_KEY env var"), + private_key: std::env::var("WG_PRIVATE_KEY").expect("Set WG_PRIVATE_KEY env var"), address: "10.0.0.2/24".into(), peers: vec![WireGuardPeer { - public_key: std::env::var("WG_PUBLIC_KEY") - .expect("Set WG_PUBLIC_KEY env var"), + public_key: std::env::var("WG_PUBLIC_KEY").expect("Set WG_PUBLIC_KEY env var"), gateway: "vpn.example.com:51820".into(), allowed_ips: vec!["0.0.0.0/0".into()], preshared_key: None, @@ -24,12 +21,12 @@ async fn main() -> nmrs::Result<()> { mtu: None, uuid: None, }; - + println!("Connecting to VPN..."); nm.connect_vpn(creds).await?; - + let info = nm.get_vpn_info("ExampleVPN").await?; println!("Connected! IP: {:?}", info.ip4_address); - + Ok(()) -} \ No newline at end of file +} diff --git a/nmrs/examples/wifi_scan.rs b/nmrs/examples/wifi_scan.rs index 8019cad2..5ee858ee 100644 --- a/nmrs/examples/wifi_scan.rs +++ b/nmrs/examples/wifi_scan.rs @@ -2,16 +2,15 @@ use nmrs::NetworkManager; #[tokio::main] async fn main() -> nmrs::Result<()> { - let nm = NetworkManager::new().await?; - + println!("Scanning for WiFi networks..."); nm.scan_networks().await?; - + let networks = nm.list_networks().await?; for net in networks { println!("{:30} {}%", net.ssid, net.strength.unwrap_or(0)); } - + Ok(()) -} \ No newline at end of file +} diff --git a/nmrs/src/api/models.rs b/nmrs/src/api/models.rs index 980742db..28f55caa 100644 --- a/nmrs/src/api/models.rs +++ b/nmrs/src/api/models.rs @@ -702,6 +702,11 @@ pub struct VpnConnection { /// Provides comprehensive information about an active VPN connection, /// including IP configuration and connection details. /// +/// # Limitations +/// +/// - `ip6_address`: IPv6 address parsing is not currently implemented and will +/// always return `None`. IPv4 addresses are fully supported. +/// /// # Example /// /// ```rust @@ -714,7 +719,7 @@ pub struct VpnConnection { /// interface: Some("wg0".into()), /// gateway: Some("vpn.example.com:51820".into()), /// ip4_address: Some("10.0.0.2/24".into()), -/// ip6_address: None, +/// ip6_address: None, // IPv6 not yet implemented /// dns_servers: vec!["1.1.1.1".into()], /// }; /// ``` @@ -726,6 +731,7 @@ pub struct VpnConnectionInfo { pub interface: Option, pub gateway: Option, pub ip4_address: Option, + /// IPv6 address (currently always `None` - IPv6 parsing not yet implemented) pub ip6_address: Option, pub dns_servers: Vec, } diff --git a/nmrs/src/api/network_manager.rs b/nmrs/src/api/network_manager.rs index 4dfd491a..aa9a2ca2 100644 --- a/nmrs/src/api/network_manager.rs +++ b/nmrs/src/api/network_manager.rs @@ -1,6 +1,5 @@ use zbus::Connection; -use crate::Result; use crate::api::models::{Device, Network, NetworkInfo, WifiSecurity}; use crate::core::connection::{connect, connect_wired, forget}; use crate::core::connection_settings::{get_saved_connection_path, has_saved_connection}; @@ -11,6 +10,7 @@ use crate::models::{VpnConnection, VpnConnectionInfo, VpnCredentials}; use crate::monitoring::device as device_monitor; use crate::monitoring::info::{current_connection_info, current_ssid, show_details}; use crate::monitoring::network as network_monitor; +use crate::Result; /// High-level interface to NetworkManager over D-Bus. /// @@ -332,11 +332,13 @@ impl NetworkManager { } /// Returns the SSID of the currently connected network, if any. + #[must_use] pub async fn current_ssid(&self) -> Option { current_ssid(&self.conn).await } /// Returns the SSID and frequency of the current connection, if any. + #[must_use] pub async fn current_connection_info(&self) -> Option<(String, Option)> { current_connection_info(&self.conn).await } diff --git a/nmrs/src/core/connection.rs b/nmrs/src/core/connection.rs index 06f745e8..728fdc09 100644 --- a/nmrs/src/core/connection.rs +++ b/nmrs/src/core/connection.rs @@ -4,7 +4,6 @@ use std::collections::HashMap; use zbus::Connection; use zvariant::OwnedObjectPath; -use crate::Result; use crate::api::builders::wifi::{build_ethernet_connection, build_wifi_connection}; use crate::api::models::{ConnectionError, ConnectionOptions, WifiSecurity}; use crate::core::connection_settings::{delete_connection, get_saved_connection_path}; @@ -13,6 +12,7 @@ use crate::dbus::{NMAccessPointProxy, NMDeviceProxy, NMProxy, NMWirelessProxy}; use crate::monitoring::info::current_ssid; use crate::types::constants::{device_state, device_type, timeouts}; use crate::util::utils::decode_ssid_or_empty; +use crate::Result; /// Decision on whether to reuse a saved connection or create a fresh one. enum SavedDecision { @@ -177,33 +177,33 @@ pub(crate) async fn forget(conn: &Connection, ssid: &str) -> Result<()> { .path(dev_path.clone())? .build() .await?; - if let Ok(ap_path) = wifi.active_access_point().await - && ap_path.as_str() != "/" - { - let ap = NMAccessPointProxy::builder(conn) - .path(ap_path.clone())? - .build() - .await?; - if let Ok(bytes) = ap.ssid().await - && decode_ssid_or_empty(&bytes) == ssid - { - debug!("Disconnecting from active network: {ssid}"); - if let Err(e) = disconnect_wifi_and_wait(conn, dev_path).await { - warn!("Disconnect wait failed: {e}"); - let final_state = dev.state().await?; - if final_state != device_state::DISCONNECTED - && final_state != device_state::UNAVAILABLE - { - error!( - "Device still connected (state: {final_state}), cannot safely delete" - ); - return Err(ConnectionError::Stuck(format!( - "disconnect failed, device in state {final_state}" - ))); + if let Ok(ap_path) = wifi.active_access_point().await { + if ap_path.as_str() != "/" { + let ap = NMAccessPointProxy::builder(conn) + .path(ap_path.clone())? + .build() + .await?; + if let Ok(bytes) = ap.ssid().await { + if decode_ssid_or_empty(&bytes) == ssid { + debug!("Disconnecting from active network: {ssid}"); + if let Err(e) = disconnect_wifi_and_wait(conn, dev_path).await { + warn!("Disconnect wait failed: {e}"); + let final_state = dev.state().await?; + if final_state != device_state::DISCONNECTED + && final_state != device_state::UNAVAILABLE + { + error!( + "Device still connected (state: {final_state}), cannot safely delete" + ); + return Err(ConnectionError::Stuck(format!( + "disconnect failed, device in state {final_state}" + ))); + } + debug!("Device confirmed disconnected, proceeding with deletion"); + } + debug!("Disconnect phase completed"); } - debug!("Device confirmed disconnected, proceeding with deletion"); } - debug!("Disconnect phase completed"); } } } @@ -236,26 +236,27 @@ pub(crate) async fn forget(conn: &Connection, ssid: &str) -> Result<()> { let mut should_delete = false; - if let Some(conn_sec) = settings_map.get("connection") - && let Some(Value::Str(id)) = conn_sec.get("id") - && id.as_str() == ssid - { - should_delete = true; - debug!("Found connection by ID: {id}"); + if let Some(conn_sec) = settings_map.get("connection") { + if let Some(Value::Str(id)) = conn_sec.get("id") { + if id.as_str() == ssid { + should_delete = true; + debug!("Found connection by ID: {id}"); + } + } } - if let Some(wifi_sec) = settings_map.get("802-11-wireless") - && let Some(Value::Array(arr)) = wifi_sec.get("ssid") - { - let mut raw = Vec::new(); - for v in arr.iter() { - if let Ok(b) = u8::try_from(v.clone()) { - raw.push(b); + if let Some(wifi_sec) = settings_map.get("802-11-wireless") { + if let Some(Value::Array(arr)) = wifi_sec.get("ssid") { + let mut raw = Vec::new(); + for v in arr.iter() { + if let Ok(b) = u8::try_from(v.clone()) { + raw.push(b); + } + } + if decode_ssid_or_empty(&raw) == ssid { + should_delete = true; + debug!("Found connection by SSID match"); } - } - if decode_ssid_or_empty(&raw) == ssid { - should_delete = true; - debug!("Found connection by SSID match"); } } diff --git a/nmrs/src/core/connection_settings.rs b/nmrs/src/core/connection_settings.rs index 33922e93..20949386 100644 --- a/nmrs/src/core/connection_settings.rs +++ b/nmrs/src/core/connection_settings.rs @@ -46,11 +46,12 @@ pub(crate) async fn get_saved_connection_path( let body = msg.body(); let all: HashMap> = body.deserialize()?; - if let Some(conn_section) = all.get("connection") - && let Some(Value::Str(id)) = conn_section.get("id") - && id == ssid - { - return Ok(Some(cpath)); + if let Some(conn_section) = all.get("connection") { + if let Some(Value::Str(id)) = conn_section.get("id") { + if id == ssid { + return Ok(Some(cpath)); + } + } } } diff --git a/nmrs/src/core/device.rs b/nmrs/src/core/device.rs index 9e18f00e..cb3008aa 100644 --- a/nmrs/src/core/device.rs +++ b/nmrs/src/core/device.rs @@ -7,11 +7,11 @@ use log::debug; use zbus::Connection; -use crate::Result; use crate::api::models::{ConnectionError, Device, DeviceIdentity, DeviceState}; use crate::core::state_wait::wait_for_wifi_device_ready; use crate::dbus::{NMDeviceProxy, NMProxy}; use crate::types::constants::device_type; +use crate::Result; /// Lists all network devices managed by NetworkManager. /// diff --git a/nmrs/src/core/scan.rs b/nmrs/src/core/scan.rs index eaa1c5ad..6c1509f9 100644 --- a/nmrs/src/core/scan.rs +++ b/nmrs/src/core/scan.rs @@ -6,11 +6,11 @@ use std::collections::HashMap; use zbus::Connection; -use crate::Result; use crate::api::models::Network; use crate::dbus::{NMDeviceProxy, NMProxy, NMWirelessProxy}; use crate::types::constants::{device_type, security_flags}; use crate::util::utils::{decode_ssid_or_hidden, for_each_access_point}; +use crate::Result; /// Triggers a Wi-Fi scan on all wireless devices. /// diff --git a/nmrs/src/core/state_wait.rs b/nmrs/src/core/state_wait.rs index d7802d47..23304ce5 100644 --- a/nmrs/src/core/state_wait.rs +++ b/nmrs/src/core/state_wait.rs @@ -18,19 +18,19 @@ //! - More reliable; at least in the sense that we won't miss rapid state transitions. //! - Better error messages with specific failure reasons -use futures::{FutureExt, StreamExt, select}; +use futures::{select, FutureExt, StreamExt}; use futures_timer::Delay; use log::{debug, warn}; use std::pin::pin; use std::time::Duration; use zbus::Connection; -use crate::Result; use crate::api::models::{ - ActiveConnectionState, ConnectionError, ConnectionStateReason, connection_state_reason_to_error, + connection_state_reason_to_error, ActiveConnectionState, ConnectionError, ConnectionStateReason, }; use crate::dbus::{NMActiveConnectionProxy, NMDeviceProxy}; use crate::types::constants::{device_state, timeouts}; +use crate::Result; /// Default timeout for connection activation (30 seconds). const CONNECTION_TIMEOUT: Duration = Duration::from_secs(30); diff --git a/nmrs/src/core/vpn.rs b/nmrs/src/core/vpn.rs index 6454dbc9..f29ec29f 100644 --- a/nmrs/src/core/vpn.rs +++ b/nmrs/src/core/vpn.rs @@ -15,13 +15,13 @@ use std::collections::HashMap; use zbus::Connection; use zvariant::OwnedObjectPath; -use crate::Result; use crate::api::models::{ ConnectionOptions, DeviceState, VpnConnection, VpnConnectionInfo, VpnCredentials, VpnType, }; use crate::builders::build_wireguard_connection; use crate::core::state_wait::wait_for_connection_activation; use crate::dbus::NMProxy; +use crate::Result; /// Connects to a VPN using WireGuard. /// @@ -142,14 +142,15 @@ pub(crate) async fn disconnect_vpn(conn: &Connection, name: &str) -> Result<()> Err(_) => continue, }; - if let Some(conn_sec) = settings_map.get("connection") - && let Some(zvariant::Value::Str(id)) = conn_sec.get("id") - && id.as_str() == name - { - debug!("Found active VPN connection, deactivating: {name}"); - let _ = nm.deactivate_connection(ac_path).await; // Ignore errors on deactivation - info!("Successfully disconnected VPN: {name}"); - return Ok(()); + if let Some(conn_sec) = settings_map.get("connection") { + if let Some(zvariant::Value::Str(id)) = conn_sec.get("id") { + if id.as_str() == name { + debug!("Found active VPN connection, deactivating: {name}"); + let _ = nm.deactivate_connection(ac_path).await; // Ignore errors on deactivation + info!("Successfully disconnected VPN: {name}"); + return Ok(()); + } + } } } @@ -192,60 +193,71 @@ pub(crate) async fn list_vpn_connections(conn: &Connection) -> Result() - { - // Get connection settings to find the name - let cproxy: zbus::Proxy<'_> = zbus::proxy::Builder::new(conn) - .destination("org.freedesktop.NetworkManager")? - .path(conn_path)? - .interface("org.freedesktop.NetworkManager.Settings.Connection")? - .build() - .await?; - - if let Ok(msg) = cproxy.call_method("GetSettings", &()).await - && let Ok(settings_map) = msg - .body() - .deserialize::>>() - && let Some(conn_sec) = settings_map.get("connection") - && let Some(zvariant::Value::Str(id)) = conn_sec.get("id") - && let Some(zvariant::Value::Str(conn_type)) = conn_sec.get("type") - && conn_type.as_str() == "vpn" - { - // Get state - let state = if let Ok(state_val) = ac_proxy.get_property::("State").await { - DeviceState::from(state_val) - } else { - DeviceState::Other(0) - }; - - // Get devices (which includes interface info) - let interface = if let Ok(dev_paths) = ac_proxy - .get_property::>("Devices") - .await - { - if let Some(dev_path) = dev_paths.first() { - // Get device interface name - match zbus::proxy::Builder::::new(conn) - .destination("org.freedesktop.NetworkManager")? - .path(dev_path.clone())? - .interface("org.freedesktop.NetworkManager.Device")? - .build() - .await - { - Ok(dev_proxy) => { - dev_proxy.get_property::("Interface").await.ok() + if let Ok(conn_msg) = ac_proxy.call_method("Connection", &()).await { + if let Ok(conn_path) = conn_msg.body().deserialize::() { + // Get connection settings to find the name + let cproxy: zbus::Proxy<'_> = zbus::proxy::Builder::new(conn) + .destination("org.freedesktop.NetworkManager")? + .path(conn_path)? + .interface("org.freedesktop.NetworkManager.Settings.Connection")? + .build() + .await?; + + if let Ok(msg) = cproxy.call_method("GetSettings", &()).await { + if let Ok(settings_map) = msg + .body() + .deserialize::>>() + { + if let Some(conn_sec) = settings_map.get("connection") { + if let Some(zvariant::Value::Str(id)) = conn_sec.get("id") { + if let Some(zvariant::Value::Str(conn_type)) = conn_sec.get("type") + { + if conn_type.as_str() == "vpn" { + // Get state + let state = if let Ok(state_val) = + ac_proxy.get_property::("State").await + { + DeviceState::from(state_val) + } else { + DeviceState::Other(0) + }; + + // Get devices (which includes interface info) + let interface = if let Ok(dev_paths) = ac_proxy + .get_property::>("Devices") + .await + { + if let Some(dev_path) = dev_paths.first() { + // Get device interface name + match zbus::proxy::Builder::::new(conn) + .destination("org.freedesktop.NetworkManager")? + .path(dev_path.clone())? + .interface( + "org.freedesktop.NetworkManager.Device", + )? + .build() + .await + { + Ok(dev_proxy) => dev_proxy + .get_property::("Interface") + .await + .ok(), + Err(_) => None, + } + } else { + None + } + } else { + None + }; + + active_vpn_map.insert(id.to_string(), (state, interface)); + } + } } - Err(_) => None, } - } else { - None } - } else { - None - }; - - active_vpn_map.insert(id.to_string(), (state, interface)); + } } } } @@ -264,40 +276,44 @@ pub(crate) async fn list_vpn_connections(conn: &Connection) -> Result> = body.deserialize()?; - if let Some(conn_sec) = settings_map.get("connection") - && let Some(zvariant::Value::Str(id)) = conn_sec.get("id") - && let Some(zvariant::Value::Str(conn_type)) = conn_sec.get("type") - && conn_type.as_str() == "vpn" - { - // Extract VPN service-type and convert to VpnType enum - let vpn_type = settings_map - .get("vpn") - .and_then(|vpn_sec| vpn_sec.get("service-type")) - .and_then(|v| match v { - zvariant::Value::Str(s) => { - // Match against known service types - match s.as_str() { - "org.freedesktop.NetworkManager.wireguard" => Some(VpnType::WireGuard), - _ => None, // Unknown VPN types are skipped for now + if let Some(conn_sec) = settings_map.get("connection") { + if let Some(zvariant::Value::Str(id)) = conn_sec.get("id") { + if let Some(zvariant::Value::Str(conn_type)) = conn_sec.get("type") { + if conn_type.as_str() == "vpn" { + // Extract VPN service-type and convert to VpnType enum + let vpn_type = settings_map + .get("vpn") + .and_then(|vpn_sec| vpn_sec.get("service-type")) + .and_then(|v| match v { + zvariant::Value::Str(s) => { + // Match against known service types + match s.as_str() { + "org.freedesktop.NetworkManager.wireguard" => { + Some(VpnType::WireGuard) + } + _ => None, // Unknown VPN types are skipped for now + } + } + _ => None, + }); + + // Only add VPN connections with recognized types + if let Some(vpn_type) = vpn_type { + let name = id.to_string(); + let (state, interface) = active_vpn_map + .get(&name) + .cloned() + .unwrap_or((DeviceState::Other(0), None)); + + vpn_conns.push(VpnConnection { + name, + vpn_type, + interface, + state, + }); } } - _ => None, - }); - - // Only add VPN connections with recognized types - if let Some(vpn_type) = vpn_type { - let name = id.to_string(); - let (state, interface) = active_vpn_map - .get(&name) - .cloned() - .unwrap_or((DeviceState::Other(0), None)); - - vpn_conns.push(VpnConnection { - name, - vpn_type, - interface, - state, - }); + } } } } @@ -341,16 +357,17 @@ pub(crate) async fn forget_vpn(conn: &Connection, name: &str) -> Result<()> { let settings_map: HashMap> = body.deserialize()?; - if let Some(conn_sec) = settings_map.get("connection") - && let Some(zvariant::Value::Str(id)) = conn_sec.get("id") - && let Some(zvariant::Value::Str(conn_type)) = conn_sec.get("type") - && conn_type.as_str() == "vpn" - && id.as_str() == name - { - debug!("Found VPN connection, deleting: {name}"); - cproxy.call_method("Delete", &()).await?; - info!("Successfully deleted VPN connection: {name}"); - return Ok(()); + if let Some(conn_sec) = settings_map.get("connection") { + if let Some(zvariant::Value::Str(id)) = conn_sec.get("id") { + if let Some(zvariant::Value::Str(conn_type)) = conn_sec.get("type") { + if conn_type.as_str() == "vpn" && id.as_str() == name { + debug!("Found VPN connection, deleting: {name}"); + cproxy.call_method("Delete", &()).await?; + info!("Successfully deleted VPN connection: {name}"); + return Ok(()); + } + } + } } } } @@ -401,140 +418,151 @@ pub(crate) async fn get_vpn_info(conn: &Connection, name: &str) -> Result> = body.deserialize()?; - if let Some(conn_sec) = settings_map.get("connection") - && let Some(zvariant::Value::Str(id)) = conn_sec.get("id") - && let Some(zvariant::Value::Str(conn_type)) = conn_sec.get("type") - && conn_type.as_str() == "vpn" - && id.as_str() == name - { - // Found the VPN connection, get details - let vpn_type = settings_map - .get("vpn") - .and_then(|vpn_sec| vpn_sec.get("service-type")) - .and_then(|v| match v { - zvariant::Value::Str(s) => match s.as_str() { - "org.freedesktop.NetworkManager.wireguard" => Some(VpnType::WireGuard), - _ => None, - }, - _ => None, - }) - .ok_or_else(|| crate::api::models::ConnectionError::NoVpnConnection)?; - - // Get state - let state_val: u32 = ac_proxy.get_property("State").await?; - let state = DeviceState::from(state_val); - - // Get interface - let dev_paths: Vec = ac_proxy.get_property("Devices").await?; - let interface = if let Some(dev_path) = dev_paths.first() { - let dev_proxy: zbus::Proxy<'_> = zbus::proxy::Builder::new(conn) - .destination("org.freedesktop.NetworkManager")? - .path(dev_path.clone())? - .interface("org.freedesktop.NetworkManager.Device")? - .build() - .await?; - - Some(dev_proxy.get_property::("Interface").await?) - } else { - None - }; - - // Get gateway from VPN settings - let gateway = settings_map - .get("vpn") - .and_then(|vpn_sec| vpn_sec.get("data")) - .and_then(|data| match data { - zvariant::Value::Dict(dict) => { - // Try to find gateway/endpoint in the data - for entry in dict.iter() { - let (key_val, value_val) = entry; - if let zvariant::Value::Str(key) = key_val - && (key.as_str().contains("endpoint") - || key.as_str().contains("gateway")) - && let zvariant::Value::Str(val) = value_val + if let Some(conn_sec) = settings_map.get("connection") { + if let Some(zvariant::Value::Str(id)) = conn_sec.get("id") { + if let Some(zvariant::Value::Str(conn_type)) = conn_sec.get("type") { + if conn_type.as_str() == "vpn" && id.as_str() == name { + // Found the VPN connection, get details + let vpn_type = settings_map + .get("vpn") + .and_then(|vpn_sec| vpn_sec.get("service-type")) + .and_then(|v| match v { + zvariant::Value::Str(s) => match s.as_str() { + "org.freedesktop.NetworkManager.wireguard" => { + Some(VpnType::WireGuard) + } + _ => None, + }, + _ => None, + }) + .ok_or_else(|| crate::api::models::ConnectionError::NoVpnConnection)?; + + // Get state + let state_val: u32 = ac_proxy.get_property("State").await?; + let state = DeviceState::from(state_val); + + // Get interface + let dev_paths: Vec = + ac_proxy.get_property("Devices").await?; + let interface = if let Some(dev_path) = dev_paths.first() { + let dev_proxy: zbus::Proxy<'_> = zbus::proxy::Builder::new(conn) + .destination("org.freedesktop.NetworkManager")? + .path(dev_path.clone())? + .interface("org.freedesktop.NetworkManager.Device")? + .build() + .await?; + + Some(dev_proxy.get_property::("Interface").await?) + } else { + None + }; + + // Get gateway from VPN settings + let gateway = settings_map + .get("vpn") + .and_then(|vpn_sec| vpn_sec.get("data")) + .and_then(|data| match data { + zvariant::Value::Dict(dict) => { + // Try to find gateway/endpoint in the data + for entry in dict.iter() { + let (key_val, value_val) = entry; + if let zvariant::Value::Str(key) = key_val { + if key.as_str().contains("endpoint") + || key.as_str().contains("gateway") + { + if let zvariant::Value::Str(val) = value_val { + return Some(val.as_str().to_string()); + } + } + } + } + None + } + _ => None, + }); + + // Get IP4 configuration + let ip4_path: OwnedObjectPath = ac_proxy.get_property("Ip4Config").await?; + let (ip4_address, dns_servers) = if ip4_path.as_str() != "/" { + let ip4_proxy: zbus::Proxy<'_> = zbus::proxy::Builder::new(conn) + .destination("org.freedesktop.NetworkManager")? + .path(ip4_path)? + .interface("org.freedesktop.NetworkManager.IP4Config")? + .build() + .await?; + + // Get address data + let ip4_address = if let Ok(addr_array) = ip4_proxy + .get_property::>>( + "AddressData", + ) + .await { - return Some(val.as_str().to_string()); - } - } - None + addr_array.first().and_then(|addr_map| { + let address = + addr_map.get("address").and_then(|v| match v { + zvariant::Value::Str(s) => Some(s.as_str().to_string()), + _ => None, + })?; + let prefix = addr_map.get("prefix").and_then(|v| match v { + zvariant::Value::U32(p) => Some(p), + _ => None, + })?; + Some(format!("{}/{}", address, prefix)) + }) + } else { + None + }; + + // Get DNS servers + let dns_servers = if let Ok(dns_array) = + ip4_proxy.get_property::>("Nameservers").await + { + dns_array + .iter() + .map(|ip| { + format!( + "{}.{}.{}.{}", + ip & 0xFF, + (ip >> 8) & 0xFF, + (ip >> 16) & 0xFF, + (ip >> 24) & 0xFF + ) + }) + .collect() + } else { + vec![] + }; + + (ip4_address, dns_servers) + } else { + (None, vec![]) + }; + + // Get IP6 configuration + // Note: IPv6 address parsing is not yet implemented. + // This is a known limitation documented in VpnConnectionInfo. + let ip6_path: OwnedObjectPath = ac_proxy.get_property("Ip6Config").await?; + let ip6_address = if ip6_path.as_str() != "/" { + // TODO: Implement IPv6 address parsing + None + } else { + None + }; + + return Ok(VpnConnectionInfo { + name: id.to_string(), + vpn_type, + state, + interface, + gateway, + ip4_address, + ip6_address, + dns_servers, + }); } - _ => None, - }); - - // Get IP4 configuration - let ip4_path: OwnedObjectPath = ac_proxy.get_property("Ip4Config").await?; - let (ip4_address, dns_servers) = if ip4_path.as_str() != "/" { - let ip4_proxy: zbus::Proxy<'_> = zbus::proxy::Builder::new(conn) - .destination("org.freedesktop.NetworkManager")? - .path(ip4_path)? - .interface("org.freedesktop.NetworkManager.IP4Config")? - .build() - .await?; - - // Get address data - let ip4_address = if let Ok(addr_array) = ip4_proxy - .get_property::>>("AddressData") - .await - { - addr_array.first().and_then(|addr_map| { - let address = addr_map.get("address").and_then(|v| match v { - zvariant::Value::Str(s) => Some(s.as_str().to_string()), - _ => None, - })?; - let prefix = addr_map.get("prefix").and_then(|v| match v { - zvariant::Value::U32(p) => Some(p), - _ => None, - })?; - Some(format!("{}/{}", address, prefix)) - }) - } else { - None - }; - - // Get DNS servers - let dns_servers = if let Ok(dns_array) = - ip4_proxy.get_property::>("Nameservers").await - { - dns_array - .iter() - .map(|ip| { - format!( - "{}.{}.{}.{}", - ip & 0xFF, - (ip >> 8) & 0xFF, - (ip >> 16) & 0xFF, - (ip >> 24) & 0xFF - ) - }) - .collect() - } else { - vec![] - }; - - (ip4_address, dns_servers) - } else { - (None, vec![]) - }; - - // Get IP6 configuration (similar to IP4, but simpler for now) - let ip6_path: OwnedObjectPath = ac_proxy.get_property("Ip6Config").await?; - let ip6_address = if ip6_path.as_str() != "/" { - // TODO: Implement IPv6 address parsing - None - } else { - None - }; - - return Ok(VpnConnectionInfo { - name: id.to_string(), - vpn_type, - state, - interface, - gateway, - ip4_address, - ip6_address, - dns_servers, - }); + } + } } } diff --git a/nmrs/src/dbus/access_point.rs b/nmrs/src/dbus/access_point.rs index b17580b8..f9f6b1c0 100644 --- a/nmrs/src/dbus/access_point.rs +++ b/nmrs/src/dbus/access_point.rs @@ -1,6 +1,6 @@ //! NetworkManager Access Point proxy. -use zbus::{Result, proxy}; +use zbus::{proxy, Result}; /// Proxy for access point interface. /// diff --git a/nmrs/src/dbus/active_connection.rs b/nmrs/src/dbus/active_connection.rs index a98d3bab..63eed9ac 100644 --- a/nmrs/src/dbus/active_connection.rs +++ b/nmrs/src/dbus/active_connection.rs @@ -1,6 +1,6 @@ //! NetworkManager Active Connection proxy. -use zbus::{Result, proxy}; +use zbus::{proxy, Result}; use zvariant::OwnedObjectPath; /// Proxy for active connection interface. diff --git a/nmrs/src/dbus/device.rs b/nmrs/src/dbus/device.rs index 7e619c47..9608ae5a 100644 --- a/nmrs/src/dbus/device.rs +++ b/nmrs/src/dbus/device.rs @@ -1,6 +1,6 @@ //! NetworkManager Device proxy. -use zbus::{Result, proxy}; +use zbus::{proxy, Result}; /// Proxy for NetworkManager device interface. /// diff --git a/nmrs/src/dbus/wireless.rs b/nmrs/src/dbus/wireless.rs index dafb6722..cb74ac74 100644 --- a/nmrs/src/dbus/wireless.rs +++ b/nmrs/src/dbus/wireless.rs @@ -1,7 +1,7 @@ //! NetworkManager Wireless Device proxy. use std::collections::HashMap; -use zbus::{Result, proxy}; +use zbus::{proxy, Result}; use zvariant::OwnedObjectPath; /// Proxy for wireless device interface. diff --git a/nmrs/src/lib.rs b/nmrs/src/lib.rs index 9dddfbac..0461dca5 100644 --- a/nmrs/src/lib.rs +++ b/nmrs/src/lib.rs @@ -1,7 +1,7 @@ //! A Rust library for managing network connections via NetworkManager. //! //! This crate provides a high-level async API for NetworkManager over D-Bus, -//! enabling easy management of WiFi, Ethernet, and (future) VPN connections on Linux. +//! enabling easy management of WiFi, Ethernet, and VPN connections on Linux. //! //! # Quick Start //! @@ -323,21 +323,12 @@ pub mod models { pub use crate::api::models::*; } -// Deprecated: Use `builders::wifi` instead -#[deprecated( - since = "0.6.0", - note = "Use `builders::wifi` module instead. This alias will be removed in 1.0.0" -)] -pub mod wifi_builders { - pub use crate::api::builders::wifi::*; -} - // Re-export commonly used types at crate root for convenience pub use api::models::{ - ActiveConnectionState, ConnectionError, ConnectionOptions, ConnectionStateReason, Device, - DeviceState, DeviceType, EapMethod, EapOptions, Network, NetworkInfo, Phase2, StateReason, - VpnConnection, VpnConnectionInfo, VpnCredentials, VpnType, WifiSecurity, WireGuardPeer, - connection_state_reason_to_error, reason_to_error, + connection_state_reason_to_error, reason_to_error, ActiveConnectionState, ConnectionError, + ConnectionOptions, ConnectionStateReason, Device, DeviceState, DeviceType, EapMethod, + EapOptions, Network, NetworkInfo, Phase2, StateReason, VpnConnection, VpnConnectionInfo, + VpnCredentials, VpnType, WifiSecurity, WireGuardPeer, }; pub use api::network_manager::NetworkManager; diff --git a/nmrs/src/monitoring/device.rs b/nmrs/src/monitoring/device.rs index 3c7f4eec..eeb632eb 100644 --- a/nmrs/src/monitoring/device.rs +++ b/nmrs/src/monitoring/device.rs @@ -9,9 +9,9 @@ use log::{debug, warn}; use std::pin::Pin; use zbus::Connection; -use crate::Result; use crate::api::models::ConnectionError; use crate::dbus::{NMDeviceProxy, NMProxy}; +use crate::Result; /// Monitors device state changes on all network devices. /// @@ -58,10 +58,11 @@ where .path(dev_path.clone())? .build() .await - && let Ok(state_stream) = dev.receive_device_state_changed().await { - streams.push(Box::pin(state_stream.map(|_| ()))); - debug!("Subscribed to state change signals on device: {dev_path}"); + if let Ok(state_stream) = dev.receive_device_state_changed().await { + streams.push(Box::pin(state_stream.map(|_| ()))); + debug!("Subscribed to state change signals on device: {dev_path}"); + } } } diff --git a/nmrs/src/monitoring/info.rs b/nmrs/src/monitoring/info.rs index b3d6c2f5..1673fcaa 100644 --- a/nmrs/src/monitoring/info.rs +++ b/nmrs/src/monitoring/info.rs @@ -5,7 +5,6 @@ use zbus::Connection; -use crate::Result; use crate::api::models::{ConnectionError, Network, NetworkInfo}; #[allow(unused_imports)] // Used within try_log! macro use crate::dbus::{NMAccessPointProxy, NMDeviceProxy, NMProxy, NMWirelessProxy}; @@ -15,6 +14,7 @@ use crate::util::utils::{ bars_from_strength, channel_from_freq, decode_ssid_or_empty, for_each_access_point, mode_to_string, strength_or_zero, }; +use crate::Result; /// Returns detailed information about a network. /// @@ -141,20 +141,20 @@ pub(crate) async fn current_ssid(conn: &Connection) -> Option { ); let wifi = try_log!(wifi_builder.build().await, "Failed to build wireless proxy"); - if let Ok(active_ap) = wifi.active_access_point().await - && active_ap.as_str() != "/" - { - let ap_builder = try_log!( - NMAccessPointProxy::builder(conn).path(active_ap), - "Failed to create access point proxy builder" - ); - let ap = try_log!( - ap_builder.build().await, - "Failed to build access point proxy" - ); - let ssid_bytes = try_log!(ap.ssid().await, "Failed to get SSID bytes"); - let ssid = decode_ssid_or_empty(&ssid_bytes); - return Some(ssid); + if let Ok(active_ap) = wifi.active_access_point().await { + if active_ap.as_str() != "/" { + let ap_builder = try_log!( + NMAccessPointProxy::builder(conn).path(active_ap), + "Failed to create access point proxy builder" + ); + let ap = try_log!( + ap_builder.build().await, + "Failed to build access point proxy" + ); + let ssid_bytes = try_log!(ap.ssid().await, "Failed to get SSID bytes"); + let ssid = decode_ssid_or_empty(&ssid_bytes); + return Some(ssid); + } } } None @@ -186,21 +186,21 @@ pub(crate) async fn current_connection_info(conn: &Connection) -> Option<(String ); let wifi = try_log!(wifi_builder.build().await, "Failed to build wireless proxy"); - if let Ok(active_ap) = wifi.active_access_point().await - && active_ap.as_str() != "/" - { - let ap_builder = try_log!( - NMAccessPointProxy::builder(conn).path(active_ap), - "Failed to create access point proxy builder" - ); - let ap = try_log!( - ap_builder.build().await, - "Failed to build access point proxy" - ); - let ssid_bytes = try_log!(ap.ssid().await, "Failed to get SSID bytes"); - let ssid = decode_ssid_or_empty(&ssid_bytes); - let frequency = ap.frequency().await.ok(); - return Some((ssid, frequency)); + if let Ok(active_ap) = wifi.active_access_point().await { + if active_ap.as_str() != "/" { + let ap_builder = try_log!( + NMAccessPointProxy::builder(conn).path(active_ap), + "Failed to create access point proxy builder" + ); + let ap = try_log!( + ap_builder.build().await, + "Failed to build access point proxy" + ); + let ssid_bytes = try_log!(ap.ssid().await, "Failed to get SSID bytes"); + let ssid = decode_ssid_or_empty(&ssid_bytes); + let frequency = ap.frequency().await.ok(); + return Some((ssid, frequency)); + } } } None diff --git a/nmrs/src/monitoring/network.rs b/nmrs/src/monitoring/network.rs index 1ea35ec6..7bce0a18 100644 --- a/nmrs/src/monitoring/network.rs +++ b/nmrs/src/monitoring/network.rs @@ -8,10 +8,10 @@ use log::{debug, warn}; use std::pin::Pin; use zbus::Connection; -use crate::Result; use crate::api::models::ConnectionError; use crate::dbus::{NMDeviceProxy, NMProxy, NMWirelessProxy}; use crate::types::constants::device_type; +use crate::Result; /// Monitors access point changes on all Wi-Fi devices. /// diff --git a/nmrs/src/util/utils.rs b/nmrs/src/util/utils.rs index a105addc..8916b765 100644 --- a/nmrs/src/util/utils.rs +++ b/nmrs/src/util/utils.rs @@ -8,9 +8,9 @@ use std::borrow::Cow; use std::str; use zbus::Connection; -use crate::Result; use crate::dbus::{NMAccessPointProxy, NMDeviceProxy, NMProxy, NMWirelessProxy}; use crate::types::constants::{device_type, frequency, signal_strength, wifi_mode}; +use crate::Result; /// Converts a Wi-Fi frequency in MHz to a channel number. /// diff --git a/nmrs/tests/integration_test.rs b/nmrs/tests/integration_test.rs index c520f81d..47f8f596 100644 --- a/nmrs/tests/integration_test.rs +++ b/nmrs/tests/integration_test.rs @@ -1,6 +1,6 @@ use nmrs::{ - ConnectionError, DeviceState, DeviceType, NetworkManager, StateReason, VpnCredentials, VpnType, - WifiSecurity, WireGuardPeer, reason_to_error, + reason_to_error, ConnectionError, DeviceState, DeviceType, NetworkManager, StateReason, + VpnCredentials, VpnType, WifiSecurity, WireGuardPeer, }; use std::time::Duration; use tokio::time::sleep; From 0a528e8ee9bc1319f305fc5b7cedf55e3f0be0bc Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Mon, 15 Dec 2025 17:13:14 -0500 Subject: [PATCH 08/11] chore: update README --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index a9a1f7c6..0a5fe527 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,12 @@ Edit `~/.config/nmrs/style.css` to customize the interface. There are also pre-d Contributions are welcome. Please read [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines. +## Requirements + +- **Rust**: 1.78.0 or later (for `nmrs` library) +- **Rust**: 1.85.1 or later (for `nmrs-gui` with GTK4) +- **NetworkManager**: Running and accessible via D-Bus +- **Linux**: This library is Linux-specific ## License From 123623ab98db98f04fd6d3d6b96e6e09eb043cd9 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Mon, 15 Dec 2025 17:16:24 -0500 Subject: [PATCH 09/11] CI: update release workflow to separate crates --- .github/workflows/release.yml | 50 +++++++++++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 68563481..8ad1a0f6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,13 @@ on: workflow_dispatch: branches: [ master ] inputs: + crate: + description: 'Crate to release' + required: true + type: choice + options: + - nmrs + - nmrs-gui version: description: 'Version to release' required: true @@ -39,6 +46,7 @@ jobs: python3 scripts/bump_version.py "${{ github.event.inputs.version }}" "${{ github.event.inputs.release_type }}" || echo "⚠ Version bump completed with warnings" echo "VERSION=${{ github.event.inputs.version }}" >> $GITHUB_ENV echo "RELEASE_TYPE=${{ github.event.inputs.release_type }}" >> $GITHUB_ENV + echo "CRATE=${{ github.event.inputs.crate }}" >> $GITHUB_ENV - name: Set up Rust uses: dtolnay/rust-toolchain@stable @@ -84,12 +92,44 @@ jobs: fi git add -A - git commit -m "chore: bump version to ${{ env.VERSION }}-${{ env.RELEASE_TYPE }}" - git tag -a "v${{ env.VERSION }}-${{ env.RELEASE_TYPE }}" -m "Release v${{ env.VERSION }}-${{ env.RELEASE_TYPE }}" + + # Create crate-specific tag + CRATE="${{ env.CRATE }}" + VERSION="${{ env.VERSION }}" + RELEASE_TYPE="${{ env.RELEASE_TYPE }}" + + if [ "$CRATE" = "nmrs" ]; then + TAG_PREFIX="nmrs-" + COMMIT_MSG="chore(nmrs): bump version to ${VERSION}" + if [ "$RELEASE_TYPE" = "beta" ]; then + TAG_NAME="${TAG_PREFIX}v${VERSION}-beta" + COMMIT_MSG="${COMMIT_MSG}-beta" + else + TAG_NAME="${TAG_PREFIX}v${VERSION}" + fi + elif [ "$CRATE" = "nmrs-gui" ]; then + TAG_PREFIX="gui-" + COMMIT_MSG="chore(nmrs-gui): bump version to ${VERSION}" + if [ "$RELEASE_TYPE" = "beta" ]; then + TAG_NAME="${TAG_PREFIX}v${VERSION}-beta" + COMMIT_MSG="${COMMIT_MSG}-beta" + else + TAG_NAME="${TAG_PREFIX}v${VERSION}" + fi + else + echo "Error: Unknown crate $CRATE" + exit 1 + fi + + echo "Creating tag: $TAG_NAME" + git commit -m "$COMMIT_MSG" + git tag -a "$TAG_NAME" -m "Release $TAG_NAME" # Force push to handle any race conditions (safe since we just reset) git push origin "$BRANCH" --force-with-lease - git push origin "v${{ env.VERSION }}-${{ env.RELEASE_TYPE }}" + git push origin "$TAG_NAME" + + echo "TAG_NAME=$TAG_NAME" >> $GITHUB_ENV - name: Extract release notes run: | @@ -98,8 +138,8 @@ jobs: - name: Create GitHub Release uses: softprops/action-gh-release@v1 with: - tag_name: v${{ env.VERSION }}-${{ env.RELEASE_TYPE }} - name: Release v${{ env.VERSION }}-${{ env.RELEASE_TYPE }} + tag_name: ${{ env.TAG_NAME }} + name: Release ${{ env.TAG_NAME }} body_path: RELEASE_NOTES.md draft: false prerelease: ${{ env.RELEASE_TYPE == 'beta' }} From faca340221f7d1b2fecad55745df06cfd28d089c Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Mon, 15 Dec 2025 17:16:57 -0500 Subject: [PATCH 10/11] update submodule pointer --- nmrs-aur | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nmrs-aur b/nmrs-aur index 93a3e1cf..4ee42d12 160000 --- a/nmrs-aur +++ b/nmrs-aur @@ -1 +1 @@ -Subproject commit 93a3e1cf219b9179c4593bff2a239e9ebfd0e503 +Subproject commit 4ee42d129ad7718052c025f11dbb3b6949d10d5a From 7b561b4f326f6e40ad06be976cfb6aeb8a587feb Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Mon, 15 Dec 2025 17:21:08 -0500 Subject: [PATCH 11/11] chore: update README --- README.md | 4 ++-- nmrs/tests/integration_test.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0a5fe527..fbaa604a 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ Edit `~/.config/nmrs/style.css` to customize the interface. There are also pre-d - [x] Generic - [x] Wireless - [ ] Any -- [ ] Wired +- [X] Wired - [ ] ADSL - [ ] Bluetooth - [ ] Bond @@ -179,7 +179,7 @@ Edit `~/.config/nmrs/style.css` to customize the interface. There are also pre-d - [ ] VXLAN - [ ] Wi-Fi P2P - [ ] WiMAX -- [ ] WireGuard +- [X] WireGuard - [ ] WPAN ### Configurations diff --git a/nmrs/tests/integration_test.rs b/nmrs/tests/integration_test.rs index 47f8f596..b697f95a 100644 --- a/nmrs/tests/integration_test.rs +++ b/nmrs/tests/integration_test.rs @@ -111,7 +111,7 @@ async fn test_wifi_enabled_get_set() { .await .expect("Failed to get WiFi enabled state after toggle"); - if new_state != !initial_state { + if new_state == initial_state { eprintln!( "Warning: WiFi state didn't change (may lack permissions). Initial: {}, New: {}", initial_state, new_state