From b7a81f1816395a0c691648da0a99daceca21b6a0 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Tue, 16 Dec 2025 16:34:04 -0500 Subject: [PATCH 01/23] fix: expose `NMWiredProxy` and propogate speed through also writes in field and display for Bluetooth device type --- nmrs/src/api/models.rs | 20 ++++++++++++-------- nmrs/src/core/connection.rs | 12 +++++++++++- nmrs/src/core/device.rs | 14 +++++++++++++- nmrs/src/dbus/mod.rs | 2 ++ nmrs/src/dbus/wired.rs | 2 +- 5 files changed, 39 insertions(+), 11 deletions(-) diff --git a/nmrs/src/api/models.rs b/nmrs/src/api/models.rs index 9cbe4976..776f82c8 100644 --- a/nmrs/src/api/models.rs +++ b/nmrs/src/api/models.rs @@ -396,9 +396,12 @@ pub struct NetworkInfo { /// println!(" This is a WiFi device"); /// } else if device.is_wired() { /// println!(" This is an Ethernet device"); +/// if let Some(speed) == device.speed { +/// println!(" Link speed: {speed} Mb/s"); +/// } /// } /// -/// if let Some(driver) = &device.driver { +/// if let Some(driver) == &device.driver { /// println!(" Driver: {}", driver); /// } /// } @@ -421,6 +424,8 @@ pub struct Device { pub managed: Option, /// Kernel driver name pub driver: Option, + /// Link speed in Mb/s (wired devices) + pub speed: Option, } /// Represents the hardware identity of a network device. @@ -859,6 +864,8 @@ pub enum DeviceType { WifiP2P, /// Loopback device (localhost). Loopback, + /// Bluetooth + Bluetooth, /// Unknown or unsupported device type with raw code. /// /// Use the methods on `DeviceType` to query capabilities of unknown device types, @@ -918,6 +925,7 @@ impl DeviceType { Self::Wifi => "802-11-wireless", Self::WifiP2P => "wifi-p2p", Self::Loopback => "loopback", + Self::Bluetooth => "bluetooth", Self::Other(code) => { crate::types::device_type_registry::connection_type_for_code(*code) .unwrap_or("generic") @@ -932,6 +940,7 @@ impl DeviceType { Self::Wifi => 2, Self::WifiP2P => 30, Self::Loopback => 32, + Self::Bluetooth => 6, Self::Other(code) => *code, } } @@ -1306,13 +1315,8 @@ impl Display for DeviceType { DeviceType::Wifi => write!(f, "Wi-Fi"), DeviceType::WifiP2P => write!(f, "Wi-Fi P2P"), DeviceType::Loopback => write!(f, "Loopback"), - DeviceType::Other(v) => { - write!( - f, - "{}", - crate::types::device_type_registry::display_name_for_code(*v) - ) - } + DeviceType::Bluetooth => write!(f, "Bluetooth"), + DeviceType::Other(v) => write!(f, "Other({v})"), } } } diff --git a/nmrs/src/core/connection.rs b/nmrs/src/core/connection.rs index 3190c20d..554a98cb 100644 --- a/nmrs/src/core/connection.rs +++ b/nmrs/src/core/connection.rs @@ -8,7 +8,7 @@ use crate::api::builders::wifi::{build_ethernet_connection, build_wifi_connectio 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::dbus::{NMAccessPointProxy, NMDeviceProxy, NMProxy, NMWiredProxy, NMWirelessProxy}; use crate::monitoring::info::current_ssid; use crate::types::constants::{device_state, device_type, timeouts}; use crate::util::utils::{decode_ssid_or_empty, nm_proxy}; @@ -149,6 +149,16 @@ pub(crate) async fn connect_wired(conn: &Connection) -> Result<()> { } } + if let Ok(wired) = NMWiredProxy::builder(conn) + .path(wired_device.clone())? + .build() + .await + { + if let Ok(speed) = wired.speed().await { + info!("Connected to wired device at {speed} Mb/s"); + } + } + info!("Successfully connected to wired device"); Ok(()) } diff --git a/nmrs/src/core/device.rs b/nmrs/src/core/device.rs index 6de84f21..9c971f22 100644 --- a/nmrs/src/core/device.rs +++ b/nmrs/src/core/device.rs @@ -9,7 +9,7 @@ use zbus::Connection; 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::dbus::{NMDeviceProxy, NMProxy, NMWiredProxy}; use crate::types::constants::device_type; use crate::Result; @@ -73,6 +73,17 @@ pub(crate) async fn list_devices(conn: &Connection) -> Result> { } }; + // Get link speed for wired devices + let speed = if raw_type == device_type::ETHERNET { + async { + let wired = NMWiredProxy::builder(conn).path(p.clone())?.build().await?; + wired.speed().await + } + .await + .ok() + } else { + None + }; devices.push(Device { path: p.to_string(), interface, @@ -84,6 +95,7 @@ pub(crate) async fn list_devices(conn: &Connection) -> Result> { state, managed, driver, + speed, }); } Ok(devices) diff --git a/nmrs/src/dbus/mod.rs b/nmrs/src/dbus/mod.rs index 3cfe0b15..38ec3483 100644 --- a/nmrs/src/dbus/mod.rs +++ b/nmrs/src/dbus/mod.rs @@ -7,10 +7,12 @@ mod access_point; mod active_connection; mod device; mod main_nm; +mod wired; 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 wired::NMWiredProxy; pub(crate) use wireless::NMWirelessProxy; diff --git a/nmrs/src/dbus/wired.rs b/nmrs/src/dbus/wired.rs index 798bfb5b..4b77202c 100644 --- a/nmrs/src/dbus/wired.rs +++ b/nmrs/src/dbus/wired.rs @@ -1,7 +1,7 @@ //! NetworkManager Wired (Ethernet) Device Proxy -use zbus::Result; use zbus::proxy; +use zbus::Result; /// Proxy for wired devices (Ethernet). /// From 705853bc239f19a989babefddd0094942e3ecb43 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Wed, 17 Dec 2025 23:22:28 -0500 Subject: [PATCH 02/23] feat: bluetooth device builder --- nmrs/src/api/builders/bluetooth.rs | 80 ++++++++++++++++++++++++++++++ nmrs/src/api/builders/mod.rs | 4 +- nmrs/src/api/models.rs | 26 +++++++++- nmrs/src/core/bluetooth.rs | 16 ++++++ nmrs/src/core/mod.rs | 1 + nmrs/src/dbus/bluetooth.rs | 21 ++++++++ nmrs/src/dbus/mod.rs | 2 + nmrs/tests/integration_test.rs | 1 + 8 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 nmrs/src/api/builders/bluetooth.rs create mode 100644 nmrs/src/core/bluetooth.rs create mode 100644 nmrs/src/dbus/bluetooth.rs diff --git a/nmrs/src/api/builders/bluetooth.rs b/nmrs/src/api/builders/bluetooth.rs new file mode 100644 index 00000000..bf3ff565 --- /dev/null +++ b/nmrs/src/api/builders/bluetooth.rs @@ -0,0 +1,80 @@ +//! Bluetooth connection management module. +//! +//! This module provides functions to create and manage Bluetooth network connections +//! using NetworkManager's D-Bus API. It includes builders for Bluetooth PAN (Personal Area +//! Network) connections and DUN (Dial-Up Networking) connections. +//! +//! # Usage +//! +//! 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. +//! +//! # Example +//! +//! ```rust +//! use nmrs::builders::build_bluetooth_connection; +//! use nmrs::models::BluetoothSettings; +//! +//! let bt_settings = BluetoothSettings { +//! bdaddr: "00:1A:7D:DA:71:13".into(), +//! bt_device_type: "pan".into(), +//! }; +//! ``` + +use std::collections::HashMap; +use zvariant::Value; + +use crate::{models::BluetoothSettings, ConnectionOptions}; + +/// Builds the `connection` section with type, id, uuid, and autoconnect settings. +pub fn base_connection_section( + name: &str, + opts: &ConnectionOptions, +) -> HashMap<&'static str, Value<'static>> { + let mut s = HashMap::new(); + s.insert("type", Value::from("bluetooth")); + s.insert("id", Value::from(name.to_string())); + s.insert("uuid", Value::from(uuid::Uuid::new_v4().to_string())); + s.insert("autoconnect", Value::from(opts.autoconnect)); + + if let Some(p) = opts.autoconnect_priority { + s.insert("autoconnect-priority", Value::from(p)); + } + + if let Some(r) = opts.autoconnect_retries { + s.insert("autoconnect-retries", Value::from(r)); + } + + s +} + +/// Builds a Bluetooth connection settings dictionary. +fn bluetooth_section(settings: &BluetoothSettings) -> HashMap<&'static str, Value<'static>> { + let mut s = HashMap::new(); + s.insert("bdaddr", Value::from(settings.bdaddr.clone())); + s.insert("type", Value::from(settings.bt_device_type.clone())); + s +} + +pub fn build_bluetooth_connection( + name: &str, + settings: &BluetoothSettings, + opts: &ConnectionOptions, +) -> HashMap<&'static str, HashMap<&'static str, Value<'static>>> { + let mut conn: HashMap<&'static str, HashMap<&'static str, Value<'static>>> = HashMap::new(); + + // Base connections + conn.insert("connection", base_connection_section(name, opts)); + conn.insert("bluetooth", bluetooth_section(settings)); + + let mut ipv4 = HashMap::new(); + ipv4.insert("method", Value::from("auto")); + conn.insert("ipv4", ipv4); + + let mut ipv6 = HashMap::new(); + ipv6.insert("method", Value::from("auto")); + conn.insert("ipv6", ipv6); + + conn +} diff --git a/nmrs/src/api/builders/mod.rs b/nmrs/src/api/builders/mod.rs index 320ec333..5fe59828 100644 --- a/nmrs/src/api/builders/mod.rs +++ b/nmrs/src/api/builders/mod.rs @@ -74,6 +74,7 @@ //! `AddConnection` or `AddAndActivateConnection` D-Bus methods. pub mod connection_builder; +pub mod bluetooth; pub mod vpn; pub mod wifi; pub mod wifi_builder; @@ -84,6 +85,7 @@ pub use connection_builder::{ConnectionBuilder, IpConfig, Route}; pub use wifi_builder::{WifiBand, WifiConnectionBuilder}; pub use wireguard_builder::WireGuardBuilder; -// Re-export builder functions for convenience (backward compatibility) +// Re-export builder functions for convenience +pub use bluetooth::build_bluetooth_connection; pub use vpn::build_wireguard_connection; pub use wifi::{build_ethernet_connection, build_wifi_connection}; diff --git a/nmrs/src/api/models.rs b/nmrs/src/api/models.rs index 776f82c8..7d22e167 100644 --- a/nmrs/src/api/models.rs +++ b/nmrs/src/api/models.rs @@ -396,12 +396,12 @@ pub struct NetworkInfo { /// println!(" This is a WiFi device"); /// } else if device.is_wired() { /// println!(" This is an Ethernet device"); -/// if let Some(speed) == device.speed { +/// if let Some(speed) = device.speed { /// println!(" Link speed: {speed} Mb/s"); /// } /// } /// -/// if let Some(driver) == &device.driver { +/// if let Some(driver) = &device.driver { /// println!(" Driver: {}", driver); /// } /// } @@ -849,6 +849,27 @@ pub struct VpnConnectionInfo { pub dns_servers: Vec, } +/// Bluetooth settings. +/// +/// Configuration options for Bluetooth devices managed by NetworkManager. +/// # Example +/// +/// ```rust +/// use nmrs::models::BluetoothSettings; +/// +/// let bt_settings = BluetoothSettings { +/// bdaddr: "00:1A:7D:DA:71:13".into(), +/// bt_device_type: "dun".into(), +/// }; +/// ``` +#[derive(Debug, Clone)] +pub struct BluetoothSettings { + /// Bluetooth device address (BDADDR) + pub bdaddr: String, + /// Bluetooth device type (DUN or PANU) + pub bt_device_type: String, +} + /// NetworkManager device types. /// /// Represents the type of network hardware managed by NetworkManager. @@ -1285,6 +1306,7 @@ impl From for DeviceType { match value { 1 => DeviceType::Ethernet, 2 => DeviceType::Wifi, + 5 => DeviceType::Bluetooth, 30 => DeviceType::WifiP2P, 32 => DeviceType::Loopback, v => DeviceType::Other(v), diff --git a/nmrs/src/core/bluetooth.rs b/nmrs/src/core/bluetooth.rs new file mode 100644 index 00000000..993988e0 --- /dev/null +++ b/nmrs/src/core/bluetooth.rs @@ -0,0 +1,16 @@ +//! Core Bluetooth connection management logic. +//! +//! This module contains the internal implementation details for managing +//! Bluetooth devices and connections. +//! +//! Similar to other device types, it handles scanning, connecting, and monitoring +//! Bluetooth devices using NetworkManager's D-Bus API. + +use crate::Result; +use zbus::Connection; + +#[allow(dead_code)] +#[warn(unused_variables)] +pub(crate) async fn connect_bluetooth(_conn: &Connection) -> Result<()> { + todo!() +} diff --git a/nmrs/src/core/mod.rs b/nmrs/src/core/mod.rs index 70853d3d..0ed7c71e 100644 --- a/nmrs/src/core/mod.rs +++ b/nmrs/src/core/mod.rs @@ -3,6 +3,7 @@ //! This module contains the internal implementation details for managing //! network connections, devices, scanning, and state monitoring. +pub(crate) mod bluetooth; pub(crate) mod connection; pub(crate) mod connection_settings; pub(crate) mod device; diff --git a/nmrs/src/dbus/bluetooth.rs b/nmrs/src/dbus/bluetooth.rs new file mode 100644 index 00000000..ae39d058 --- /dev/null +++ b/nmrs/src/dbus/bluetooth.rs @@ -0,0 +1,21 @@ +//! Bluetooth Device Proxy + +use zbus::proxy; +use zbus::Result; + +/// Proxy for Bluetooth devices +/// +/// Provides access to Bluetooth-specific properties and methods. +#[proxy( + interface = "org.freedesktop.NetworkManager.Device.Bluetooth", + default_service = "org.freedesktop.NetworkManager" +)] +pub trait NMBluetooth { + /// Bluetooth name of device. + #[zbus(property)] + fn name(&self) -> Result; + + /// Bluetooth capabilities of the device (either DUN or NAP). + #[zbus(property)] + fn bt_capabilities(&self) -> Result; +} diff --git a/nmrs/src/dbus/mod.rs b/nmrs/src/dbus/mod.rs index 38ec3483..581ade33 100644 --- a/nmrs/src/dbus/mod.rs +++ b/nmrs/src/dbus/mod.rs @@ -5,6 +5,7 @@ mod access_point; mod active_connection; +mod bluetooth; mod device; mod main_nm; mod wired; @@ -12,6 +13,7 @@ mod wireless; pub(crate) use access_point::NMAccessPointProxy; pub(crate) use active_connection::NMActiveConnectionProxy; +// pub(crate) use bluetooth::NMBluetoothProxy; pub(crate) use device::NMDeviceProxy; pub(crate) use main_nm::NMProxy; pub(crate) use wired::NMWiredProxy; diff --git a/nmrs/tests/integration_test.rs b/nmrs/tests/integration_test.rs index b697f95a..40be75bb 100644 --- a/nmrs/tests/integration_test.rs +++ b/nmrs/tests/integration_test.rs @@ -592,6 +592,7 @@ async fn test_device_types() { match device.device_type { DeviceType::Ethernet | DeviceType::Wifi + | DeviceType::Bluetooth | DeviceType::WifiP2P | DeviceType::Loopback | DeviceType::Other(_) => { From e11b3933ca1fd2fc036aa8d711a91ea98a19180d Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Fri, 19 Dec 2025 17:28:16 -0500 Subject: [PATCH 03/23] feat: add Bluetooth device connection support - Implement Bluetooth connection logic - Add BlueZ D-Bus integration for device name/alias retrieval - Create Bluetooth connection builder with PANU/DUN support - Add `ActiveTransport` trait for unified connection monitoring - Refactor WiFi monitoring functions into `wifi.rs` module - Add `BluetoothDevice`, `BluetoothIdentity` models - Implement `list_bluetooth_devices()` API - Add `bluetooth` and `bluetooth_connect` examples - Update `connection.rs` to use `ActiveTransport` trait --- Cargo.lock | 4 + nmrs/Cargo.toml | 1 + nmrs/examples/bluetooth.rs | 29 +++++ nmrs/examples/bluetooth_connect.rs | 47 ++++++++ nmrs/src/api/builders/bluetooth.rs | 19 ++-- nmrs/src/api/models.rs | 119 +++++++++++++++++++-- nmrs/src/api/network_manager.rs | 26 ++++- nmrs/src/core/bluetooth.rs | 166 ++++++++++++++++++++++++++++- nmrs/src/core/connection.rs | 7 +- nmrs/src/core/device.rs | 36 ++++++- nmrs/src/dbus/bluetooth.rs | 18 +++- nmrs/src/dbus/mod.rs | 2 +- nmrs/src/monitoring/bluetooth.rs | 129 ++++++++++++++++++++++ nmrs/src/monitoring/info.rs | 14 ++- nmrs/src/monitoring/mod.rs | 3 + nmrs/src/monitoring/transport.rs | 9 ++ nmrs/src/monitoring/wifi.rs | 118 ++++++++++++++++++++ nmrs/src/types/constants.rs | 1 + 18 files changed, 712 insertions(+), 36 deletions(-) create mode 100644 nmrs/examples/bluetooth.rs create mode 100644 nmrs/examples/bluetooth_connect.rs create mode 100644 nmrs/src/monitoring/bluetooth.rs create mode 100644 nmrs/src/monitoring/transport.rs create mode 100644 nmrs/src/monitoring/wifi.rs diff --git a/Cargo.lock b/Cargo.lock index 96dd9e21..82b2559b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -925,7 +925,11 @@ dependencies = [ name = "nmrs" version = "1.3.5" dependencies = [ +<<<<<<< HEAD "base64", +======= + "async-trait", +>>>>>>> 26d6801 (feat: add Bluetooth device connection support) "futures", "futures-timer", "log", diff --git a/nmrs/Cargo.toml b/nmrs/Cargo.toml index e1b3ce6e..0dea0705 100644 --- a/nmrs/Cargo.toml +++ b/nmrs/Cargo.toml @@ -23,6 +23,7 @@ futures.workspace = true futures-timer.workspace = true base64.workspace = true tokio.workspace = true +async-trait = "0.1.89" [dev-dependencies] tokio.workspace = true diff --git a/nmrs/examples/bluetooth.rs b/nmrs/examples/bluetooth.rs new file mode 100644 index 00000000..032b05c5 --- /dev/null +++ b/nmrs/examples/bluetooth.rs @@ -0,0 +1,29 @@ +use nmrs::{ + models::{BluetoothIdentity, BluetoothNetworkRole}, + NetworkManager, Result, +}; + +#[tokio::main] +async fn main() -> Result<()> { + let nm = NetworkManager::new().await?; + + println!("Scanning for Bluetooth devices..."); + let devices = nm.list_bluetooth_devices().await?; + + let mut bucket = Vec::new(); + // List bluetooth devices + for d in devices { + println!("{d}"); + bucket.push(d); + nm.connect_bluetooth( + "unknown", + &BluetoothIdentity { + bdaddr: "00:00:00:00:00".into(), + bt_device_type: BluetoothNetworkRole::Dun, + }, + ) + .await?; + } + + Ok(()) +} diff --git a/nmrs/examples/bluetooth_connect.rs b/nmrs/examples/bluetooth_connect.rs new file mode 100644 index 00000000..6b6d84ef --- /dev/null +++ b/nmrs/examples/bluetooth_connect.rs @@ -0,0 +1,47 @@ +use nmrs::models::BluetoothIdentity; +use nmrs::{NetworkManager, Result}; + +#[tokio::main] +async fn main() -> Result<()> { + let nm = NetworkManager::new().await?; + + println!("Scanning for Bluetooth devices..."); + let devices = nm.list_bluetooth_devices().await?; + + if devices.is_empty() { + println!("No Bluetooth devices found."); + println!("\nMake sure:"); + println!(" 1. Bluetooth is enabled"); + println!(" 2. Device is paired (use 'bluetoothctl')"); + return Ok(()); + } + + println!("\nAvailable Bluetooth devices:"); + for (i, device) in devices.iter().enumerate() { + println!(" {}. {}", i + 1, device); + } + + // Example: Connect to the first device + if let Some(device) = devices.first { + println!("\nConnecting to: {}", device); + + let settings = BluetoothIdentity { + bdaddr: device.bdaddr.clone(), + bt_device_type: device.bt_device_type.clone(), + }; + + let name = device + .alias + .as_ref() + .or(device.name.as_ref()) + .map(|s| s.as_str()) + .unwrap_or("Bluetooth Device"); + + match nm.connect_bluetooth(name, &settings).await { + Ok(_) => println!("✓ Successfully connected to {}", name), + Err(e) => eprintln!("✗ Failed to connect: {}", e), + } + } + + Ok(()) +} diff --git a/nmrs/src/api/builders/bluetooth.rs b/nmrs/src/api/builders/bluetooth.rs index bf3ff565..3ee9de24 100644 --- a/nmrs/src/api/builders/bluetooth.rs +++ b/nmrs/src/api/builders/bluetooth.rs @@ -14,9 +14,9 @@ //! //! ```rust //! use nmrs::builders::build_bluetooth_connection; -//! use nmrs::models::BluetoothSettings; +//! use nmrs::models::BluetoothIdentity; //! -//! let bt_settings = BluetoothSettings { +//! let bt_settings = BluetoothIdentity { //! bdaddr: "00:1A:7D:DA:71:13".into(), //! bt_device_type: "pan".into(), //! }; @@ -25,7 +25,10 @@ use std::collections::HashMap; use zvariant::Value; -use crate::{models::BluetoothSettings, ConnectionOptions}; +use crate::{ + models::{BluetoothIdentity, BluetoothNetworkRole}, + ConnectionOptions, +}; /// Builds the `connection` section with type, id, uuid, and autoconnect settings. pub fn base_connection_section( @@ -50,16 +53,20 @@ pub fn base_connection_section( } /// Builds a Bluetooth connection settings dictionary. -fn bluetooth_section(settings: &BluetoothSettings) -> HashMap<&'static str, Value<'static>> { +fn bluetooth_section(settings: &BluetoothIdentity) -> HashMap<&'static str, Value<'static>> { let mut s = HashMap::new(); s.insert("bdaddr", Value::from(settings.bdaddr.clone())); - s.insert("type", Value::from(settings.bt_device_type.clone())); + let bt_type = match settings.bt_device_type { + BluetoothNetworkRole::PanU => "panu", + BluetoothNetworkRole::Dun => "dun", + }; + s.insert("type", Value::from(bt_type)); s } pub fn build_bluetooth_connection( name: &str, - settings: &BluetoothSettings, + settings: &BluetoothIdentity, opts: &ConnectionOptions, ) -> HashMap<&'static str, HashMap<&'static str, Value<'static>>> { let mut conn: HashMap<&'static str, HashMap<&'static str, Value<'static>>> = HashMap::new(); diff --git a/nmrs/src/api/models.rs b/nmrs/src/api/models.rs index 7d22e167..6447add2 100644 --- a/nmrs/src/api/models.rs +++ b/nmrs/src/api/models.rs @@ -849,25 +849,69 @@ pub struct VpnConnectionInfo { pub dns_servers: Vec, } -/// Bluetooth settings. +/// Bluetooth network role. +/// +/// Specifies the role of the Bluetooth device in the network connection. +#[derive(Debug, Clone)] +pub enum BluetoothNetworkRole { + PanU, // Personal Area Network User + Dun, // Dial-Up Networking +} + +/// Bluetooth device identity information. +/// +/// Relevant info for Bluetooth devices managed by NetworkManager. +/// +/// # Example +///```rust +/// use nmrs::models::{BluetoothIdentity, BluetoothNetworkRole}; +/// +/// let bt_settings = BluetoothIdentity { +/// bdaddr: "00:1A:7D:DA:71:13".into(), +/// bt_device_type: BluetoothNetworkRole::Dun, +/// }; +/// ``` +#[derive(Debug, Clone)] +pub struct BluetoothIdentity { + /// MAC address of Bluetooth device + pub bdaddr: String, + /// Bluetooth device type (DUN or PANU) + pub bt_device_type: BluetoothNetworkRole, +} + +/// Bluetooth device with friendly name from BlueZ. +/// +/// Contains information about a Bluetooth device managed by NetworkManager, +/// proxying data from BlueZ. +/// +/// This is a specialized struct for Bluetooth devices, separate from the +/// general `Device` struct. /// -/// Configuration options for Bluetooth devices managed by NetworkManager. /// # Example /// /// ```rust -/// use nmrs::models::BluetoothSettings; +/// use nmrs::models::{BluetoothDevice, BluetoothNetworkRole, DeviceState}; /// -/// let bt_settings = BluetoothSettings { +/// let bt_device = BluetoothDevice { /// bdaddr: "00:1A:7D:DA:71:13".into(), -/// bt_device_type: "dun".into(), +/// name: Some("Foo".into()), +/// alias: Some("Bar".into()), +/// bt_device_type: BluetoothNetworkRole::PanU, +/// state: DeviceState::Activated, /// }; /// ``` #[derive(Debug, Clone)] -pub struct BluetoothSettings { - /// Bluetooth device address (BDADDR) +pub struct BluetoothDevice { + /// Bluetooth MAC address pub bdaddr: String, + /// Friendly device name from BlueZ + pub name: Option, + /// Device alias from BlueZ + pub alias: Option, /// Bluetooth device type (DUN or PANU) - pub bt_device_type: String, + pub bt_device_type: BluetoothNetworkRole, + /// Current device state + pub state: DeviceState, } /// NetworkManager device types. @@ -1002,6 +1046,51 @@ impl Device { pub fn is_wireless(&self) -> bool { matches!(self.device_type, DeviceType::Wifi) } + + /// Returns 'true' if this is a Bluetooth (DUN or PANU) device. + pub fn is_bluetooth(&self) -> bool { + matches!(self.device_type, DeviceType::Bluetooth) + } +} + +/// Display implementation for Device struct. +/// +/// Formats the device information as "interface (device_type) [state]". +impl Display for Device { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{} ({}) [{}]", + self.interface, self.device_type, self.state + ) + } +} + +/// Display implementation for BluetoothDevice struct. +/// +/// Formats the device information as "alias (device_type) [bdaddr]". +impl Display for BluetoothDevice { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{} ({}) [{}]", + self.alias.as_deref().unwrap_or("unknown"), + self.bt_device_type, + self.bdaddr + ) + } +} + +/// Display implementation for Device struct. +/// +/// Formats the device information as "interface (device_type) [state]". +impl Display for BluetoothNetworkRole { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + BluetoothNetworkRole::Dun => write!(f, "DUN"), + BluetoothNetworkRole::PanU => write!(f, "PANU"), + } + } } /// Errors that can occur during network operations. @@ -1151,6 +1240,10 @@ pub enum ConnectionError { /// VPN connection failed #[error("VPN connection failed: {0}")] VpnFailed(String), + + /// Bluetooth device not found + #[error("Bluetooth device not found")] + NoBluetoothDevice, } /// NetworkManager device state reason codes. @@ -1359,6 +1452,16 @@ impl Display for DeviceState { } } +impl From for BluetoothNetworkRole { + fn from(value: u32) -> Self { + match value { + 0 => Self::PanU, + 1 => Self::Dun, + _ => Self::PanU, + } + } +} + impl WifiSecurity { /// Returns `true` if this security type requires authentication. pub fn secured(&self) -> bool { diff --git a/nmrs/src/api/network_manager.rs b/nmrs/src/api/network_manager.rs index 18cd226f..5a362c81 100644 --- a/nmrs/src/api/network_manager.rs +++ b/nmrs/src/api/network_manager.rs @@ -10,11 +10,21 @@ use crate::core::connection_settings::{ }; use crate::core::device::{list_devices, set_wifi_enabled, wait_for_wifi_ready, wifi_enabled}; use crate::core::scan::{current_network, list_networks, scan_networks}; +use crate::core::bluetooth::connect_bluetooth; +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_bluetooth_devices, 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::models::{ + BluetoothDevice, BluetoothIdentity, VpnConnection, VpnConnectionInfo, VpnCredentials, +}; use crate::monitoring::device as device_monitor; -use crate::monitoring::info::{current_connection_info, current_ssid, show_details}; +use crate::monitoring::info::show_details; use crate::monitoring::network as network_monitor; +use crate::monitoring::wifi::{current_connection_info, current_ssid}; use crate::Result; /// High-level interface to NetworkManager over D-Bus. @@ -123,6 +133,11 @@ impl NetworkManager { list_devices(&self.conn).await } + /// List all bluetooth devices. + pub async fn list_bluetooth_devices(&self) -> Result> { + list_bluetooth_devices(&self.conn).await + } + /// Lists all network devices managed by NetworkManager. pub async fn list_wireless_devices(&self) -> Result> { let devices = list_devices(&self.conn).await?; @@ -164,6 +179,13 @@ impl NetworkManager { connect_wired(&self.conn).await } + /// Connects to a bluetooth device using the provided identity. + /// + /// # Example + pub async fn connect_bluetooth(&self, name: &str, identity: &BluetoothIdentity) -> Result<()> { + connect_bluetooth(&self.conn, name, identity).await + } + /// Connects to a VPN using the provided credentials. /// /// Currently supports WireGuard VPN connections. The function checks for an diff --git a/nmrs/src/core/bluetooth.rs b/nmrs/src/core/bluetooth.rs index 993988e0..9c972768 100644 --- a/nmrs/src/core/bluetooth.rs +++ b/nmrs/src/core/bluetooth.rs @@ -6,11 +6,167 @@ //! Similar to other device types, it handles scanning, connecting, and monitoring //! Bluetooth devices using NetworkManager's D-Bus API. -use crate::Result; +use log::debug; use zbus::Connection; +use zvariant::OwnedObjectPath; -#[allow(dead_code)] -#[warn(unused_variables)] -pub(crate) async fn connect_bluetooth(_conn: &Connection) -> Result<()> { - todo!() +use crate::builders::bluetooth; +use crate::core::connection_settings::get_saved_connection_path; +use crate::dbus::{BluezDeviceExtProxy, NMDeviceProxy}; +use crate::monitoring::bluetooth::Bluetooth; +use crate::monitoring::transport::ActiveTransport; +use crate::types::constants::device_type; +use crate::ConnectionError; +use crate::{dbus::NMProxy, models::BluetoothIdentity, Result}; + +/// Populated Bluetooth device information via BlueZ. +/// +/// Given a Bluetooth device address (BDADDR), this function queries BlueZ +/// over D-Bus to retrieve the device's name and alias. It constructs the +/// appropriate D-Bus object path based on the BDADDR format. +/// +/// NetworkManager does not expose Bluetooth device names/aliases directly, +/// hence this additional step is necessary to obtain user-friendly +/// identifiers for Bluetooth devices. (See `BluezDeviceExtProxy` for details.) +pub(crate) async fn populate_bluez_info( + conn: &Connection, + bdaddr: &str, +) -> Result<(Option, Option)> { + // [variable prefix]/{hci0,hci1,...}/dev_XX_XX_XX_XX_XX_XX + // This replaces ':' with '_' in the BDADDR to form the correct D-Bus object path. + let bluez_path = format!("/org/bluez/hci0/dev_{}", bdaddr.replace(':', "_")); + + match BluezDeviceExtProxy::builder(conn) + .path(bluez_path)? + .build() + .await + { + Ok(proxy) => { + let name = proxy.name().await.ok(); + let alias = proxy.alias().await.ok(); + Ok((name, alias)) + } + Err(_) => Ok((None, None)), + } +} + +/// Connects to a Bluetooth device using NetworkManager. +/// +/// This function establishes a Bluetooth network connection. The flow: +/// 1. Check if already connected to this device +/// 2. Find the Bluetooth hardware adapter +/// 3. Check for an existing saved connection +/// 4. Either activate the saved connection or create a new one +/// 5. Wait for the connection to reach the activated state +/// +/// **Important:** The Bluetooth device must already be paired via BlueZ +/// (using `bluetoothctl` or similar) before NetworkManager can connect to it. +/// +/// # Arguments +/// +/// * `conn` - D-Bus connection +/// * `name` - Connection name/identifier +/// * `settings` - Bluetooth device settings (bdaddr and type) +/// +/// # Example +/// +/// ```no_run +/// use nmrs::models::{BluetoothIdentity, BluetoothNetworkRole}; +/// +/// let settings = BluetoothIdentity { +/// bdaddr: "C8:1F:E8:F0:51:57".into(), +/// bt_device_type: BluetoothNetworkRole::PanU, +/// }; +/// // connect_bluetooth(&conn, "My Phone", &settings).await?; +/// ``` +pub(crate) async fn connect_bluetooth( + conn: &Connection, + name: &str, + settings: &BluetoothIdentity, +) -> Result<()> { + debug!( + "Connecting to '{}' (Bluetooth) | bdaddr={} type={:?}", + name, settings.bdaddr, settings.bt_device_type + ); + + let nm = NMProxy::new(conn).await?; + + // Check if already connected to this device + if let Some(active) = Bluetooth::current(conn).await { + debug!("Currently connected to Bluetooth device: {active}"); + if active == settings.bdaddr { + debug!("Already connected to {active}, skipping connect()"); + return Ok(()); + } + } else { + debug!("Not currently connected to any Bluetooth device"); + } + + // Find the Bluetooth hardware adapter + let bt_device = find_bluetooth_device(conn, &nm).await?; + debug!("Found Bluetooth adapter: {}", bt_device.as_str()); + + // Check for saved connection + let saved = get_saved_connection_path(conn, name).await?; + + // For Bluetooth, the "specific_object" is the remote device's D-Bus path + // Format: /org/bluez/hci0/dev_XX_XX_XX_XX_XX_XX + // TODO: Instead of hardcoding the hci0, we should use the actual hardware adapter name. + let specific_object = OwnedObjectPath::try_from(format!( + "/org/bluez/hci0/dev_{}", + settings.bdaddr.replace(':', "_") + )) + .map_err(|e| ConnectionError::InvalidAddress(format!("Invalid BlueZ path: {}", e)))?; + + match saved { + Some(saved_path) => { + debug!( + "Activating saved Bluetooth connection: {}", + saved_path.as_str() + ); + let active_conn = nm + .activate_connection(saved_path, bt_device.clone(), specific_object) + .await?; + + crate::core::state_wait::wait_for_connection_activation(conn, &active_conn).await?; + } + None => { + debug!("No saved connection found, creating new Bluetooth connection"); + let opts = crate::api::models::ConnectionOptions { + autoconnect: false, // Bluetooth typically doesn't auto-connect + autoconnect_priority: None, + autoconnect_retries: None, + }; + + let connection_settings = bluetooth::build_bluetooth_connection(name, settings, &opts); + + let (_, active_conn) = nm + .add_and_activate_connection( + connection_settings, + bt_device.clone(), + specific_object, + ) + .await?; + + crate::core::state_wait::wait_for_connection_activation(conn, &active_conn).await?; + } + } + + log::info!("Successfully connected to Bluetooth device '{name}'"); + Ok(()) +} + +async fn find_bluetooth_device(conn: &Connection, nm: &NMProxy<'_>) -> Result { + let devices = nm.get_devices().await?; + + for dp in devices { + let dev = NMDeviceProxy::builder(conn) + .path(dp.clone())? + .build() + .await?; + if dev.device_type().await? == device_type::BLUETOOTH { + return Ok(dp); + } + } + Err(ConnectionError::NoBluetoothDevice) } diff --git a/nmrs/src/core/connection.rs b/nmrs/src/core/connection.rs index 554a98cb..ec8f061c 100644 --- a/nmrs/src/core/connection.rs +++ b/nmrs/src/core/connection.rs @@ -9,7 +9,8 @@ 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, NMWiredProxy, NMWirelessProxy}; -use crate::monitoring::info::current_ssid; +use crate::monitoring::transport::ActiveTransport; +use crate::monitoring::wifi::Wifi; use crate::types::constants::{device_state, device_type, timeouts}; use crate::util::utils::{decode_ssid_or_empty, nm_proxy}; use crate::util::validation::{validate_ssid, validate_wifi_security}; @@ -60,7 +61,7 @@ pub(crate) async fn connect(conn: &Connection, ssid: &str, creds: WifiSecurity) .build() .await?; - if let Some(active) = current_ssid(conn).await { + if let Some(active) = Wifi::current(conn).await { debug!("Currently connected to: {active}"); if active == ssid { debug!("Already connected to {active}, skipping connect()"); @@ -435,7 +436,7 @@ async fn ensure_disconnected( nm: &NMProxy<'_>, wifi_device: &OwnedObjectPath, ) -> Result<()> { - if let Some(active) = current_ssid(conn).await { + if let Some(active) = Wifi::current(conn).await { debug!("Disconnecting from {active}"); if let Ok(conns) = nm.active_connections().await { diff --git a/nmrs/src/core/device.rs b/nmrs/src/core/device.rs index 9c971f22..53ee86de 100644 --- a/nmrs/src/core/device.rs +++ b/nmrs/src/core/device.rs @@ -7,7 +7,8 @@ use log::{debug, warn}; use zbus::Connection; -use crate::api::models::{ConnectionError, Device, DeviceIdentity, DeviceState}; +use crate::api::models::{BluetoothDevice, ConnectionError, Device, DeviceIdentity, DeviceState}; +use crate::core::bluetooth::populate_bluez_info; use crate::core::state_wait::wait_for_wifi_device_ready; use crate::dbus::{NMDeviceProxy, NMProxy, NMWiredProxy}; use crate::types::constants::device_type; @@ -101,6 +102,39 @@ pub(crate) async fn list_devices(conn: &Connection) -> Result> { Ok(devices) } +pub(crate) async fn list_bluetooth_devices(conn: &Connection) -> Result> { + let proxy = NMProxy::new(conn).await?; + let paths = proxy.get_devices().await?; + + let mut devices = Vec::new(); + for p in paths { + let d_proxy = NMDeviceProxy::builder(conn) + .path(p.clone())? + .build() + .await?; + + let bdaddr = d_proxy + .hw_address() + .await + .unwrap_or_else(|_| String::from("00:00:00:00:00:00")); + let raw_bt_device_type = d_proxy.device_type().await?; + let bt_device_type = raw_bt_device_type.into(); + let raw_state = d_proxy.state().await?; + let state = raw_state.into(); + + let bluez_info = populate_bluez_info(conn, &bdaddr).await?; + + devices.push(BluetoothDevice { + bdaddr, + name: bluez_info.0, + alias: bluez_info.1, + bt_device_type, + state, + }); + } + Ok(devices) +} + /// Waits for a Wi-Fi device to become ready for operations. /// /// Uses D-Bus signals to efficiently wait until a Wi-Fi device reaches diff --git a/nmrs/src/dbus/bluetooth.rs b/nmrs/src/dbus/bluetooth.rs index ae39d058..ae205598 100644 --- a/nmrs/src/dbus/bluetooth.rs +++ b/nmrs/src/dbus/bluetooth.rs @@ -11,11 +11,25 @@ use zbus::Result; default_service = "org.freedesktop.NetworkManager" )] pub trait NMBluetooth { - /// Bluetooth name of device. + /// Bluetooth MAC address of the device. #[zbus(property)] - fn name(&self) -> Result; + fn bd_address(&self) -> Result; /// Bluetooth capabilities of the device (either DUN or NAP). #[zbus(property)] fn bt_capabilities(&self) -> Result; } + +/// Extension trait for Bluetooth device information via BlueZ. +/// Provides convenient methods to access Bluetooth-specific properties otherwise +/// not exposed by NetworkManager. +#[proxy(interface = "org.bluez.Device1", default_service = "org.bluez")] +pub trait BluezDeviceExt { + /// Returns the name of the Bluetooth device. + #[zbus(property)] + fn name(&self) -> Result; + + /// Returns the alias of the Bluetooth device. + #[zbus(property)] + fn alias(&self) -> Result; +} diff --git a/nmrs/src/dbus/mod.rs b/nmrs/src/dbus/mod.rs index 581ade33..4627bd4c 100644 --- a/nmrs/src/dbus/mod.rs +++ b/nmrs/src/dbus/mod.rs @@ -13,7 +13,7 @@ mod wireless; pub(crate) use access_point::NMAccessPointProxy; pub(crate) use active_connection::NMActiveConnectionProxy; -// pub(crate) use bluetooth::NMBluetoothProxy; +pub(crate) use bluetooth::{BluezDeviceExtProxy, NMBluetoothProxy}; pub(crate) use device::NMDeviceProxy; pub(crate) use main_nm::NMProxy; pub(crate) use wired::NMWiredProxy; diff --git a/nmrs/src/monitoring/bluetooth.rs b/nmrs/src/monitoring/bluetooth.rs new file mode 100644 index 00000000..0fc0b9da --- /dev/null +++ b/nmrs/src/monitoring/bluetooth.rs @@ -0,0 +1,129 @@ +//! Bluetooth device monitoring and current connection status. +//! +//! Provides functions to retrieve information about currently connected +//! Bluetooth devices and their connection state. + +use async_trait::async_trait; +use zbus::Connection; + +use crate::dbus::{NMBluetoothProxy, NMDeviceProxy, NMProxy}; +use crate::monitoring::transport::ActiveTransport; +use crate::try_log; +use crate::types::constants::device_type; + +pub(crate) struct Bluetooth; + +#[async_trait] +impl ActiveTransport for Bluetooth { + type Output = String; + + async fn current(conn: &Connection) -> Option { + current_bluetooth_bdaddr(conn).await + } +} + +/// Returns the Bluetooth MAC address (bdaddr) of the currently connected Bluetooth device. +/// +/// Checks all Bluetooth devices for an active connection and returns +/// the MAC address. Returns `None` if not connected to any Bluetooth device. +/// +/// Uses the `try_log!` macro to gracefully handle errors without +/// propagating them, since this is often used in non-critical contexts. +/// +/// # Example +/// +/// ```no_run +/// use nmrs::monitoring::bluetooth::current_bluetooth_bdaddr; +/// use zbus::Connection; +/// +/// # async fn example() -> Result<(), Box> { +/// let conn = Connection::system().await?; +/// if let Some(bdaddr) = current_bluetooth_bdaddr(&conn).await { +/// println!("Connected to Bluetooth device: {}", bdaddr); +/// } else { +/// println!("No Bluetooth device connected"); +/// } +/// # Ok(()) +/// # } +/// ``` +pub(crate) async fn current_bluetooth_bdaddr(conn: &Connection) -> Option { + let nm = try_log!(NMProxy::new(conn).await, "Failed to create NM proxy"); + let devices = try_log!(nm.get_devices().await, "Failed to get devices"); + + for dp in devices { + let dev_builder = try_log!( + NMDeviceProxy::builder(conn).path(dp.clone()), + "Failed to create device proxy builder" + ); + let dev = try_log!(dev_builder.build().await, "Failed to build device proxy"); + + let dev_type = try_log!(dev.device_type().await, "Failed to get device type"); + if dev_type != device_type::BLUETOOTH { + continue; + } + + // Check if device is in an active/connected state + let state = try_log!(dev.state().await, "Failed to get device state"); + // State 100 = Activated (connected) + if state != 100 { + continue; + } + + // Get the Bluetooth MAC address from the Bluetooth-specific interface + let bt_builder = try_log!( + NMBluetoothProxy::builder(conn).path(dp.clone()), + "Failed to create Bluetooth proxy builder" + ); + let bt = try_log!(bt_builder.build().await, "Failed to build Bluetooth proxy"); + + if let Ok(bdaddr) = bt.bd_address().await { + return Some(bdaddr); + } + } + None +} + +/// Returns detailed information about the current Bluetooth connection. +/// +/// Similar to `current_bluetooth_bdaddr` but also returns the Bluetooth +/// capabilities (DUN or PANU) of the connected device. +/// +/// Returns `Some((bdaddr, capabilities))` if connected, `None` otherwise. +#[allow(dead_code)] +pub(crate) async fn current_bluetooth_info(conn: &Connection) -> Option<(String, u32)> { + let nm = try_log!(NMProxy::new(conn).await, "Failed to create NM proxy"); + let devices = try_log!(nm.get_devices().await, "Failed to get devices"); + + for dp in devices { + let dev_builder = try_log!( + NMDeviceProxy::builder(conn).path(dp.clone()), + "Failed to create device proxy builder" + ); + let dev = try_log!(dev_builder.build().await, "Failed to build device proxy"); + + let dev_type = try_log!(dev.device_type().await, "Failed to get device type"); + if dev_type != device_type::BLUETOOTH { + continue; + } + + // Check if device is in an active/connected state + let state = try_log!(dev.state().await, "Failed to get device state"); + // State 100 = Activated (connected) + if state != 100 { + continue; + } + + // Get the Bluetooth MAC address and capabilities + let bt_builder = try_log!( + NMBluetoothProxy::builder(conn).path(dp.clone()), + "Failed to create Bluetooth proxy builder" + ); + let bt = try_log!(bt_builder.build().await, "Failed to build Bluetooth proxy"); + + if let (Ok(bdaddr), Ok(capabilities)) = (bt.bd_address().await, bt.bt_capabilities().await) + { + return Some((bdaddr, capabilities)); + } + } + None +} diff --git a/nmrs/src/monitoring/info.rs b/nmrs/src/monitoring/info.rs index 0ee92bd7..09aa54ff 100644 --- a/nmrs/src/monitoring/info.rs +++ b/nmrs/src/monitoring/info.rs @@ -1,23 +1,21 @@ -//! Network information and current connection status. +//! Network information and detailed network status. //! -//! Provides functions to retrieve detailed information about networks -//! and query the current connection state. +//! Provides functions to retrieve detailed information about WiFi networks, +//! including security capabilities, signal strength, and connection details. use log::debug; use zbus::Connection; 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::types::constants::{device_type, rate, security_flags}; +use crate::monitoring::wifi::current_ssid; +use crate::types::constants::{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, }; use crate::Result; -/// Returns detailed information about a network. +/// Returns detailed information about a WiFi network. /// /// Queries the access point for comprehensive details including: /// - BSSID (MAC address) diff --git a/nmrs/src/monitoring/mod.rs b/nmrs/src/monitoring/mod.rs index fb2a8ffa..d5853c3f 100644 --- a/nmrs/src/monitoring/mod.rs +++ b/nmrs/src/monitoring/mod.rs @@ -3,6 +3,9 @@ //! This module provides functions for monitoring network state changes, //! device state changes, and retrieving current connection information. +pub(crate) mod bluetooth; pub(crate) mod device; pub(crate) mod info; pub(crate) mod network; +pub(crate) mod transport; +pub(crate) mod wifi; diff --git a/nmrs/src/monitoring/transport.rs b/nmrs/src/monitoring/transport.rs new file mode 100644 index 00000000..f9f5d84e --- /dev/null +++ b/nmrs/src/monitoring/transport.rs @@ -0,0 +1,9 @@ +use async_trait::async_trait; +use zbus::Connection; + +#[async_trait] +pub trait ActiveTransport { + type Output; + + async fn current(conn: &Connection) -> Option; +} diff --git a/nmrs/src/monitoring/wifi.rs b/nmrs/src/monitoring/wifi.rs new file mode 100644 index 00000000..abf7b838 --- /dev/null +++ b/nmrs/src/monitoring/wifi.rs @@ -0,0 +1,118 @@ +//! WiFi connection monitoring and current connection status. +//! +//! Provides functions to retrieve information about currently connected +//! WiFi networks and their connection state. + +use async_trait::async_trait; +use zbus::Connection; + +use crate::dbus::{NMAccessPointProxy, NMDeviceProxy, NMProxy, NMWirelessProxy}; +use crate::monitoring::transport::ActiveTransport; +use crate::try_log; +use crate::types::constants::device_type; +use crate::util::utils::decode_ssid_or_empty; + +pub(crate) struct Wifi; + +#[async_trait] +impl ActiveTransport for Wifi { + type Output = String; + + async fn current(conn: &Connection) -> Option { + current_ssid(conn).await + } +} + +/// Returns the SSID of the currently connected Wi-Fi network. +/// +/// Checks all Wi-Fi devices for an active access point and returns +/// its SSID. Returns `None` if not connected to any Wi-Fi network. +/// +/// Uses the `try_log!` macro to gracefully handle errors without +/// propagating them, since this is often used in non-critical contexts. +pub(crate) async fn current_ssid(conn: &Connection) -> Option { + let nm = try_log!(NMProxy::new(conn).await, "Failed to create NM proxy"); + let devices = try_log!(nm.get_devices().await, "Failed to get devices"); + + for dp in devices { + let dev_builder = try_log!( + NMDeviceProxy::builder(conn).path(dp.clone()), + "Failed to create device proxy builder" + ); + let dev = try_log!(dev_builder.build().await, "Failed to build device proxy"); + + let dev_type = try_log!(dev.device_type().await, "Failed to get device type"); + if dev_type != device_type::WIFI { + continue; + } + + let wifi_builder = try_log!( + NMWirelessProxy::builder(conn).path(dp.clone()), + "Failed to create wireless proxy builder" + ); + let wifi = try_log!(wifi_builder.build().await, "Failed to build wireless proxy"); + + 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 +} + +/// Returns the SSID and frequency of the current Wi-Fi connection. +/// +/// Similar to `current_ssid` but also returns the operating frequency +/// in MHz, useful for determining if connected to 2.4GHz or 5GHz band. +pub(crate) async fn current_connection_info(conn: &Connection) -> Option<(String, Option)> { + let nm = try_log!(NMProxy::new(conn).await, "Failed to create NM proxy"); + let devices = try_log!(nm.get_devices().await, "Failed to get devices"); + + for dp in devices { + let dev_builder = try_log!( + NMDeviceProxy::builder(conn).path(dp.clone()), + "Failed to create device proxy builder" + ); + let dev = try_log!(dev_builder.build().await, "Failed to build device proxy"); + + let dev_type = try_log!(dev.device_type().await, "Failed to get device type"); + if dev_type != device_type::WIFI { + continue; + } + + let wifi_builder = try_log!( + NMWirelessProxy::builder(conn).path(dp.clone()), + "Failed to create wireless proxy builder" + ); + let wifi = try_log!(wifi_builder.build().await, "Failed to build wireless proxy"); + + 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/types/constants.rs b/nmrs/src/types/constants.rs index 04c5d3ff..b0b35d2f 100644 --- a/nmrs/src/types/constants.rs +++ b/nmrs/src/types/constants.rs @@ -7,6 +7,7 @@ pub mod device_type { pub const ETHERNET: u32 = 1; pub const WIFI: u32 = 2; + pub const BLUETOOTH: u32 = 5; // pub const WIFI_P2P: u32 = 30; // pub const LOOPBACK: u32 = 32; } From 64fdfb5cee0b55075cec0e3f173225f25be86f10 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Sat, 20 Dec 2025 01:00:48 -0500 Subject: [PATCH 04/23] fix: default object path to "/" for bluetooth device Also clean up docs. --- nmrs/examples/bluetooth.rs | 12 +----------- nmrs/examples/bluetooth_connect.rs | 5 +++-- nmrs/examples/vpn_connect.rs | 1 + nmrs/examples/wifi_scan.rs | 1 + nmrs/src/api/builders/bluetooth.rs | 4 ++-- nmrs/src/api/builders/mod.rs | 13 ++++--------- nmrs/src/core/bluetooth.rs | 28 +++++++++------------------- nmrs/src/lib.rs | 12 ++++++------ nmrs/src/monitoring/bluetooth.rs | 2 +- 9 files changed, 28 insertions(+), 50 deletions(-) diff --git a/nmrs/examples/bluetooth.rs b/nmrs/examples/bluetooth.rs index 032b05c5..9dff8279 100644 --- a/nmrs/examples/bluetooth.rs +++ b/nmrs/examples/bluetooth.rs @@ -1,5 +1,5 @@ +/// List Bluetooth devices using NetworkManager use nmrs::{ - models::{BluetoothIdentity, BluetoothNetworkRole}, NetworkManager, Result, }; @@ -10,19 +10,9 @@ async fn main() -> Result<()> { println!("Scanning for Bluetooth devices..."); let devices = nm.list_bluetooth_devices().await?; - let mut bucket = Vec::new(); // List bluetooth devices for d in devices { println!("{d}"); - bucket.push(d); - nm.connect_bluetooth( - "unknown", - &BluetoothIdentity { - bdaddr: "00:00:00:00:00".into(), - bt_device_type: BluetoothNetworkRole::Dun, - }, - ) - .await?; } Ok(()) diff --git a/nmrs/examples/bluetooth_connect.rs b/nmrs/examples/bluetooth_connect.rs index 6b6d84ef..92b56eca 100644 --- a/nmrs/examples/bluetooth_connect.rs +++ b/nmrs/examples/bluetooth_connect.rs @@ -1,3 +1,4 @@ +/// Connect to a Bluetooth device using NetworkManager. use nmrs::models::BluetoothIdentity; use nmrs::{NetworkManager, Result}; @@ -21,8 +22,8 @@ async fn main() -> Result<()> { println!(" {}. {}", i + 1, device); } - // Example: Connect to the first device - if let Some(device) = devices.first { + // Example: Connect to the fourth device + if let Some(device) = devices.get(3) { println!("\nConnecting to: {}", device); let settings = BluetoothIdentity { diff --git a/nmrs/examples/vpn_connect.rs b/nmrs/examples/vpn_connect.rs index 65f30b6c..a398c63e 100644 --- a/nmrs/examples/vpn_connect.rs +++ b/nmrs/examples/vpn_connect.rs @@ -1,3 +1,4 @@ +/// Connect to a WireGuard VPN using NetworkManager and print the assigned IP address. use nmrs::{NetworkManager, VpnCredentials, VpnType, WireGuardPeer}; #[tokio::main] diff --git a/nmrs/examples/wifi_scan.rs b/nmrs/examples/wifi_scan.rs index 5ee858ee..19788373 100644 --- a/nmrs/examples/wifi_scan.rs +++ b/nmrs/examples/wifi_scan.rs @@ -1,3 +1,4 @@ +/// Scan for available WiFi networks and print their SSIDs and signal strengths. use nmrs::NetworkManager; #[tokio::main] diff --git a/nmrs/src/api/builders/bluetooth.rs b/nmrs/src/api/builders/bluetooth.rs index 3ee9de24..943c0c7f 100644 --- a/nmrs/src/api/builders/bluetooth.rs +++ b/nmrs/src/api/builders/bluetooth.rs @@ -14,11 +14,11 @@ //! //! ```rust //! use nmrs::builders::build_bluetooth_connection; -//! use nmrs::models::BluetoothIdentity; +//! use nmrs::models::{BluetoothIdentity, BluetoothNetworkRole}; //! //! let bt_settings = BluetoothIdentity { //! bdaddr: "00:1A:7D:DA:71:13".into(), -//! bt_device_type: "pan".into(), +//! bt_device_type: BluetoothNetworkRole::PanU, //! }; //! ``` diff --git a/nmrs/src/api/builders/mod.rs b/nmrs/src/api/builders/mod.rs index 5fe59828..5c83cf1e 100644 --- a/nmrs/src/api/builders/mod.rs +++ b/nmrs/src/api/builders/mod.rs @@ -18,9 +18,9 @@ //! //! # Examples //! -//! ```rust -//! use nmrs::builders::{build_wifi_connection, build_ethernet_connection}; -//! use nmrs::{WifiSecurity, ConnectionOptions}; +//! ```ignore +//! use nmrs::builders::{build_wifi_connection, build_wireguard_connection, build_ethernet_connection}; +//! use nmrs::{WifiSecurity, ConnectionOptions, VpnCredentials, VpnType, WireGuardPeer}; //! //! let opts = ConnectionOptions { //! autoconnect: true, @@ -34,14 +34,9 @@ //! &WifiSecurity::WpaPsk { psk: "password".into() }, //! &opts //! ); -//! +//! //! // Build Ethernet connection settings //! let eth_settings = build_ethernet_connection("eth0", &opts); -//! ``` -//! -//! ```rust -//! # use nmrs::builders::build_wireguard_connection; -//! # use nmrs::{VpnCredentials, VpnType, WireGuardPeer, ConnectionOptions}; //! // Build WireGuard VPN connection settings //! let opts = ConnectionOptions { //! autoconnect: true, diff --git a/nmrs/src/core/bluetooth.rs b/nmrs/src/core/bluetooth.rs index 9c972768..93728181 100644 --- a/nmrs/src/core/bluetooth.rs +++ b/nmrs/src/core/bluetooth.rs @@ -12,10 +12,9 @@ use zvariant::OwnedObjectPath; use crate::builders::bluetooth; use crate::core::connection_settings::get_saved_connection_path; -use crate::dbus::{BluezDeviceExtProxy, NMDeviceProxy}; +use crate::dbus::{BluezDeviceExtProxy}; use crate::monitoring::bluetooth::Bluetooth; use crate::monitoring::transport::ActiveTransport; -use crate::types::constants::device_type; use crate::ConnectionError; use crate::{dbus::NMProxy, models::BluetoothIdentity, Result}; @@ -34,6 +33,7 @@ pub(crate) async fn populate_bluez_info( ) -> Result<(Option, Option)> { // [variable prefix]/{hci0,hci1,...}/dev_XX_XX_XX_XX_XX_XX // This replaces ':' with '_' in the BDADDR to form the correct D-Bus object path. + // TODO: Instead of hardcoding hci0, we should determine the actual adapter name. let bluez_path = format!("/org/bluez/hci0/dev_{}", bdaddr.replace(':', "_")); match BluezDeviceExtProxy::builder(conn) @@ -103,8 +103,11 @@ pub(crate) async fn connect_bluetooth( } // Find the Bluetooth hardware adapter - let bt_device = find_bluetooth_device(conn, &nm).await?; - debug!("Found Bluetooth adapter: {}", bt_device.as_str()); + // Note: Unlike WiFi, Bluetooth connections in NetworkManager don't require + // specifying a specific device. We use "/" to let NetworkManager auto-select. + let bt_device = OwnedObjectPath::try_from("/") + .map_err(|e| ConnectionError::InvalidAddress(format!("Invalid device path: {}", e)))?; + debug!("Using auto-select device path for Bluetooth connection"); // Check for saved connection let saved = get_saved_connection_path(conn, name).await?; @@ -140,6 +143,8 @@ pub(crate) async fn connect_bluetooth( let connection_settings = bluetooth::build_bluetooth_connection(name, settings, &opts); + println!("Creating Bluetooth connection with settings: {:#?}", connection_settings); + let (_, active_conn) = nm .add_and_activate_connection( connection_settings, @@ -155,18 +160,3 @@ pub(crate) async fn connect_bluetooth( log::info!("Successfully connected to Bluetooth device '{name}'"); Ok(()) } - -async fn find_bluetooth_device(conn: &Connection, nm: &NMProxy<'_>) -> Result { - let devices = nm.get_devices().await?; - - for dp in devices { - let dev = NMDeviceProxy::builder(conn) - .path(dp.clone())? - .build() - .await?; - if dev.device_type().await? == device_type::BLUETOOTH { - return Ok(dp); - } - } - Err(ConnectionError::NoBluetoothDevice) -} diff --git a/nmrs/src/lib.rs b/nmrs/src/lib.rs index 0461dca5..e80539df 100644 --- a/nmrs/src/lib.rs +++ b/nmrs/src/lib.rs @@ -7,7 +7,7 @@ //! //! ## WiFi Connection //! -//! ```no_run +//! ```rust //! use nmrs::{NetworkManager, WifiSecurity}; //! //! # async fn example() -> nmrs::Result<()> { @@ -34,7 +34,7 @@ //! //! ## VPN Connection (WireGuard) //! -//! ```no_run +//! ```rust //! use nmrs::{NetworkManager, VpnCredentials, VpnType, WireGuardPeer}; //! //! # async fn example() -> nmrs::Result<()> { @@ -107,7 +107,7 @@ //! //! ## Connecting to Different Network Types //! -//! ```no_run +//! ```rust //! use nmrs::{NetworkManager, WifiSecurity, EapOptions, EapMethod, Phase2}; //! //! # async fn example() -> nmrs::Result<()> { @@ -146,7 +146,7 @@ //! All operations return [`Result`], which is an alias for `Result`. //! The [`ConnectionError`] type provides specific variants for different failure modes: //! -//! ```no_run +//! ```rust //! use nmrs::{NetworkManager, WifiSecurity, ConnectionError}; //! //! # async fn example() -> nmrs::Result<()> { @@ -176,7 +176,7 @@ //! //! ## Device Management //! -//! ```no_run +//! ```rust //! use nmrs::NetworkManager; //! //! # async fn example() -> nmrs::Result<()> { @@ -203,7 +203,7 @@ //! //! Monitor network and device changes in real-time using D-Bus signals: //! -//! ```ignore +//! ```rust //! use nmrs::NetworkManager; //! //! # async fn example() -> nmrs::Result<()> { diff --git a/nmrs/src/monitoring/bluetooth.rs b/nmrs/src/monitoring/bluetooth.rs index 0fc0b9da..dc98fea7 100644 --- a/nmrs/src/monitoring/bluetooth.rs +++ b/nmrs/src/monitoring/bluetooth.rs @@ -32,7 +32,7 @@ impl ActiveTransport for Bluetooth { /// /// # Example /// -/// ```no_run +/// ```ignore /// use nmrs::monitoring::bluetooth::current_bluetooth_bdaddr; /// use zbus::Connection; /// From ef656d85a2e6e5ae0d2c909df193ac5762d0cc1f Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Sat, 20 Dec 2025 01:03:17 -0500 Subject: [PATCH 05/23] chore: revert breaking change to `Device` struct --- nmrs/examples/bluetooth.rs | 4 +--- nmrs/src/api/builders/mod.rs | 2 +- nmrs/src/api/models.rs | 4 ++-- nmrs/src/core/bluetooth.rs | 7 +++++-- nmrs/src/core/device.rs | 9 +++++---- 5 files changed, 14 insertions(+), 12 deletions(-) diff --git a/nmrs/examples/bluetooth.rs b/nmrs/examples/bluetooth.rs index 9dff8279..3d53a222 100644 --- a/nmrs/examples/bluetooth.rs +++ b/nmrs/examples/bluetooth.rs @@ -1,7 +1,5 @@ /// List Bluetooth devices using NetworkManager -use nmrs::{ - NetworkManager, Result, -}; +use nmrs::{NetworkManager, Result}; #[tokio::main] async fn main() -> Result<()> { diff --git a/nmrs/src/api/builders/mod.rs b/nmrs/src/api/builders/mod.rs index 5c83cf1e..d74f42c5 100644 --- a/nmrs/src/api/builders/mod.rs +++ b/nmrs/src/api/builders/mod.rs @@ -34,7 +34,7 @@ //! &WifiSecurity::WpaPsk { psk: "password".into() }, //! &opts //! ); -//! +//! //! // Build Ethernet connection settings //! let eth_settings = build_ethernet_connection("eth0", &opts); //! // Build WireGuard VPN connection settings diff --git a/nmrs/src/api/models.rs b/nmrs/src/api/models.rs index 6447add2..11868e81 100644 --- a/nmrs/src/api/models.rs +++ b/nmrs/src/api/models.rs @@ -424,8 +424,8 @@ pub struct Device { pub managed: Option, /// Kernel driver name pub driver: Option, - /// Link speed in Mb/s (wired devices) - pub speed: Option, + // Link speed in Mb/s (wired devices) + // pub speed: Option, } /// Represents the hardware identity of a network device. diff --git a/nmrs/src/core/bluetooth.rs b/nmrs/src/core/bluetooth.rs index 93728181..eb9f3711 100644 --- a/nmrs/src/core/bluetooth.rs +++ b/nmrs/src/core/bluetooth.rs @@ -12,7 +12,7 @@ use zvariant::OwnedObjectPath; use crate::builders::bluetooth; use crate::core::connection_settings::get_saved_connection_path; -use crate::dbus::{BluezDeviceExtProxy}; +use crate::dbus::BluezDeviceExtProxy; use crate::monitoring::bluetooth::Bluetooth; use crate::monitoring::transport::ActiveTransport; use crate::ConnectionError; @@ -143,7 +143,10 @@ pub(crate) async fn connect_bluetooth( let connection_settings = bluetooth::build_bluetooth_connection(name, settings, &opts); - println!("Creating Bluetooth connection with settings: {:#?}", connection_settings); + println!( + "Creating Bluetooth connection with settings: {:#?}", + connection_settings + ); let (_, active_conn) = nm .add_and_activate_connection( diff --git a/nmrs/src/core/device.rs b/nmrs/src/core/device.rs index 53ee86de..605980bd 100644 --- a/nmrs/src/core/device.rs +++ b/nmrs/src/core/device.rs @@ -10,7 +10,7 @@ use zbus::Connection; use crate::api::models::{BluetoothDevice, ConnectionError, Device, DeviceIdentity, DeviceState}; use crate::core::bluetooth::populate_bluez_info; use crate::core::state_wait::wait_for_wifi_device_ready; -use crate::dbus::{NMDeviceProxy, NMProxy, NMWiredProxy}; +use crate::dbus::{NMDeviceProxy, NMProxy}; use crate::types::constants::device_type; use crate::Result; @@ -74,8 +74,9 @@ pub(crate) async fn list_devices(conn: &Connection) -> Result> { } }; + // Avoiding this breaking change for now // Get link speed for wired devices - let speed = if raw_type == device_type::ETHERNET { + /* let speed = if raw_type == device_type::ETHERNET { async { let wired = NMWiredProxy::builder(conn).path(p.clone())?.build().await?; wired.speed().await @@ -84,7 +85,7 @@ pub(crate) async fn list_devices(conn: &Connection) -> Result> { .ok() } else { None - }; + };*/ devices.push(Device { path: p.to_string(), interface, @@ -96,7 +97,7 @@ pub(crate) async fn list_devices(conn: &Connection) -> Result> { state, managed, driver, - speed, + // speed, }); } Ok(devices) From 865fe48837d5f25e7865313d3e2a4e61d21c0689 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Sat, 20 Dec 2025 01:27:19 -0500 Subject: [PATCH 06/23] feat: unit + integration tests for Bluetooth device components This commit also marks various structs/enums as `non_exhaustive` so as to avoid breaking the public API --- README.md | 4 +- nmrs/src/api/builders/bluetooth.rs | 231 +++++++++++++++++++++++++++++ nmrs/src/api/models.rs | 137 ++++++++++++++++- nmrs/src/core/bluetooth.rs | 46 ++++++ nmrs/src/core/device.rs | 44 +++++- nmrs/src/dbus/bluetooth.rs | 65 +++++++- nmrs/src/monitoring/bluetooth.rs | 15 ++ nmrs/tests/integration_test.rs | 188 +++++++++++++++++++++++ 8 files changed, 719 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 063bb073..19e74591 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ Edit `~/.config/nmrs/style.css` to customize the interface. There are also pre-d - [ ] Any - [X] Wired - [ ] ADSL -- [ ] Bluetooth +- [X] Bluetooth - [ ] Bond - [ ] Bridge - [ ] Dummy @@ -202,7 +202,7 @@ Edit `~/.config/nmrs/style.css` to customize the interface. There are also pre-d - [ ] DNS Manager - [ ] PPP - [ ] Secret Agent -- [ ] VPN Connection +- [X] VPN Connection (WireGuard) - [ ] VPN Plugin - [ ] Wi-Fi P2P - [ ] WiMAX NSP diff --git a/nmrs/src/api/builders/bluetooth.rs b/nmrs/src/api/builders/bluetooth.rs index 943c0c7f..030b133e 100644 --- a/nmrs/src/api/builders/bluetooth.rs +++ b/nmrs/src/api/builders/bluetooth.rs @@ -85,3 +85,234 @@ pub fn build_bluetooth_connection( conn } + +#[cfg(test)] +mod tests { + use super::*; + + fn create_test_opts() -> ConnectionOptions { + ConnectionOptions { + autoconnect: true, + autoconnect_priority: Some(10), + autoconnect_retries: Some(3), + } + } + + fn create_test_identity_panu() -> BluetoothIdentity { + BluetoothIdentity { + bdaddr: "00:1A:7D:DA:71:13".into(), + bt_device_type: BluetoothNetworkRole::PanU, + } + } + + fn create_test_identity_dun() -> BluetoothIdentity { + BluetoothIdentity { + bdaddr: "C8:1F:E8:F0:51:57".into(), + bt_device_type: BluetoothNetworkRole::Dun, + } + } + + #[test] + fn test_base_connection_section() { + let opts = create_test_opts(); + let section = base_connection_section("TestBluetooth", &opts); + + // Check required fields + assert!(section.contains_key("type")); + assert!(section.contains_key("id")); + assert!(section.contains_key("uuid")); + assert!(section.contains_key("autoconnect")); + + // Verify values + if let Some(Value::Str(conn_type)) = section.get("type") { + assert_eq!(conn_type.as_str(), "bluetooth"); + } else { + panic!("type field not found or wrong type"); + } + + if let Some(Value::Str(id)) = section.get("id") { + assert_eq!(id.as_str(), "TestBluetooth"); + } else { + panic!("id field not found or wrong type"); + } + + if let Some(Value::Bool(autoconnect)) = section.get("autoconnect") { + assert!(*autoconnect, "{}", true); + } else { + panic!("autoconnect field not found or wrong type"); + } + + // Check optional fields + assert!(section.contains_key("autoconnect-priority")); + assert!(section.contains_key("autoconnect-retries")); + } + + #[test] + fn test_base_connection_section_without_optional_fields() { + let opts = ConnectionOptions { + autoconnect: false, + autoconnect_priority: None, + autoconnect_retries: None, + }; + let section = base_connection_section("MinimalBT", &opts); + + assert!(section.contains_key("type")); + assert!(section.contains_key("id")); + assert!(section.contains_key("uuid")); + assert!(section.contains_key("autoconnect")); + + // Optional fields should not be present + assert!(!section.contains_key("autoconnect-priority")); + assert!(!section.contains_key("autoconnect-retries")); + } + + #[test] + fn test_bluetooth_section_panu() { + let identity = create_test_identity_panu(); + let section = bluetooth_section(&identity); + + assert!(section.contains_key("bdaddr")); + assert!(section.contains_key("type")); + + if let Some(Value::Str(bdaddr)) = section.get("bdaddr") { + assert_eq!(bdaddr.as_str(), "00:1A:7D:DA:71:13"); + } else { + panic!("bdaddr field not found or wrong type"); + } + + if let Some(Value::Str(bt_type)) = section.get("type") { + assert_eq!(bt_type.as_str(), "panu"); + } else { + panic!("type field not found or wrong type"); + } + } + + #[test] + fn test_bluetooth_section_dun() { + let identity = create_test_identity_dun(); + let section = bluetooth_section(&identity); + + assert!(section.contains_key("bdaddr")); + assert!(section.contains_key("type")); + + if let Some(Value::Str(bdaddr)) = section.get("bdaddr") { + assert_eq!(bdaddr.as_str(), "C8:1F:E8:F0:51:57"); + } else { + panic!("bdaddr field not found or wrong type"); + } + + if let Some(Value::Str(bt_type)) = section.get("type") { + assert_eq!(bt_type.as_str(), "dun"); + } else { + panic!("type field not found or wrong type"); + } + } + + #[test] + fn test_build_bluetooth_connection_panu() { + let identity = create_test_identity_panu(); + let opts = create_test_opts(); + let conn = build_bluetooth_connection("MyPhone", &identity, &opts); + + // Check main sections + assert!(conn.contains_key("connection")); + assert!(conn.contains_key("bluetooth")); + assert!(conn.contains_key("ipv4")); + assert!(conn.contains_key("ipv6")); + + // Verify connection section + let connection_section = conn.get("connection").unwrap(); + if let Some(Value::Str(id)) = connection_section.get("id") { + assert_eq!(id.as_str(), "MyPhone"); + } + + // Verify bluetooth section + let bt_section = conn.get("bluetooth").unwrap(); + if let Some(Value::Str(bdaddr)) = bt_section.get("bdaddr") { + assert_eq!(bdaddr.as_str(), "00:1A:7D:DA:71:13"); + } + if let Some(Value::Str(bt_type)) = bt_section.get("type") { + assert_eq!(bt_type.as_str(), "panu"); + } + + // Verify IP sections + let ipv4_section = conn.get("ipv4").unwrap(); + if let Some(Value::Str(method)) = ipv4_section.get("method") { + assert_eq!(method.as_str(), "auto"); + } + + let ipv6_section = conn.get("ipv6").unwrap(); + if let Some(Value::Str(method)) = ipv6_section.get("method") { + assert_eq!(method.as_str(), "auto"); + } + } + + #[test] + fn test_build_bluetooth_connection_dun() { + let identity = create_test_identity_dun(); + let opts = ConnectionOptions { + autoconnect: false, + autoconnect_priority: None, + autoconnect_retries: None, + }; + let conn = build_bluetooth_connection("MobileHotspot", &identity, &opts); + + assert!(conn.contains_key("connection")); + assert!(conn.contains_key("bluetooth")); + assert!(conn.contains_key("ipv4")); + assert!(conn.contains_key("ipv6")); + + // Verify DUN type + let bt_section = conn.get("bluetooth").unwrap(); + if let Some(Value::Str(bt_type)) = bt_section.get("type") { + assert_eq!(bt_type.as_str(), "dun"); + } + } + + #[test] + fn test_uuid_is_unique() { + let identity = create_test_identity_panu(); + let opts = create_test_opts(); + + let conn1 = build_bluetooth_connection("BT1", &identity, &opts); + let conn2 = build_bluetooth_connection("BT2", &identity, &opts); + + let uuid1 = if let Some(section) = conn1.get("connection") { + if let Some(Value::Str(uuid)) = section.get("uuid") { + uuid.as_str() + } else { + panic!("uuid not found in conn1"); + } + } else { + panic!("connection section not found in conn1"); + }; + + let uuid2 = if let Some(section) = conn2.get("connection") { + if let Some(Value::Str(uuid)) = section.get("uuid") { + uuid.as_str() + } else { + panic!("uuid not found in conn2"); + } + } else { + panic!("connection section not found in conn2"); + }; + + // UUIDs should be different + assert_ne!(uuid1, uuid2, "UUIDs should be unique"); + } + + #[test] + fn test_bdaddr_format_preserved() { + let identity = BluetoothIdentity { + bdaddr: "AA:BB:CC:DD:EE:FF".into(), + bt_device_type: BluetoothNetworkRole::PanU, + }; + let opts = create_test_opts(); + let conn = build_bluetooth_connection("Test", &identity, &opts); + + let bt_section = conn.get("bluetooth").unwrap(); + if let Some(Value::Str(bdaddr)) = bt_section.get("bdaddr") { + assert_eq!(bdaddr.as_str(), "AA:BB:CC:DD:EE:FF"); + } + } +} diff --git a/nmrs/src/api/models.rs b/nmrs/src/api/models.rs index 11868e81..95655aa9 100644 --- a/nmrs/src/api/models.rs +++ b/nmrs/src/api/models.rs @@ -396,9 +396,8 @@ pub struct NetworkInfo { /// println!(" This is a WiFi device"); /// } else if device.is_wired() { /// println!(" This is an Ethernet device"); -/// if let Some(speed) = device.speed { -/// println!(" Link speed: {speed} Mb/s"); -/// } +/// } else if device.is_bluetooth() { +/// println!(" This is a Bluetooth device"); /// } /// /// if let Some(driver) = &device.driver { @@ -852,7 +851,13 @@ pub struct VpnConnectionInfo { /// Bluetooth network role. /// /// Specifies the role of the Bluetooth device in the network connection. -#[derive(Debug, Clone)] +/// +/// # Stability +/// +/// This enum is marked as `#[non_exhaustive]` so as to assume that new Bluetooth roles may be +/// added in future versions. When pattern matching, always include a wildcard arm. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum BluetoothNetworkRole { PanU, // Personal Area Network User Dun, // Dial-Up Networking @@ -1955,4 +1960,128 @@ mod tests { "connection activation failed: no secrets (password) provided" ); } + + #[test] + fn test_bluetooth_network_role_from_u32() { + assert_eq!(BluetoothNetworkRole::from(0), BluetoothNetworkRole::PanU); + assert_eq!(BluetoothNetworkRole::from(1), BluetoothNetworkRole::Dun); + // Unknown values default to PanU + assert_eq!(BluetoothNetworkRole::from(999), BluetoothNetworkRole::PanU); + } + + #[test] + fn test_bluetooth_network_role_display() { + assert_eq!(format!("{}", BluetoothNetworkRole::PanU), "PANU"); + assert_eq!(format!("{}", BluetoothNetworkRole::Dun), "DUN"); + } + + #[test] + fn test_bluetooth_identity_creation() { + let identity = BluetoothIdentity { + bdaddr: "00:1A:7D:DA:71:13".into(), + bt_device_type: BluetoothNetworkRole::PanU, + }; + + assert_eq!(identity.bdaddr, "00:1A:7D:DA:71:13"); + assert!(matches!( + identity.bt_device_type, + BluetoothNetworkRole::PanU + )); + } + + #[test] + fn test_bluetooth_identity_dun() { + let identity = BluetoothIdentity { + bdaddr: "C8:1F:E8:F0:51:57".into(), + bt_device_type: BluetoothNetworkRole::Dun, + }; + + assert_eq!(identity.bdaddr, "C8:1F:E8:F0:51:57"); + assert!(matches!(identity.bt_device_type, BluetoothNetworkRole::Dun)); + } + + #[test] + fn test_bluetooth_device_creation() { + let device = BluetoothDevice { + bdaddr: "00:1A:7D:DA:71:13".into(), + name: Some("MyPhone".into()), + alias: Some("Phone".into()), + bt_device_type: BluetoothNetworkRole::PanU, + state: DeviceState::Activated, + }; + + assert_eq!(device.bdaddr, "00:1A:7D:DA:71:13"); + assert_eq!(device.name, Some("MyPhone".into())); + assert_eq!(device.alias, Some("Phone".into())); + assert!(matches!(device.bt_device_type, BluetoothNetworkRole::PanU)); + assert_eq!(device.state, DeviceState::Activated); + } + + #[test] + fn test_bluetooth_device_display() { + let device = BluetoothDevice { + bdaddr: "00:1A:7D:DA:71:13".into(), + name: Some("MyPhone".into()), + alias: Some("Phone".into()), + bt_device_type: BluetoothNetworkRole::PanU, + state: DeviceState::Activated, + }; + + let display_str = format!("{}", device); + assert!(display_str.contains("Phone")); + assert!(display_str.contains("00:1A:7D:DA:71:13")); + assert!(display_str.contains("PANU")); + } + + #[test] + fn test_bluetooth_device_display_no_alias() { + let device = BluetoothDevice { + bdaddr: "00:1A:7D:DA:71:13".into(), + name: Some("MyPhone".into()), + alias: None, + bt_device_type: BluetoothNetworkRole::Dun, + state: DeviceState::Disconnected, + }; + + let display_str = format!("{}", device); + assert!(display_str.contains("unknown")); + assert!(display_str.contains("00:1A:7D:DA:71:13")); + assert!(display_str.contains("DUN")); + } + + #[test] + fn test_device_is_bluetooth() { + let bt_device = Device { + path: "/org/freedesktop/NetworkManager/Devices/1".into(), + interface: "bt0".into(), + identity: DeviceIdentity { + permanent_mac: "00:1A:7D:DA:71:13".into(), + current_mac: "00:1A:7D:DA:71:13".into(), + }, + device_type: DeviceType::Bluetooth, + state: DeviceState::Activated, + managed: Some(true), + driver: Some("btusb".into()), + }; + + assert!(bt_device.is_bluetooth()); + assert!(!bt_device.is_wireless()); + assert!(!bt_device.is_wired()); + } + + #[test] + fn test_device_type_bluetooth() { + assert_eq!(DeviceType::from(5), DeviceType::Bluetooth); + } + + #[test] + fn test_device_type_bluetooth_display() { + assert_eq!(format!("{}", DeviceType::Bluetooth), "Bluetooth"); + } + + #[test] + fn test_connection_error_no_bluetooth_device() { + let err = ConnectionError::NoBluetoothDevice; + assert_eq!(format!("{}", err), "Bluetooth device not found"); + } } diff --git a/nmrs/src/core/bluetooth.rs b/nmrs/src/core/bluetooth.rs index eb9f3711..6d2e71c3 100644 --- a/nmrs/src/core/bluetooth.rs +++ b/nmrs/src/core/bluetooth.rs @@ -163,3 +163,49 @@ pub(crate) async fn connect_bluetooth( log::info!("Successfully connected to Bluetooth device '{name}'"); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::BluetoothNetworkRole; + + #[test] + fn test_bluez_path_format() { + // Test that bdaddr format is converted correctly for D-Bus path + let bdaddr = "00:1A:7D:DA:71:13"; + let expected_path = "/org/bluez/hci0/dev_00_1A_7D_DA_71_13"; + let actual_path = format!("/org/bluez/hci0/dev_{}", bdaddr.replace(':', "_")); + assert_eq!(actual_path, expected_path); + } + + #[test] + fn test_bluez_path_format_various_addresses() { + let test_cases = vec![ + ("AA:BB:CC:DD:EE:FF", "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF"), + ("00:00:00:00:00:00", "/org/bluez/hci0/dev_00_00_00_00_00_00"), + ("C8:1F:E8:F0:51:57", "/org/bluez/hci0/dev_C8_1F_E8_F0_51_57"), + ]; + + for (bdaddr, expected_path) in test_cases { + let actual_path = format!("/org/bluez/hci0/dev_{}", bdaddr.replace(':', "_")); + assert_eq!(actual_path, expected_path, "Failed for bdaddr: {}", bdaddr); + } + } + + #[test] + fn test_bluetooth_identity_structure() { + let identity = BluetoothIdentity { + bdaddr: "00:1A:7D:DA:71:13".into(), + bt_device_type: BluetoothNetworkRole::PanU, + }; + + assert_eq!(identity.bdaddr, "00:1A:7D:DA:71:13"); + assert!(matches!( + identity.bt_device_type, + BluetoothNetworkRole::PanU + )); + } + + // Note: Most of the core connection functions require a real D-Bus connection + // and NetworkManager running, so they are better suited for integration tests. +} diff --git a/nmrs/src/core/device.rs b/nmrs/src/core/device.rs index 605980bd..ce36baa1 100644 --- a/nmrs/src/core/device.rs +++ b/nmrs/src/core/device.rs @@ -114,12 +114,18 @@ pub(crate) async fn list_bluetooth_devices(conn: &Connection) -> Result Result { let nm = NMProxy::new(conn).await?; Ok(nm.wireless_enabled().await?) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::BluetoothNetworkRole; + + #[test] + fn test_default_bluetooth_address() { + // Test that the default address used for devices without hardware address is valid + let default_addr = "00:00:00:00:00:00"; + assert_eq!(default_addr.len(), 17); + assert_eq!(default_addr.matches(':').count(), 5); + } + + #[test] + fn test_bluetooth_device_construction() { + let device = BluetoothDevice { + bdaddr: "00:1A:7D:DA:71:13".into(), + name: Some("TestDevice".into()), + alias: Some("Test".into()), + bt_device_type: BluetoothNetworkRole::PanU, + state: DeviceState::Activated, + }; + + assert_eq!(device.bdaddr, "00:1A:7D:DA:71:13"); + assert_eq!(device.name, Some("TestDevice".into())); + assert_eq!(device.alias, Some("Test".into())); + assert!(matches!(device.bt_device_type, BluetoothNetworkRole::PanU)); + assert_eq!(device.state, DeviceState::Activated); + } + + // Note: Most device listing functions require a real D-Bus connection + // and NetworkManager running, so they are better suited for integration tests. +} diff --git a/nmrs/src/dbus/bluetooth.rs b/nmrs/src/dbus/bluetooth.rs index ae205598..17ed3fd6 100644 --- a/nmrs/src/dbus/bluetooth.rs +++ b/nmrs/src/dbus/bluetooth.rs @@ -1,35 +1,94 @@ //! Bluetooth Device Proxy +//! +//! This module provides D-Bus proxy interfaces for interacting with Bluetooth +//! devices through NetworkManager and BlueZ. use zbus::proxy; use zbus::Result; /// Proxy for Bluetooth devices /// -/// Provides access to Bluetooth-specific properties and methods. +/// Provides access to Bluetooth-specific properties and methods through +/// NetworkManager's D-Bus interface. +/// +/// # Example +/// +/// ```ignore +/// use nmrs::dbus::NMBluetoothProxy; +/// use zbus::Connection; +/// +/// # async fn example() -> Result<(), Box> { +/// let conn = Connection::system().await?; +/// let proxy = NMBluetoothProxy::builder(&conn) +/// .path("/org/freedesktop/NetworkManager/Devices/1")? +/// .build() +/// .await?; +/// +/// let bdaddr = proxy.bd_address().await?; +/// println!("Bluetooth address: {}", bdaddr); +/// # Ok(()) +/// # } +/// ``` #[proxy( interface = "org.freedesktop.NetworkManager.Device.Bluetooth", default_service = "org.freedesktop.NetworkManager" )] pub trait NMBluetooth { /// Bluetooth MAC address of the device. + /// + /// Returns the BD_ADDR (Bluetooth Device Address) in the format + /// "XX:XX:XX:XX:XX:XX" where each XX is a hexadecimal value. #[zbus(property)] fn bd_address(&self) -> Result; /// Bluetooth capabilities of the device (either DUN or NAP). + /// + /// Returns a bitmask where: + /// - 0x01 = DUN (Dial-Up Networking) + /// - 0x02 = NAP (Network Access Point) + /// + /// A device may support multiple capabilities. #[zbus(property)] fn bt_capabilities(&self) -> Result; } /// Extension trait for Bluetooth device information via BlueZ. -/// Provides convenient methods to access Bluetooth-specific properties otherwise -/// not exposed by NetworkManager. +/// +/// Provides convenient methods to access Bluetooth-specific properties +/// that are otherwise not exposed by NetworkManager. This interfaces directly +/// with BlueZ, the Linux Bluetooth stack. +/// +/// # Example +/// +/// ```ignore +/// use nmrs::dbus::BluezDeviceExtProxy; +/// use zbus::Connection; +/// +/// # async fn example() -> Result<(), Box> { +/// let conn = Connection::system().await?; +/// let proxy = BluezDeviceExtProxy::builder(&conn) +/// .path("/org/bluez/hci0/dev_00_1A_7D_DA_71_13")? +/// .build() +/// .await?; +/// +/// let name = proxy.name().await?; +/// let alias = proxy.alias().await?; +/// println!("Device: {} ({})", alias, name); +/// # Ok(()) +/// # } +/// ``` #[proxy(interface = "org.bluez.Device1", default_service = "org.bluez")] pub trait BluezDeviceExt { /// Returns the name of the Bluetooth device. + /// + /// This is typically the manufacturer-assigned name of the device. #[zbus(property)] fn name(&self) -> Result; /// Returns the alias of the Bluetooth device. + /// + /// This is typically a user-friendly name that can be customized. + /// If no alias is set, this usually returns the same value as `name()`. #[zbus(property)] fn alias(&self) -> Result; } diff --git a/nmrs/src/monitoring/bluetooth.rs b/nmrs/src/monitoring/bluetooth.rs index dc98fea7..3fc250c1 100644 --- a/nmrs/src/monitoring/bluetooth.rs +++ b/nmrs/src/monitoring/bluetooth.rs @@ -127,3 +127,18 @@ pub(crate) async fn current_bluetooth_info(conn: &Connection) -> Option<(String, } None } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_bluetooth_struct_exists() { + // Verify the Bluetooth struct can be instantiated + let _bt = Bluetooth; + } + + // Most of the monitoring functions require a real D-Bus connection + // and NetworkManager running, so they are better suited for integration tests. + // We can add unit tests for helper functions if they are extracted. +} diff --git a/nmrs/tests/integration_test.rs b/nmrs/tests/integration_test.rs index 40be75bb..9a64dc7b 100644 --- a/nmrs/tests/integration_test.rs +++ b/nmrs/tests/integration_test.rs @@ -1031,3 +1031,191 @@ async fn test_vpn_credentials_structure() { assert_eq!(creds.dns.as_ref().unwrap().len(), 2); assert_eq!(creds.mtu, Some(1420)); } + +/// Check if Bluetooth is available +#[allow(dead_code)] +async fn has_bluetooth_device(nm: &NetworkManager) -> bool { + nm.list_bluetooth_devices() + .await + .map(|d| !d.is_empty()) + .unwrap_or(false) +} + +/// Skip tests if Bluetooth device is not available +#[allow(unused_macros)] +macro_rules! require_bluetooth { + ($nm:expr) => { + if !has_bluetooth_device($nm).await { + eprintln!("Skipping test: No Bluetooth device available"); + return; + } + }; +} + +/// Test listing Bluetooth devices +#[tokio::test] +async fn test_list_bluetooth_devices() { + require_networkmanager!(); + + let nm = NetworkManager::new() + .await + .expect("Failed to create NetworkManager"); + + let devices = nm + .list_bluetooth_devices() + .await + .expect("Failed to list Bluetooth devices"); + + // Verify device structure for Bluetooth devices + for device in &devices { + assert!( + !device.bdaddr.is_empty(), + "Bluetooth address should not be empty" + ); + eprintln!( + "Bluetooth device: {} ({}) - {}", + device.alias.as_deref().unwrap_or("unknown"), + device.bdaddr, + device.bt_device_type + ); + } +} + +/// Test Bluetooth device type enum +#[test] +fn test_bluetooth_network_role() { + use nmrs::models::BluetoothNetworkRole; + + let panu = BluetoothNetworkRole::PanU; + assert_eq!(format!("{}", panu), "PANU"); + + let dun = BluetoothNetworkRole::Dun; + assert_eq!(format!("{}", dun), "DUN"); +} + +/// Test BluetoothIdentity structure +#[test] +fn test_bluetooth_identity_structure() { + use nmrs::models::{BluetoothIdentity, BluetoothNetworkRole}; + + let identity = BluetoothIdentity { + bdaddr: "00:1A:7D:DA:71:13".into(), + bt_device_type: BluetoothNetworkRole::PanU, + }; + + assert_eq!(identity.bdaddr, "00:1A:7D:DA:71:13"); + assert!(matches!( + identity.bt_device_type, + BluetoothNetworkRole::PanU + )); +} + +/// Test BluetoothDevice structure +#[test] +fn test_bluetooth_device_structure() { + use nmrs::models::{BluetoothDevice, BluetoothNetworkRole}; + + let device = BluetoothDevice { + bdaddr: "00:1A:7D:DA:71:13".into(), + name: Some("MyPhone".into()), + alias: Some("Phone".into()), + bt_device_type: BluetoothNetworkRole::PanU, + state: DeviceState::Activated, + }; + + assert_eq!(device.bdaddr, "00:1A:7D:DA:71:13"); + assert_eq!(device.name, Some("MyPhone".into())); + assert_eq!(device.alias, Some("Phone".into())); + assert_eq!(device.state, DeviceState::Activated); +} + +/// Test BluetoothDevice display +#[test] +fn test_bluetooth_device_display() { + use nmrs::models::{BluetoothDevice, BluetoothNetworkRole}; + + let device = BluetoothDevice { + bdaddr: "00:1A:7D:DA:71:13".into(), + name: Some("MyPhone".into()), + alias: Some("Phone".into()), + bt_device_type: BluetoothNetworkRole::PanU, + state: DeviceState::Activated, + }; + + let display = format!("{}", device); + assert!(display.contains("Phone")); + assert!(display.contains("00:1A:7D:DA:71:13")); +} + +/// Test Device::is_bluetooth method +#[tokio::test] +async fn test_device_is_bluetooth() { + require_networkmanager!(); + + let nm = NetworkManager::new() + .await + .expect("Failed to create NetworkManager"); + + let devices = nm.list_devices().await.expect("Failed to list devices"); + + for device in &devices { + if device.is_bluetooth() { + assert_eq!(device.device_type, DeviceType::Bluetooth); + eprintln!("Found Bluetooth device: {}", device.interface); + } + } +} + +/// Test Bluetooth device in all devices list +#[tokio::test] +async fn test_bluetooth_in_device_types() { + require_networkmanager!(); + + let nm = NetworkManager::new() + .await + .expect("Failed to create NetworkManager"); + + let devices = nm.list_devices().await.expect("Failed to list devices"); + + // Check if any Bluetooth devices exist + let bluetooth_devices: Vec<_> = devices + .iter() + .filter(|d| matches!(d.device_type, DeviceType::Bluetooth)) + .collect(); + + if !bluetooth_devices.is_empty() { + eprintln!("Found {} Bluetooth device(s)", bluetooth_devices.len()); + for device in bluetooth_devices { + eprintln!(" - {}: {}", device.interface, device.state); + } + } else { + eprintln!("No Bluetooth devices found (this is OK)"); + } +} + +/// Test ConnectionError::NoBluetoothDevice +#[test] +fn test_connection_error_no_bluetooth_device() { + let err = ConnectionError::NoBluetoothDevice; + assert_eq!(format!("{}", err), "Bluetooth device not found"); +} + +/// Test BluetoothNetworkRole conversion from u32 +#[test] +fn test_bluetooth_network_role_from_u32() { + use nmrs::models::BluetoothNetworkRole; + + assert!(matches!( + BluetoothNetworkRole::from(0), + BluetoothNetworkRole::PanU + )); + assert!(matches!( + BluetoothNetworkRole::from(1), + BluetoothNetworkRole::Dun + )); + // Unknown values should default to PanU + assert!(matches!( + BluetoothNetworkRole::from(999), + BluetoothNetworkRole::PanU + )); +} From 4bc93206c0c3cef0714e0092d675f5f20ac6cc44 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Sat, 20 Dec 2025 18:47:43 -0500 Subject: [PATCH 07/23] chore: mark more enums `#[non_exhaustive]` --- nmrs/src/api/models.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/nmrs/src/api/models.rs b/nmrs/src/api/models.rs index 95655aa9..018a0659 100644 --- a/nmrs/src/api/models.rs +++ b/nmrs/src/api/models.rs @@ -7,6 +7,7 @@ use uuid::Uuid; /// /// These values represent the lifecycle states of an active connection /// as reported by the NM D-Bus API. +#[non_exhaustive] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ActiveConnectionState { /// Connection state is unknown. @@ -54,6 +55,7 @@ impl Display for ActiveConnectionState { /// These values indicate why an active connection transitioned to its /// current state. Use `ConnectionStateReason::from(code)` to convert /// from the raw u32 values returned by NetworkManager signals. +#[non_exhaustive] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ConnectionStateReason { /// The reason is unknown. @@ -166,6 +168,7 @@ pub fn connection_state_reason_to_error(code: u32) -> ConnectionError { /// These values come from the NM D-Bus API and indicate why a device /// transitioned to its current state. Use `StateReason::from(code)` to /// convert from the raw u32 values returned by NetworkManager. +#[non_exhaustive] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum StateReason { /// The reason is unknown. @@ -924,6 +927,7 @@ pub struct BluetoothDevice { /// Represents the type of network hardware managed by NetworkManager. /// This enum uses a registry-based system to support adding new device /// types without breaking the API. +#[non_exhaustive] #[derive(Debug, Clone, PartialEq)] pub enum DeviceType { /// Wired Ethernet device. @@ -1019,6 +1023,7 @@ impl DeviceType { /// NetworkManager device states. /// /// Represents the current operational state of a network device. +#[non_exhaustive] #[derive(Debug, Clone, PartialEq)] pub enum DeviceState { /// Device is not managed by NetworkManager. @@ -1156,6 +1161,7 @@ impl Display for BluetoothNetworkRole { /// # Ok(()) /// # } /// ``` +#[non_exhaustive] #[derive(Debug, Error)] pub enum ConnectionError { /// A D-Bus communication error occurred. From 41dbc3da550b6f1cdd24d6d2e16d71226293f719 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Mon, 22 Dec 2025 15:57:03 -0500 Subject: [PATCH 08/23] fix: proxy field name for `bd_addr` replaced with correct `hw_addr` - We now list bluetooth devices based on their capabilities, which requires us to proxy the Bluetooth interface of course - Refactored `forget()` to target more than just wifi devices, as with the case in bluetooth devices, we dont actually need all the fields wifi does - Changed the `BluetoothDevice` model to take `bt_caps` instead of the `bt_device_type` which is canonical with what NM expects from Bluetooth devices (since we can just grab the type whenever we want via `DeviceType` in `NMDeviceProxy`) - Other pedantic changes to tests, and docs due to changes above --- Cargo.lock | 104 +++++++++++++++++++- Cargo.toml | 1 + nmrs/Cargo.toml | 4 + nmrs/examples/bluetooth_connect.rs | 21 ++-- nmrs/src/api/models.rs | 19 ++-- nmrs/src/api/network_manager.rs | 22 ++++- nmrs/src/core/bluetooth.rs | 68 ++++++++++++- nmrs/src/core/connection.rs | 152 +++++++++++++++++++++-------- nmrs/src/core/device.rs | 24 +++-- nmrs/src/dbus/bluetooth.rs | 2 +- nmrs/src/monitoring/bluetooth.rs | 4 +- nmrs/tests/integration_test.rs | 16 ++- 12 files changed, 359 insertions(+), 78 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 82b2559b..4a5481ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "anstream" version = "0.6.21" @@ -386,6 +395,29 @@ dependencies = [ "syn", ] +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -868,6 +900,30 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "jiff" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49cce2b81f2098e7e3efc35bc2e0a6b7abec9d34128283d7a26fa8f32a6dbb35" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "980af8b43c3ad5d8d349ace167ec8170839f753a42d233ba19e08afe1850fa69" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "js-sys" version = "0.3.81" @@ -925,11 +981,9 @@ dependencies = [ name = "nmrs" version = "1.3.5" dependencies = [ -<<<<<<< HEAD "base64", -======= "async-trait", ->>>>>>> 26d6801 (feat: add Bluetooth device connection support) + "env_logger", "futures", "futures-timer", "log", @@ -1058,6 +1112,21 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "portable-atomic" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f59e70c4aef1e55797c2e8fd94a4f2a973fc972cfde0e0b05f683667b0cd39dd" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + [[package]] name = "proc-macro-crate" version = "3.4.0" @@ -1102,6 +1171,35 @@ dependencies = [ "thiserror", ] +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + [[package]] name = "rustc_version" version = "0.4.1" diff --git a/Cargo.toml b/Cargo.toml index 64e961bf..cb3bc92f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,5 +40,6 @@ dirs = "6.0.0" fs2 = "0.4.3" anyhow = "1.0.100" clap = { version = "4.5.53", features = ["derive"] } +async-trait= = "*" tokio = { version = "1.48.0", features = ["rt-multi-thread", "macros", "sync", "time"] } tokio-util = { version = "0.7.18" } diff --git a/nmrs/Cargo.toml b/nmrs/Cargo.toml index 0dea0705..af0a98d0 100644 --- a/nmrs/Cargo.toml +++ b/nmrs/Cargo.toml @@ -22,8 +22,12 @@ uuid.workspace = true futures.workspace = true futures-timer.workspace = true base64.workspace = true +<<<<<<< HEAD tokio.workspace = true async-trait = "0.1.89" +======= +async-trait.workspace = true +>>>>>>> 0884154 (fix: proxy field name for `bd_addr` replaced with correct `hw_addr`) [dev-dependencies] tokio.workspace = true diff --git a/nmrs/examples/bluetooth_connect.rs b/nmrs/examples/bluetooth_connect.rs index 92b56eca..c07eb541 100644 --- a/nmrs/examples/bluetooth_connect.rs +++ b/nmrs/examples/bluetooth_connect.rs @@ -1,7 +1,6 @@ /// Connect to a Bluetooth device using NetworkManager. use nmrs::models::BluetoothIdentity; use nmrs::{NetworkManager, Result}; - #[tokio::main] async fn main() -> Result<()> { let nm = NetworkManager::new().await?; @@ -17,18 +16,20 @@ async fn main() -> Result<()> { return Ok(()); } + // This will print all devices that have been explicitly paired using + // `bluetoothctl pair ` println!("\nAvailable Bluetooth devices:"); for (i, device) in devices.iter().enumerate() { println!(" {}. {}", i + 1, device); } - // Example: Connect to the fourth device - if let Some(device) = devices.get(3) { + // Connect to the first device in the list + if let Some(device) = devices.first() { println!("\nConnecting to: {}", device); let settings = BluetoothIdentity { bdaddr: device.bdaddr.clone(), - bt_device_type: device.bt_device_type.clone(), + bt_device_type: device.bt_caps.into(), }; let name = device @@ -39,9 +40,17 @@ async fn main() -> Result<()> { .unwrap_or("Bluetooth Device"); match nm.connect_bluetooth(name, &settings).await { - Ok(_) => println!("✓ Successfully connected to {}", name), - Err(e) => eprintln!("✗ Failed to connect: {}", e), + Ok(_) => println!("✓ Successfully connected to {name}"), + Err(e) => { + eprintln!("✗ Failed to connect: {}", e); + return Ok(()); + } } + + /* match nm.forget_bluetooth(name).await { + Ok(_) => println!("Disconnected {name}"), + Err(e) => eprintln!("Failed to forget: {e}"), + }*/ } Ok(()) diff --git a/nmrs/src/api/models.rs b/nmrs/src/api/models.rs index 018a0659..b1129609 100644 --- a/nmrs/src/api/models.rs +++ b/nmrs/src/api/models.rs @@ -900,11 +900,12 @@ pub struct BluetoothIdentity { /// ```rust /// use nmrs::models::{BluetoothDevice, BluetoothNetworkRole, DeviceState}; /// +/// let role = BluetoothNetworkRole::PanU as u32; /// let bt_device = BluetoothDevice { /// bdaddr: "00:1A:7D:DA:71:13".into(), /// name: Some("Foo".into()), /// alias: Some("Bar".into()), -/// bt_device_type: BluetoothNetworkRole::PanU, +/// bt_caps: role, /// state: DeviceState::Activated, /// }; /// ``` @@ -917,7 +918,7 @@ pub struct BluetoothDevice { /// Device alias from BlueZ pub alias: Option, /// Bluetooth device type (DUN or PANU) - pub bt_device_type: BluetoothNetworkRole, + pub bt_caps: u32, /// Current device state pub state: DeviceState, } @@ -1081,11 +1082,12 @@ impl Display for Device { /// Formats the device information as "alias (device_type) [bdaddr]". impl Display for BluetoothDevice { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let role = BluetoothNetworkRole::from(self.bt_caps); write!( f, "{} ({}) [{}]", self.alias.as_deref().unwrap_or("unknown"), - self.bt_device_type, + role, self.bdaddr ) } @@ -2008,28 +2010,30 @@ mod tests { #[test] fn test_bluetooth_device_creation() { + let role = BluetoothNetworkRole::PanU as u32; let device = BluetoothDevice { bdaddr: "00:1A:7D:DA:71:13".into(), name: Some("MyPhone".into()), alias: Some("Phone".into()), - bt_device_type: BluetoothNetworkRole::PanU, + bt_caps: role, state: DeviceState::Activated, }; assert_eq!(device.bdaddr, "00:1A:7D:DA:71:13"); assert_eq!(device.name, Some("MyPhone".into())); assert_eq!(device.alias, Some("Phone".into())); - assert!(matches!(device.bt_device_type, BluetoothNetworkRole::PanU)); + assert!(matches!(device.bt_caps, _role)); assert_eq!(device.state, DeviceState::Activated); } #[test] fn test_bluetooth_device_display() { + let role = BluetoothNetworkRole::PanU as u32; let device = BluetoothDevice { bdaddr: "00:1A:7D:DA:71:13".into(), name: Some("MyPhone".into()), alias: Some("Phone".into()), - bt_device_type: BluetoothNetworkRole::PanU, + bt_caps: role, state: DeviceState::Activated, }; @@ -2041,11 +2045,12 @@ mod tests { #[test] fn test_bluetooth_device_display_no_alias() { + let role = BluetoothNetworkRole::Dun as u32; let device = BluetoothDevice { bdaddr: "00:1A:7D:DA:71:13".into(), name: Some("MyPhone".into()), alias: None, - bt_device_type: BluetoothNetworkRole::Dun, + bt_caps: role, state: DeviceState::Disconnected, }; diff --git a/nmrs/src/api/network_manager.rs b/nmrs/src/api/network_manager.rs index 5a362c81..954a2b3a 100644 --- a/nmrs/src/api/network_manager.rs +++ b/nmrs/src/api/network_manager.rs @@ -11,7 +11,7 @@ use crate::core::connection_settings::{ use crate::core::device::{list_devices, set_wifi_enabled, wait_for_wifi_ready, wifi_enabled}; use crate::core::scan::{current_network, list_networks, scan_networks}; use crate::core::bluetooth::connect_bluetooth; -use crate::core::connection::{connect, connect_wired, forget}; +use crate::core::connection::{connect, connect_wired, forget_by_name_and_type}; use crate::core::connection_settings::{get_saved_connection_path, has_saved_connection}; use crate::core::device::{ list_bluetooth_devices, list_devices, set_wifi_enabled, wait_for_wifi_ready, wifi_enabled, @@ -25,6 +25,7 @@ use crate::monitoring::device as device_monitor; use crate::monitoring::info::show_details; use crate::monitoring::network as network_monitor; use crate::monitoring::wifi::{current_connection_info, current_ssid}; +use crate::types::constants::device_type; use crate::Result; /// High-level interface to NetworkManager over D-Bus. @@ -492,7 +493,24 @@ impl NetworkManager { /// Returns `Ok(())` if at least one connection was deleted successfully. /// Returns `NoSavedConnection` if no matching connections were found. pub async fn forget(&self, ssid: &str) -> Result<()> { - forget(&self.conn, ssid).await + forget_by_name_and_type(&self.conn, ssid, Some(device_type::WIFI)).await + } + + /// Forgets (deletes) a saved Bluetooth connection. + /// + /// If currently connected to this device, it will disconnect first before + /// deleting the connection profile. Can match by connection name or bdaddr. + /// + /// # Arguments + /// + /// * `name` - Connection name or bdaddr to forget + /// + /// # Returns + /// + /// Returns `Ok(())` if the connection was deleted successfully. + /// Returns `NoSavedConnection` if no matching connection was found. + pub async fn forget_bluetooth(&self, name: &str) -> Result<()> { + forget_by_name_and_type(&self.conn, name, Some(device_type::BLUETOOTH)).await } /// /// Subscribes to D-Bus signals for access point additions and removals diff --git a/nmrs/src/core/bluetooth.rs b/nmrs/src/core/bluetooth.rs index 6d2e71c3..515d3ae0 100644 --- a/nmrs/src/core/bluetooth.rs +++ b/nmrs/src/core/bluetooth.rs @@ -9,12 +9,16 @@ use log::debug; use zbus::Connection; use zvariant::OwnedObjectPath; +// use futures_timer::Delay; use crate::builders::bluetooth; use crate::core::connection_settings::get_saved_connection_path; -use crate::dbus::BluezDeviceExtProxy; +use crate::core::state_wait::{wait_for_connection_activation, wait_for_device_disconnect}; +use crate::dbus::{BluezDeviceExtProxy, NMDeviceProxy}; use crate::monitoring::bluetooth::Bluetooth; use crate::monitoring::transport::ActiveTransport; +use crate::types::constants::device_state; +use crate::types::constants::device_type; use crate::ConnectionError; use crate::{dbus::NMProxy, models::BluetoothIdentity, Result}; @@ -50,6 +54,24 @@ pub(crate) async fn populate_bluez_info( } } +pub(crate) async fn find_bluetooth_device( + conn: &Connection, + nm: &NMProxy<'_>, +) -> Result { + let devices = nm.get_devices().await?; + + for dp in devices { + let dev = NMDeviceProxy::builder(conn) + .path(dp.clone())? + .build() + .await?; + if dev.device_type().await? == device_type::BLUETOOTH { + return Ok(dp); + } + } + Err(ConnectionError::NoBluetoothDevice) +} + /// Connects to a Bluetooth device using NetworkManager. /// /// This function establishes a Bluetooth network connection. The flow: @@ -105,8 +127,7 @@ pub(crate) async fn connect_bluetooth( // Find the Bluetooth hardware adapter // Note: Unlike WiFi, Bluetooth connections in NetworkManager don't require // specifying a specific device. We use "/" to let NetworkManager auto-select. - let bt_device = OwnedObjectPath::try_from("/") - .map_err(|e| ConnectionError::InvalidAddress(format!("Invalid device path: {}", e)))?; + let bt_device = find_bluetooth_device(conn, &nm).await?; debug!("Using auto-select device path for Bluetooth connection"); // Check for saved connection @@ -156,7 +177,7 @@ pub(crate) async fn connect_bluetooth( ) .await?; - crate::core::state_wait::wait_for_connection_activation(conn, &active_conn).await?; + wait_for_connection_activation(conn, &active_conn).await?; } } @@ -164,6 +185,45 @@ pub(crate) async fn connect_bluetooth( Ok(()) } +/// Disconnects a Bluetooth device and waits for it to reach disconnected state. +/// +/// Calls the Disconnect method on the device and waits for the `StateChanged` +/// signal to indicate the device has reached Disconnected or Unavailable state. +pub(crate) async fn disconnect_bluetooth_and_wait( + conn: &Connection, + dev_path: &OwnedObjectPath, +) -> Result<()> { + let dev = NMDeviceProxy::builder(conn) + .path(dev_path.clone())? + .build() + .await?; + + // Check if already disconnected + let current_state = dev.state().await?; + if current_state == device_state::DISCONNECTED || current_state == device_state::UNAVAILABLE { + debug!("Bluetooth device already disconnected"); + return Ok(()); + } + + let raw: zbus::proxy::Proxy = zbus::proxy::Builder::new(conn) + .destination("org.freedesktop.NetworkManager")? + .path(dev_path.clone())? + .interface("org.freedesktop.NetworkManager.Device")? + .build() + .await?; + + debug!("Sending disconnect request to Bluetooth device"); + let _ = raw.call_method("Disconnect", &()).await; + + // Wait for disconnect using signal-based monitoring + wait_for_device_disconnect(&dev).await?; + + // Brief stabilization delay + // Delay::new(timeouts::stabilization_delay()).await; + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/nmrs/src/core/connection.rs b/nmrs/src/core/connection.rs index ec8f061c..839c4d4b 100644 --- a/nmrs/src/core/connection.rs +++ b/nmrs/src/core/connection.rs @@ -164,69 +164,120 @@ pub(crate) async fn connect_wired(conn: &Connection) -> Result<()> { Ok(()) } -/// Forgets (deletes) all saved connections for a network. +/// Generic function to forget (delete) connections by name and optionally by device type. /// -/// If currently connected to this network, disconnects first, then deletes -/// all saved connection profiles matching the SSID. Matches are found by -/// both the connection ID and the wireless SSID bytes. +/// This handles disconnection if currently active, then deletes the connection profile(s). +/// Can be used for WiFi, Bluetooth, or any NetworkManager connection type. /// +/// # Arguments +/// +/// * `conn` - D-Bus connection +/// * `name` - Connection name/identifier to forget +/// * `device_filter` - Optional device type filter (e.g., `Some(device_type::BLUETOOTH)`) +/// +/// # Returns +/// +/// Returns `Ok(())` if at least one connection was deleted successfully. /// Returns `NoSavedConnection` if no matching connections were found. -pub(crate) async fn forget(conn: &Connection, ssid: &str) -> Result<()> { +pub(crate) async fn forget_by_name_and_type( + conn: &Connection, + name: &str, + device_filter: Option, +) -> Result<()> { use std::collections::HashMap; use zvariant::{OwnedObjectPath, Value}; // Validate SSID validate_ssid(ssid)?; - debug!("Starting forget operation for: {ssid}"); + debug!( + "Starting forget operation for: {name} (device filter: {:?})", + device_filter + ); let nm = NMProxy::new(conn).await?; + // Disconnect if currently active let devices = nm.get_devices().await?; for dev_path in &devices { let dev = NMDeviceProxy::builder(conn) .path(dev_path.clone())? .build() .await?; - if dev.device_type().await? != device_type::WIFI { - continue; + + let dev_type = dev.device_type().await?; + + // Skip if device type doesn't match our filter + if let Some(filter) = device_filter { + if dev_type != filter { + continue; + } } - let wifi = NMWirelessProxy::builder(conn) - .path(dev_path.clone())? - .build() - .await?; - 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}" - ))); + // Handle WiFi-specific disconnect logic + if dev_type == device_type::WIFI { + let wifi = NMWirelessProxy::builder(conn) + .path(dev_path.clone())? + .build() + .await?; + 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) == name { + debug!("Disconnecting from active WiFi network: {name}"); + 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!("Device confirmed disconnected, proceeding with deletion"); + debug!("WiFi disconnect phase completed"); } - debug!("Disconnect phase completed"); } } } } + // Handle Bluetooth-specific disconnect logic + else if dev_type == device_type::BLUETOOTH { + // Check if this Bluetooth device is currently active + let state = dev.state().await?; + if state != device_state::DISCONNECTED && state != device_state::UNAVAILABLE { + debug!("Disconnecting from active Bluetooth device: {name}"); + if let Err(e) = + crate::core::bluetooth::disconnect_bluetooth_and_wait(conn, dev_path).await + { + warn!("Bluetooth disconnect failed: {e}"); + let final_state = dev.state().await?; + if final_state != device_state::DISCONNECTED + && final_state != device_state::UNAVAILABLE + { + error!( + "Bluetooth device still connected (state: {final_state}), cannot safely delete" + ); + return Err(ConnectionError::Stuck(format!( + "disconnect failed, device in state {final_state}" + ))); + } + } + debug!("Bluetooth disconnect phase completed"); + } + } } + // Delete connection profiles (generic, works for all types) debug!("Starting connection deletion phase..."); let settings = nm_proxy( @@ -255,15 +306,17 @@ pub(crate) async fn forget(conn: &Connection, ssid: &str) -> Result<()> { let mut should_delete = false; + // Match by connection ID (works for all connection types) if let Some(conn_sec) = settings_map.get("connection") { if let Some(Value::Str(id)) = conn_sec.get("id") { - if id.as_str() == ssid { + if id.as_str() == name { should_delete = true; debug!("Found connection by ID: {id}"); } } } + // Additional WiFi-specific matching by SSID 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(); @@ -272,9 +325,19 @@ pub(crate) async fn forget(conn: &Connection, ssid: &str) -> Result<()> { raw.push(b); } } - if decode_ssid_or_empty(&raw) == ssid { + if decode_ssid_or_empty(&raw) == name { should_delete = true; - debug!("Found connection by SSID match"); + debug!("Found WiFi connection by SSID match"); + } + } + } + + // Matching by bdaddr for Bluetooth connections + if let Some(bt_sec) = settings_map.get("bluetooth") { + if let Some(Value::Str(bdaddr)) = bt_sec.get("bdaddr") { + if bdaddr.as_str() == name { + should_delete = true; + debug!("Found Bluetooth connection by bdaddr match"); } } } @@ -303,11 +366,18 @@ pub(crate) async fn forget(conn: &Connection, ssid: &str) -> Result<()> { } if deleted_count > 0 { - info!("Successfully deleted {deleted_count} connection(s) for '{ssid}'"); + info!("Successfully deleted {deleted_count} connection(s) for '{name}'"); Ok(()) } else { - debug!("No saved connections found for '{ssid}'"); - Err(ConnectionError::NoSavedConnection) + debug!("No saved connections found for '{name}'"); + + // For Bluetooth, it's normal to have no NetworkManager connection profile if the device is only paired in BlueZ. + if device_filter == Some(device_type::BLUETOOTH) { + debug!("Bluetooth device '{name}' has no NetworkManager connection profile (device may only be paired in BlueZ)"); + Ok(()) + } else { + Err(ConnectionError::NoSavedConnection) + } } } diff --git a/nmrs/src/core/device.rs b/nmrs/src/core/device.rs index ce36baa1..12c942e8 100644 --- a/nmrs/src/core/device.rs +++ b/nmrs/src/core/device.rs @@ -10,7 +10,7 @@ use zbus::Connection; use crate::api::models::{BluetoothDevice, ConnectionError, Device, DeviceIdentity, DeviceState}; use crate::core::bluetooth::populate_bluez_info; use crate::core::state_wait::wait_for_wifi_device_ready; -use crate::dbus::{NMDeviceProxy, NMProxy}; +use crate::dbus::{NMBluetoothProxy, NMDeviceProxy, NMProxy}; use crate::types::constants::device_type; use crate::Result; @@ -109,23 +109,28 @@ pub(crate) async fn list_bluetooth_devices(conn: &Connection) -> Result Result Result; + fn hw_address(&self) -> Result; /// Bluetooth capabilities of the device (either DUN or NAP). /// diff --git a/nmrs/src/monitoring/bluetooth.rs b/nmrs/src/monitoring/bluetooth.rs index 3fc250c1..42f72156 100644 --- a/nmrs/src/monitoring/bluetooth.rs +++ b/nmrs/src/monitoring/bluetooth.rs @@ -76,7 +76,7 @@ pub(crate) async fn current_bluetooth_bdaddr(conn: &Connection) -> Option Option<(String, ); let bt = try_log!(bt_builder.build().await, "Failed to build Bluetooth proxy"); - if let (Ok(bdaddr), Ok(capabilities)) = (bt.bd_address().await, bt.bt_capabilities().await) + if let (Ok(bdaddr), Ok(capabilities)) = (bt.hw_address().await, bt.bt_capabilities().await) { return Some((bdaddr, capabilities)); } diff --git a/nmrs/tests/integration_test.rs b/nmrs/tests/integration_test.rs index 9a64dc7b..cc15cb94 100644 --- a/nmrs/tests/integration_test.rs +++ b/nmrs/tests/integration_test.rs @@ -560,6 +560,7 @@ async fn test_device_states() { // Verify that all devices have valid states for device in &devices { // DeviceState should be one of the known states + // The struct is non-exhaustive and so we allow Other(_) match device.state { DeviceState::Unmanaged | DeviceState::Unavailable @@ -572,6 +573,9 @@ async fn test_device_states() { | DeviceState::Other(_) => { // Valid state } + _ => { + panic!("Invalid device state: {:?}", device.state); + } } } } @@ -589,6 +593,7 @@ async fn test_device_types() { // Verify that all devices have valid types for device in &devices { // DeviceType should be one of the known types + // The struct is non-exhaustive and so we allow Other(_) match device.device_type { DeviceType::Ethernet | DeviceType::Wifi @@ -598,6 +603,9 @@ async fn test_device_types() { | DeviceType::Other(_) => { // Valid type } + _ => { + panic!("Invalid device type: {:?}", device.device_type); + } } } } @@ -1076,7 +1084,7 @@ async fn test_list_bluetooth_devices() { "Bluetooth device: {} ({}) - {}", device.alias.as_deref().unwrap_or("unknown"), device.bdaddr, - device.bt_device_type + device.bt_caps ); } } @@ -1115,11 +1123,12 @@ fn test_bluetooth_identity_structure() { fn test_bluetooth_device_structure() { use nmrs::models::{BluetoothDevice, BluetoothNetworkRole}; + let role = BluetoothNetworkRole::PanU as u32; let device = BluetoothDevice { bdaddr: "00:1A:7D:DA:71:13".into(), name: Some("MyPhone".into()), alias: Some("Phone".into()), - bt_device_type: BluetoothNetworkRole::PanU, + bt_caps: role, state: DeviceState::Activated, }; @@ -1134,11 +1143,12 @@ fn test_bluetooth_device_structure() { fn test_bluetooth_device_display() { use nmrs::models::{BluetoothDevice, BluetoothNetworkRole}; + let role = BluetoothNetworkRole::PanU as u32; let device = BluetoothDevice { bdaddr: "00:1A:7D:DA:71:13".into(), name: Some("MyPhone".into()), alias: Some("Phone".into()), - bt_device_type: BluetoothNetworkRole::PanU, + bt_caps: role, state: DeviceState::Activated, }; From 0d8a4b295b2c6945fa8cfeec3b00119775bbf2d5 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Mon, 22 Dec 2025 15:58:56 -0500 Subject: [PATCH 09/23] chore: bump cargo version to `2.0.0-dev` --- Cargo.lock | 103 +---------------------------------------------------- 1 file changed, 1 insertion(+), 102 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4a5481ca..e610335c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,15 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - [[package]] name = "anstream" version = "0.6.21" @@ -395,29 +386,6 @@ dependencies = [ "syn", ] -[[package]] -name = "env_filter" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" -dependencies = [ - "log", - "regex", -] - -[[package]] -name = "env_logger" -version = "0.11.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" -dependencies = [ - "anstream", - "anstyle", - "env_filter", - "jiff", - "log", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -900,30 +868,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" -[[package]] -name = "jiff" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49cce2b81f2098e7e3efc35bc2e0a6b7abec9d34128283d7a26fa8f32a6dbb35" -dependencies = [ - "jiff-static", - "log", - "portable-atomic", - "portable-atomic-util", - "serde_core", -] - -[[package]] -name = "jiff-static" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "980af8b43c3ad5d8d349ace167ec8170839f753a42d233ba19e08afe1850fa69" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "js-sys" version = "0.3.81" @@ -979,11 +923,10 @@ dependencies = [ [[package]] name = "nmrs" -version = "1.3.5" +version = "2.0.0-dev" dependencies = [ "base64", "async-trait", - "env_logger", "futures", "futures-timer", "log", @@ -1112,21 +1055,6 @@ dependencies = [ "windows-sys", ] -[[package]] -name = "portable-atomic" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f59e70c4aef1e55797c2e8fd94a4f2a973fc972cfde0e0b05f683667b0cd39dd" - -[[package]] -name = "portable-atomic-util" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" -dependencies = [ - "portable-atomic", -] - [[package]] name = "proc-macro-crate" version = "3.4.0" @@ -1171,35 +1099,6 @@ dependencies = [ "thiserror", ] -[[package]] -name = "regex" -version = "1.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" - [[package]] name = "rustc_version" version = "0.4.1" From 02db432d5491ab865b5c51abb4785c20fe9e6dda Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Mon, 22 Dec 2025 16:21:00 -0500 Subject: [PATCH 10/23] docs: update example for `connect_bluetooth()` and bump version --- nmrs/Cargo.toml | 2 +- nmrs/src/api/network_manager.rs | 19 ++++++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/nmrs/Cargo.toml b/nmrs/Cargo.toml index af0a98d0..9a741bff 100644 --- a/nmrs/Cargo.toml +++ b/nmrs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nmrs" -version = "1.3.5" +version = "2.0.0-dev" authors = ["Akrm Al-Hakimi "] edition.workspace = true rust-version = "1.78.0" diff --git a/nmrs/src/api/network_manager.rs b/nmrs/src/api/network_manager.rs index 954a2b3a..ee90b2fe 100644 --- a/nmrs/src/api/network_manager.rs +++ b/nmrs/src/api/network_manager.rs @@ -183,6 +183,23 @@ impl NetworkManager { /// Connects to a bluetooth device using the provided identity. /// /// # Example + /// + /// ```no_run + /// use nmrs::{NetworkManager, models::BluetoothIdentity, models::BluetoothNetworkRole}; + /// + /// # async fn example() -> nmrs::Result<()> { + /// let nm = NetworkManager::new().await?; + /// + /// let identity = BluetoothIdentity { + /// bdaddr: "C8:1F:E8:F0:51:57".into(), + /// bt_device_type: BluetoothNetworkRole::PanU, + /// }; + /// + /// nm.connect_bluetooth("My Phone", &identity).await?; + /// Ok(()) + /// } + /// + /// ``` pub async fn connect_bluetooth(&self, name: &str, identity: &BluetoothIdentity) -> Result<()> { connect_bluetooth(&self.conn, name, identity).await } @@ -196,7 +213,7 @@ impl NetworkManager { /// /// # Example /// - /// ```no_run + /// ```rust /// use nmrs::{NetworkManager, VpnCredentials, VpnType, WireGuardPeer}; /// /// # async fn example() -> nmrs::Result<()> { From f7c8dbfd11b04ffd2e72650c552704291c1879ee Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Mon, 12 Jan 2026 12:11:48 -0500 Subject: [PATCH 11/23] chore: update imports and dead code from rebase --- nmrs/src/api/builders/mod.rs | 2 +- nmrs/src/monitoring/info.rs | 6 ++++-- nmrs/src/monitoring/wifi.rs | 4 ++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/nmrs/src/api/builders/mod.rs b/nmrs/src/api/builders/mod.rs index d74f42c5..6588c4ef 100644 --- a/nmrs/src/api/builders/mod.rs +++ b/nmrs/src/api/builders/mod.rs @@ -68,8 +68,8 @@ //! These settings can then be passed to NetworkManager's //! `AddConnection` or `AddAndActivateConnection` D-Bus methods. -pub mod connection_builder; pub mod bluetooth; +pub mod connection_builder; pub mod vpn; pub mod wifi; pub mod wifi_builder; diff --git a/nmrs/src/monitoring/info.rs b/nmrs/src/monitoring/info.rs index 09aa54ff..56bfc8f7 100644 --- a/nmrs/src/monitoring/info.rs +++ b/nmrs/src/monitoring/info.rs @@ -7,8 +7,9 @@ use log::debug; use zbus::Connection; use crate::api::models::{ConnectionError, Network, NetworkInfo}; -use crate::monitoring::wifi::current_ssid; -use crate::types::constants::{rate, security_flags}; +use crate::dbus::{NMAccessPointProxy, NMDeviceProxy, NMProxy, NMWirelessProxy}; +use crate::try_log; +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, @@ -181,6 +182,7 @@ pub(crate) async fn current_ssid(conn: &Connection) -> Option { /// /// Similar to `current_ssid` but also returns the operating frequency /// in MHz, useful for determining if connected to 2.4GHz or 5GHz band. +#[allow(dead_code)] pub(crate) async fn current_connection_info(conn: &Connection) -> Option<(String, Option)> { let nm = try_log!(NMProxy::new(conn).await, "Failed to create NM proxy"); let devices = try_log!(nm.get_devices().await, "Failed to get devices"); diff --git a/nmrs/src/monitoring/wifi.rs b/nmrs/src/monitoring/wifi.rs index abf7b838..47225389 100644 --- a/nmrs/src/monitoring/wifi.rs +++ b/nmrs/src/monitoring/wifi.rs @@ -64,7 +64,7 @@ pub(crate) async fn current_ssid(conn: &Connection) -> Option { ); let ssid_bytes = try_log!(ap.ssid().await, "Failed to get SSID bytes"); let ssid = decode_ssid_or_empty(&ssid_bytes); - return Some(ssid); + return Some(ssid.to_string()); } } } @@ -110,7 +110,7 @@ pub(crate) async fn current_connection_info(conn: &Connection) -> Option<(String 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)); + return Some((ssid.to_string(), frequency)); } } } From 0ea382d9fd2d73a5acee68a006e5b2da7fc1c2fe Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Sun, 18 Jan 2026 00:24:50 -0500 Subject: [PATCH 12/23] chore: cleanup conflict residue --- Cargo.lock | 2 +- Cargo.toml | 2 +- nmrs/src/api/models.rs | 6 +++++- nmrs/src/core/connection.rs | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e610335c..4a45aa68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -925,8 +925,8 @@ dependencies = [ name = "nmrs" version = "2.0.0-dev" dependencies = [ - "base64", "async-trait", + "base64", "futures", "futures-timer", "log", diff --git a/Cargo.toml b/Cargo.toml index cb3bc92f..673216fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,6 @@ dirs = "6.0.0" fs2 = "0.4.3" anyhow = "1.0.100" clap = { version = "4.5.53", features = ["derive"] } -async-trait= = "*" +async-trait = "0.1.89" tokio = { version = "1.48.0", features = ["rt-multi-thread", "macros", "sync", "time"] } tokio-util = { version = "0.7.18" } diff --git a/nmrs/src/api/models.rs b/nmrs/src/api/models.rs index b1129609..87bdf4fc 100644 --- a/nmrs/src/api/models.rs +++ b/nmrs/src/api/models.rs @@ -1444,7 +1444,11 @@ impl Display for DeviceType { DeviceType::WifiP2P => write!(f, "Wi-Fi P2P"), DeviceType::Loopback => write!(f, "Loopback"), DeviceType::Bluetooth => write!(f, "Bluetooth"), - DeviceType::Other(v) => write!(f, "Other({v})"), + DeviceType::Other(v) => write!( + f, + "{}", + crate::types::device_type_registry::display_name_for_code(*v) + ), } } } diff --git a/nmrs/src/core/connection.rs b/nmrs/src/core/connection.rs index 839c4d4b..d0e5ac8e 100644 --- a/nmrs/src/core/connection.rs +++ b/nmrs/src/core/connection.rs @@ -188,7 +188,7 @@ pub(crate) async fn forget_by_name_and_type( use zvariant::{OwnedObjectPath, Value}; // Validate SSID - validate_ssid(ssid)?; + validate_ssid(name)?; debug!( "Starting forget operation for: {name} (device filter: {:?})", From 68dbc5cec2a87335e8a976597780eb777ea8da4f Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Sun, 18 Jan 2026 00:11:59 -0500 Subject: [PATCH 13/23] fix(#176): better error for missing psks --- nmrs/src/api/models.rs | 4 ++++ nmrs/src/core/connection.rs | 2 +- nmrs/tests/validation_test.rs | 1 + 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/nmrs/src/api/models.rs b/nmrs/src/api/models.rs index 87bdf4fc..3ae510dd 100644 --- a/nmrs/src/api/models.rs +++ b/nmrs/src/api/models.rs @@ -1214,6 +1214,10 @@ pub enum ConnectionError { #[error("no saved connection for network")] NoSavedConnection, + /// An empty password was provided for the requested network. + #[error("no password was provided")] + MissingPassword, + /// A general connection failure with a device state reason code. #[error("connection failed: {0}")] DeviceFailed(StateReason), diff --git a/nmrs/src/core/connection.rs b/nmrs/src/core/connection.rs index d0e5ac8e..341032f0 100644 --- a/nmrs/src/core/connection.rs +++ b/nmrs/src/core/connection.rs @@ -725,7 +725,7 @@ fn decide_saved_connection( Some(path) => Ok(SavedDecision::UseSaved(path)), None if matches!(creds, WifiSecurity::WpaPsk { psk } if psk.trim().is_empty()) => { - Err(ConnectionError::NoSavedConnection) + Err(ConnectionError::MissingPassword) } None => Ok(SavedDecision::RebuildFresh), diff --git a/nmrs/tests/validation_test.rs b/nmrs/tests/validation_test.rs index 0e527ac3..e7191f03 100644 --- a/nmrs/tests/validation_test.rs +++ b/nmrs/tests/validation_test.rs @@ -289,4 +289,5 @@ fn test_connection_error_types() { let _err6 = ConnectionError::InvalidPeers("test".to_string()); let _err7 = ConnectionError::InvalidPrivateKey("test".to_string()); let _err8 = ConnectionError::InvalidPublicKey("test".to_string()); + let _err9 = ConnectionError::MissingPassword; } From b307b67e0f4e0cc8577e1538f98eb92edd270ae0 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Sun, 18 Jan 2026 00:30:27 -0500 Subject: [PATCH 14/23] chore: `non_exhaustive` on more stuff --- nmrs/src/api/models.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/nmrs/src/api/models.rs b/nmrs/src/api/models.rs index 3ae510dd..86788b99 100644 --- a/nmrs/src/api/models.rs +++ b/nmrs/src/api/models.rs @@ -303,6 +303,7 @@ pub enum StateReason { /// # Ok(()) /// # } /// ``` +#[non_exhaustive] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Network { /// Device interface name (e.g., "wlan0") @@ -352,6 +353,7 @@ pub struct Network { /// # Ok(()) /// # } /// ``` +#[non_exhaustive] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NetworkInfo { /// Network SSID (name) @@ -410,6 +412,7 @@ pub struct NetworkInfo { /// # Ok(()) /// # } /// ``` +#[non_exhaustive] #[derive(Debug, Clone)] pub struct Device { /// D-Bus object path @@ -446,6 +449,7 @@ pub struct DeviceIdentity { /// EAP (Extensible Authentication Protocol) method for WPA-Enterprise Wi-Fi. /// /// These are the outer authentication methods used in 802.1X authentication. +#[non_exhaustive] #[derive(Debug, Clone, PartialEq, Eq)] pub enum EapMethod { /// Protected EAP (PEAPv0) - tunnels inner authentication in TLS. @@ -460,6 +464,7 @@ pub enum EapMethod { /// /// These methods run inside the TLS tunnel established by the outer /// EAP method (PEAP or TTLS). +#[non_exhaustive] #[derive(Debug, Clone, PartialEq, Eq)] pub enum Phase2 { /// Microsoft Challenge Handshake Authentication Protocol v2. @@ -511,6 +516,7 @@ pub enum Phase2 { /// phase2: Phase2::Pap, /// }; /// ``` +#[non_exhaustive] #[derive(Debug, Clone, PartialEq, Eq)] pub struct EapOptions { /// User identity (usually email or username) @@ -558,6 +564,7 @@ pub struct EapOptions { /// autoconnect_retries: None, /// }; /// ``` +#[non_exhaustive] #[derive(Debug, Clone)] pub struct ConnectionOptions { /// Whether to automatically connect when available @@ -642,6 +649,7 @@ impl Default for ConnectionOptions { /// # Ok(()) /// # } /// ``` +#[non_exhaustive] #[derive(Debug, Clone, PartialEq, Eq)] pub enum WifiSecurity { /// Open network (no authentication) @@ -662,6 +670,7 @@ pub enum WifiSecurity { /// /// Identifies the VPN protocol/technology used for the connection. /// Currently only WireGuard is supported. +#[non_exhaustive] #[derive(Debug, Clone, PartialEq, Eq)] pub enum VpnType { /// WireGuard - modern, high-performance VPN protocol. @@ -708,6 +717,7 @@ pub enum VpnType { /// uuid: None, /// }; /// ``` +#[non_exhaustive] #[derive(Debug, Clone)] pub struct VpnCredentials { /// The type of VPN (currently only WireGuard). @@ -755,6 +765,7 @@ pub struct VpnCredentials { /// persistent_keepalive: Some(25), /// }; /// ``` +#[non_exhaustive] #[derive(Debug, Clone)] pub struct WireGuardPeer { /// The peer's WireGuard public key (base64 encoded). @@ -793,6 +804,7 @@ pub struct WireGuardPeer { /// interface: Some("wg0".into()), /// }; /// ``` +#[non_exhaustive] #[derive(Debug, Clone)] pub struct VpnConnection { /// The connection name/identifier. @@ -831,6 +843,7 @@ pub struct VpnConnection { /// dns_servers: vec!["1.1.1.1".into()], /// }; /// ``` +#[non_exhaustive] #[derive(Debug, Clone)] pub struct VpnConnectionInfo { /// The connection name/identifier. From 5aa407b32678c6209761dd356e422892f371b77d Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Sun, 18 Jan 2026 01:15:16 -0500 Subject: [PATCH 15/23] feat: mark public structs/enums as #[non_exhaustive] for future extensibility - Add #[non_exhaustive] to all public enums and structs that may evolve in 2.x - Implement constructors and builder methods for structs that need external instantiation - EapOptions::new() with builder methods - ConnectionOptions::new() with builder methods - VpnCredentials::new() with builder methods - WireGuardPeer::new() with builder methods - Update all doctests, examples, and tests to use new constructors - Update VpnConnection and VpnConnectionInfo doctests to reflect read-only nature This ensures we can add new enum variants, struct fields, device types, and error types in future 2.x releases without breaking changes to the public API. --- nmrs-gui/src/ui/connect.rs | 20 +- nmrs/examples/vpn_connect.rs | 33 +- nmrs/src/api/builders/vpn.rs | 106 +++---- nmrs/src/api/builders/wifi_builder.rs | 15 +- nmrs/src/api/builders/wireguard_builder.rs | 12 +- nmrs/src/api/models.rs | 345 ++++++++++++++++----- nmrs/src/lib.rs | 54 ++-- nmrs/tests/integration_test.rs | 48 +-- nmrs/tests/validation_test.rs | 241 +++++++------- 9 files changed, 491 insertions(+), 383 deletions(-) diff --git a/nmrs-gui/src/ui/connect.rs b/nmrs-gui/src/ui/connect.rs index 4248c677..f4039376 100644 --- a/nmrs-gui/src/ui/connect.rs +++ b/nmrs-gui/src/ui/connect.rs @@ -191,18 +191,16 @@ fn draw_connect_modal( glib::MainContext::default().spawn_local(async move { let creds = if is_eap { - WifiSecurity::WpaEap { - opts: EapOptions { - identity: username, - password: pwd, - anonymous_identity: None, - domain_suffix_match: None, - ca_cert_path: cert_path, - system_ca_certs: use_system_ca, - method: EapMethod::Peap, - phase2: Phase2::Mschapv2, - }, + let mut opts = EapOptions::new(username, pwd) + .with_method(EapMethod::Peap) + .with_phase2(Phase2::Mschapv2) + .with_system_ca_certs(use_system_ca); + + if let Some(cert) = cert_path { + opts = opts.with_ca_cert_path(format!("file://{}", cert)); } + + WifiSecurity::WpaEap { opts } } else { WifiSecurity::WpaPsk { psk: pwd } }; diff --git a/nmrs/examples/vpn_connect.rs b/nmrs/examples/vpn_connect.rs index a398c63e..18f7e1e0 100644 --- a/nmrs/examples/vpn_connect.rs +++ b/nmrs/examples/vpn_connect.rs @@ -5,23 +5,22 @@ use nmrs::{NetworkManager, VpnCredentials, VpnType, WireGuardPeer}; 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, - }; + let peer = WireGuardPeer::new( + std::env::var("WG_PUBLIC_KEY").expect("Set WG_PUBLIC_KEY env var"), + "vpn.example.com:51820", + vec!["0.0.0.0/0".into()], + ) + .with_persistent_keepalive(25); + + let creds = VpnCredentials::new( + VpnType::WireGuard, + "ExampleVPN", + "vpn.example.com:51820", + std::env::var("WG_PRIVATE_KEY").expect("Set WG_PRIVATE_KEY env var"), + "10.0.0.2/24", + vec![peer], + ) + .with_dns(vec!["1.1.1.1".into()]); println!("Connecting to VPN..."); nm.connect_vpn(creds).await?; diff --git a/nmrs/src/api/builders/vpn.rs b/nmrs/src/api/builders/vpn.rs index 097fde45..8cda4508 100644 --- a/nmrs/src/api/builders/vpn.rs +++ b/nmrs/src/api/builders/vpn.rs @@ -19,13 +19,11 @@ //! use nmrs::builders::WireGuardBuilder; //! use nmrs::WireGuardPeer; //! -//! let peer = WireGuardPeer { -//! public_key: "HIgo9xNzJMWLKAShlKl6/bUT1VI9Q0SDBXGtLXkPFXc=".into(), -//! gateway: "vpn.example.com:51820".into(), -//! allowed_ips: vec!["0.0.0.0/0".into()], -//! preshared_key: None, -//! persistent_keepalive: Some(25), -//! }; +//! let peer = WireGuardPeer::new( +//! "HIgo9xNzJMWLKAShlKl6/bUT1VI9Q0SDBXGtLXkPFXc=", +//! "vpn.example.com:51820", +//! vec!["0.0.0.0/0".into()], +//! ).with_persistent_keepalive(25); //! //! let settings = WireGuardBuilder::new("MyVPN") //! .private_key("YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=") @@ -44,31 +42,22 @@ //! 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 peer = WireGuardPeer::new( +//! "HIgo9xNzJMWLKAShlKl6/bUT1VI9Q0SDBXGtLXkPFXc=", +//! "vpn.example.com:51820", +//! vec!["0.0.0.0/0".into()], +//! ).with_persistent_keepalive(25); //! -//! let opts = ConnectionOptions { -//! autoconnect: false, -//! autoconnect_priority: None, -//! autoconnect_retries: None, -//! }; +//! let creds = VpnCredentials::new( +//! VpnType::WireGuard, +//! "MyVPN", +//! "vpn.example.com:51820", +//! "YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=", +//! "10.0.0.2/24", +//! vec![peer], +//! ).with_dns(vec!["1.1.1.1".into()]); +//! +//! let opts = ConnectionOptions::new(false); //! //! let settings = build_wireguard_connection(&creds, &opts).unwrap(); //! // Pass settings to NetworkManager's AddAndActivateConnection @@ -125,31 +114,27 @@ mod tests { 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, - } + let peer = WireGuardPeer::new( + "HIgo9xNzJMWLKAShlKl6/bUT1VI9Q0SDBXGtLXkPFXc=", + "vpn.example.com:51820", + vec!["0.0.0.0/0".into()], + ) + .with_persistent_keepalive(25); + + VpnCredentials::new( + VpnType::WireGuard, + "TestVPN", + "vpn.example.com:51820", + "YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=", + "10.0.0.2/24", + vec![peer], + ) + .with_dns(vec!["1.1.1.1".into(), "8.8.8.8".into()]) + .with_mtu(1420) } fn create_test_options() -> ConnectionOptions { - ConnectionOptions { - autoconnect: false, - autoconnect_priority: None, - autoconnect_retries: None, - } + ConnectionOptions::new(false) } #[test] @@ -276,13 +261,14 @@ mod tests { #[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 extra_peer = WireGuardPeer::new( + "xScVkH3fUGUVRvGLFcjkx+GGD7cf5eBVyN3Gh4FLjmI=", + "peer2.example.com:51821", + vec!["192.168.0.0/16".into()], + ) + .with_preshared_key("PSKABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklm="); + + creds.peers.push(extra_peer); let opts = create_test_options(); let result = build_wireguard_connection(&creds, &opts); diff --git a/nmrs/src/api/builders/wifi_builder.rs b/nmrs/src/api/builders/wifi_builder.rs index 9c990931..14327c14 100644 --- a/nmrs/src/api/builders/wifi_builder.rs +++ b/nmrs/src/api/builders/wifi_builder.rs @@ -54,16 +54,11 @@ pub enum WifiBand { /// use nmrs::builders::WifiConnectionBuilder; /// use nmrs::{EapOptions, EapMethod, Phase2}; /// -/// let eap_opts = EapOptions { -/// identity: "user@company.com".into(), -/// password: "password".into(), -/// anonymous_identity: None, -/// domain_suffix_match: Some("company.com".into()), -/// ca_cert_path: None, -/// system_ca_certs: true, -/// method: EapMethod::Peap, -/// phase2: Phase2::Mschapv2, -/// }; +/// let eap_opts = EapOptions::new("user@company.com", "password") +/// .with_domain_suffix_match("company.com") +/// .with_system_ca_certs(true) +/// .with_method(EapMethod::Peap) +/// .with_phase2(Phase2::Mschapv2); /// /// let settings = WifiConnectionBuilder::new("CorpNetwork") /// .wpa_eap(eap_opts) diff --git a/nmrs/src/api/builders/wireguard_builder.rs b/nmrs/src/api/builders/wireguard_builder.rs index 38a811f1..164ca2a4 100644 --- a/nmrs/src/api/builders/wireguard_builder.rs +++ b/nmrs/src/api/builders/wireguard_builder.rs @@ -22,13 +22,11 @@ use crate::api::models::{ConnectionError, ConnectionOptions, WireGuardPeer}; /// use nmrs::builders::WireGuardBuilder; /// use nmrs::{WireGuardPeer, ConnectionOptions}; /// -/// let peer = WireGuardPeer { -/// public_key: "HIgo9xNzJMWLKAShlKl6/bUT1VI9Q0SDBXGtLXkPFXc=".into(), -/// gateway: "vpn.example.com:51820".into(), -/// allowed_ips: vec!["0.0.0.0/0".into()], -/// preshared_key: None, -/// persistent_keepalive: Some(25), -/// }; +/// let peer = WireGuardPeer::new( +/// "HIgo9xNzJMWLKAShlKl6/bUT1VI9Q0SDBXGtLXkPFXc=", +/// "vpn.example.com:51820", +/// vec!["0.0.0.0/0".into()], +/// ).with_persistent_keepalive(25); /// /// let settings = WireGuardBuilder::new("MyVPN") /// .private_key("YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=") diff --git a/nmrs/src/api/models.rs b/nmrs/src/api/models.rs index 86788b99..c905ba1a 100644 --- a/nmrs/src/api/models.rs +++ b/nmrs/src/api/models.rs @@ -488,16 +488,12 @@ pub enum Phase2 { /// ```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, -/// }; +/// let opts = EapOptions::new("employee@company.com", "my_password") +/// .with_anonymous_identity("anonymous@company.com") +/// .with_domain_suffix_match("company.com") +/// .with_system_ca_certs(true) // Use system certificate store +/// .with_method(EapMethod::Peap) +/// .with_phase2(Phase2::Mschapv2); /// ``` /// /// ## TTLS with PAP (Alternative Setup) @@ -505,16 +501,10 @@ pub enum Phase2 { /// ```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, -/// }; +/// let opts = EapOptions::new("student@university.edu", "password") +/// .with_ca_cert_path("file:///etc/ssl/certs/university-ca.pem") +/// .with_method(EapMethod::Ttls) +/// .with_phase2(Phase2::Pap); /// ``` #[non_exhaustive] #[derive(Debug, Clone, PartialEq, Eq)] @@ -537,6 +527,78 @@ pub struct EapOptions { pub phase2: Phase2, } +impl Default for EapOptions { + fn default() -> Self { + Self { + identity: String::new(), + password: String::new(), + anonymous_identity: None, + domain_suffix_match: None, + ca_cert_path: None, + system_ca_certs: false, + method: EapMethod::Peap, + phase2: Phase2::Mschapv2, + } + } +} + +impl EapOptions { + /// Creates a new `EapOptions` with the minimum required fields. + /// + /// # Examples + /// + /// ```rust + /// use nmrs::{EapOptions, EapMethod, Phase2}; + /// + /// let opts = EapOptions::new("user@example.com", "password") + /// .with_method(EapMethod::Peap) + /// .with_phase2(Phase2::Mschapv2); + /// ``` + pub fn new(identity: impl Into, password: impl Into) -> Self { + Self { + identity: identity.into(), + password: password.into(), + ..Default::default() + } + } + + /// Sets the anonymous identity for privacy. + pub fn with_anonymous_identity(mut self, anonymous_identity: impl Into) -> Self { + self.anonymous_identity = Some(anonymous_identity.into()); + self + } + + /// Sets the domain suffix to match against the server certificate. + pub fn with_domain_suffix_match(mut self, domain: impl Into) -> Self { + self.domain_suffix_match = Some(domain.into()); + self + } + + /// Sets the path to the CA certificate file (must start with `file://`). + pub fn with_ca_cert_path(mut self, path: impl Into) -> Self { + self.ca_cert_path = Some(path.into()); + self + } + + /// Sets whether to use the system CA certificate store. + pub fn with_system_ca_certs(mut self, use_system: bool) -> Self { + self.system_ca_certs = use_system; + self + } + + /// Sets the EAP method (PEAP or TTLS). + pub fn with_method(mut self, method: EapMethod) -> Self { + self.method = method; + self + } + + /// Sets the Phase 2 authentication method. + pub fn with_phase2(mut self, phase2: Phase2) -> Self { + self.phase2 = phase2; + self + } +} + /// Connection options for saved NetworkManager connections. /// /// Controls how NetworkManager handles saved connection profiles, @@ -551,18 +613,12 @@ pub struct EapOptions { /// let opts = ConnectionOptions::default(); /// /// // 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 -/// }; +/// let opts_priority = ConnectionOptions::new(true) +/// .with_priority(10) // Higher = more preferred +/// .with_retries(3); // Retry up to 3 times /// /// // Manual connection only -/// let opts_manual = ConnectionOptions { -/// autoconnect: false, -/// autoconnect_priority: None, -/// autoconnect_retries: None, -/// }; +/// let opts_manual = ConnectionOptions::new(false); /// ``` #[non_exhaustive] #[derive(Debug, Clone)] @@ -591,6 +647,37 @@ impl Default for ConnectionOptions { } } +impl ConnectionOptions { + /// Creates new `ConnectionOptions` with the specified autoconnect setting. + /// + /// # Examples + /// + /// ```rust + /// use nmrs::ConnectionOptions; + /// + /// let opts = ConnectionOptions::new(true); + /// ``` + pub fn new(autoconnect: bool) -> Self { + Self { + autoconnect, + autoconnect_priority: None, + autoconnect_retries: None, + } + } + + /// Sets the auto-connection priority. + pub fn with_priority(mut self, priority: i32) -> Self { + self.autoconnect_priority = Some(priority); + self + } + + /// Sets the maximum number of auto-connect retry attempts. + pub fn with_retries(mut self, retries: i32) -> Self { + self.autoconnect_retries = Some(retries); + self + } +} + /// Wi-Fi connection security types. /// /// Represents the authentication method for connecting to a WiFi network. @@ -634,17 +721,14 @@ impl Default for ConnectionOptions { /// # async fn example() -> nmrs::Result<()> { /// let nm = NetworkManager::new().await?; /// +/// let eap_opts = EapOptions::new("user@company.com", "password") +/// .with_domain_suffix_match("company.com") +/// .with_system_ca_certs(true) +/// .with_method(EapMethod::Peap) +/// .with_phase2(Phase2::Mschapv2); +/// /// 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, -/// } +/// opts: eap_opts /// }).await?; /// # Ok(()) /// # } @@ -699,23 +783,20 @@ pub enum VpnType { /// ```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, -/// }; +/// let peer = WireGuardPeer::new( +/// "server_public_key", +/// "vpn.home.com:51820", +/// vec!["0.0.0.0/0".into()], +/// ).with_persistent_keepalive(25); +/// +/// let creds = VpnCredentials::new( +/// VpnType::WireGuard, +/// "HomeVPN", +/// "vpn.home.com:51820", +/// "aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789=", +/// "10.0.0.2/24", +/// vec![peer], +/// ).with_dns(vec!["1.1.1.1".into()]); /// ``` #[non_exhaustive] #[derive(Debug, Clone)] @@ -740,6 +821,69 @@ pub struct VpnCredentials { pub uuid: Option, } +impl VpnCredentials { + /// Creates new `VpnCredentials` with the required fields. + /// + /// # Examples + /// + /// ```rust + /// use nmrs::{VpnCredentials, VpnType, WireGuardPeer}; + /// + /// let peer = WireGuardPeer::new( + /// "server_public_key", + /// "vpn.example.com:51820", + /// vec!["0.0.0.0/0".into()], + /// ); + /// + /// let creds = VpnCredentials::new( + /// VpnType::WireGuard, + /// "MyVPN", + /// "vpn.example.com:51820", + /// "client_private_key", + /// "10.0.0.2/24", + /// vec![peer], + /// ); + /// ``` + pub fn new( + vpn_type: VpnType, + name: impl Into, + gateway: impl Into, + private_key: impl Into, + address: impl Into, + peers: Vec, + ) -> Self { + Self { + vpn_type, + name: name.into(), + gateway: gateway.into(), + private_key: private_key.into(), + address: address.into(), + peers, + dns: None, + mtu: None, + uuid: None, + } + } + + /// Sets the DNS servers to use when connected. + pub fn with_dns(mut self, dns: Vec) -> Self { + self.dns = Some(dns); + self + } + + /// Sets the MTU (Maximum Transmission Unit) size. + pub fn with_mtu(mut self, mtu: u32) -> Self { + self.mtu = Some(mtu); + self + } + + /// Sets the UUID for the connection. + pub fn with_uuid(mut self, uuid: Uuid) -> Self { + self.uuid = Some(uuid); + self + } +} + /// WireGuard peer configuration. /// /// Represents a single WireGuard peer (server) to connect to. @@ -757,13 +901,11 @@ pub struct VpnCredentials { /// ```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), -/// }; +/// let peer = WireGuardPeer::new( +/// "aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789=", +/// "vpn.example.com:51820", +/// vec!["0.0.0.0/0".into(), "::/0".into()], +/// ); /// ``` #[non_exhaustive] #[derive(Debug, Clone)] @@ -780,6 +922,47 @@ pub struct WireGuardPeer { pub persistent_keepalive: Option, } +impl WireGuardPeer { + /// Creates a new `WireGuardPeer` with the required fields. + /// + /// # Examples + /// + /// ```rust + /// use nmrs::WireGuardPeer; + /// + /// let peer = WireGuardPeer::new( + /// "aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789=", + /// "vpn.example.com:51820", + /// vec!["0.0.0.0/0".into()], + /// ); + /// ``` + pub fn new( + public_key: impl Into, + gateway: impl Into, + allowed_ips: Vec, + ) -> Self { + Self { + public_key: public_key.into(), + gateway: gateway.into(), + allowed_ips, + preshared_key: None, + persistent_keepalive: None, + } + } + + /// Sets the pre-shared key for additional security. + pub fn with_preshared_key(mut self, psk: impl Into) -> Self { + self.preshared_key = Some(psk.into()); + self + } + + /// Sets the persistent keepalive interval in seconds. + pub fn with_persistent_keepalive(mut self, interval: u32) -> Self { + self.persistent_keepalive = Some(interval); + self + } +} + /// VPN Connection information. /// /// Represents a VPN connection managed by NetworkManager, including both @@ -794,15 +977,11 @@ pub struct WireGuardPeer { /// /// # Example /// -/// ```rust -/// use nmrs::{VpnConnection, VpnType, DeviceState}; -/// -/// let vpn = VpnConnection { -/// name: "WorkVPN".into(), -/// vpn_type: VpnType::WireGuard, -/// state: DeviceState::Activated, -/// interface: Some("wg0".into()), -/// }; +/// ```no_run +/// # use nmrs::{VpnConnection, VpnType, DeviceState}; +/// # // This struct is returned by the library, not constructed directly +/// # let vpn: VpnConnection = todo!(); +/// println!("VPN: {}, State: {:?}", vpn.name, vpn.state); /// ``` #[non_exhaustive] #[derive(Debug, Clone)] @@ -829,19 +1008,13 @@ pub struct VpnConnection { /// /// # 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, // IPv6 not yet implemented -/// dns_servers: vec!["1.1.1.1".into()], -/// }; +/// ```no_run +/// # use nmrs::{VpnConnectionInfo, VpnType, DeviceState}; +/// # // This struct is returned by the library, not constructed directly +/// # let info: VpnConnectionInfo = todo!(); +/// if let Some(ip) = &info.ip4_address { +/// println!("VPN IP: {}", ip); +/// } /// ``` #[non_exhaustive] #[derive(Debug, Clone)] diff --git a/nmrs/src/lib.rs b/nmrs/src/lib.rs index e80539df..8725c172 100644 --- a/nmrs/src/lib.rs +++ b/nmrs/src/lib.rs @@ -41,23 +41,20 @@ //! 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, -//! }; +//! let peer = WireGuardPeer::new( +//! "peer_public_key", +//! "vpn.example.com:51820", +//! vec!["0.0.0.0/0".into()], +//! ).with_persistent_keepalive(25); +//! +//! let creds = VpnCredentials::new( +//! VpnType::WireGuard, +//! "MyVPN", +//! "vpn.example.com:51820", +//! "your_private_key", +//! "10.0.0.2/24", +//! vec![peer], +//! ).with_dns(vec!["1.1.1.1".into(), "8.8.8.8".into()]); //! //! // Connect to VPN //! nm.connect_vpn(creds).await?; @@ -122,17 +119,14 @@ //! }).await?; //! //! // WPA-EAP (Enterprise) +//! let eap_opts = EapOptions::new("user@company.com", "password") +//! .with_domain_suffix_match("company.com") +//! .with_system_ca_certs(true) +//! .with_method(EapMethod::Peap) +//! .with_phase2(Phase2::Mschapv2); +//! //! 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, -//! } +//! opts: eap_opts //! }).await?; //! //! // Ethernet (auto-connects when cable is plugged in) @@ -274,11 +268,7 @@ mod util; /// use nmrs::builders::build_wifi_connection; /// use nmrs::{WifiSecurity, ConnectionOptions}; /// -/// let opts = ConnectionOptions { -/// autoconnect: true, -/// autoconnect_priority: None, -/// autoconnect_retries: None, -/// }; +/// let opts = ConnectionOptions::new(true); /// /// let settings = build_wifi_connection( /// "MyNetwork", diff --git a/nmrs/tests/integration_test.rs b/nmrs/tests/integration_test.rs index cc15cb94..b53ba527 100644 --- a/nmrs/tests/integration_test.rs +++ b/nmrs/tests/integration_test.rs @@ -851,23 +851,23 @@ 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, - } + let peer = WireGuardPeer::new( + "HIgo9xNzJMWLKAShlKl6/bUT1VI9Q0SDBXGtLXkPFXc=", + "test.example.com:51820", + vec!["0.0.0.0/0".into(), "::/0".into()], + ) + .with_persistent_keepalive(25); + + VpnCredentials::new( + VpnType::WireGuard, + name, + "test.example.com:51820", + "YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=", + "10.100.0.2/24", + vec![peer], + ) + .with_dns(vec!["1.1.1.1".into(), "8.8.8.8".into()]) + .with_mtu(1420) } /// Test listing VPN connections @@ -1011,13 +1011,13 @@ async fn test_vpn_type() { /// 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), - }; + let peer = WireGuardPeer::new( + "test_key", + "test.example.com:51820", + vec!["0.0.0.0/0".into()], + ) + .with_preshared_key("psk") + .with_persistent_keepalive(25); assert_eq!(peer.public_key, "test_key"); assert_eq!(peer.gateway, "test.example.com:51820"); diff --git a/nmrs/tests/validation_test.rs b/nmrs/tests/validation_test.rs index e7191f03..109878f7 100644 --- a/nmrs/tests/validation_test.rs +++ b/nmrs/tests/validation_test.rs @@ -3,10 +3,7 @@ //! These tests verify that invalid inputs are rejected before attempting //! D-Bus operations, providing clear error messages to users. -use nmrs::{ - ConnectionError, EapMethod, EapOptions, Phase2, VpnCredentials, VpnType, WifiSecurity, - WireGuardPeer, -}; +use nmrs::{ConnectionError, EapOptions, VpnCredentials, VpnType, WifiSecurity, WireGuardPeer}; #[test] fn test_invalid_ssid_empty() { @@ -85,77 +82,53 @@ fn test_empty_wpa_psk_allowed() { #[test] fn test_invalid_eap_empty_identity() { - let eap = WifiSecurity::WpaEap { - opts: EapOptions { - identity: "".to_string(), // Empty identity should be rejected - password: "password".to_string(), - anonymous_identity: None, - domain_suffix_match: None, - ca_cert_path: None, - system_ca_certs: true, - method: EapMethod::Peap, - phase2: Phase2::Mschapv2, - }, - }; + let opts = EapOptions::new("", "password").with_system_ca_certs(true); + + let eap = WifiSecurity::WpaEap { opts }; assert!(eap.is_eap()); } #[test] fn test_invalid_eap_ca_cert_path() { - let eap = WifiSecurity::WpaEap { - opts: EapOptions { - identity: "user@example.com".to_string(), - password: "password".to_string(), - anonymous_identity: None, - domain_suffix_match: None, - ca_cert_path: Some("/etc/ssl/cert.pem".to_string()), // Missing file:// prefix - system_ca_certs: false, - method: EapMethod::Peap, - phase2: Phase2::Mschapv2, - }, - }; + let opts = + EapOptions::new("user@example.com", "password").with_ca_cert_path("/etc/ssl/cert.pem"); // Missing file:// prefix + + let eap = WifiSecurity::WpaEap { opts }; assert!(eap.is_eap()); } #[test] fn test_valid_eap() { - let eap = WifiSecurity::WpaEap { - opts: EapOptions { - identity: "user@example.com".to_string(), - password: "password".to_string(), - anonymous_identity: Some("anonymous@example.com".to_string()), - domain_suffix_match: Some("example.com".to_string()), - ca_cert_path: Some("file:///etc/ssl/cert.pem".to_string()), - system_ca_certs: false, - method: EapMethod::Peap, - phase2: Phase2::Mschapv2, - }, - }; + let opts = EapOptions::new("user@example.com", "password") + .with_anonymous_identity("anonymous@example.com") + .with_domain_suffix_match("example.com") + .with_ca_cert_path("file:///etc/ssl/cert.pem"); + + let eap = WifiSecurity::WpaEap { opts }; assert!(eap.is_eap()); } #[test] fn test_invalid_vpn_empty_name() { - let creds = VpnCredentials { - vpn_type: VpnType::WireGuard, - name: "".to_string(), // Empty name should be rejected - gateway: "vpn.example.com:51820".to_string(), - private_key: "YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=".to_string(), - address: "10.0.0.2/24".to_string(), - peers: vec![WireGuardPeer { - public_key: "HIgo9xNzJMWLKAShlKl6/bUT1VI9Q0SDBXGtLXkPFXc=".to_string(), - gateway: "vpn.example.com:51820".to_string(), - allowed_ips: vec!["0.0.0.0/0".to_string()], - preshared_key: None, - persistent_keepalive: Some(25), - }], - dns: Some(vec!["1.1.1.1".to_string()]), - mtu: None, - uuid: None, - }; + let peer = WireGuardPeer::new( + "HIgo9xNzJMWLKAShlKl6/bUT1VI9Q0SDBXGtLXkPFXc=", + "vpn.example.com:51820", + vec!["0.0.0.0/0".to_string()], + ) + .with_persistent_keepalive(25); + + let creds = VpnCredentials::new( + VpnType::WireGuard, + "", // Empty name should be rejected + "vpn.example.com:51820", + "YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=", + "10.0.0.2/24", + vec![peer], + ) + .with_dns(vec!["1.1.1.1".to_string()]); // Validation will catch this assert_eq!(creds.name, ""); @@ -163,23 +136,22 @@ fn test_invalid_vpn_empty_name() { #[test] fn test_invalid_vpn_gateway_no_port() { - let creds = VpnCredentials { - vpn_type: VpnType::WireGuard, - name: "TestVPN".to_string(), - gateway: "vpn.example.com".to_string(), // Missing port - private_key: "YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=".to_string(), - address: "10.0.0.2/24".to_string(), - peers: vec![WireGuardPeer { - public_key: "HIgo9xNzJMWLKAShlKl6/bUT1VI9Q0SDBXGtLXkPFXc=".to_string(), - gateway: "vpn.example.com:51820".to_string(), - allowed_ips: vec!["0.0.0.0/0".to_string()], - preshared_key: None, - persistent_keepalive: Some(25), - }], - dns: Some(vec!["1.1.1.1".to_string()]), - mtu: None, - uuid: None, - }; + let peer = WireGuardPeer::new( + "HIgo9xNzJMWLKAShlKl6/bUT1VI9Q0SDBXGtLXkPFXc=", + "vpn.example.com:51820", + vec!["0.0.0.0/0".to_string()], + ) + .with_persistent_keepalive(25); + + let creds = VpnCredentials::new( + VpnType::WireGuard, + "TestVPN", + "vpn.example.com", // Missing port + "YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=", + "10.0.0.2/24", + vec![peer], + ) + .with_dns(vec!["1.1.1.1".to_string()]); // Validation will catch missing port assert!(!creds.gateway.contains(':')); @@ -187,17 +159,15 @@ fn test_invalid_vpn_gateway_no_port() { #[test] fn test_invalid_vpn_no_peers() { - let creds = VpnCredentials { - vpn_type: VpnType::WireGuard, - name: "TestVPN".to_string(), - gateway: "vpn.example.com:51820".to_string(), - private_key: "YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=".to_string(), - address: "10.0.0.2/24".to_string(), - peers: vec![], // No peers should be rejected - dns: Some(vec!["1.1.1.1".to_string()]), - mtu: None, - uuid: None, - }; + let creds = VpnCredentials::new( + VpnType::WireGuard, + "TestVPN", + "vpn.example.com:51820", + "YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=", + "10.0.0.2/24", + vec![], // No peers should be rejected + ) + .with_dns(vec!["1.1.1.1".to_string()]); // Validation will catch empty peers assert!(creds.peers.is_empty()); @@ -205,23 +175,22 @@ fn test_invalid_vpn_no_peers() { #[test] fn test_invalid_vpn_bad_cidr() { - let creds = VpnCredentials { - vpn_type: VpnType::WireGuard, - name: "TestVPN".to_string(), - gateway: "vpn.example.com:51820".to_string(), - private_key: "YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=".to_string(), - address: "10.0.0.2".to_string(), // Missing /prefix - peers: vec![WireGuardPeer { - public_key: "HIgo9xNzJMWLKAShlKl6/bUT1VI9Q0SDBXGtLXkPFXc=".to_string(), - gateway: "vpn.example.com:51820".to_string(), - allowed_ips: vec!["0.0.0.0/0".to_string()], - preshared_key: None, - persistent_keepalive: Some(25), - }], - dns: Some(vec!["1.1.1.1".to_string()]), - mtu: None, - uuid: None, - }; + let peer = WireGuardPeer::new( + "HIgo9xNzJMWLKAShlKl6/bUT1VI9Q0SDBXGtLXkPFXc=", + "vpn.example.com:51820", + vec!["0.0.0.0/0".to_string()], + ) + .with_persistent_keepalive(25); + + let creds = VpnCredentials::new( + VpnType::WireGuard, + "TestVPN", + "vpn.example.com:51820", + "YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=", + "10.0.0.2", // Missing /prefix + vec![peer], + ) + .with_dns(vec!["1.1.1.1".to_string()]); // Validation will catch invalid CIDR assert!(!creds.address.contains('/')); @@ -229,23 +198,23 @@ fn test_invalid_vpn_bad_cidr() { #[test] fn test_invalid_vpn_mtu_too_small() { - let creds = VpnCredentials { - vpn_type: VpnType::WireGuard, - name: "TestVPN".to_string(), - gateway: "vpn.example.com:51820".to_string(), - private_key: "YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=".to_string(), - address: "10.0.0.2/24".to_string(), - peers: vec![WireGuardPeer { - public_key: "HIgo9xNzJMWLKAShlKl6/bUT1VI9Q0SDBXGtLXkPFXc=".to_string(), - gateway: "vpn.example.com:51820".to_string(), - allowed_ips: vec!["0.0.0.0/0".to_string()], - preshared_key: None, - persistent_keepalive: Some(25), - }], - dns: Some(vec!["1.1.1.1".to_string()]), - mtu: Some(500), // Too small (minimum is 576) - uuid: None, - }; + let peer = WireGuardPeer::new( + "HIgo9xNzJMWLKAShlKl6/bUT1VI9Q0SDBXGtLXkPFXc=", + "vpn.example.com:51820", + vec!["0.0.0.0/0".to_string()], + ) + .with_persistent_keepalive(25); + + let creds = VpnCredentials::new( + VpnType::WireGuard, + "TestVPN", + "vpn.example.com:51820", + "YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=", + "10.0.0.2/24", + vec![peer], + ) + .with_dns(vec!["1.1.1.1".to_string()]) + .with_mtu(500); // Too small (minimum is 576) // Validation will catch MTU too small assert!(creds.mtu.unwrap() < 576); @@ -253,23 +222,23 @@ fn test_invalid_vpn_mtu_too_small() { #[test] fn test_valid_vpn_credentials() { - let creds = VpnCredentials { - vpn_type: VpnType::WireGuard, - name: "TestVPN".to_string(), - gateway: "vpn.example.com:51820".to_string(), - private_key: "YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=".to_string(), - address: "10.0.0.2/24".to_string(), - peers: vec![WireGuardPeer { - public_key: "HIgo9xNzJMWLKAShlKl6/bUT1VI9Q0SDBXGtLXkPFXc=".to_string(), - gateway: "vpn.example.com:51820".to_string(), - allowed_ips: vec!["0.0.0.0/0".to_string(), "::/0".to_string()], - preshared_key: None, - persistent_keepalive: Some(25), - }], - dns: Some(vec!["1.1.1.1".to_string(), "8.8.8.8".to_string()]), - mtu: Some(1420), - uuid: None, - }; + let peer = WireGuardPeer::new( + "HIgo9xNzJMWLKAShlKl6/bUT1VI9Q0SDBXGtLXkPFXc=", + "vpn.example.com:51820", + vec!["0.0.0.0/0".to_string(), "::/0".to_string()], + ) + .with_persistent_keepalive(25); + + let creds = VpnCredentials::new( + VpnType::WireGuard, + "TestVPN", + "vpn.example.com:51820", + "YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=", + "10.0.0.2/24", + vec![peer], + ) + .with_dns(vec!["1.1.1.1".to_string(), "8.8.8.8".to_string()]) + .with_mtu(1420); // All fields should be valid assert!(!creds.name.is_empty()); From 0786a04702a44da3bd41b79f225b6875e2d024d4 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Sun, 18 Jan 2026 22:58:22 -0500 Subject: [PATCH 16/23] chore: update dev deps --- nmrs/Cargo.toml | 7 ------- 1 file changed, 7 deletions(-) diff --git a/nmrs/Cargo.toml b/nmrs/Cargo.toml index 9a741bff..09ae5602 100644 --- a/nmrs/Cargo.toml +++ b/nmrs/Cargo.toml @@ -22,15 +22,8 @@ uuid.workspace = true futures.workspace = true futures-timer.workspace = true base64.workspace = true -<<<<<<< HEAD tokio.workspace = true -async-trait = "0.1.89" -======= async-trait.workspace = true ->>>>>>> 0884154 (fix: proxy field name for `bd_addr` replaced with correct `hw_addr`) - -[dev-dependencies] -tokio.workspace = true [package.metadata.docs.rs] all-features = true From b7afc729231bd173120275f8703decc6fe1dd5ed Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Sun, 18 Jan 2026 22:59:53 -0500 Subject: [PATCH 17/23] chore: cleanup conflict residue --- nmrs/src/api/network_manager.rs | 11 ++++------- nmrs/src/core/connection.rs | 1 + 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/nmrs/src/api/network_manager.rs b/nmrs/src/api/network_manager.rs index ee90b2fe..46ffcce3 100644 --- a/nmrs/src/api/network_manager.rs +++ b/nmrs/src/api/network_manager.rs @@ -2,21 +2,18 @@ use tokio::sync::watch; use zbus::Connection; use crate::api::models::{Device, Network, NetworkInfo, WifiSecurity}; +use crate::core::bluetooth::connect_bluetooth; use crate::core::connection::{ - connect, connect_wired, disconnect, forget, get_device_by_interface, is_connected, + connect, connect_wired, disconnect, forget_by_name_and_type, get_device_by_interface, + is_connected, }; use crate::core::connection_settings::{ get_saved_connection_path, has_saved_connection, list_saved_connections, }; -use crate::core::device::{list_devices, set_wifi_enabled, wait_for_wifi_ready, wifi_enabled}; -use crate::core::scan::{current_network, list_networks, scan_networks}; -use crate::core::bluetooth::connect_bluetooth; -use crate::core::connection::{connect, connect_wired, forget_by_name_and_type}; -use crate::core::connection_settings::{get_saved_connection_path, has_saved_connection}; use crate::core::device::{ list_bluetooth_devices, list_devices, set_wifi_enabled, wait_for_wifi_ready, wifi_enabled, }; -use crate::core::scan::{list_networks, scan_networks}; +use crate::core::scan::{current_network, list_networks, scan_networks}; use crate::core::vpn::{connect_vpn, disconnect_vpn, get_vpn_info, list_vpn_connections}; use crate::models::{ BluetoothDevice, BluetoothIdentity, VpnConnection, VpnConnectionInfo, VpnCredentials, diff --git a/nmrs/src/core/connection.rs b/nmrs/src/core/connection.rs index 341032f0..8b3492cb 100644 --- a/nmrs/src/core/connection.rs +++ b/nmrs/src/core/connection.rs @@ -9,6 +9,7 @@ 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, NMWiredProxy, NMWirelessProxy}; +use crate::monitoring::info::current_ssid; use crate::monitoring::transport::ActiveTransport; use crate::monitoring::wifi::Wifi; use crate::types::constants::{device_state, device_type, timeouts}; From dd499ee7d8d4959ef05e69ba306ba5fd1bc7bc1a Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Sun, 18 Jan 2026 23:18:40 -0500 Subject: [PATCH 18/23] test: update doc + integration tests to match new enum behavior also updates CHANGELOG accordingly for 2.0.0 release --- Cargo.lock | 2 +- nmrs-gui/src/ui/mod.rs | 11 +- nmrs/CHANGELOG.md | 6 + nmrs/Cargo.toml | 2 +- nmrs/examples/bluetooth_connect.rs | 5 +- nmrs/src/api/builders/bluetooth.rs | 24 ++-- nmrs/src/api/models.rs | 169 +++++++++++++++++++++-------- nmrs/src/api/network_manager.rs | 57 ++++------ nmrs/src/core/bluetooth.rs | 14 +-- nmrs/src/core/device.rs | 27 ++--- nmrs/src/lib.rs | 9 +- nmrs/tests/integration_test.rs | 33 +++--- 12 files changed, 206 insertions(+), 153 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4a45aa68..60a94434 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -923,7 +923,7 @@ dependencies = [ [[package]] name = "nmrs" -version = "2.0.0-dev" +version = "2.0.0" dependencies = [ "async-trait", "base64", diff --git a/nmrs-gui/src/ui/mod.rs b/nmrs-gui/src/ui/mod.rs index 22f0f3b4..43132ded 100644 --- a/nmrs-gui/src/ui/mod.rs +++ b/nmrs-gui/src/ui/mod.rs @@ -12,7 +12,6 @@ use gtk::{ }; use std::cell::Cell; use std::rc::Rc; -use tokio::sync::watch; use crate::ui::header::THEMES; @@ -49,8 +48,6 @@ pub fn build_ui(app: &Application) { } } - let (_shutdown_tx, shutdown_rx) = watch::channel(()); - let vbox = GtkBox::new(Orientation::Vertical, 0); let status = Label::new(None); let list_container = GtkBox::new(Orientation::Vertical, 0); @@ -164,7 +161,6 @@ pub fn build_ui(app: &Application) { let is_scanning_device = is_scanning_clone.clone(); let ctx_device = ctx.clone(); let pending_device_refresh = Rc::new(std::cell::RefCell::new(false)); - let shutdown_rx_device = shutdown_rx.clone(); glib::MainContext::default().spawn_local(async move { loop { @@ -172,10 +168,9 @@ pub fn build_ui(app: &Application) { let list_container_clone = list_container_device.clone(); let is_scanning_clone = is_scanning_device.clone(); let pending_device_refresh_clone = pending_device_refresh.clone(); - let shutdown_rx_monitor = shutdown_rx_device.clone(); let result = nm_device_monitor - .monitor_device_changes(shutdown_rx_monitor, move || { + .monitor_device_changes(move || { let ctx = ctx_device_clone.clone(); let list_container = list_container_clone.clone(); let is_scanning = is_scanning_clone.clone(); @@ -219,7 +214,6 @@ pub fn build_ui(app: &Application) { let is_scanning_network = is_scanning_clone.clone(); let ctx_network = ctx.clone(); let pending_network_refresh = Rc::new(std::cell::RefCell::new(false)); - let shutdown_rx_network = shutdown_rx.clone(); glib::MainContext::default().spawn_local(async move { loop { @@ -227,10 +221,9 @@ pub fn build_ui(app: &Application) { let list_container_clone = list_container_network.clone(); let is_scanning_clone = is_scanning_network.clone(); let pending_network_refresh_clone = pending_network_refresh.clone(); - let shutdown_rx_monitor = shutdown_rx_network.clone(); let result = nm_network_monitor - .monitor_network_changes(shutdown_rx_monitor, move || { + .monitor_network_changes(move || { let ctx = ctx_network_clone.clone(); let list_container = list_container_clone.clone(); let is_scanning = is_scanning_clone.clone(); diff --git a/nmrs/CHANGELOG.md b/nmrs/CHANGELOG.md index 0a1595c3..94adaa1d 100644 --- a/nmrs/CHANGELOG.md +++ b/nmrs/CHANGELOG.md @@ -4,14 +4,20 @@ All notable changes to the `nmrs` crate will be documented in this file. ## [Unreleased] ### Added +- Bluetooth device support ([#198](https://github.com/cachebag/nmrs/pull/198)) - Input validation before any D-Bus operations ([#173](https://github.com/cachebag/nmrs/pull/173)) - CI: adjust workflow to auto-update nix hashes on PRs ([#182](https://github.com/cachebag/nmrs/pull/182)) - More helpful methods to `network_manager` facade ([#190](https://github.com/cachebag/nmrs/pull/190)) - Explicitly clean up signal streams to ensure unsubscription ([#197](https://github.com/cachebag/nmrs/pull/197)) ### Fixed +- Better error message for empty passkeys ([#198](https://github.com/cachebag/nmrs/pull/198)) - Race condition in signal subscription ([#191](https://github.com/cachebag/nmrs/pull/191)) +### Changed +- Various enums and structs marked non-exhaustive ([#198](https://github.com/cachebag/nmrs/pull/198)) +- Expose `NMWiredProxy` and propogate speed through + write in field and display for BT device type ([#198](https://github.com/cachebag/nmrs/pull/198)) + ## [1.3.5] - 2026-01-13 ### Changed - Add `Debug` derive to `NetworkManager` ([#171](https://github.com/cachebag/nmrs/pull/171)) diff --git a/nmrs/Cargo.toml b/nmrs/Cargo.toml index 09ae5602..25cb53ce 100644 --- a/nmrs/Cargo.toml +++ b/nmrs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nmrs" -version = "2.0.0-dev" +version = "2.0.0" authors = ["Akrm Al-Hakimi "] edition.workspace = true rust-version = "1.78.0" diff --git a/nmrs/examples/bluetooth_connect.rs b/nmrs/examples/bluetooth_connect.rs index c07eb541..2a9738cc 100644 --- a/nmrs/examples/bluetooth_connect.rs +++ b/nmrs/examples/bluetooth_connect.rs @@ -27,10 +27,7 @@ async fn main() -> Result<()> { if let Some(device) = devices.first() { println!("\nConnecting to: {}", device); - let settings = BluetoothIdentity { - bdaddr: device.bdaddr.clone(), - bt_device_type: device.bt_caps.into(), - }; + let settings = BluetoothIdentity::new(device.bdaddr.clone(), device.bt_caps.into()); let name = device .alias diff --git a/nmrs/src/api/builders/bluetooth.rs b/nmrs/src/api/builders/bluetooth.rs index 030b133e..f03f352c 100644 --- a/nmrs/src/api/builders/bluetooth.rs +++ b/nmrs/src/api/builders/bluetooth.rs @@ -16,10 +16,10 @@ //! use nmrs::builders::build_bluetooth_connection; //! use nmrs::models::{BluetoothIdentity, BluetoothNetworkRole}; //! -//! let bt_settings = BluetoothIdentity { -//! bdaddr: "00:1A:7D:DA:71:13".into(), -//! bt_device_type: BluetoothNetworkRole::PanU, -//! }; +//! let bt_settings = BluetoothIdentity::new( +//! "00:1A:7D:DA:71:13".into(), +//! BluetoothNetworkRole::PanU, +//! ); //! ``` use std::collections::HashMap; @@ -99,17 +99,11 @@ mod tests { } fn create_test_identity_panu() -> BluetoothIdentity { - BluetoothIdentity { - bdaddr: "00:1A:7D:DA:71:13".into(), - bt_device_type: BluetoothNetworkRole::PanU, - } + BluetoothIdentity::new("00:1A:7D:DA:71:13".into(), BluetoothNetworkRole::PanU) } fn create_test_identity_dun() -> BluetoothIdentity { - BluetoothIdentity { - bdaddr: "C8:1F:E8:F0:51:57".into(), - bt_device_type: BluetoothNetworkRole::Dun, - } + BluetoothIdentity::new("C8:1F:E8:F0:51:57".into(), BluetoothNetworkRole::Dun) } #[test] @@ -303,10 +297,8 @@ mod tests { #[test] fn test_bdaddr_format_preserved() { - let identity = BluetoothIdentity { - bdaddr: "AA:BB:CC:DD:EE:FF".into(), - bt_device_type: BluetoothNetworkRole::PanU, - }; + let identity = + BluetoothIdentity::new("AA:BB:CC:DD:EE:FF".into(), BluetoothNetworkRole::PanU); let opts = create_test_opts(); let conn = build_bluetooth_connection("Test", &identity, &opts); diff --git a/nmrs/src/api/models.rs b/nmrs/src/api/models.rs index c905ba1a..16dfcc7f 100644 --- a/nmrs/src/api/models.rs +++ b/nmrs/src/api/models.rs @@ -438,6 +438,7 @@ pub struct Device { /// Contains MAC addresses that uniquely identify the device. The permanent /// MAC is burned into the hardware, while the current MAC may be different /// if MAC address randomization or spoofing is enabled. +#[non_exhaustive] #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct DeviceIdentity { /// The permanent (factory-assigned) MAC address. @@ -446,6 +447,21 @@ pub struct DeviceIdentity { pub current_mac: String, } +impl DeviceIdentity { + /// Creates a new `DeviceIdentity`. + /// + /// # Arguments + /// + /// * `permanent_mac` - The permanent (factory-assigned) MAC address + /// * `current_mac` - The current MAC address in use + pub fn new(permanent_mac: String, current_mac: String) -> Self { + Self { + permanent_mac, + current_mac, + } + } +} + /// EAP (Extensible Authentication Protocol) method for WPA-Enterprise Wi-Fi. /// /// These are the outer authentication methods used in 802.1X authentication. @@ -1060,11 +1076,12 @@ pub enum BluetoothNetworkRole { ///```rust /// use nmrs::models::{BluetoothIdentity, BluetoothNetworkRole}; /// -/// let bt_settings = BluetoothIdentity { -/// bdaddr: "00:1A:7D:DA:71:13".into(), -/// bt_device_type: BluetoothNetworkRole::Dun, -/// }; +/// let bt_settings = BluetoothIdentity::new( +/// "00:1A:7D:DA:71:13".into(), +/// BluetoothNetworkRole::Dun, +/// ); /// ``` +#[non_exhaustive] #[derive(Debug, Clone)] pub struct BluetoothIdentity { /// MAC address of Bluetooth device @@ -1073,6 +1090,32 @@ pub struct BluetoothIdentity { pub bt_device_type: BluetoothNetworkRole, } +impl BluetoothIdentity { + /// Creates a new `BluetoothIdentity`. + /// + /// # Arguments + /// + /// * `bdaddr` - Bluetooth MAC address (e.g., "00:1A:7D:DA:71:13") + /// * `bt_device_type` - Bluetooth network role (PanU or Dun) + /// + /// # Example + /// + /// ```rust + /// use nmrs::models::{BluetoothIdentity, BluetoothNetworkRole}; + /// + /// let identity = BluetoothIdentity::new( + /// "00:1A:7D:DA:71:13".into(), + /// BluetoothNetworkRole::PanU, + /// ); + /// ``` + pub fn new(bdaddr: String, bt_device_type: BluetoothNetworkRole) -> Self { + Self { + bdaddr, + bt_device_type, + } + } +} + /// Bluetooth device with friendly name from BlueZ. /// /// Contains information about a Bluetooth device managed by NetworkManager, @@ -1083,18 +1126,21 @@ pub struct BluetoothIdentity { /// /// # Example /// +/// # Example +/// /// ```rust /// use nmrs::models::{BluetoothDevice, BluetoothNetworkRole, DeviceState}; /// /// let role = BluetoothNetworkRole::PanU as u32; -/// let bt_device = BluetoothDevice { -/// bdaddr: "00:1A:7D:DA:71:13".into(), -/// name: Some("Foo".into()), -/// alias: Some("Bar".into()), -/// bt_caps: role, -/// state: DeviceState::Activated, -/// }; +/// let device = BluetoothDevice::new( +/// "00:1A:7D:DA:71:13".into(), +/// Some("My Phone".into()), +/// Some("Phone".into()), +/// role, +/// DeviceState::Activated, +/// ); /// ``` +#[non_exhaustive] #[derive(Debug, Clone)] pub struct BluetoothDevice { /// Bluetooth MAC address @@ -1109,6 +1155,48 @@ pub struct BluetoothDevice { pub state: DeviceState, } +impl BluetoothDevice { + /// Creates a new `BluetoothDevice`. + /// + /// # Arguments + /// + /// * `bdaddr` - Bluetooth MAC address + /// * `name` - Friendly device name from BlueZ + /// * `alias` - Device alias from BlueZ + /// * `bt_caps` - Bluetooth device capabilities/type + /// * `state` - Current device state + /// + /// # Example + /// + /// ```rust + /// use nmrs::models::{BluetoothDevice, BluetoothNetworkRole, DeviceState}; + /// + /// let role = BluetoothNetworkRole::PanU as u32; + /// let device = BluetoothDevice::new( + /// "00:1A:7D:DA:71:13".into(), + /// Some("My Phone".into()), + /// Some("Phone".into()), + /// role, + /// DeviceState::Activated, + /// ); + /// ``` + pub fn new( + bdaddr: String, + name: Option, + alias: Option, + bt_caps: u32, + state: DeviceState, + ) -> Self { + Self { + bdaddr, + name, + alias, + bt_caps, + state, + } + } +} + /// NetworkManager device types. /// /// Represents the type of network hardware managed by NetworkManager. @@ -2179,10 +2267,8 @@ mod tests { #[test] fn test_bluetooth_identity_creation() { - let identity = BluetoothIdentity { - bdaddr: "00:1A:7D:DA:71:13".into(), - bt_device_type: BluetoothNetworkRole::PanU, - }; + let identity = + BluetoothIdentity::new("00:1A:7D:DA:71:13".into(), BluetoothNetworkRole::PanU); assert_eq!(identity.bdaddr, "00:1A:7D:DA:71:13"); assert!(matches!( @@ -2193,10 +2279,8 @@ mod tests { #[test] fn test_bluetooth_identity_dun() { - let identity = BluetoothIdentity { - bdaddr: "C8:1F:E8:F0:51:57".into(), - bt_device_type: BluetoothNetworkRole::Dun, - }; + let identity = + BluetoothIdentity::new("C8:1F:E8:F0:51:57".into(), BluetoothNetworkRole::Dun); assert_eq!(identity.bdaddr, "C8:1F:E8:F0:51:57"); assert!(matches!(identity.bt_device_type, BluetoothNetworkRole::Dun)); @@ -2205,13 +2289,13 @@ mod tests { #[test] fn test_bluetooth_device_creation() { let role = BluetoothNetworkRole::PanU as u32; - let device = BluetoothDevice { - bdaddr: "00:1A:7D:DA:71:13".into(), - name: Some("MyPhone".into()), - alias: Some("Phone".into()), - bt_caps: role, - state: DeviceState::Activated, - }; + let device = BluetoothDevice::new( + "00:1A:7D:DA:71:13".into(), + Some("MyPhone".into()), + Some("Phone".into()), + role, + DeviceState::Activated, + ); assert_eq!(device.bdaddr, "00:1A:7D:DA:71:13"); assert_eq!(device.name, Some("MyPhone".into())); @@ -2223,13 +2307,13 @@ mod tests { #[test] fn test_bluetooth_device_display() { let role = BluetoothNetworkRole::PanU as u32; - let device = BluetoothDevice { - bdaddr: "00:1A:7D:DA:71:13".into(), - name: Some("MyPhone".into()), - alias: Some("Phone".into()), - bt_caps: role, - state: DeviceState::Activated, - }; + let device = BluetoothDevice::new( + "00:1A:7D:DA:71:13".into(), + Some("MyPhone".into()), + Some("Phone".into()), + role, + DeviceState::Activated, + ); let display_str = format!("{}", device); assert!(display_str.contains("Phone")); @@ -2240,13 +2324,13 @@ mod tests { #[test] fn test_bluetooth_device_display_no_alias() { let role = BluetoothNetworkRole::Dun as u32; - let device = BluetoothDevice { - bdaddr: "00:1A:7D:DA:71:13".into(), - name: Some("MyPhone".into()), - alias: None, - bt_caps: role, - state: DeviceState::Disconnected, - }; + let device = BluetoothDevice::new( + "00:1A:7D:DA:71:13".into(), + Some("MyPhone".into()), + None, + role, + DeviceState::Disconnected, + ); let display_str = format!("{}", device); assert!(display_str.contains("unknown")); @@ -2259,10 +2343,7 @@ mod tests { let bt_device = Device { path: "/org/freedesktop/NetworkManager/Devices/1".into(), interface: "bt0".into(), - identity: DeviceIdentity { - permanent_mac: "00:1A:7D:DA:71:13".into(), - current_mac: "00:1A:7D:DA:71:13".into(), - }, + identity: DeviceIdentity::new("00:1A:7D:DA:71:13".into(), "00:1A:7D:DA:71:13".into()), device_type: DeviceType::Bluetooth, state: DeviceState::Activated, managed: Some(true), diff --git a/nmrs/src/api/network_manager.rs b/nmrs/src/api/network_manager.rs index 46ffcce3..19dd7912 100644 --- a/nmrs/src/api/network_manager.rs +++ b/nmrs/src/api/network_manager.rs @@ -187,10 +187,10 @@ impl NetworkManager { /// # async fn example() -> nmrs::Result<()> { /// let nm = NetworkManager::new().await?; /// - /// let identity = BluetoothIdentity { - /// bdaddr: "C8:1F:E8:F0:51:57".into(), - /// bt_device_type: BluetoothNetworkRole::PanU, - /// }; + /// let identity = BluetoothIdentity::new( + /// "C8:1F:E8:F0:51:57".into(), + /// BluetoothNetworkRole::PanU, + /// ); /// /// nm.connect_bluetooth("My Phone", &identity).await?; /// Ok(()) @@ -216,23 +216,20 @@ impl NetworkManager { /// # 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, - /// }; + /// let peer = WireGuardPeer::new( + /// "peer_public_key", + /// "vpn.example.com:51820", + /// vec!["0.0.0.0/0".into()], + /// ).with_persistent_keepalive(25); + /// + /// let creds = VpnCredentials::new( + /// VpnType::WireGuard, + /// "MyVPN", + /// "vpn.example.com:51820", + /// "your_private_key", + /// "10.0.0.2/24", + /// vec![peer], + /// ).with_dns(vec!["1.1.1.1".into()]); /// /// nm.connect_vpn(creds).await?; /// # Ok(()) @@ -553,15 +550,12 @@ impl NetworkManager { /// # Ok(()) /// # } /// ``` - pub async fn monitor_network_changes( - &self, - shutdown: watch::Receiver<()>, - callback: F, - ) -> Result<()> + pub async fn monitor_network_changes(&self, callback: F) -> Result<()> where F: Fn() + 'static, { - network_monitor::monitor_network_changes(&self.conn, shutdown, callback).await + let (_tx, rx) = watch::channel(()); + network_monitor::monitor_network_changes(&self.conn, rx, callback).await } /// Monitors device state changes in real-time. @@ -593,14 +587,11 @@ impl NetworkManager { /// # Ok(()) /// # } /// ``` - pub async fn monitor_device_changes( - &self, - shutdown: watch::Receiver<()>, - callback: F, - ) -> Result<()> + pub async fn monitor_device_changes(&self, callback: F) -> Result<()> where F: Fn() + 'static, { - device_monitor::monitor_device_changes(&self.conn, shutdown, callback).await + let (_tx, rx) = watch::channel(()); + device_monitor::monitor_device_changes(&self.conn, rx, callback).await } } diff --git a/nmrs/src/core/bluetooth.rs b/nmrs/src/core/bluetooth.rs index 515d3ae0..9eebd8b5 100644 --- a/nmrs/src/core/bluetooth.rs +++ b/nmrs/src/core/bluetooth.rs @@ -95,10 +95,10 @@ pub(crate) async fn find_bluetooth_device( /// ```no_run /// use nmrs::models::{BluetoothIdentity, BluetoothNetworkRole}; /// -/// let settings = BluetoothIdentity { -/// bdaddr: "C8:1F:E8:F0:51:57".into(), -/// bt_device_type: BluetoothNetworkRole::PanU, -/// }; +/// let settings = BluetoothIdentity::new( +/// "C8:1F:E8:F0:51:57".into(), +/// BluetoothNetworkRole::PanU, +/// ); /// // connect_bluetooth(&conn, "My Phone", &settings).await?; /// ``` pub(crate) async fn connect_bluetooth( @@ -254,10 +254,8 @@ mod tests { #[test] fn test_bluetooth_identity_structure() { - let identity = BluetoothIdentity { - bdaddr: "00:1A:7D:DA:71:13".into(), - bt_device_type: BluetoothNetworkRole::PanU, - }; + let identity = + BluetoothIdentity::new("00:1A:7D:DA:71:13".into(), BluetoothNetworkRole::PanU); assert_eq!(identity.bdaddr, "00:1A:7D:DA:71:13"); assert!(matches!( diff --git a/nmrs/src/core/device.rs b/nmrs/src/core/device.rs index 12c942e8..98fa87a5 100644 --- a/nmrs/src/core/device.rs +++ b/nmrs/src/core/device.rs @@ -89,10 +89,7 @@ pub(crate) async fn list_devices(conn: &Connection) -> Result> { devices.push(Device { path: p.to_string(), interface, - identity: DeviceIdentity { - permanent_mac: perm_mac, - current_mac, - }, + identity: DeviceIdentity::new(perm_mac, current_mac), device_type, state, managed, @@ -136,13 +133,13 @@ pub(crate) async fn list_bluetooth_devices(conn: &Connection) -> Result Date: Sun, 18 Jan 2026 23:27:47 -0500 Subject: [PATCH 19/23] feat(#188): builder pattern for `VpnCredentials` and `EapOptions` --- nmrs/CHANGELOG.md | 1 + nmrs/examples/vpn_connect.rs | 25 +- nmrs/examples/wifi_enterprise.rs | 30 ++ nmrs/src/api/models.rs | 798 +++++++++++++++++++++++++++++++ 4 files changed, 844 insertions(+), 10 deletions(-) create mode 100644 nmrs/examples/wifi_enterprise.rs diff --git a/nmrs/CHANGELOG.md b/nmrs/CHANGELOG.md index 94adaa1d..c6955833 100644 --- a/nmrs/CHANGELOG.md +++ b/nmrs/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to the `nmrs` crate will be documented in this file. ## [Unreleased] ### Added +- Builder pattern for `VpnCredentials` and `EapOptions` ([#188](https://github.com/cachebag/nmrs/issues/188)) - Bluetooth device support ([#198](https://github.com/cachebag/nmrs/pull/198)) - Input validation before any D-Bus operations ([#173](https://github.com/cachebag/nmrs/pull/173)) - CI: adjust workflow to auto-update nix hashes on PRs ([#182](https://github.com/cachebag/nmrs/pull/182)) diff --git a/nmrs/examples/vpn_connect.rs b/nmrs/examples/vpn_connect.rs index 18f7e1e0..28fbb64a 100644 --- a/nmrs/examples/vpn_connect.rs +++ b/nmrs/examples/vpn_connect.rs @@ -1,10 +1,14 @@ /// Connect to a WireGuard VPN using NetworkManager and print the assigned IP address. -use nmrs::{NetworkManager, VpnCredentials, VpnType, WireGuardPeer}; +/// +/// This example demonstrates using the builder pattern for creating VPN credentials, +/// which provides a more ergonomic and readable API compared to the traditional constructor. +use nmrs::{NetworkManager, VpnCredentials, WireGuardPeer}; #[tokio::main] async fn main() -> nmrs::Result<()> { let nm = NetworkManager::new().await?; + // Create a WireGuard peer with keepalive let peer = WireGuardPeer::new( std::env::var("WG_PUBLIC_KEY").expect("Set WG_PUBLIC_KEY env var"), "vpn.example.com:51820", @@ -12,15 +16,16 @@ async fn main() -> nmrs::Result<()> { ) .with_persistent_keepalive(25); - let creds = VpnCredentials::new( - VpnType::WireGuard, - "ExampleVPN", - "vpn.example.com:51820", - std::env::var("WG_PRIVATE_KEY").expect("Set WG_PRIVATE_KEY env var"), - "10.0.0.2/24", - vec![peer], - ) - .with_dns(vec!["1.1.1.1".into()]); + // Use the builder pattern for a more readable configuration + let creds = VpnCredentials::builder() + .name("ExampleVPN") + .wireguard() + .gateway("vpn.example.com:51820") + .private_key(std::env::var("WG_PRIVATE_KEY").expect("Set WG_PRIVATE_KEY env var")) + .address("10.0.0.2/24") + .add_peer(peer) + .with_dns(vec!["1.1.1.1".into()]) + .build(); println!("Connecting to VPN..."); nm.connect_vpn(creds).await?; diff --git a/nmrs/examples/wifi_enterprise.rs b/nmrs/examples/wifi_enterprise.rs new file mode 100644 index 00000000..e931d6b8 --- /dev/null +++ b/nmrs/examples/wifi_enterprise.rs @@ -0,0 +1,30 @@ +/// Connect to a WPA-Enterprise (802.1X) WiFi network using EAP authentication. +/// +/// This example demonstrates using the builder pattern for creating EAP options, +/// which is useful for corporate/university WiFi networks that require 802.1X authentication. +use nmrs::{EapMethod, EapOptions, NetworkManager, Phase2, WifiSecurity}; + +#[tokio::main] +async fn main() -> nmrs::Result<()> { + let nm = NetworkManager::new().await?; + + // Use the builder pattern for a more readable EAP configuration + let eap_opts = EapOptions::builder() + .identity("user@company.com") + .password(std::env::var("WIFI_PASSWORD").expect("Set WIFI_PASSWORD env var")) + .method(EapMethod::Peap) + .phase2(Phase2::Mschapv2) + .anonymous_identity("anonymous@company.com") + .domain_suffix_match("company.com") + .system_ca_certs(true) + .build(); + + let security = WifiSecurity::WpaEap { opts: eap_opts }; + + println!("Connecting to enterprise WiFi network..."); + nm.connect("CorpNetwork", security).await?; + + println!("Successfully connected to enterprise WiFi!"); + + Ok(()) +} diff --git a/nmrs/src/api/models.rs b/nmrs/src/api/models.rs index 16dfcc7f..c06f78b9 100644 --- a/nmrs/src/api/models.rs +++ b/nmrs/src/api/models.rs @@ -578,6 +578,29 @@ impl EapOptions { } } + /// Creates a new `EapOptions` builder. + /// + /// This provides an alternative way to construct EAP options with a fluent API, + /// making it clearer what each configuration option does. + /// + /// # Examples + /// + /// ```rust + /// use nmrs::{EapOptions, EapMethod, Phase2}; + /// + /// let opts = EapOptions::builder() + /// .identity("user@company.com") + /// .password("my_password") + /// .method(EapMethod::Peap) + /// .phase2(Phase2::Mschapv2) + /// .domain_suffix_match("company.com") + /// .system_ca_certs(true) + /// .build(); + /// ``` + pub fn builder() -> EapOptionsBuilder { + EapOptionsBuilder::default() + } + /// Sets the anonymous identity for privacy. pub fn with_anonymous_identity(mut self, anonymous_identity: impl Into) -> Self { self.anonymous_identity = Some(anonymous_identity.into()); @@ -615,6 +638,218 @@ impl EapOptions { } } +/// Builder for constructing `EapOptions` with a fluent API. +/// +/// This builder provides an ergonomic way to create EAP (Enterprise WiFi) +/// authentication options, making the configuration more explicit and readable. +/// +/// # Examples +/// +/// ## PEAP with MSCHAPv2 (Common Corporate Setup) +/// +/// ```rust +/// use nmrs::{EapOptions, EapMethod, Phase2}; +/// +/// let opts = EapOptions::builder() +/// .identity("employee@company.com") +/// .password("my_password") +/// .method(EapMethod::Peap) +/// .phase2(Phase2::Mschapv2) +/// .anonymous_identity("anonymous@company.com") +/// .domain_suffix_match("company.com") +/// .system_ca_certs(true) +/// .build(); +/// ``` +/// +/// ## TTLS with PAP +/// +/// ```rust +/// use nmrs::{EapOptions, EapMethod, Phase2}; +/// +/// let opts = EapOptions::builder() +/// .identity("student@university.edu") +/// .password("password") +/// .method(EapMethod::Ttls) +/// .phase2(Phase2::Pap) +/// .ca_cert_path("file:///etc/ssl/certs/university-ca.pem") +/// .build(); +/// ``` +#[derive(Debug, Default)] +pub struct EapOptionsBuilder { + identity: Option, + password: Option, + anonymous_identity: Option, + domain_suffix_match: Option, + ca_cert_path: Option, + system_ca_certs: bool, + method: Option, + phase2: Option, +} + +impl EapOptionsBuilder { + /// Sets the user identity (usually email or username). + /// + /// This is a required field. + pub fn identity(mut self, identity: impl Into) -> Self { + self.identity = Some(identity.into()); + self + } + + /// Sets the password for authentication. + /// + /// This is a required field. + pub fn password(mut self, password: impl Into) -> Self { + self.password = Some(password.into()); + self + } + + /// Sets the anonymous outer identity for privacy. + /// + /// This identity is sent in the clear during the initial handshake, + /// while the real identity is protected inside the TLS tunnel. + /// + /// # Examples + /// + /// ```rust + /// use nmrs::EapOptions; + /// + /// let builder = EapOptions::builder() + /// .anonymous_identity("anonymous@company.com"); + /// ``` + pub fn anonymous_identity(mut self, anonymous_identity: impl Into) -> Self { + self.anonymous_identity = Some(anonymous_identity.into()); + self + } + + /// Sets the domain suffix to match against the server certificate. + /// + /// This provides additional security by verifying the server's certificate + /// matches the expected domain. + /// + /// # Examples + /// + /// ```rust + /// use nmrs::EapOptions; + /// + /// let builder = EapOptions::builder() + /// .domain_suffix_match("company.com"); + /// ``` + pub fn domain_suffix_match(mut self, domain: impl Into) -> Self { + self.domain_suffix_match = Some(domain.into()); + self + } + + /// Sets the path to the CA certificate file. + /// + /// The path must start with `file://` (e.g., "file:///etc/ssl/certs/ca.pem"). + /// + /// # Examples + /// + /// ```rust + /// use nmrs::EapOptions; + /// + /// let builder = EapOptions::builder() + /// .ca_cert_path("file:///etc/ssl/certs/company-ca.pem"); + /// ``` + pub fn ca_cert_path(mut self, path: impl Into) -> Self { + self.ca_cert_path = Some(path.into()); + self + } + + /// Sets whether to use the system CA certificate store. + /// + /// When enabled, the system's trusted CA certificates will be used + /// to validate the server certificate. + /// + /// # Examples + /// + /// ```rust + /// use nmrs::EapOptions; + /// + /// let builder = EapOptions::builder() + /// .system_ca_certs(true); + /// ``` + pub fn system_ca_certs(mut self, use_system: bool) -> Self { + self.system_ca_certs = use_system; + self + } + + /// Sets the EAP method (PEAP or TTLS). + /// + /// This is a required field. PEAP is more common in corporate environments, + /// while TTLS offers more flexibility in inner authentication methods. + /// + /// # Examples + /// + /// ```rust + /// use nmrs::{EapOptions, EapMethod}; + /// + /// let builder = EapOptions::builder() + /// .method(EapMethod::Peap); + /// ``` + pub fn method(mut self, method: EapMethod) -> Self { + self.method = Some(method); + self + } + + /// Sets the Phase 2 (inner) authentication method. + /// + /// This is a required field. MSCHAPv2 is commonly used with PEAP, + /// while PAP is often used with TTLS. + /// + /// # Examples + /// + /// ```rust + /// use nmrs::{EapOptions, Phase2}; + /// + /// let builder = EapOptions::builder() + /// .phase2(Phase2::Mschapv2); + /// ``` + pub fn phase2(mut self, phase2: Phase2) -> Self { + self.phase2 = Some(phase2); + self + } + + /// Builds the `EapOptions` from the configured values. + /// + /// # Panics + /// + /// Panics if any required field is missing: + /// - `identity` (use [`identity()`](Self::identity)) + /// - `password` (use [`password()`](Self::password)) + /// - `method` (use [`method()`](Self::method)) + /// - `phase2` (use [`phase2()`](Self::phase2)) + /// + /// # Examples + /// + /// ```rust + /// use nmrs::{EapOptions, EapMethod, Phase2}; + /// + /// let opts = EapOptions::builder() + /// .identity("user@example.com") + /// .password("password") + /// .method(EapMethod::Peap) + /// .phase2(Phase2::Mschapv2) + /// .build(); + /// ``` + pub fn build(self) -> EapOptions { + EapOptions { + identity: self + .identity + .expect("identity is required (use .identity())"), + password: self + .password + .expect("password is required (use .password())"), + anonymous_identity: self.anonymous_identity, + domain_suffix_match: self.domain_suffix_match, + ca_cert_path: self.ca_cert_path, + system_ca_certs: self.system_ca_certs, + method: self.method.expect("method is required (use .method())"), + phase2: self.phase2.expect("phase2 is required (use .phase2())"), + } + } +} + /// Connection options for saved NetworkManager connections. /// /// Controls how NetworkManager handles saved connection profiles, @@ -881,6 +1116,36 @@ impl VpnCredentials { } } + /// Creates a new `VpnCredentials` builder. + /// + /// This provides a more ergonomic way to construct VPN credentials with a fluent API, + /// making it harder to mix up parameter order and easier to see what each value represents. + /// + /// # Examples + /// + /// ```rust + /// use nmrs::{VpnCredentials, VpnType, WireGuardPeer}; + /// + /// let peer = WireGuardPeer::new( + /// "server_public_key", + /// "vpn.example.com:51820", + /// vec!["0.0.0.0/0".into()], + /// ); + /// + /// let creds = VpnCredentials::builder() + /// .name("MyVPN") + /// .wireguard() + /// .gateway("vpn.example.com:51820") + /// .private_key("client_private_key") + /// .address("10.0.0.2/24") + /// .add_peer(peer) + /// .with_dns(vec!["1.1.1.1".into()]) + /// .build(); + /// ``` + pub fn builder() -> VpnCredentialsBuilder { + VpnCredentialsBuilder::default() + } + /// Sets the DNS servers to use when connected. pub fn with_dns(mut self, dns: Vec) -> Self { self.dns = Some(dns); @@ -900,6 +1165,225 @@ impl VpnCredentials { } } +/// Builder for constructing `VpnCredentials` with a fluent API. +/// +/// This builder provides a more ergonomic way to create VPN credentials, +/// making the code more readable and less error-prone compared to the +/// traditional constructor with many positional parameters. +/// +/// # Examples +/// +/// ## Basic WireGuard VPN +/// +/// ```rust +/// use nmrs::{VpnCredentials, WireGuardPeer}; +/// +/// let peer = WireGuardPeer::new( +/// "HIgo9xNzJMWLKAShlKl6/bUT1VI9Q0SDBXGtLXkPFXc=", +/// "vpn.example.com:51820", +/// vec!["0.0.0.0/0".into()], +/// ); +/// +/// let creds = VpnCredentials::builder() +/// .name("HomeVPN") +/// .wireguard() +/// .gateway("vpn.example.com:51820") +/// .private_key("YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=") +/// .address("10.0.0.2/24") +/// .add_peer(peer) +/// .build(); +/// ``` +/// +/// ## With Optional DNS and MTU +/// +/// ```rust +/// use nmrs::{VpnCredentials, WireGuardPeer}; +/// +/// let peer = WireGuardPeer::new( +/// "server_public_key", +/// "vpn.example.com:51820", +/// vec!["0.0.0.0/0".into()], +/// ).with_persistent_keepalive(25); +/// +/// let creds = VpnCredentials::builder() +/// .name("CorpVPN") +/// .wireguard() +/// .gateway("vpn.corp.com:51820") +/// .private_key("private_key_here") +/// .address("10.8.0.2/24") +/// .add_peer(peer) +/// .with_dns(vec!["1.1.1.1".into(), "8.8.8.8".into()]) +/// .with_mtu(1420) +/// .build(); +/// ``` +#[derive(Debug, Default)] +pub struct VpnCredentialsBuilder { + vpn_type: Option, + name: Option, + gateway: Option, + private_key: Option, + address: Option, + peers: Vec, + dns: Option>, + mtu: Option, + uuid: Option, +} + +impl VpnCredentialsBuilder { + /// Sets the VPN type to WireGuard. + /// + /// Currently, WireGuard is the only supported VPN type. + pub fn wireguard(mut self) -> Self { + self.vpn_type = Some(VpnType::WireGuard); + self + } + + /// Sets the VPN type. + /// + /// For most use cases, prefer using [`wireguard()`](Self::wireguard) instead. + pub fn vpn_type(mut self, vpn_type: VpnType) -> Self { + self.vpn_type = Some(vpn_type); + self + } + + /// Sets the connection name. + /// + /// This is the unique identifier for the VPN connection profile. + pub fn name(mut self, name: impl Into) -> Self { + self.name = Some(name.into()); + self + } + + /// Sets the VPN gateway endpoint. + /// + /// Should be in "host:port" format (e.g., "vpn.example.com:51820"). + pub fn gateway(mut self, gateway: impl Into) -> Self { + self.gateway = Some(gateway.into()); + self + } + + /// Sets the client's WireGuard private key. + /// + /// The private key should be base64 encoded. + pub fn private_key(mut self, private_key: impl Into) -> Self { + self.private_key = Some(private_key.into()); + self + } + + /// Sets the client's IP address with CIDR notation. + /// + /// # Examples + /// + /// - "10.0.0.2/24" for a /24 subnet + /// - "192.168.1.10/32" for a single IP + pub fn address(mut self, address: impl Into) -> Self { + self.address = Some(address.into()); + self + } + + /// Adds a WireGuard peer to the connection. + /// + /// Multiple peers can be added by calling this method multiple times. + pub fn add_peer(mut self, peer: WireGuardPeer) -> Self { + self.peers.push(peer); + self + } + + /// Sets all WireGuard peers at once. + /// + /// This replaces any previously added peers. + pub fn peers(mut self, peers: Vec) -> Self { + self.peers = peers; + self + } + + /// Sets the DNS servers to use when connected. + /// + /// # Examples + /// + /// ```rust + /// use nmrs::VpnCredentials; + /// + /// let builder = VpnCredentials::builder() + /// .with_dns(vec!["1.1.1.1".into(), "8.8.8.8".into()]); + /// ``` + pub fn with_dns(mut self, dns: Vec) -> Self { + self.dns = Some(dns); + self + } + + /// Sets the MTU (Maximum Transmission Unit) size. + /// + /// Typical values are 1420 for WireGuard over standard networks. + pub fn with_mtu(mut self, mtu: u32) -> Self { + self.mtu = Some(mtu); + self + } + + /// Sets a specific UUID for the connection. + /// + /// If not set, NetworkManager will generate one automatically. + pub fn with_uuid(mut self, uuid: Uuid) -> Self { + self.uuid = Some(uuid); + self + } + + /// Builds the `VpnCredentials` from the configured values. + /// + /// # Panics + /// + /// Panics if any required field is missing: + /// - `vpn_type` (use [`wireguard()`](Self::wireguard)) + /// - `name` (use [`name()`](Self::name)) + /// - `gateway` (use [`gateway()`](Self::gateway)) + /// - `private_key` (use [`private_key()`](Self::private_key)) + /// - `address` (use [`address()`](Self::address)) + /// - At least one peer must be added (use [`add_peer()`](Self::add_peer)) + /// + /// # Examples + /// + /// ```rust + /// use nmrs::{VpnCredentials, WireGuardPeer}; + /// + /// let peer = WireGuardPeer::new( + /// "public_key", + /// "vpn.example.com:51820", + /// vec!["0.0.0.0/0".into()], + /// ); + /// + /// let creds = VpnCredentials::builder() + /// .name("MyVPN") + /// .wireguard() + /// .gateway("vpn.example.com:51820") + /// .private_key("private_key") + /// .address("10.0.0.2/24") + /// .add_peer(peer) + /// .build(); + /// ``` + pub fn build(self) -> VpnCredentials { + VpnCredentials { + vpn_type: self + .vpn_type + .expect("vpn_type is required (use .wireguard())"), + name: self.name.expect("name is required (use .name())"), + gateway: self.gateway.expect("gateway is required (use .gateway())"), + private_key: self + .private_key + .expect("private_key is required (use .private_key())"), + address: self.address.expect("address is required (use .address())"), + peers: { + if self.peers.is_empty() { + panic!("at least one peer is required (use .add_peer())"); + } + self.peers + }, + dns: self.dns, + mtu: self.mtu, + uuid: self.uuid, + } + } +} + /// WireGuard peer configuration. /// /// Represents a single WireGuard peer (server) to connect to. @@ -2370,4 +2854,318 @@ mod tests { let err = ConnectionError::NoBluetoothDevice; assert_eq!(format!("{}", err), "Bluetooth device not found"); } + + // Builder pattern tests + + #[test] + fn test_vpn_credentials_builder_basic() { + let peer = WireGuardPeer::new( + "HIgo9xNzJMWLKAShlKl6/bUT1VI9Q0SDBXGtLXkPFXc=", + "vpn.example.com:51820", + vec!["0.0.0.0/0".into()], + ); + + let creds = VpnCredentials::builder() + .name("TestVPN") + .wireguard() + .gateway("vpn.example.com:51820") + .private_key("YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=") + .address("10.0.0.2/24") + .add_peer(peer) + .build(); + + assert_eq!(creds.name, "TestVPN"); + assert_eq!(creds.vpn_type, VpnType::WireGuard); + assert_eq!(creds.gateway, "vpn.example.com:51820"); + assert_eq!( + creds.private_key, + "YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=" + ); + assert_eq!(creds.address, "10.0.0.2/24"); + assert_eq!(creds.peers.len(), 1); + assert!(creds.dns.is_none()); + assert!(creds.mtu.is_none()); + } + + #[test] + fn test_vpn_credentials_builder_with_optionals() { + let peer = WireGuardPeer::new( + "public_key", + "vpn.example.com:51820", + vec!["0.0.0.0/0".into()], + ); + + let uuid = Uuid::new_v4(); + let creds = VpnCredentials::builder() + .name("TestVPN") + .wireguard() + .gateway("vpn.example.com:51820") + .private_key("private_key") + .address("10.0.0.2/24") + .add_peer(peer) + .with_dns(vec!["1.1.1.1".into(), "8.8.8.8".into()]) + .with_mtu(1420) + .with_uuid(uuid) + .build(); + + assert_eq!(creds.dns, Some(vec!["1.1.1.1".into(), "8.8.8.8".into()])); + assert_eq!(creds.mtu, Some(1420)); + assert_eq!(creds.uuid, Some(uuid)); + } + + #[test] + fn test_vpn_credentials_builder_multiple_peers() { + let peer1 = + WireGuardPeer::new("key1", "vpn1.example.com:51820", vec!["10.0.0.0/24".into()]); + let peer2 = WireGuardPeer::new( + "key2", + "vpn2.example.com:51820", + vec!["192.168.0.0/24".into()], + ); + + let creds = VpnCredentials::builder() + .name("MultiPeerVPN") + .wireguard() + .gateway("vpn.example.com:51820") + .private_key("private_key") + .address("10.0.0.2/24") + .add_peer(peer1) + .add_peer(peer2) + .build(); + + assert_eq!(creds.peers.len(), 2); + } + + #[test] + fn test_vpn_credentials_builder_peers_method() { + let peers = vec![ + WireGuardPeer::new("key1", "vpn1.example.com:51820", vec!["0.0.0.0/0".into()]), + WireGuardPeer::new("key2", "vpn2.example.com:51820", vec!["0.0.0.0/0".into()]), + ]; + + let creds = VpnCredentials::builder() + .name("TestVPN") + .wireguard() + .gateway("vpn.example.com:51820") + .private_key("private_key") + .address("10.0.0.2/24") + .peers(peers) + .build(); + + assert_eq!(creds.peers.len(), 2); + } + + #[test] + #[should_panic(expected = "name is required")] + fn test_vpn_credentials_builder_missing_name() { + let peer = WireGuardPeer::new("key", "vpn.example.com:51820", vec!["0.0.0.0/0".into()]); + + VpnCredentials::builder() + .wireguard() + .gateway("vpn.example.com:51820") + .private_key("private_key") + .address("10.0.0.2/24") + .add_peer(peer) + .build(); + } + + #[test] + #[should_panic(expected = "vpn_type is required")] + fn test_vpn_credentials_builder_missing_vpn_type() { + let peer = WireGuardPeer::new("key", "vpn.example.com:51820", vec!["0.0.0.0/0".into()]); + + VpnCredentials::builder() + .name("TestVPN") + .gateway("vpn.example.com:51820") + .private_key("private_key") + .address("10.0.0.2/24") + .add_peer(peer) + .build(); + } + + #[test] + #[should_panic(expected = "at least one peer is required")] + fn test_vpn_credentials_builder_missing_peers() { + VpnCredentials::builder() + .name("TestVPN") + .wireguard() + .gateway("vpn.example.com:51820") + .private_key("private_key") + .address("10.0.0.2/24") + .build(); + } + + #[test] + fn test_eap_options_builder_basic() { + let opts = EapOptions::builder() + .identity("user@example.com") + .password("password") + .method(EapMethod::Peap) + .phase2(Phase2::Mschapv2) + .build(); + + assert_eq!(opts.identity, "user@example.com"); + assert_eq!(opts.password, "password"); + assert_eq!(opts.method, EapMethod::Peap); + assert_eq!(opts.phase2, Phase2::Mschapv2); + assert!(opts.anonymous_identity.is_none()); + assert!(opts.domain_suffix_match.is_none()); + assert!(opts.ca_cert_path.is_none()); + assert!(!opts.system_ca_certs); + } + + #[test] + fn test_eap_options_builder_with_optionals() { + let opts = EapOptions::builder() + .identity("user@company.com") + .password("password") + .method(EapMethod::Ttls) + .phase2(Phase2::Pap) + .anonymous_identity("anonymous@company.com") + .domain_suffix_match("company.com") + .ca_cert_path("file:///etc/ssl/certs/ca.pem") + .system_ca_certs(true) + .build(); + + assert_eq!(opts.identity, "user@company.com"); + assert_eq!(opts.password, "password"); + assert_eq!(opts.method, EapMethod::Ttls); + assert_eq!(opts.phase2, Phase2::Pap); + assert_eq!( + opts.anonymous_identity, + Some("anonymous@company.com".into()) + ); + assert_eq!(opts.domain_suffix_match, Some("company.com".into())); + assert_eq!( + opts.ca_cert_path, + Some("file:///etc/ssl/certs/ca.pem".into()) + ); + assert!(opts.system_ca_certs); + } + + #[test] + fn test_eap_options_builder_peap_mschapv2() { + let opts = EapOptions::builder() + .identity("employee@corp.com") + .password("secret") + .method(EapMethod::Peap) + .phase2(Phase2::Mschapv2) + .system_ca_certs(true) + .build(); + + assert_eq!(opts.method, EapMethod::Peap); + assert_eq!(opts.phase2, Phase2::Mschapv2); + assert!(opts.system_ca_certs); + } + + #[test] + fn test_eap_options_builder_ttls_pap() { + let opts = EapOptions::builder() + .identity("student@university.edu") + .password("password") + .method(EapMethod::Ttls) + .phase2(Phase2::Pap) + .ca_cert_path("file:///etc/ssl/certs/university.pem") + .build(); + + assert_eq!(opts.method, EapMethod::Ttls); + assert_eq!(opts.phase2, Phase2::Pap); + assert_eq!( + opts.ca_cert_path, + Some("file:///etc/ssl/certs/university.pem".into()) + ); + } + + #[test] + #[should_panic(expected = "identity is required")] + fn test_eap_options_builder_missing_identity() { + EapOptions::builder() + .password("password") + .method(EapMethod::Peap) + .phase2(Phase2::Mschapv2) + .build(); + } + + #[test] + #[should_panic(expected = "password is required")] + fn test_eap_options_builder_missing_password() { + EapOptions::builder() + .identity("user@example.com") + .method(EapMethod::Peap) + .phase2(Phase2::Mschapv2) + .build(); + } + + #[test] + #[should_panic(expected = "method is required")] + fn test_eap_options_builder_missing_method() { + EapOptions::builder() + .identity("user@example.com") + .password("password") + .phase2(Phase2::Mschapv2) + .build(); + } + + #[test] + #[should_panic(expected = "phase2 is required")] + fn test_eap_options_builder_missing_phase2() { + EapOptions::builder() + .identity("user@example.com") + .password("password") + .method(EapMethod::Peap) + .build(); + } + + #[test] + fn test_vpn_credentials_builder_equivalence_to_new() { + let peer = WireGuardPeer::new( + "public_key", + "vpn.example.com:51820", + vec!["0.0.0.0/0".into()], + ); + + let creds_new = VpnCredentials::new( + VpnType::WireGuard, + "TestVPN", + "vpn.example.com:51820", + "private_key", + "10.0.0.2/24", + vec![peer.clone()], + ); + + let creds_builder = VpnCredentials::builder() + .name("TestVPN") + .wireguard() + .gateway("vpn.example.com:51820") + .private_key("private_key") + .address("10.0.0.2/24") + .add_peer(peer) + .build(); + + assert_eq!(creds_new.name, creds_builder.name); + assert_eq!(creds_new.vpn_type, creds_builder.vpn_type); + assert_eq!(creds_new.gateway, creds_builder.gateway); + assert_eq!(creds_new.private_key, creds_builder.private_key); + assert_eq!(creds_new.address, creds_builder.address); + assert_eq!(creds_new.peers.len(), creds_builder.peers.len()); + } + + #[test] + fn test_eap_options_builder_equivalence_to_new() { + let opts_new = EapOptions::new("user@example.com", "password") + .with_method(EapMethod::Peap) + .with_phase2(Phase2::Mschapv2); + + let opts_builder = EapOptions::builder() + .identity("user@example.com") + .password("password") + .method(EapMethod::Peap) + .phase2(Phase2::Mschapv2) + .build(); + + assert_eq!(opts_new.identity, opts_builder.identity); + assert_eq!(opts_new.password, opts_builder.password); + assert_eq!(opts_new.method, opts_builder.method); + assert_eq!(opts_new.phase2, opts_builder.phase2); + } } From aeb0216bb8ef38cd1f88bdfcfda4e71e79f61ef7 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Sun, 18 Jan 2026 23:39:00 -0500 Subject: [PATCH 20/23] feat(#185): configurable timeout values for all code paths --- nmrs/CHANGELOG.md | 3 +- nmrs/examples/custom_timeouts.rs | 53 ++++++++++ nmrs/src/api/models.rs | 162 +++++++++++++++++++++++++++++++ nmrs/src/api/network_manager.rs | 84 ++++++++++++++-- nmrs/src/core/bluetooth.rs | 18 +++- nmrs/src/core/connection.rs | 87 +++++++++++++---- nmrs/src/core/state_wait.rs | 25 ++++- nmrs/src/core/vpn.rs | 12 ++- nmrs/src/lib.rs | 5 +- 9 files changed, 406 insertions(+), 43 deletions(-) create mode 100644 nmrs/examples/custom_timeouts.rs diff --git a/nmrs/CHANGELOG.md b/nmrs/CHANGELOG.md index c6955833..ab3d038f 100644 --- a/nmrs/CHANGELOG.md +++ b/nmrs/CHANGELOG.md @@ -4,10 +4,11 @@ All notable changes to the `nmrs` crate will be documented in this file. ## [Unreleased] ### Added +- Configurable timeout values for connection and disconnection operations ([#185](https://github.com/cachebag/nmrs/issues/185)) - Builder pattern for `VpnCredentials` and `EapOptions` ([#188](https://github.com/cachebag/nmrs/issues/188)) - Bluetooth device support ([#198](https://github.com/cachebag/nmrs/pull/198)) - Input validation before any D-Bus operations ([#173](https://github.com/cachebag/nmrs/pull/173)) -- CI: adjust workflow to auto-update nix hashes on PRs ([#182](https://github.com/cachebag/nmrs/pull/182)) +~~- CI: adjust workflow to auto-update nix hashes on PRs ([#182](https://github.com/cachebag/nmrs/pull/182))~~ - More helpful methods to `network_manager` facade ([#190](https://github.com/cachebag/nmrs/pull/190)) - Explicitly clean up signal streams to ensure unsubscription ([#197](https://github.com/cachebag/nmrs/pull/197)) diff --git a/nmrs/examples/custom_timeouts.rs b/nmrs/examples/custom_timeouts.rs new file mode 100644 index 00000000..ae0e0512 --- /dev/null +++ b/nmrs/examples/custom_timeouts.rs @@ -0,0 +1,53 @@ +/// Example demonstrating custom timeout configuration for NetworkManager operations. +/// +/// This shows how to configure longer timeouts for slow networks or enterprise +/// authentication that may take more time to complete. +use nmrs::{NetworkManager, TimeoutConfig, WifiSecurity}; +use std::time::Duration; + +#[tokio::main] +async fn main() -> nmrs::Result<()> { + // Configure custom timeouts for slow networks + let config = TimeoutConfig::new() + .with_connection_timeout(Duration::from_secs(60)) // Wait up to 60s for connection + .with_disconnect_timeout(Duration::from_secs(20)); // Wait up to 20s for disconnection + + // Create NetworkManager with custom timeout configuration + let nm = NetworkManager::with_config(config).await?; + + println!("NetworkManager configured with custom timeouts:"); + println!( + " Connection timeout: {:?}", + nm.timeout_config().connection_timeout + ); + println!( + " Disconnect timeout: {:?}", + nm.timeout_config().disconnect_timeout + ); + + // Connect to a network (will use the custom 60s timeout) + println!("\nConnecting to network..."); + nm.connect( + "MyNetwork", + WifiSecurity::WpaPsk { + psk: std::env::var("WIFI_PASSWORD").unwrap_or_else(|_| "password".to_string()), + }, + ) + .await?; + + println!("Connected successfully!"); + + // You can also use default timeouts + let nm_default = NetworkManager::new().await?; + println!("\nDefault NetworkManager timeouts:"); + println!( + " Connection timeout: {:?}", + nm_default.timeout_config().connection_timeout + ); + println!( + " Disconnect timeout: {:?}", + nm_default.timeout_config().disconnect_timeout + ); + + Ok(()) +} diff --git a/nmrs/src/api/models.rs b/nmrs/src/api/models.rs index c06f78b9..26ad0bcd 100644 --- a/nmrs/src/api/models.rs +++ b/nmrs/src/api/models.rs @@ -1,5 +1,6 @@ use serde::{Deserialize, Serialize}; use std::fmt::{Display, Formatter}; +use std::time::Duration; use thiserror::Error; use uuid::Uuid; @@ -850,6 +851,108 @@ impl EapOptionsBuilder { } } +/// Timeout configuration for NetworkManager operations. +/// +/// Controls how long NetworkManager will wait for various network operations +/// to complete before timing out. This allows customization for different +/// network environments (slow networks, enterprise auth, etc.). +/// +/// # Examples +/// +/// ```rust +/// use nmrs::TimeoutConfig; +/// use std::time::Duration; +/// +/// // Use default timeouts (30s connect, 10s disconnect) +/// let config = TimeoutConfig::default(); +/// +/// // Custom timeouts for slow networks +/// let config = TimeoutConfig::new() +/// .with_connection_timeout(Duration::from_secs(60)) +/// .with_disconnect_timeout(Duration::from_secs(20)); +/// +/// // Quick timeouts for fast networks +/// let config = TimeoutConfig::new() +/// .with_connection_timeout(Duration::from_secs(15)) +/// .with_disconnect_timeout(Duration::from_secs(5)); +/// ``` +#[non_exhaustive] +#[derive(Debug, Clone, Copy)] +pub struct TimeoutConfig { + /// Timeout for connection activation (default: 30 seconds) + pub connection_timeout: Duration, + /// Timeout for device disconnection (default: 10 seconds) + pub disconnect_timeout: Duration, +} + +impl Default for TimeoutConfig { + /// Returns the default timeout configuration. + /// + /// Defaults: + /// - `connection_timeout`: 30 seconds + /// - `disconnect_timeout`: 10 seconds + fn default() -> Self { + Self { + connection_timeout: Duration::from_secs(30), + disconnect_timeout: Duration::from_secs(10), + } + } +} + +impl TimeoutConfig { + /// Creates a new `TimeoutConfig` with default values. + /// + /// # Examples + /// + /// ```rust + /// use nmrs::TimeoutConfig; + /// + /// let config = TimeoutConfig::new(); + /// ``` + pub fn new() -> Self { + Self::default() + } + + /// Sets the connection activation timeout. + /// + /// This controls how long to wait for a network connection to activate + /// before giving up. Increase this for slow networks or enterprise + /// authentication that may take longer. + /// + /// # Examples + /// + /// ```rust + /// use nmrs::TimeoutConfig; + /// use std::time::Duration; + /// + /// let config = TimeoutConfig::new() + /// .with_connection_timeout(Duration::from_secs(60)); + /// ``` + pub fn with_connection_timeout(mut self, timeout: Duration) -> Self { + self.connection_timeout = timeout; + self + } + + /// Sets the disconnection timeout. + /// + /// This controls how long to wait for a device to disconnect before + /// giving up. + /// + /// # Examples + /// + /// ```rust + /// use nmrs::TimeoutConfig; + /// use std::time::Duration; + /// + /// let config = TimeoutConfig::new() + /// .with_disconnect_timeout(Duration::from_secs(20)); + /// ``` + pub fn with_disconnect_timeout(mut self, timeout: Duration) -> Self { + self.disconnect_timeout = timeout; + self + } +} + /// Connection options for saved NetworkManager connections. /// /// Controls how NetworkManager handles saved connection profiles, @@ -3168,4 +3271,63 @@ mod tests { assert_eq!(opts_new.method, opts_builder.method); assert_eq!(opts_new.phase2, opts_builder.phase2); } + + // Timeout configuration tests + + #[test] + fn test_timeout_config_default() { + let config = TimeoutConfig::default(); + assert_eq!(config.connection_timeout, Duration::from_secs(30)); + assert_eq!(config.disconnect_timeout, Duration::from_secs(10)); + } + + #[test] + fn test_timeout_config_new() { + let config = TimeoutConfig::new(); + assert_eq!(config.connection_timeout, Duration::from_secs(30)); + assert_eq!(config.disconnect_timeout, Duration::from_secs(10)); + } + + #[test] + fn test_timeout_config_with_connection_timeout() { + let config = TimeoutConfig::new().with_connection_timeout(Duration::from_secs(60)); + assert_eq!(config.connection_timeout, Duration::from_secs(60)); + assert_eq!(config.disconnect_timeout, Duration::from_secs(10)); + } + + #[test] + fn test_timeout_config_with_disconnect_timeout() { + let config = TimeoutConfig::new().with_disconnect_timeout(Duration::from_secs(20)); + assert_eq!(config.connection_timeout, Duration::from_secs(30)); + assert_eq!(config.disconnect_timeout, Duration::from_secs(20)); + } + + #[test] + fn test_timeout_config_with_both_timeouts() { + let config = TimeoutConfig::new() + .with_connection_timeout(Duration::from_secs(90)) + .with_disconnect_timeout(Duration::from_secs(30)); + assert_eq!(config.connection_timeout, Duration::from_secs(90)); + assert_eq!(config.disconnect_timeout, Duration::from_secs(30)); + } + + #[test] + fn test_timeout_config_chaining() { + let config = TimeoutConfig::default() + .with_connection_timeout(Duration::from_secs(45)) + .with_disconnect_timeout(Duration::from_secs(15)) + .with_connection_timeout(Duration::from_secs(60)); // Override previous value + + assert_eq!(config.connection_timeout, Duration::from_secs(60)); + assert_eq!(config.disconnect_timeout, Duration::from_secs(15)); + } + + #[test] + fn test_timeout_config_copy() { + let config1 = TimeoutConfig::new().with_connection_timeout(Duration::from_secs(120)); + let config2 = config1; // Should copy, not move + + assert_eq!(config1.connection_timeout, Duration::from_secs(120)); + assert_eq!(config2.connection_timeout, Duration::from_secs(120)); + } } diff --git a/nmrs/src/api/network_manager.rs b/nmrs/src/api/network_manager.rs index 19dd7912..e65f3e25 100644 --- a/nmrs/src/api/network_manager.rs +++ b/nmrs/src/api/network_manager.rs @@ -117,13 +117,67 @@ use crate::Result; #[derive(Debug, Clone)] pub struct NetworkManager { conn: Connection, + timeout_config: crate::api::models::TimeoutConfig, } impl NetworkManager { - /// Creates a new `NetworkManager` connected to the system D-Bus. + /// Creates a new `NetworkManager` connected to the system D-Bus with default timeout configuration. + /// + /// Uses default timeouts of 30 seconds for connection and 10 seconds for disconnection. + /// To customize timeouts, use [`with_config()`](Self::with_config) instead. pub async fn new() -> Result { let conn = Connection::system().await?; - Ok(Self { conn }) + Ok(Self { + conn, + timeout_config: crate::api::models::TimeoutConfig::default(), + }) + } + + /// Creates a new `NetworkManager` with custom timeout configuration. + /// + /// This allows you to customize how long NetworkManager will wait for + /// various operations to complete before timing out. + /// + /// # Examples + /// + /// ```no_run + /// use nmrs::{NetworkManager, TimeoutConfig}; + /// use std::time::Duration; + /// + /// # async fn example() -> nmrs::Result<()> { + /// // Configure longer timeouts for slow networks + /// let config = TimeoutConfig::new() + /// .with_connection_timeout(Duration::from_secs(60)) + /// .with_disconnect_timeout(Duration::from_secs(20)); + /// + /// let nm = NetworkManager::with_config(config).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn with_config(timeout_config: crate::api::models::TimeoutConfig) -> Result { + let conn = Connection::system().await?; + Ok(Self { + conn, + timeout_config, + }) + } + + /// Returns the current timeout configuration. + /// + /// # Examples + /// + /// ```no_run + /// use nmrs::NetworkManager; + /// + /// # async fn example() -> nmrs::Result<()> { + /// let nm = NetworkManager::new().await?; + /// let config = nm.timeout_config(); + /// println!("Connection timeout: {:?}", config.connection_timeout); + /// # Ok(()) + /// # } + /// ``` + pub fn timeout_config(&self) -> crate::api::models::TimeoutConfig { + self.timeout_config } /// List all network devices managed by NetworkManager. @@ -161,7 +215,7 @@ impl NetworkManager { /// `ConnectionError::AuthFailed` if authentication fails, or other /// variants for specific failure reasons. pub async fn connect(&self, ssid: &str, creds: WifiSecurity) -> Result<()> { - connect(&self.conn, ssid, creds).await + connect(&self.conn, ssid, creds, Some(self.timeout_config)).await } /// Connects to a wired (Ethernet) device. @@ -174,7 +228,7 @@ impl NetworkManager { /// /// Returns `ConnectionError::NoWiredDevice` if no wired device is found. pub async fn connect_wired(&self) -> Result<()> { - connect_wired(&self.conn).await + connect_wired(&self.conn, Some(self.timeout_config)).await } /// Connects to a bluetooth device using the provided identity. @@ -198,7 +252,7 @@ impl NetworkManager { /// /// ``` pub async fn connect_bluetooth(&self, name: &str, identity: &BluetoothIdentity) -> Result<()> { - connect_bluetooth(&self.conn, name, identity).await + connect_bluetooth(&self.conn, name, identity, Some(self.timeout_config)).await } /// Connects to a VPN using the provided credentials. @@ -243,7 +297,7 @@ impl NetworkManager { /// - 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 + connect_vpn(&self.conn, creds, Some(self.timeout_config)).await } /// Disconnects from an active VPN connection by name. @@ -394,7 +448,7 @@ impl NetworkManager { /// # } /// ``` pub async fn disconnect(&self) -> Result<()> { - disconnect(&self.conn).await + disconnect(&self.conn, Some(self.timeout_config)).await } /// Returns the full `Network` object for the currently connected WiFi network. @@ -504,7 +558,13 @@ impl NetworkManager { /// Returns `Ok(())` if at least one connection was deleted successfully. /// Returns `NoSavedConnection` if no matching connections were found. pub async fn forget(&self, ssid: &str) -> Result<()> { - forget_by_name_and_type(&self.conn, ssid, Some(device_type::WIFI)).await + forget_by_name_and_type( + &self.conn, + ssid, + Some(device_type::WIFI), + Some(self.timeout_config), + ) + .await } /// Forgets (deletes) a saved Bluetooth connection. @@ -521,7 +581,13 @@ impl NetworkManager { /// Returns `Ok(())` if the connection was deleted successfully. /// Returns `NoSavedConnection` if no matching connection was found. pub async fn forget_bluetooth(&self, name: &str) -> Result<()> { - forget_by_name_and_type(&self.conn, name, Some(device_type::BLUETOOTH)).await + forget_by_name_and_type( + &self.conn, + name, + Some(device_type::BLUETOOTH), + Some(self.timeout_config), + ) + .await } /// /// Subscribes to D-Bus signals for access point additions and removals diff --git a/nmrs/src/core/bluetooth.rs b/nmrs/src/core/bluetooth.rs index 9eebd8b5..cd7f662a 100644 --- a/nmrs/src/core/bluetooth.rs +++ b/nmrs/src/core/bluetooth.rs @@ -20,7 +20,11 @@ use crate::monitoring::transport::ActiveTransport; use crate::types::constants::device_state; use crate::types::constants::device_type; use crate::ConnectionError; -use crate::{dbus::NMProxy, models::BluetoothIdentity, Result}; +use crate::{ + dbus::NMProxy, + models::{BluetoothIdentity, TimeoutConfig}, + Result, +}; /// Populated Bluetooth device information via BlueZ. /// @@ -105,6 +109,7 @@ pub(crate) async fn connect_bluetooth( conn: &Connection, name: &str, settings: &BluetoothIdentity, + timeout_config: Option, ) -> Result<()> { debug!( "Connecting to '{}' (Bluetooth) | bdaddr={} type={:?}", @@ -152,7 +157,9 @@ pub(crate) async fn connect_bluetooth( .activate_connection(saved_path, bt_device.clone(), specific_object) .await?; - crate::core::state_wait::wait_for_connection_activation(conn, &active_conn).await?; + let timeout = timeout_config.map(|c| c.connection_timeout); + crate::core::state_wait::wait_for_connection_activation(conn, &active_conn, timeout) + .await?; } None => { debug!("No saved connection found, creating new Bluetooth connection"); @@ -177,7 +184,8 @@ pub(crate) async fn connect_bluetooth( ) .await?; - wait_for_connection_activation(conn, &active_conn).await?; + let timeout = timeout_config.map(|c| c.connection_timeout); + wait_for_connection_activation(conn, &active_conn, timeout).await?; } } @@ -192,6 +200,7 @@ pub(crate) async fn connect_bluetooth( pub(crate) async fn disconnect_bluetooth_and_wait( conn: &Connection, dev_path: &OwnedObjectPath, + timeout_config: Option, ) -> Result<()> { let dev = NMDeviceProxy::builder(conn) .path(dev_path.clone())? @@ -216,7 +225,8 @@ pub(crate) async fn disconnect_bluetooth_and_wait( let _ = raw.call_method("Disconnect", &()).await; // Wait for disconnect using signal-based monitoring - wait_for_device_disconnect(&dev).await?; + let timeout = timeout_config.map(|c| c.disconnect_timeout); + wait_for_device_disconnect(&dev, timeout).await?; // Brief stabilization delay // Delay::new(timeouts::stabilization_delay()).await; diff --git a/nmrs/src/core/connection.rs b/nmrs/src/core/connection.rs index 8b3492cb..9ca33956 100644 --- a/nmrs/src/core/connection.rs +++ b/nmrs/src/core/connection.rs @@ -5,7 +5,7 @@ use zbus::Connection; use zvariant::OwnedObjectPath; use crate::api::builders::wifi::{build_ethernet_connection, build_wifi_connection}; -use crate::api::models::{ConnectionError, ConnectionOptions, WifiSecurity}; +use crate::api::models::{ConnectionError, ConnectionOptions, TimeoutConfig, 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, NMWiredProxy, NMWirelessProxy}; @@ -36,7 +36,12 @@ enum SavedDecision { /// /// If a saved connection exists but fails, it will be deleted and a fresh /// connection will be attempted with the provided credentials. -pub(crate) async fn connect(conn: &Connection, ssid: &str, creds: WifiSecurity) -> Result<()> { +pub(crate) async fn connect( + conn: &Connection, + ssid: &str, + creds: WifiSecurity, + timeout_config: Option, +) -> Result<()> { // Validate inputs before attempting connection validate_ssid(ssid)?; validate_wifi_security(&creds)?; @@ -76,11 +81,29 @@ pub(crate) async fn connect(conn: &Connection, ssid: &str, creds: WifiSecurity) match decision { SavedDecision::UseSaved(saved) => { - ensure_disconnected(conn, &nm, &wifi_device).await?; - connect_via_saved(conn, &nm, &wifi_device, &specific_object, &creds, saved).await?; + ensure_disconnected(conn, &nm, &wifi_device, timeout_config).await?; + connect_via_saved( + conn, + &nm, + &wifi_device, + &specific_object, + &creds, + saved, + timeout_config, + ) + .await?; } SavedDecision::RebuildFresh => { - build_and_activate_new(conn, &nm, &wifi_device, &specific_object, ssid, creds).await?; + build_and_activate_new( + conn, + &nm, + &wifi_device, + &specific_object, + ssid, + creds, + timeout_config, + ) + .await?; } } @@ -101,7 +124,10 @@ pub(crate) async fn connect(conn: &Connection, ssid: &str, creds: WifiSecurity) /// /// Ethernet connections are typically simpler than Wi-Fi - no scanning or /// access points needed. The connection will activate when a cable is plugged in. -pub(crate) async fn connect_wired(conn: &Connection) -> Result<()> { +pub(crate) async fn connect_wired( + conn: &Connection, + timeout_config: Option, +) -> Result<()> { debug!("Connecting to wired device"); let nm = NMProxy::new(conn).await?; @@ -133,7 +159,8 @@ pub(crate) async fn connect_wired(conn: &Connection) -> Result<()> { let active_conn = nm .activate_connection(saved_path, wired_device.clone(), specific_object) .await?; - wait_for_connection_activation(conn, &active_conn).await?; + let timeout = timeout_config.map(|c| c.connection_timeout); + wait_for_connection_activation(conn, &active_conn, timeout).await?; } None => { debug!("No saved connection found, creating new wired connection"); @@ -147,7 +174,8 @@ pub(crate) async fn connect_wired(conn: &Connection) -> Result<()> { let (_, active_conn) = nm .add_and_activate_connection(settings, wired_device.clone(), specific_object) .await?; - wait_for_connection_activation(conn, &active_conn).await?; + let timeout = timeout_config.map(|c| c.connection_timeout); + wait_for_connection_activation(conn, &active_conn, timeout).await?; } } @@ -184,6 +212,7 @@ pub(crate) async fn forget_by_name_and_type( conn: &Connection, name: &str, device_filter: Option, + timeout_config: Option, ) -> Result<()> { use std::collections::HashMap; use zvariant::{OwnedObjectPath, Value}; @@ -230,7 +259,9 @@ pub(crate) async fn forget_by_name_and_type( if let Ok(bytes) = ap.ssid().await { if decode_ssid_or_empty(&bytes) == name { debug!("Disconnecting from active WiFi network: {name}"); - if let Err(e) = disconnect_wifi_and_wait(conn, dev_path).await { + if let Err(e) = + disconnect_wifi_and_wait(conn, dev_path, timeout_config).await + { warn!("Disconnect wait failed: {e}"); let final_state = dev.state().await?; if final_state != device_state::DISCONNECTED @@ -257,8 +288,12 @@ pub(crate) async fn forget_by_name_and_type( let state = dev.state().await?; if state != device_state::DISCONNECTED && state != device_state::UNAVAILABLE { debug!("Disconnecting from active Bluetooth device: {name}"); - if let Err(e) = - crate::core::bluetooth::disconnect_bluetooth_and_wait(conn, dev_path).await + if let Err(e) = crate::core::bluetooth::disconnect_bluetooth_and_wait( + conn, + dev_path, + timeout_config, + ) + .await { warn!("Bluetooth disconnect failed: {e}"); let final_state = dev.state().await?; @@ -391,6 +426,7 @@ pub(crate) async fn forget_by_name_and_type( pub(crate) async fn disconnect_wifi_and_wait( conn: &Connection, dev_path: &OwnedObjectPath, + timeout_config: Option, ) -> Result<()> { let dev = NMDeviceProxy::builder(conn) .path(dev_path.clone())? @@ -421,7 +457,8 @@ pub(crate) async fn disconnect_wifi_and_wait( } // Wait for disconnect using signal-based monitoring - wait_for_device_disconnect(&dev).await?; + let timeout = timeout_config.map(|c| c.disconnect_timeout); + wait_for_device_disconnect(&dev, timeout).await?; // Brief stabilization delay Delay::new(timeouts::stabilization_delay()).await; @@ -506,6 +543,7 @@ async fn ensure_disconnected( conn: &Connection, nm: &NMProxy<'_>, wifi_device: &OwnedObjectPath, + timeout_config: Option, ) -> Result<()> { if let Some(active) = Wifi::current(conn).await { debug!("Disconnecting from {active}"); @@ -519,7 +557,7 @@ async fn ensure_disconnected( } } - disconnect_wifi_and_wait(conn, wifi_device).await?; + disconnect_wifi_and_wait(conn, wifi_device, timeout_config).await?; } Ok(()) @@ -540,6 +578,7 @@ async fn connect_via_saved( ap: &OwnedObjectPath, creds: &WifiSecurity, saved: OwnedObjectPath, + timeout_config: Option, ) -> Result<()> { debug!("Activating saved connection: {}", saved.as_str()); @@ -554,7 +593,8 @@ async fn connect_via_saved( ); // Wait for connection activation using signal-based monitoring - match wait_for_connection_activation(conn, &active_conn).await { + let timeout = timeout_config.map(|c| c.connection_timeout); + match wait_for_connection_activation(conn, &active_conn, timeout).await { Ok(()) => { debug!("Saved connection activated successfully"); } @@ -589,7 +629,8 @@ async fn connect_via_saved( })?; // Wait for the fresh connection to activate - wait_for_connection_activation(conn, &new_active_conn).await?; + let timeout = timeout_config.map(|c| c.connection_timeout); + wait_for_connection_activation(conn, &new_active_conn, timeout).await?; } } } @@ -620,7 +661,8 @@ async fn connect_via_saved( })?; // Wait for the fresh connection to activate - wait_for_connection_activation(conn, &active_conn).await?; + let timeout = timeout_config.map(|c| c.connection_timeout); + wait_for_connection_activation(conn, &active_conn, timeout).await?; } } @@ -640,6 +682,7 @@ async fn build_and_activate_new( ap: &OwnedObjectPath, ssid: &str, creds: WifiSecurity, + timeout_config: Option, ) -> Result<()> { let opts = ConnectionOptions { autoconnect: true, @@ -651,7 +694,7 @@ async fn build_and_activate_new( debug!("Creating new connection, settings: \n{settings:#?}"); - ensure_disconnected(conn, nm, wifi_device).await?; + ensure_disconnected(conn, nm, wifi_device, timeout_config).await?; let (_, active_conn) = match nm .add_and_activate_connection(settings, wifi_device.clone(), ap.clone()) @@ -673,7 +716,8 @@ async fn build_and_activate_new( debug!("Waiting for connection activation using signal monitoring..."); // Wait for connection activation using the ActiveConnection signals - wait_for_connection_activation(conn, &active_conn).await?; + let timeout = timeout_config.map(|c| c.connection_timeout); + wait_for_connection_activation(conn, &active_conn, timeout).await?; info!("Connection to '{ssid}' activated successfully"); @@ -756,7 +800,10 @@ pub(crate) async fn is_connected(conn: &Connection, ssid: &str) -> Result /// then waits for the device to reach disconnected state. /// /// Returns `Ok(())` if disconnected successfully or if no active connection exists. -pub(crate) async fn disconnect(conn: &Connection) -> Result<()> { +pub(crate) async fn disconnect( + conn: &Connection, + timeout_config: Option, +) -> Result<()> { let nm = NMProxy::new(conn).await?; let wifi_device = match find_wifi_device(conn, &nm).await { @@ -788,7 +835,7 @@ pub(crate) async fn disconnect(conn: &Connection) -> Result<()> { } } - disconnect_wifi_and_wait(conn, &wifi_device).await?; + disconnect_wifi_and_wait(conn, &wifi_device, timeout_config).await?; info!("Disconnected from network"); Ok(()) diff --git a/nmrs/src/core/state_wait.rs b/nmrs/src/core/state_wait.rs index cdd3d8b2..98688216 100644 --- a/nmrs/src/core/state_wait.rs +++ b/nmrs/src/core/state_wait.rs @@ -43,9 +43,16 @@ const DISCONNECT_TIMEOUT: Duration = Duration::from_secs(10); /// Monitors the connection activation process by subscribing to the /// `StateChanged` signal on the active connection object. This provides /// more detailed error information than device-level monitoring. +/// +/// # Arguments +/// +/// * `conn` - D-Bus connection +/// * `active_conn_path` - Path to the active connection object +/// * `timeout` - Optional timeout duration (uses default if None) pub(crate) async fn wait_for_connection_activation( conn: &Connection, active_conn_path: &zvariant::OwnedObjectPath, + timeout: Option, ) -> Result<()> { let active_conn = NMActiveConnectionProxy::builder(conn) .path(active_conn_path.clone())? @@ -76,7 +83,8 @@ pub(crate) async fn wait_for_connection_activation( } // Wait for state change with timeout (runtime-agnostic) - let mut timeout_delay = pin!(Delay::new(CONNECTION_TIMEOUT).fuse()); + let timeout_duration = timeout.unwrap_or(CONNECTION_TIMEOUT); + let mut timeout_delay = pin!(Delay::new(timeout_duration).fuse()); loop { // Re-check state to catch any changes that occurred during subscription @@ -100,7 +108,7 @@ pub(crate) async fn wait_for_connection_activation( select! { _ = timeout_delay => { - warn!("Connection activation timed out after {:?}", CONNECTION_TIMEOUT); + warn!("Connection activation timed out after {:?}", timeout_duration); return Err(ConnectionError::Timeout); } signal_opt = stream.next() => { @@ -139,7 +147,15 @@ pub(crate) async fn wait_for_connection_activation( } /// Waits for a device to reach the disconnected state using D-Bus signals. -pub(crate) async fn wait_for_device_disconnect(dev: &NMDeviceProxy<'_>) -> Result<()> { +/// +/// # Arguments +/// +/// * `dev` - Device proxy +/// * `timeout` - Optional timeout duration (uses default if None) +pub(crate) async fn wait_for_device_disconnect( + dev: &NMDeviceProxy<'_>, + timeout: Option, +) -> Result<()> { // Subscribe to signals FIRST to avoid race condition let mut stream = dev.receive_device_state_changed().await?; debug!("Subscribed to device StateChanged signal for disconnect"); @@ -153,7 +169,8 @@ pub(crate) async fn wait_for_device_disconnect(dev: &NMDeviceProxy<'_>) -> Resul } // Wait for disconnect with timeout (runtime-agnostic) - let mut timeout_delay = pin!(Delay::new(DISCONNECT_TIMEOUT).fuse()); + let timeout_duration = timeout.unwrap_or(DISCONNECT_TIMEOUT); + let mut timeout_delay = pin!(Delay::new(timeout_duration).fuse()); loop { // Re-check state to catch any changes that occurred during subscription diff --git a/nmrs/src/core/vpn.rs b/nmrs/src/core/vpn.rs index fdb4e5f1..b7a57bb8 100644 --- a/nmrs/src/core/vpn.rs +++ b/nmrs/src/core/vpn.rs @@ -16,7 +16,8 @@ use zbus::Connection; use zvariant::OwnedObjectPath; use crate::api::models::{ - ConnectionOptions, DeviceState, VpnConnection, VpnConnectionInfo, VpnCredentials, VpnType, + ConnectionOptions, DeviceState, TimeoutConfig, VpnConnection, VpnConnectionInfo, + VpnCredentials, VpnType, }; use crate::builders::build_wireguard_connection; use crate::core::state_wait::wait_for_connection_activation; @@ -35,7 +36,11 @@ use crate::Result; /// /// WireGuard activations do not require binding to an underlying device. /// Use "/" so NetworkManager auto-selects. -pub(crate) async fn connect_vpn(conn: &Connection, creds: VpnCredentials) -> Result<()> { +pub(crate) async fn connect_vpn( + conn: &Connection, + creds: VpnCredentials, + timeout_config: Option, +) -> Result<()> { // Validate VPN credentials before attempting connection validate_vpn_credentials(&creds)?; @@ -78,7 +83,8 @@ pub(crate) async fn connect_vpn(conn: &Connection, creds: VpnCredentials) -> Res .await? }; - wait_for_connection_activation(conn, &active_conn).await?; + let timeout = timeout_config.map(|c| c.connection_timeout); + wait_for_connection_activation(conn, &active_conn, timeout).await?; debug!("Connection reached Activated state, waiting briefly..."); match NMActiveConnectionProxy::builder(conn).path(active_conn.clone()) { diff --git a/nmrs/src/lib.rs b/nmrs/src/lib.rs index d61904db..165d9efe 100644 --- a/nmrs/src/lib.rs +++ b/nmrs/src/lib.rs @@ -294,6 +294,7 @@ pub mod builders { /// - [`WifiSecurity`] - WiFi security types (Open, WPA-PSK, WPA-EAP) /// - [`EapOptions`] - Enterprise authentication options /// - [`ConnectionOptions`] - Connection settings (autoconnect, priority, etc.) +/// - [`TimeoutConfig`] - Timeout configuration for network operations /// /// # Enums /// - [`DeviceType`] - Device types (Ethernet, WiFi, etc.) @@ -318,8 +319,8 @@ pub use api::models::{ connection_state_reason_to_error, reason_to_error, ActiveConnectionState, BluetoothDevice, BluetoothIdentity, BluetoothNetworkRole, ConnectionError, ConnectionOptions, ConnectionStateReason, Device, DeviceState, DeviceType, EapMethod, EapOptions, Network, - NetworkInfo, Phase2, StateReason, VpnConnection, VpnConnectionInfo, VpnCredentials, VpnType, - WifiSecurity, WireGuardPeer, + NetworkInfo, Phase2, StateReason, TimeoutConfig, VpnConnection, VpnConnectionInfo, + VpnCredentials, VpnType, WifiSecurity, WireGuardPeer, }; pub use api::network_manager::NetworkManager; From 7884c58df7cdd112aab5f6ec1505745aa8c7ba09 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Sun, 18 Jan 2026 23:47:30 -0500 Subject: [PATCH 21/23] chore: add security policy --- SECURITY.md | 101 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..244b5ed3 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,101 @@ +# Security Policy + +## Supported Versions + +We take security seriously and provide security updates for the latest version of nmrs and nmrs-gui alike. +We strongly recommend keeping your nmrs dependencies up to date. + +## Reporting a Vulnerability + +**Please do not report security vulnerabilities through public GitHub issues.** + +If you discover a security vulnerability in nmrs or any of the related crates, please report it privately by emailing +**alhakimiakrmjATgmailDOTcom**. + +Please include the following information in your report: + +- A clear description of the vulnerability +- Steps to reproduce the issue +- Potential impact and attack scenarios +- Any suggested fixes or mitigations +- Your contact information for follow-up questions + +### What constitutes a security vulnerability? + +For nmrs, security vulnerabilities may include but are not limited to: + +- **Authentication bypass**: Ability to connect to protected networks without proper credentials +- **Privilege escalation**: Unauthorized access to NetworkManager operations that should require elevated permissions +- **Credential exposure**: Leaking WiFi passwords, VPN keys, or other sensitive connection data through logs, errors, or memory +- **D-Bus injection**: Malicious D-Bus messages that could manipulate network connections or device state +- **Denial of service**: Crashes, hangs, or resource exhaustion that prevent legitimate network management +- **Information disclosure**: Exposing network SSIDs, MAC addresses, or connection details to unauthorized processes +- **Input validation failures**: Improper handling of malformed SSIDs, credentials, or configuration data leading to undefined behavior +- **Race conditions**: Timing vulnerabilities in connection state management that could lead to security issues +- **Dependency vulnerabilities**: Security issues in upstream crates (zbus, tokio, etc.) that affect nmrs + +For nmrs-gui specifically: +- **UI injection**: Malicious network names or data that could execute unintended actions in the GUI +- **File system access**: Unauthorized reading or writing of configuration files outside the intended scope + + +## Response Timeline + +We are committed to responding to security reports promptly: + +- **Acknowledgment**: We will acknowledge receipt of your vulnerability report within + **24 hours** +- **Initial assessment**: We will provide an initial assessment of the report within + **5 business days** +- **Regular updates**: We will provide progress updates at least every **7 days** until + resolution +- **Resolution**: We aim to provide a fix or mitigation within **30 days** for critical + vulnerabilities + +Response times may vary based on the complexity of the issue and availability of maintainers. + +## Disclosure Policy + +We follow a coordinated disclosure process: + +1. **Private disclosure**: We will work with you to understand and validate the vulnerability +2. **Fix development**: We will develop and test a fix in a private repository if necessary +3. **Coordinated release**: We will coordinate the public disclosure with the release of a fix +4. **Public disclosure**: After a fix is available, we will publish a security advisory + +We request that you: +- Give us reasonable time to address the vulnerability before making it public +- Avoid accessing or modifying data beyond what is necessary to demonstrate the vulnerability +- Act in good faith and avoid privacy violations or destructive behavior + +## Security Advisories + +Published security advisories will be available through: + +- GitHub Security Advisories on the + [nmrs repository](https://github.com/cachebag/nmrs/security/advisories) +- [RustSec Advisory Database](https://rustsec.org/) +- Release notes and changelog entries + +## Recognition + +We appreciate the security research community's efforts to improve the security of nmrs. With +your permission, we will acknowledge your contribution in: + +- Security advisories +- Release notes +- Project documentation + +If you prefer to remain anonymous, please let us know in your report. + +## Scope + +This security policy covers both nmrs and nmrs-gui alike. + +## Additional Resources + +- [Contributing Guidelines](CONTRIBUTING.md) +- [Code of Conduct](https://www.rust-lang.org/policies/code-of-conduct) +- [Rust Security Policy](https://www.rust-lang.org/policies/security) + +Thank you for helping to keep nmrs and the Rust ecosystem secure! From 3788b77402fa79f8e24cb5bc4ee7424cc6c9999c Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Sun, 18 Jan 2026 23:55:33 -0500 Subject: [PATCH 22/23] chore: add discord channel to README --- README.md | 3 +++ nmrs/README.md | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 19e74591..6711458f 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,9 @@ async fn main() -> nmrs::Result<()> { Ok(()) } ``` + +To follow and/or discuss the development of nmrs, you can join the [public Discord channel](https://discord.gg/Sk3VfrHrN4). + #

nmrs-gui

![Version](https://img.shields.io/badge/nmrs--gui-1.1.0-orange?style=flat-square) diff --git a/nmrs/README.md b/nmrs/README.md index 9b969eee..af12ff04 100644 --- a/nmrs/README.md +++ b/nmrs/README.md @@ -25,7 +25,7 @@ Rust bindings for NetworkManager via D-Bus. ```toml [dependencies] -nmrs = "1.2.0" +nmrs = "2.0.0" ``` or ```bash From 437b3bab960c9a3d6690e5f9c633ba0758833c11 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Mon, 19 Jan 2026 11:18:22 -0500 Subject: [PATCH 23/23] chore: update CHANGELOG --- nmrs/CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/nmrs/CHANGELOG.md b/nmrs/CHANGELOG.md index ab3d038f..a7433c01 100644 --- a/nmrs/CHANGELOG.md +++ b/nmrs/CHANGELOG.md @@ -3,6 +3,8 @@ All notable changes to the `nmrs` crate will be documented in this file. ## [Unreleased] + +## [2.0.0] - 2026-01-19 ### Added - Configurable timeout values for connection and disconnection operations ([#185](https://github.com/cachebag/nmrs/issues/185)) - Builder pattern for `VpnCredentials` and `EapOptions` ([#188](https://github.com/cachebag/nmrs/issues/188)) @@ -146,7 +148,9 @@ All notable changes to the `nmrs` crate will be documented in this file. [1.2.0]: https://github.com/cachebag/nmrs/compare/nmrs-v1.1.0...nmrs-v1.2.0 [1.3.0]: https://github.com/cachebag/nmrs/compare/nmrs-v1.2.0...nmrs-v1.3.0 [1.3.5]: https://github.com/cachebag/nmrs/compare/nmrs-v1.2.0...nmrs-v1.3.5 -[Unreleased]: https://github.com/cachebag/nmrs/compare/nmrs-v1.3.5...HEAD +[2.0.0]: https://github.com/cachebag/nmrs/compare/nmrs-v1.2.0...nmrs-v2.0.0 +[2.0.0]: https://github.com/cachebag/nmrs/compare/nmrs-v1.2.0...nmrs-v2.0.0 +[Unreleased]: https://github.com/cachebag/nmrs/compare/nmrs-v2.0.0...HEAD [1.1.0]: https://github.com/cachebag/nmrs/compare/nmrs-v1.0.1...nmrs-v1.1.0 [1.0.1]: https://github.com/cachebag/nmrs/compare/nmrs-v1.0.0...nmrs-v1.0.1 [1.0.0]: https://github.com/cachebag/nmrs/compare/v0.5.0-beta...nmrs-v1.0.0