diff --git a/Cargo.lock b/Cargo.lock
index 96dd9e21..60a94434 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -923,8 +923,9 @@ dependencies = [
[[package]]
name = "nmrs"
-version = "1.3.5"
+version = "2.0.0"
dependencies = [
+ "async-trait",
"base64",
"futures",
"futures-timer",
diff --git a/Cargo.toml b/Cargo.toml
index 64e961bf..673216fd 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 = "0.1.89"
tokio = { version = "1.48.0", features = ["rt-multi-thread", "macros", "sync", "time"] }
tokio-util = { version = "0.7.18" }
diff --git a/README.md b/README.md
index 063bb073..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

@@ -152,7 +155,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 +205,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/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!
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-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..a7433c01 100644
--- a/nmrs/CHANGELOG.md
+++ b/nmrs/CHANGELOG.md
@@ -3,15 +3,25 @@
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))
+- 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))
### 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))
@@ -138,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
diff --git a/nmrs/Cargo.toml b/nmrs/Cargo.toml
index e1b3ce6e..25cb53ce 100644
--- a/nmrs/Cargo.toml
+++ b/nmrs/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "nmrs"
-version = "1.3.5"
+version = "2.0.0"
authors = ["Akrm Al-Hakimi "]
edition.workspace = true
rust-version = "1.78.0"
@@ -23,9 +23,7 @@ futures.workspace = true
futures-timer.workspace = true
base64.workspace = true
tokio.workspace = true
-
-[dev-dependencies]
-tokio.workspace = true
+async-trait.workspace = true
[package.metadata.docs.rs]
all-features = true
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
diff --git a/nmrs/examples/bluetooth.rs b/nmrs/examples/bluetooth.rs
new file mode 100644
index 00000000..3d53a222
--- /dev/null
+++ b/nmrs/examples/bluetooth.rs
@@ -0,0 +1,17 @@
+/// List Bluetooth devices using NetworkManager
+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?;
+
+ // List bluetooth devices
+ for d in devices {
+ println!("{d}");
+ }
+
+ Ok(())
+}
diff --git a/nmrs/examples/bluetooth_connect.rs b/nmrs/examples/bluetooth_connect.rs
new file mode 100644
index 00000000..2a9738cc
--- /dev/null
+++ b/nmrs/examples/bluetooth_connect.rs
@@ -0,0 +1,54 @@
+/// 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?;
+
+ 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(());
+ }
+
+ // 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);
+ }
+
+ // Connect to the first device in the list
+ if let Some(device) = devices.first() {
+ println!("\nConnecting to: {}", device);
+
+ let settings = BluetoothIdentity::new(device.bdaddr.clone(), device.bt_caps.into());
+
+ 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);
+ return Ok(());
+ }
+ }
+
+ /* match nm.forget_bluetooth(name).await {
+ Ok(_) => println!("Disconnected {name}"),
+ Err(e) => eprintln!("Failed to forget: {e}"),
+ }*/
+ }
+
+ Ok(())
+}
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/examples/vpn_connect.rs b/nmrs/examples/vpn_connect.rs
index 65f30b6c..28fbb64a 100644
--- a/nmrs/examples/vpn_connect.rs
+++ b/nmrs/examples/vpn_connect.rs
@@ -1,26 +1,31 @@
-use nmrs::{NetworkManager, VpnCredentials, VpnType, WireGuardPeer};
+/// Connect to a WireGuard VPN using NetworkManager and print the assigned IP address.
+///
+/// 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?;
- 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,
- };
+ // 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",
+ vec!["0.0.0.0/0".into()],
+ )
+ .with_persistent_keepalive(25);
+
+ // 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/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
new file mode 100644
index 00000000..f03f352c
--- /dev/null
+++ b/nmrs/src/api/builders/bluetooth.rs
@@ -0,0 +1,310 @@
+//! 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::{BluetoothIdentity, BluetoothNetworkRole};
+//!
+//! let bt_settings = BluetoothIdentity::new(
+//! "00:1A:7D:DA:71:13".into(),
+//! BluetoothNetworkRole::PanU,
+//! );
+//! ```
+
+use std::collections::HashMap;
+use zvariant::Value;
+
+use crate::{
+ models::{BluetoothIdentity, BluetoothNetworkRole},
+ 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: &BluetoothIdentity) -> HashMap<&'static str, Value<'static>> {
+ let mut s = HashMap::new();
+ s.insert("bdaddr", Value::from(settings.bdaddr.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: &BluetoothIdentity,
+ 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
+}
+
+#[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::new("00:1A:7D:DA:71:13".into(), BluetoothNetworkRole::PanU)
+ }
+
+ fn create_test_identity_dun() -> BluetoothIdentity {
+ BluetoothIdentity::new("C8:1F:E8:F0:51:57".into(), 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::new("AA:BB:CC:DD:EE:FF".into(), 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/builders/mod.rs b/nmrs/src/api/builders/mod.rs
index 320ec333..6588c4ef 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,
@@ -37,11 +37,6 @@
//!
//! // 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,
@@ -73,6 +68,7 @@
//! These settings can then be passed to NetworkManager's
//! `AddConnection` or `AddAndActivateConnection` D-Bus methods.
+pub mod bluetooth;
pub mod connection_builder;
pub mod vpn;
pub mod wifi;
@@ -84,6 +80,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/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 9cbe4976..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;
@@ -7,6 +8,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 +56,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 +169,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.
@@ -300,6 +304,7 @@ pub enum StateReason {
/// # Ok(())
/// # }
/// ```
+#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Network {
/// Device interface name (e.g., "wlan0")
@@ -349,6 +354,7 @@ pub struct Network {
/// # Ok(())
/// # }
/// ```
+#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetworkInfo {
/// Network SSID (name)
@@ -396,6 +402,8 @@ pub struct NetworkInfo {
/// println!(" This is a WiFi device");
/// } else if device.is_wired() {
/// println!(" This is an Ethernet device");
+/// } else if device.is_bluetooth() {
+/// println!(" This is a Bluetooth device");
/// }
///
/// if let Some(driver) = &device.driver {
@@ -405,6 +413,7 @@ pub struct NetworkInfo {
/// # Ok(())
/// # }
/// ```
+#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct Device {
/// D-Bus object path
@@ -421,6 +430,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.
@@ -428,6 +439,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.
@@ -436,9 +448,25 @@ 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.
+#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EapMethod {
/// Protected EAP (PEAPv0) - tunnels inner authentication in TLS.
@@ -453,6 +481,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.
@@ -476,16 +505,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)
@@ -493,17 +518,12 @@ 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)]
pub struct EapOptions {
/// User identity (usually email or username)
@@ -524,6 +544,415 @@ 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()
+ }
+ }
+
+ /// 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());
+ 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
+ }
+}
+
+/// 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())"),
+ }
+ }
+}
+
+/// 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,
@@ -538,19 +967,14 @@ 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)]
pub struct ConnectionOptions {
/// Whether to automatically connect when available
@@ -577,6 +1001,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.
@@ -620,21 +1075,19 @@ 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(())
/// # }
/// ```
+#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WifiSecurity {
/// Open network (no authentication)
@@ -655,6 +1108,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.
@@ -683,24 +1137,22 @@ 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)]
pub struct VpnCredentials {
/// The type of VPN (currently only WireGuard).
@@ -723,6 +1175,318 @@ 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,
+ }
+ }
+
+ /// 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);
+ 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
+ }
+}
+
+/// 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.
@@ -740,14 +1504,13 @@ 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)]
pub struct WireGuardPeer {
/// The peer's WireGuard public key (base64 encoded).
@@ -762,6 +1525,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
@@ -776,16 +1580,13 @@ 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)]
pub struct VpnConnection {
/// The connection name/identifier.
@@ -810,20 +1611,15 @@ 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)]
pub struct VpnConnectionInfo {
/// The connection name/identifier.
@@ -844,11 +1640,156 @@ pub struct VpnConnectionInfo {
pub dns_servers: Vec,
}
+/// Bluetooth network role.
+///
+/// Specifies the role of the Bluetooth device in the network connection.
+///
+/// # 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
+}
+
+/// Bluetooth device identity information.
+///
+/// Relevant info for Bluetooth devices managed by NetworkManager.
+///
+/// # Example
+///```rust
+/// use nmrs::models::{BluetoothIdentity, BluetoothNetworkRole};
+///
+/// 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
+ pub bdaddr: String,
+ /// Bluetooth device type (DUN or PANU)
+ 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,
+/// proxying data from BlueZ.
+///
+/// This is a specialized struct for Bluetooth devices, separate from the
+/// general `Device` struct.
+///
+/// # Example
+///
+/// # 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,
+/// );
+/// ```
+#[non_exhaustive]
+#[derive(Debug, Clone)]
+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_caps: u32,
+ /// Current device state
+ 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.
/// 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.
@@ -859,6 +1800,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 +1861,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 +1876,7 @@ impl DeviceType {
Self::Wifi => 2,
Self::WifiP2P => 30,
Self::Loopback => 32,
+ Self::Bluetooth => 6,
Self::Other(code) => *code,
}
}
@@ -940,6 +1885,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.
@@ -972,6 +1918,52 @@ 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 {
+ let role = BluetoothNetworkRole::from(self.bt_caps);
+ write!(
+ f,
+ "{} ({}) [{}]",
+ self.alias.as_deref().unwrap_or("unknown"),
+ role,
+ 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.
@@ -1032,6 +2024,7 @@ impl Device {
/// # Ok(())
/// # }
/// ```
+#[non_exhaustive]
#[derive(Debug, Error)]
pub enum ConnectionError {
/// A D-Bus communication error occurred.
@@ -1082,6 +2075,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),
@@ -1121,6 +2118,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.
@@ -1276,6 +2277,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),
@@ -1306,13 +2308,12 @@ 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,
+ "{}",
+ crate::types::device_type_registry::display_name_for_code(*v)
+ ),
}
}
}
@@ -1333,6 +2334,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 {
@@ -1826,4 +2837,497 @@ 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::new("00:1A:7D:DA:71:13".into(), 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::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));
+ }
+
+ #[test]
+ fn test_bluetooth_device_creation() {
+ let role = BluetoothNetworkRole::PanU as u32;
+ 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()));
+ assert_eq!(device.alias, Some("Phone".into()));
+ 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::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"));
+ assert!(display_str.contains("00:1A:7D:DA:71:13"));
+ assert!(display_str.contains("PANU"));
+ }
+
+ #[test]
+ fn test_bluetooth_device_display_no_alias() {
+ let role = BluetoothNetworkRole::Dun as u32;
+ 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"));
+ 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::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),
+ 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");
+ }
+
+ // 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);
+ }
+
+ // 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 18cd226f..e65f3e25 100644
--- a/nmrs/src/api/network_manager.rs
+++ b/nmrs/src/api/network_manager.rs
@@ -2,19 +2,27 @@ 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::device::{
+ list_bluetooth_devices, list_devices, set_wifi_enabled, wait_for_wifi_ready, wifi_enabled,
+};
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::{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::types::constants::device_type;
use crate::Result;
/// High-level interface to NetworkManager over D-Bus.
@@ -109,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.
@@ -123,6 +185,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?;
@@ -148,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.
@@ -161,7 +228,31 @@ 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.
+ ///
+ /// # Example
+ ///
+ /// ```no_run
+ /// use nmrs::{NetworkManager, models::BluetoothIdentity, models::BluetoothNetworkRole};
+ ///
+ /// # async fn example() -> nmrs::Result<()> {
+ /// let nm = NetworkManager::new().await?;
+ ///
+ /// let identity = BluetoothIdentity::new(
+ /// "C8:1F:E8:F0:51:57".into(),
+ /// 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, Some(self.timeout_config)).await
}
/// Connects to a VPN using the provided credentials.
@@ -173,29 +264,26 @@ impl NetworkManager {
///
/// # Example
///
- /// ```no_run
+ /// ```rust
/// use nmrs::{NetworkManager, VpnCredentials, VpnType, WireGuardPeer};
///
/// # async fn example() -> nmrs::Result<()> {
/// let nm = NetworkManager::new().await?;
///
- /// let creds = VpnCredentials {
- /// vpn_type: VpnType::WireGuard,
- /// name: "MyVPN".into(),
- /// gateway: "vpn.example.com:51820".into(),
- /// private_key: "your_private_key".into(),
- /// address: "10.0.0.2/24".into(),
- /// peers: vec![WireGuardPeer {
- /// public_key: "peer_public_key".into(),
- /// gateway: "vpn.example.com:51820".into(),
- /// allowed_ips: vec!["0.0.0.0/0".into()],
- /// preshared_key: None,
- /// persistent_keepalive: Some(25),
- /// }],
- /// dns: Some(vec!["1.1.1.1".into()]),
- /// mtu: None,
- /// uuid: None,
- /// };
+ /// 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(())
@@ -209,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.
@@ -360,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.
@@ -470,7 +558,36 @@ 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),
+ Some(self.timeout_config),
+ )
+ .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),
+ Some(self.timeout_config),
+ )
+ .await
}
///
/// Subscribes to D-Bus signals for access point additions and removals
@@ -499,15 +616,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.
@@ -539,14 +653,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
new file mode 100644
index 00000000..cd7f662a
--- /dev/null
+++ b/nmrs/src/core/bluetooth.rs
@@ -0,0 +1,279 @@
+//! 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 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::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, TimeoutConfig},
+ 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.
+ // 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)
+ .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)),
+ }
+}
+
+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:
+/// 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::new(
+/// "C8:1F:E8:F0:51:57".into(),
+/// BluetoothNetworkRole::PanU,
+/// );
+/// // connect_bluetooth(&conn, "My Phone", &settings).await?;
+/// ```
+pub(crate) async fn connect_bluetooth(
+ conn: &Connection,
+ name: &str,
+ settings: &BluetoothIdentity,
+ timeout_config: Option,
+) -> 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
+ // Note: Unlike WiFi, Bluetooth connections in NetworkManager don't require
+ // specifying a specific device. We use "/" to let NetworkManager auto-select.
+ let bt_device = find_bluetooth_device(conn, &nm).await?;
+ debug!("Using auto-select device path for Bluetooth connection");
+
+ // 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?;
+
+ 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");
+ 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);
+
+ println!(
+ "Creating Bluetooth connection with settings: {:#?}",
+ connection_settings
+ );
+
+ let (_, active_conn) = nm
+ .add_and_activate_connection(
+ connection_settings,
+ bt_device.clone(),
+ specific_object,
+ )
+ .await?;
+
+ let timeout = timeout_config.map(|c| c.connection_timeout);
+ wait_for_connection_activation(conn, &active_conn, timeout).await?;
+ }
+ }
+
+ log::info!("Successfully connected to Bluetooth device '{name}'");
+ 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,
+ timeout_config: Option,
+) -> 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
+ 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;
+
+ 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::new("00:1A:7D:DA:71:13".into(), 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/connection.rs b/nmrs/src/core/connection.rs
index 3190c20d..9ca33956 100644
--- a/nmrs/src/core/connection.rs
+++ b/nmrs/src/core/connection.rs
@@ -5,11 +5,13 @@ 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, NMWirelessProxy};
+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};
@@ -34,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)?;
@@ -60,7 +67,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()");
@@ -74,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?;
}
}
@@ -99,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?;
@@ -131,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");
@@ -145,7 +174,18 @@ 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?;
+ }
+ }
+
+ 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");
}
}
@@ -153,69 +193,127 @@ 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.
+///
+/// This handles disconnection if currently active, then deletes the connection profile(s).
+/// Can be used for WiFi, Bluetooth, or any NetworkManager connection type.
+///
+/// # Arguments
///
-/// 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.
+/// * `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,
+ timeout_config: Option,
+) -> Result<()> {
use std::collections::HashMap;
use zvariant::{OwnedObjectPath, Value};
// Validate SSID
- validate_ssid(ssid)?;
+ validate_ssid(name)?;
- 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
+ // 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, timeout_config).await
{
- error!(
- "Device still connected (state: {final_state}), cannot safely delete"
- );
- return Err(ConnectionError::Stuck(format!(
- "disconnect failed, device in state {final_state}"
- )));
+ 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,
+ timeout_config,
+ )
+ .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(
@@ -244,15 +342,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();
@@ -261,9 +361,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");
}
}
}
@@ -292,11 +402,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)
+ }
}
}
@@ -309,6 +426,7 @@ pub(crate) async fn forget(conn: &Connection, ssid: &str) -> Result<()> {
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())?
@@ -339,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;
@@ -424,8 +543,9 @@ async fn ensure_disconnected(
conn: &Connection,
nm: &NMProxy<'_>,
wifi_device: &OwnedObjectPath,
+ timeout_config: Option,
) -> 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 {
@@ -437,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(())
@@ -458,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());
@@ -472,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");
}
@@ -507,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?;
}
}
}
@@ -538,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?;
}
}
@@ -558,6 +682,7 @@ async fn build_and_activate_new(
ap: &OwnedObjectPath,
ssid: &str,
creds: WifiSecurity,
+ timeout_config: Option,
) -> Result<()> {
let opts = ConnectionOptions {
autoconnect: true,
@@ -569,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())
@@ -591,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");
@@ -644,7 +770,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),
@@ -674,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 {
@@ -706,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/device.rs b/nmrs/src/core/device.rs
index 6de84f21..98fa87a5 100644
--- a/nmrs/src/core/device.rs
+++ b/nmrs/src/core/device.rs
@@ -7,9 +7,10 @@
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};
+use crate::dbus::{NMBluetoothProxy, NMDeviceProxy, NMProxy};
use crate::types::constants::device_type;
use crate::Result;
@@ -73,22 +74,76 @@ 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 {
+ 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,
- identity: DeviceIdentity {
- permanent_mac: perm_mac,
- current_mac,
- },
+ identity: DeviceIdentity::new(perm_mac, current_mac),
device_type,
state,
managed,
driver,
+ // speed,
});
}
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 {
+ // So we can get the device type and state
+ let d_proxy = NMDeviceProxy::builder(conn)
+ .path(p.clone())?
+ .build()
+ .await?;
+
+ // Only process Bluetooth devices
+ if d_proxy.device_type().await? != device_type::BLUETOOTH {
+ continue;
+ }
+ // Bluetooth-specific proxy
+ // to get BD_ADDR and capabilities
+ let bd_proxy = NMBluetoothProxy::builder(conn)
+ .path(p.clone())?
+ .build()
+ .await?;
+
+ let bdaddr = bd_proxy
+ .hw_address()
+ .await
+ .unwrap_or_else(|_| String::from("00:00:00:00:00:00"));
+ let bt_caps = bd_proxy.bt_capabilities().await?;
+ let raw_state = d_proxy.state().await?;
+ let state = raw_state.into();
+
+ let bluez_info = populate_bluez_info(conn, &bdaddr).await?;
+
+ devices.push(BluetoothDevice::new(
+ bdaddr,
+ bluez_info.0,
+ bluez_info.1,
+ bt_caps,
+ 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
@@ -145,3 +200,38 @@ pub(crate) async fn wifi_enabled(conn: &Connection) -> 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 panu = BluetoothNetworkRole::PanU as u32;
+ let device = BluetoothDevice::new(
+ "00:1A:7D:DA:71:13".into(),
+ Some("TestDevice".into()),
+ Some("Test".into()),
+ panu,
+ 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_caps, _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/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/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/dbus/bluetooth.rs b/nmrs/src/dbus/bluetooth.rs
new file mode 100644
index 00000000..80b2f6b9
--- /dev/null
+++ b/nmrs/src/dbus/bluetooth.rs
@@ -0,0 +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 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 hw_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
+/// 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/dbus/mod.rs b/nmrs/src/dbus/mod.rs
index 3cfe0b15..4627bd4c 100644
--- a/nmrs/src/dbus/mod.rs
+++ b/nmrs/src/dbus/mod.rs
@@ -5,12 +5,16 @@
mod access_point;
mod active_connection;
+mod bluetooth;
mod device;
mod main_nm;
+mod wired;
mod wireless;
pub(crate) use access_point::NMAccessPointProxy;
pub(crate) use active_connection::NMActiveConnectionProxy;
+pub(crate) use bluetooth::{BluezDeviceExtProxy, NMBluetoothProxy};
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).
///
diff --git a/nmrs/src/lib.rs b/nmrs/src/lib.rs
index 0461dca5..165d9efe 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,30 +34,27 @@
//!
//! ## VPN Connection (WireGuard)
//!
-//! ```no_run
+//! ```rust
//! use nmrs::{NetworkManager, VpnCredentials, VpnType, WireGuardPeer};
//!
//! # async fn example() -> nmrs::Result<()> {
//! let nm = NetworkManager::new().await?;
//!
//! // Configure WireGuard VPN
-//! let creds = VpnCredentials {
-//! vpn_type: VpnType::WireGuard,
-//! name: "MyVPN".into(),
-//! gateway: "vpn.example.com:51820".into(),
-//! private_key: "your_private_key".into(),
-//! address: "10.0.0.2/24".into(),
-//! peers: vec![WireGuardPeer {
-//! public_key: "peer_public_key".into(),
-//! gateway: "vpn.example.com:51820".into(),
-//! allowed_ips: vec!["0.0.0.0/0".into()],
-//! preshared_key: None,
-//! persistent_keepalive: Some(25),
-//! }],
-//! dns: Some(vec!["1.1.1.1".into(), "8.8.8.8".into()]),
-//! mtu: None,
-//! uuid: None,
-//! };
+//! 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?;
@@ -107,7 +104,7 @@
//!
//! ## Connecting to Different Network Types
//!
-//! ```no_run
+//! ```rust
//! use nmrs::{NetworkManager, WifiSecurity, EapOptions, EapMethod, Phase2};
//!
//! # async fn example() -> nmrs::Result<()> {
@@ -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)
@@ -146,7 +140,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 +170,7 @@
//!
//! ## Device Management
//!
-//! ```no_run
+//! ```rust
//! use nmrs::NetworkManager;
//!
//! # async fn example() -> nmrs::Result<()> {
@@ -203,7 +197,7 @@
//!
//! Monitor network and device changes in real-time using D-Bus signals:
//!
-//! ```ignore
+//! ```rust
//! use nmrs::NetworkManager;
//!
//! # async fn example() -> nmrs::Result<()> {
@@ -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",
@@ -304,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.)
@@ -325,9 +316,10 @@ pub mod models {
// Re-export commonly used types at crate root for convenience
pub use api::models::{
- connection_state_reason_to_error, reason_to_error, ActiveConnectionState, ConnectionError,
- ConnectionOptions, ConnectionStateReason, Device, DeviceState, DeviceType, EapMethod,
- EapOptions, Network, NetworkInfo, Phase2, StateReason, VpnConnection, VpnConnectionInfo,
+ connection_state_reason_to_error, reason_to_error, ActiveConnectionState, BluetoothDevice,
+ BluetoothIdentity, BluetoothNetworkRole, ConnectionError, ConnectionOptions,
+ ConnectionStateReason, Device, DeviceState, DeviceType, EapMethod, EapOptions, Network,
+ NetworkInfo, Phase2, StateReason, TimeoutConfig, VpnConnection, VpnConnectionInfo,
VpnCredentials, VpnType, WifiSecurity, WireGuardPeer,
};
pub use api::network_manager::NetworkManager;
diff --git a/nmrs/src/monitoring/bluetooth.rs b/nmrs/src/monitoring/bluetooth.rs
new file mode 100644
index 00000000..42f72156
--- /dev/null
+++ b/nmrs/src/monitoring/bluetooth.rs
@@ -0,0 +1,144 @@
+//! 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
+///
+/// ```ignore
+/// 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.hw_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.hw_address().await, bt.bt_capabilities().await)
+ {
+ return Some((bdaddr, capabilities));
+ }
+ }
+ 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/src/monitoring/info.rs b/nmrs/src/monitoring/info.rs
index 0ee92bd7..56bfc8f7 100644
--- a/nmrs/src/monitoring/info.rs
+++ b/nmrs/src/monitoring/info.rs
@@ -1,13 +1,12 @@
-//! 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};
@@ -17,7 +16,7 @@ use crate::util::utils::{
};
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)
@@ -183,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/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..47225389
--- /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.to_string());
+ }
+ }
+ }
+ 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.to_string(), 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;
}
diff --git a/nmrs/tests/integration_test.rs b/nmrs/tests/integration_test.rs
index b697f95a..b989fe48 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,14 +593,19 @@ 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
+ | DeviceType::Bluetooth
| DeviceType::WifiP2P
| DeviceType::Loopback
| DeviceType::Other(_) => {
// Valid type
}
+ _ => {
+ panic!("Invalid device type: {:?}", device.device_type);
+ }
}
}
}
@@ -842,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
@@ -1002,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");
@@ -1030,3 +1039,190 @@ 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_caps
+ );
+ }
+}
+
+/// 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::new("00:1A:7D:DA:71:13".into(), 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 role = BluetoothNetworkRole::PanU as u32;
+ 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()));
+ 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 role = BluetoothNetworkRole::PanU as u32;
+ let device = BluetoothDevice::new(
+ "00:1A:7D:DA:71:13".into(),
+ Some("MyPhone".into()),
+ Some("Phone".into()),
+ role,
+ 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
+ ));
+}
diff --git a/nmrs/tests/validation_test.rs b/nmrs/tests/validation_test.rs
index 0e527ac3..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());
@@ -289,4 +258,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;
}