diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 68563481..8ad1a0f6 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -4,6 +4,13 @@ on:
workflow_dispatch:
branches: [ master ]
inputs:
+ crate:
+ description: 'Crate to release'
+ required: true
+ type: choice
+ options:
+ - nmrs
+ - nmrs-gui
version:
description: 'Version to release'
required: true
@@ -39,6 +46,7 @@ jobs:
python3 scripts/bump_version.py "${{ github.event.inputs.version }}" "${{ github.event.inputs.release_type }}" || echo "⚠ Version bump completed with warnings"
echo "VERSION=${{ github.event.inputs.version }}" >> $GITHUB_ENV
echo "RELEASE_TYPE=${{ github.event.inputs.release_type }}" >> $GITHUB_ENV
+ echo "CRATE=${{ github.event.inputs.crate }}" >> $GITHUB_ENV
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
@@ -84,12 +92,44 @@ jobs:
fi
git add -A
- git commit -m "chore: bump version to ${{ env.VERSION }}-${{ env.RELEASE_TYPE }}"
- git tag -a "v${{ env.VERSION }}-${{ env.RELEASE_TYPE }}" -m "Release v${{ env.VERSION }}-${{ env.RELEASE_TYPE }}"
+
+ # Create crate-specific tag
+ CRATE="${{ env.CRATE }}"
+ VERSION="${{ env.VERSION }}"
+ RELEASE_TYPE="${{ env.RELEASE_TYPE }}"
+
+ if [ "$CRATE" = "nmrs" ]; then
+ TAG_PREFIX="nmrs-"
+ COMMIT_MSG="chore(nmrs): bump version to ${VERSION}"
+ if [ "$RELEASE_TYPE" = "beta" ]; then
+ TAG_NAME="${TAG_PREFIX}v${VERSION}-beta"
+ COMMIT_MSG="${COMMIT_MSG}-beta"
+ else
+ TAG_NAME="${TAG_PREFIX}v${VERSION}"
+ fi
+ elif [ "$CRATE" = "nmrs-gui" ]; then
+ TAG_PREFIX="gui-"
+ COMMIT_MSG="chore(nmrs-gui): bump version to ${VERSION}"
+ if [ "$RELEASE_TYPE" = "beta" ]; then
+ TAG_NAME="${TAG_PREFIX}v${VERSION}-beta"
+ COMMIT_MSG="${COMMIT_MSG}-beta"
+ else
+ TAG_NAME="${TAG_PREFIX}v${VERSION}"
+ fi
+ else
+ echo "Error: Unknown crate $CRATE"
+ exit 1
+ fi
+
+ echo "Creating tag: $TAG_NAME"
+ git commit -m "$COMMIT_MSG"
+ git tag -a "$TAG_NAME" -m "Release $TAG_NAME"
# Force push to handle any race conditions (safe since we just reset)
git push origin "$BRANCH" --force-with-lease
- git push origin "v${{ env.VERSION }}-${{ env.RELEASE_TYPE }}"
+ git push origin "$TAG_NAME"
+
+ echo "TAG_NAME=$TAG_NAME" >> $GITHUB_ENV
- name: Extract release notes
run: |
@@ -98,8 +138,8 @@ jobs:
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
- tag_name: v${{ env.VERSION }}-${{ env.RELEASE_TYPE }}
- name: Release v${{ env.VERSION }}-${{ env.RELEASE_TYPE }}
+ tag_name: ${{ env.TAG_NAME }}
+ name: Release ${{ env.TAG_NAME }}
body_path: RELEASE_NOTES.md
draft: false
prerelease: ${{ env.RELEASE_TYPE == 'beta' }}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a0ace2e1..ed24d5bb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,6 @@
# Changelog
## [Unreleased]
+- Core: Full WireGuard VPN support ([#92](https://github.com/cachebag/nmrs/issues/92))
## [0.5.0-beta] - 2025-12-15
### Changed
@@ -118,19 +119,10 @@
- EAP connections default to no certificates (advanced certificate management coming in future releases)
- VPN connections planned for near future
-[0.3.0-beta]: https://github.com/cachebag/nmrs/compare/v0.2.0-beta
-[0...v0.3.0-beta
-[0.4.0-beta]: https://github.com/cachebag/nmrs/compare/v0.3.0-beta
-[0.4.0-beta]: https://github.com/cachebag/nmrs/compare/v0.3.0-beta
-[0...v0.4.0-beta
-[0.5.0-beta]: https://github.com/cachebag/nmrs/compare/v0.3.0-beta
-[0...v0.5.0-beta
-[unreleased]: https://github...v0.4.0-beta
-[0.4.0-beta]: https://github.com/cachebag/nmrs/compare/v0.3.0-beta
-[0...v0.4.0-beta
-[0.5.0-beta]: https://github.com/cachebag/nmrs/compare/v0.3.0-beta
-[0...v0.5.0-beta
-[unreleased]: https://github.com/cachebag/nmrs/compare/v0.5.0-beta...HEAD
+[Unreleased]: https://github.com/cachebag/nmrs/compare/v0.5.0-beta...HEAD
+[0.5.0-beta]: https://github.com/cachebag/nmrs/compare/v0.4.0-beta...v0.5.0-beta
+[0.4.0-beta]: https://github.com/cachebag/nmrs/compare/v0.3.0-beta...v0.4.0-beta
+[0.3.0-beta]: https://github.com/cachebag/nmrs/compare/v0.2.0-beta...v0.3.0-beta
[0.2.0-beta]: https://github.com/cachebag/nmrs/compare/v0.1.1-beta...v0.2.0-beta
[0.1.1-beta]: https://github.com/cachebag/nmrs/compare/v0.1.0-beta...v0.1.1-beta
[0.1.0-beta]: https://github.com/cachebag/nmrs/releases/tag/v0.1.0-beta
diff --git a/Cargo.lock b/Cargo.lock
index 863bbff5..ce95e569 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -957,7 +957,7 @@ dependencies = [
[[package]]
name = "nmrs"
-version = "0.5.0"
+version = "1.0.0"
dependencies = [
"futures",
"futures-timer",
@@ -1252,6 +1252,12 @@ dependencies = [
"serde",
]
+[[package]]
+name = "sha1_smol"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
+
[[package]]
name = "signal-hook-registry"
version = "1.4.6"
@@ -1513,6 +1519,7 @@ dependencies = [
"getrandom 0.3.3",
"js-sys",
"serde_core",
+ "sha1_smol",
"wasm-bindgen",
]
diff --git a/Cargo.toml b/Cargo.toml
index b1579ca6..eb6c6fbc 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,4 +4,23 @@ members = [
"nmrs-gui"
]
-resolver = "3"
+resolver = "2"
+
+[workspace.package]
+edition = "2021"
+license = "MIT"
+repository = "https://github.com/cachebag/nmrs"
+
+[workspace.dependencies]
+# Core dependencies
+zbus = "5.12.0"
+zvariant = "5.8.0"
+log = "0.4.29"
+serde = { version = "1.0.228", features = ["derive"] }
+thiserror = "2.0.17"
+uuid = { version = "1.19.0", features = ["v4", "v5"] }
+futures = "0.3.31"
+futures-timer = "3.0.3"
+
+# Dev dependencies
+tokio = { version = "1.48.0", features = ["rt-multi-thread", "macros", "sync", "time"] }
\ No newline at end of file
diff --git a/README.md b/README.md
index 958ead9d..fbaa604a 100644
--- a/README.md
+++ b/README.md
@@ -96,7 +96,9 @@ async fn main() -> nmrs::Result<()> {
Ok(())
}
```
-#
GUI Application
+# nmrs-gui
+
+
This repository also includes `nmrs-gui`, a Wayland-compatible NetworkManager frontend built with GTK4.
@@ -148,7 +150,7 @@ Edit `~/.config/nmrs/style.css` to customize the interface. There are also pre-d
- [x] Generic
- [x] Wireless
- [ ] Any
-- [ ] Wired
+- [X] Wired
- [ ] ADSL
- [ ] Bluetooth
- [ ] Bond
@@ -177,7 +179,7 @@ Edit `~/.config/nmrs/style.css` to customize the interface. There are also pre-d
- [ ] VXLAN
- [ ] Wi-Fi P2P
- [ ] WiMAX
-- [ ] WireGuard
+- [X] WireGuard
- [ ] WPAN
### Configurations
@@ -211,6 +213,12 @@ Edit `~/.config/nmrs/style.css` to customize the interface. There are also pre-d
Contributions are welcome. Please read [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines.
+## Requirements
+
+- **Rust**: 1.78.0 or later (for `nmrs` library)
+- **Rust**: 1.85.1 or later (for `nmrs-gui` with GTK4)
+- **NetworkManager**: Running and accessible via D-Bus
+- **Linux**: This library is Linux-specific
## License
diff --git a/nmrs-aur b/nmrs-aur
index 1e81b65d..4ee42d12 160000
--- a/nmrs-aur
+++ b/nmrs-aur
@@ -1 +1 @@
-Subproject commit 1e81b65d39b800e5d5a72accea36d8400642b2fd
+Subproject commit 4ee42d129ad7718052c025f11dbb3b6949d10d5a
diff --git a/nmrs-gui/Cargo.toml b/nmrs-gui/Cargo.toml
index 32856c1c..98495858 100644
--- a/nmrs-gui/Cargo.toml
+++ b/nmrs-gui/Cargo.toml
@@ -1,14 +1,21 @@
[package]
name = "nmrs-gui"
version = "0.5.0"
-edition = "2024"
+authors = ["Akrm Al-Hakimi "]
+edition.workspace = true
+rust-version = "1.85.1"
+description = "GTK4 GUI for managing NetworkManager connections"
+license.workspace = true
+repository.workspace = true
+keywords = ["networkmanager", "gui", "gtk", "linux"]
+categories = ["gui"]
[dependencies]
-log = "0.4.29"
-nmrs = { path = "../nmrs" }
-tokio = { version = "1.48.0", features = ["full"] }
+nmrs = { path = "../nmrs", version = "1.0.0" }
gtk = { version = "0.10.3", package = "gtk4" }
glib = "0.21.5"
+tokio = { version = "1.48.0", features = ["full"] }
+log = "0.4.29"
dirs = "6.0.0"
fs2 = "0.4.3"
anyhow = "1.0.100"
diff --git a/nmrs-gui/src/lib.rs b/nmrs-gui/src/lib.rs
index ed6c5d4c..ac78f004 100644
--- a/nmrs-gui/src/lib.rs
+++ b/nmrs-gui/src/lib.rs
@@ -5,8 +5,8 @@ pub mod theme_config;
pub mod ui;
use clap::{ArgAction, Parser};
-use gtk::Application;
use gtk::prelude::*;
+use gtk::Application;
use crate::file_lock::acquire_app_lock;
use crate::style::load_css;
diff --git a/nmrs-gui/src/ui/connect.rs b/nmrs-gui/src/ui/connect.rs
index 0b046733..4248c677 100644
--- a/nmrs-gui/src/ui/connect.rs
+++ b/nmrs-gui/src/ui/connect.rs
@@ -1,12 +1,12 @@
use glib::Propagation;
use gtk::{
- ApplicationWindow, Box as GtkBox, Button, CheckButton, Dialog, Entry, EventControllerKey,
- FileChooserAction, FileChooserDialog, Label, Orientation, ResponseType, prelude::*,
+ prelude::*, ApplicationWindow, Box as GtkBox, Button, CheckButton, Dialog, Entry,
+ EventControllerKey, FileChooserAction, FileChooserDialog, Label, Orientation, ResponseType,
};
use log::{debug, error};
use nmrs::{
- NetworkManager,
models::{EapMethod, EapOptions, Phase2, WifiSecurity},
+ NetworkManager,
};
use std::rc::Rc;
@@ -21,11 +21,11 @@ pub fn connect_modal(
let parent_weak = parent.downgrade();
glib::MainContext::default().spawn_local(async move {
- if let Some(current) = nm.current_ssid().await
- && current == ssid_owned
- {
- debug!("Already connected to {current}, skipping modal");
- return;
+ if let Some(current) = nm.current_ssid().await {
+ if current == ssid_owned {
+ debug!("Already connected to {current}, skipping modal");
+ return;
+ }
}
if let Some(parent) = parent_weak.upgrade() {
@@ -127,14 +127,15 @@ fn draw_connect_modal(
let cert_entry = cert_entry_for_browse.clone();
file_dialog.connect_response(move |dialog, response| {
- if response == ResponseType::Accept
- && let Some(file) = dialog.file()
- && let Some(path) = file.path()
- {
- cert_entry
- .as_ref()
- .unwrap()
- .set_text(&path.to_string_lossy());
+ if response == ResponseType::Accept {
+ if let Some(file) = dialog.file() {
+ if let Some(path) = file.path() {
+ cert_entry
+ .as_ref()
+ .unwrap()
+ .set_text(&path.to_string_lossy());
+ }
+ }
}
dialog.close();
});
diff --git a/nmrs-gui/src/ui/header.rs b/nmrs-gui/src/ui/header.rs
index a9fcb88e..28b6dd9f 100644
--- a/nmrs-gui/src/ui/header.rs
+++ b/nmrs-gui/src/ui/header.rs
@@ -1,7 +1,7 @@
use glib::clone;
-use gtk::STYLE_PROVIDER_PRIORITY_USER;
use gtk::prelude::*;
-use gtk::{Align, Box as GtkBox, HeaderBar, Label, ListBox, Orientation, Switch, glib};
+use gtk::STYLE_PROVIDER_PRIORITY_USER;
+use gtk::{glib, Align, Box as GtkBox, HeaderBar, Label, ListBox, Orientation, Switch};
use std::cell::Cell;
use std::collections::HashSet;
use std::rc::Rc;
@@ -65,10 +65,10 @@ pub fn build_header(
let names: Vec<&str> = THEMES.iter().map(|t| t.name).collect();
let dropdown = gtk::DropDown::from_strings(&names);
- if let Some(saved) = crate::theme_config::load_theme()
- && let Some(idx) = THEMES.iter().position(|t| t.key == saved.as_str())
- {
- dropdown.set_selected(idx as u32);
+ if let Some(saved) = crate::theme_config::load_theme() {
+ if let Some(idx) = THEMES.iter().position(|t| t.key == saved.as_str()) {
+ dropdown.set_selected(idx as u32);
+ }
}
dropdown.set_valign(gtk::Align::Center);
diff --git a/nmrs-gui/src/ui/mod.rs b/nmrs-gui/src/ui/mod.rs
index 8001f079..43132ded 100644
--- a/nmrs-gui/src/ui/mod.rs
+++ b/nmrs-gui/src/ui/mod.rs
@@ -7,8 +7,8 @@ pub mod wired_page;
use gtk::prelude::*;
use gtk::{
- Application, ApplicationWindow, Box as GtkBox, Label, Orientation,
- STYLE_PROVIDER_PRIORITY_USER, ScrolledWindow, Spinner, Stack,
+ Application, ApplicationWindow, Box as GtkBox, Label, Orientation, ScrolledWindow, Spinner,
+ Stack, STYLE_PROVIDER_PRIORITY_USER,
};
use std::cell::Cell;
use std::rc::Rc;
@@ -32,20 +32,20 @@ pub fn build_ui(app: &Application) {
win.set_title(Some(""));
win.set_default_size(100, 600);
- if let Some(key) = crate::theme_config::load_theme()
- && let Some(theme) = THEMES.iter().find(|t| t.key == key.as_str())
- {
- let provider = gtk::CssProvider::new();
- provider.load_from_data(theme.css);
+ if let Some(key) = crate::theme_config::load_theme() {
+ if let Some(theme) = THEMES.iter().find(|t| t.key == key.as_str()) {
+ let provider = gtk::CssProvider::new();
+ provider.load_from_data(theme.css);
- let display = gtk::prelude::RootExt::display(&win);
- gtk::style_context_add_provider_for_display(
- &display,
- &provider,
- STYLE_PROVIDER_PRIORITY_USER,
- );
+ let display = gtk::prelude::RootExt::display(&win);
+ gtk::style_context_add_provider_for_display(
+ &display,
+ &provider,
+ STYLE_PROVIDER_PRIORITY_USER,
+ );
- win.add_css_class("dark-theme");
+ win.add_css_class("dark-theme");
+ }
}
let vbox = GtkBox::new(Orientation::Vertical, 0);
diff --git a/nmrs-gui/src/ui/network_page.rs b/nmrs-gui/src/ui/network_page.rs
index e750c375..86a61146 100644
--- a/nmrs-gui/src/ui/network_page.rs
+++ b/nmrs-gui/src/ui/network_page.rs
@@ -1,8 +1,8 @@
use glib::clone;
use gtk::prelude::*;
use gtk::{Align, Box, Button, Image, Label, Orientation};
-use nmrs::NetworkManager;
use nmrs::models::NetworkInfo;
+use nmrs::NetworkManager;
use std::cell::RefCell;
use std::rc::Rc;
@@ -75,12 +75,12 @@ impl NetworkPage {
let on_success = on_success_clone.clone();
glib::MainContext::default().spawn_local(async move {
- if let Ok(nm) = NetworkManager::new().await
- && nm.forget(&ssid).await.is_ok()
- {
- stack.set_visible_child_name("networks");
- if let Some(callback) = on_success.borrow().as_ref() {
- callback();
+ if let Ok(nm) = NetworkManager::new().await {
+ if nm.forget(&ssid).await.is_ok() {
+ stack.set_visible_child_name("networks");
+ if let Some(callback) = on_success.borrow().as_ref() {
+ callback();
+ }
}
}
});
diff --git a/nmrs-gui/src/ui/networks.rs b/nmrs-gui/src/ui/networks.rs
index 8de7f5a3..f0437cb1 100644
--- a/nmrs-gui/src/ui/networks.rs
+++ b/nmrs-gui/src/ui/networks.rs
@@ -1,10 +1,10 @@
use anyhow::Result;
+use gtk::prelude::*;
use gtk::Align;
use gtk::GestureClick;
-use gtk::prelude::*;
use gtk::{Box, Image, Label, ListBox, ListBoxRow, Orientation};
use nmrs::models::WifiSecurity;
-use nmrs::{NetworkManager, models};
+use nmrs::{models, NetworkManager};
use std::rc::Rc;
use crate::ui::connect;
diff --git a/nmrs-gui/src/ui/wired_devices.rs b/nmrs-gui/src/ui/wired_devices.rs
index 302519fa..3170566b 100644
--- a/nmrs-gui/src/ui/wired_devices.rs
+++ b/nmrs-gui/src/ui/wired_devices.rs
@@ -1,8 +1,8 @@
+use gtk::prelude::*;
use gtk::Align;
use gtk::GestureClick;
-use gtk::prelude::*;
use gtk::{Box, Image, Label, ListBox, ListBoxRow, Orientation};
-use nmrs::{NetworkManager, models};
+use nmrs::{models, NetworkManager};
use std::rc::Rc;
use crate::ui::wired_page::WiredPage;
diff --git a/nmrs/Cargo.toml b/nmrs/Cargo.toml
index 7b65839f..cacae4d3 100644
--- a/nmrs/Cargo.toml
+++ b/nmrs/Cargo.toml
@@ -1,23 +1,30 @@
[package]
name = "nmrs"
-version = "0.5.0"
-edition = "2024"
+version = "1.0.0"
+authors = ["Akrm Al-Hakimi "]
+edition.workspace = true
+rust-version = "1.78.0"
description = "A Rust library for NetworkManager over D-Bus"
-license = "MIT"
-repository = "https://github.com/cachebag/nmrs"
+license.workspace = true
+repository.workspace = true
documentation = "https://docs.rs/nmrs"
keywords = ["networkmanager", "dbus", "wifi", "linux", "networking"]
categories = ["api-bindings", "asynchronous"]
+readme = "README.md"
[dependencies]
-log = "0.4.29"
-zbus = "5.12.0"
-zvariant = "5.8.0"
-serde = { version = "1.0.228", features = ["derive"] }
-thiserror = "2.0.17"
-uuid = { version = "1.19.0", features = ["v4"] }
-futures = "0.3.31"
-futures-timer = "3.0.3"
+log.workspace = true
+zbus.workspace = true
+zvariant.workspace = true
+serde.workspace = true
+thiserror.workspace = true
+uuid.workspace = true
+futures.workspace = true
+futures-timer.workspace = true
[dev-dependencies]
-tokio = { version = "1.48.0", features = ["rt-multi-thread", "macros", "sync", "time"] }
+tokio.workspace = true
+
+[package.metadata.docs.rs]
+all-features = true
+targets = ["x86_64-unknown-linux-gnu"]
diff --git a/nmrs/README.md b/nmrs/README.md
index cd482f06..48bf2c59 100644
--- a/nmrs/README.md
+++ b/nmrs/README.md
@@ -6,28 +6,32 @@
Rust bindings for NetworkManager via D-Bus.
-## Overview
+## Why?
`nmrs` provides a high-level, async API for managing Wi-Fi connections on Linux systems. It abstracts the complexity of D-Bus communication with NetworkManager, offering typed error handling and an ergonomic interface.
## Features
-- **Network Operations**: Connect to WPA-PSK, WPA-EAP, and open networks
-- **Discovery**: Scan for and list available access points with signal strength
-- **Profile Management**: Query, create, and delete saved connection profiles
-- **Status Queries**: Get current connection state, SSID, and detailed network information
-- **Typed Errors**: Structured error types mapping NetworkManager state reason codes
-- **Fully Async**: Built on `tokio` with `async/await` support
+- **WiFi Management**: Connect to WPA-PSK, WPA-EAP, and open networks
+- **VPN Support**: WireGuard VPN connections with full configuration
+- **Ethernet**: Wired network connection management
+- **Network Discovery**: Scan and list available access points with signal strength
+- **Profile Management**: Create, query, and delete saved connection profiles
+- **Real-Time Monitoring**: Signal-based network and device state change notifications
+- **Typed Errors**: Structured error types with specific failure reasons
+- **Fully Async**: Built on `zbus` with async/await throughout
## Installation
```toml
[dependencies]
-nmrs = "0.4"
+nmrs = "1.0.0"
```
## Quick Start
+### WiFi Connection
+
```rust
use nmrs::{NetworkManager, WifiSecurity};
@@ -37,53 +41,212 @@ async fn main() -> nmrs::Result<()> {
// List networks
let networks = nm.list_networks().await?;
- for net in networks {
- println!("{} ({}%)", net.ssid, net.strength.unwrap_or(0));
+ for net in &networks {
+ println!("{} - Signal: {}%", net.ssid, net.strength.unwrap_or(0));
}
- // Connect
+ // Connect to WPA-PSK network
nm.connect("MyNetwork", WifiSecurity::WpaPsk {
psk: "password".into()
}).await?;
+ // Check current connection
+ if let Some(ssid) = nm.current_ssid().await {
+ println!("Connected to: {}", ssid);
+ }
+
+ Ok(())
+}
+```
+
+### WireGuard VPN
+
+```rust
+use nmrs::{NetworkManager, VpnCredentials, VpnType, WireGuardPeer};
+
+#[tokio::main]
+async fn main() -> nmrs::Result<()> {
+ let nm = NetworkManager::new().await?;
+
+ let creds = VpnCredentials {
+ vpn_type: VpnType::WireGuard,
+ name: "WorkVPN".into(),
+ gateway: "vpn.example.com:51820".into(),
+ private_key: "your_private_key_here".into(),
+ address: "10.0.0.2/24".into(),
+ peers: vec![WireGuardPeer {
+ public_key: "server_public_key".into(),
+ gateway: "vpn.example.com:51820".into(),
+ allowed_ips: vec!["0.0.0.0/0".into()],
+ preshared_key: None,
+ persistent_keepalive: Some(25),
+ }],
+ dns: Some(vec!["1.1.1.1".into()]),
+ mtu: None,
+ uuid: None,
+ };
+
+ // Connect to VPN
+ nm.connect_vpn(creds).await?;
+
+ // Get connection details
+ let info = nm.get_vpn_info("WorkVPN").await?;
+ println!("VPN IP: {:?}", info.ip4_address);
+
+ // Disconnect
+ nm.disconnect_vpn("WorkVPN").await?;
+
+ Ok(())
+}
+```
+
+### WPA-Enterprise (EAP)
+
+```rust
+use nmrs::{NetworkManager, WifiSecurity, EapOptions, EapMethod, Phase2};
+
+#[tokio::main]
+async fn main() -> nmrs::Result<()> {
+ let nm = NetworkManager::new().await?;
+
+ nm.connect("CorpNetwork", WifiSecurity::WpaEap {
+ opts: EapOptions {
+ identity: "user@company.com".into(),
+ password: "password".into(),
+ anonymous_identity: None,
+ domain_suffix_match: Some("company.com".into()),
+ ca_cert_path: None,
+ system_ca_certs: true,
+ method: EapMethod::Peap,
+ phase2: Phase2::Mschapv2,
+ }
+ }).await?;
+
+ Ok(())
+}
+```
+
+### Device Management
+
+```rust
+use nmrs::NetworkManager;
+
+#[tokio::main]
+async fn main() -> nmrs::Result<()> {
+ let nm = NetworkManager::new().await?;
+
+ // List all network devices
+ let devices = nm.list_devices().await?;
+ for device in devices {
+ println!("{}: {} ({})", device.interface, device.device_type, device.state);
+ }
+
+ // Control WiFi radio
+ nm.set_wifi_enabled(false).await?;
+ nm.set_wifi_enabled(true).await?;
+
+ Ok(())
+}
+```
+
+### Real-Time Monitoring
+
+```rust
+use nmrs::NetworkManager;
+
+#[tokio::main]
+async fn main() -> nmrs::Result<()> {
+ let nm = NetworkManager::new().await?;
+
+ // Monitor network changes
+ nm.monitor_network_changes(|| {
+ println!("Network list changed");
+ }).await?;
+
Ok(())
}
```
## Error Handling
-All operations return `Result` with specific error variants:
+All operations return `Result` with specific variants:
```rust
-use nmrs::ConnectionError;
+use nmrs::{NetworkManager, WifiSecurity, ConnectionError};
-match nm.connect(ssid, creds).await {
+match nm.connect("MyNetwork", WifiSecurity::WpaPsk {
+ psk: "wrong".into()
+}).await {
Ok(_) => println!("Connected"),
- Err(ConnectionError::AuthFailed) => eprintln!("Wrong password"),
+ Err(ConnectionError::AuthFailed) => eprintln!("Authentication failed"),
Err(ConnectionError::NotFound) => eprintln!("Network not in range"),
Err(ConnectionError::Timeout) => eprintln!("Connection timed out"),
+ Err(ConnectionError::DhcpFailed) => eprintln!("Failed to obtain IP address"),
Err(e) => eprintln!("Error: {}", e),
}
```
+## Async Runtime Support
-## Logging
+`nmrs` is **runtime-agnostic** and works with any async runtime:
+
+- **Tokio**
+- **async-std**
+- **smol**
+- Any runtime supporting standard Rust `async/await`
-This crate uses the [`log`](https://docs.rs/log) facade. Enable logging with:
+All examples use Tokio, but you can use your preferred runtime:
+**With Tokio:**
```rust
-env_logger::init();
+#[tokio::main]
+async fn main() -> nmrs::Result<()> {
+ let nm = nmrs::NetworkManager::new().await?;
+ // ...
+ Ok(())
+}
+```
+**With async-std:**
+```rust
+#[async_std::main]
+async fn main() -> nmrs::Result<()> {
+ let nm = nmrs::NetworkManager::new().await?;
+ // ...
+ Ok(())
+}
+```
+
+**With smol:**
+```rust
+fn main() -> nmrs::Result<()> {
+ smol::block_on(async {
+ let nm = nmrs::NetworkManager::new().await?;
+ // ...
+ Ok(())
+ })
+}
```
-Then run with `RUST_LOG=nmrs=debug` to see detailed logs.
+`nmrs` uses `zbus` for D-Bus communication, which launches a background thread to handle D-Bus message processing. This design ensures compatibility across all async runtimes without requiring manual executor management.
## Documentation
-Full API documentation is available at [docs.rs/nmrs](https://docs.rs/nmrs).
+Complete API documentation: [docs.rs/nmrs](https://docs.rs/nmrs)
## Requirements
-- Linux with NetworkManager
+- Linux with NetworkManager (1.0+)
- D-Bus system bus access
+- Appropriate permissions for network management
+
+## Logging
+
+Enable logging via the `log` crate:
+
+```rust
+env_logger::init();
+```
+
+Set `RUST_LOG=nmrs=debug` for detailed logs.
## License
diff --git a/nmrs/examples/vpn_connect.rs b/nmrs/examples/vpn_connect.rs
new file mode 100644
index 00000000..65f30b6c
--- /dev/null
+++ b/nmrs/examples/vpn_connect.rs
@@ -0,0 +1,32 @@
+use nmrs::{NetworkManager, VpnCredentials, VpnType, WireGuardPeer};
+
+#[tokio::main]
+async fn main() -> nmrs::Result<()> {
+ let nm = NetworkManager::new().await?;
+
+ let creds = VpnCredentials {
+ vpn_type: VpnType::WireGuard,
+ name: "ExampleVPN".into(),
+ gateway: "vpn.example.com:51820".into(),
+ private_key: std::env::var("WG_PRIVATE_KEY").expect("Set WG_PRIVATE_KEY env var"),
+ address: "10.0.0.2/24".into(),
+ peers: vec![WireGuardPeer {
+ public_key: std::env::var("WG_PUBLIC_KEY").expect("Set WG_PUBLIC_KEY env var"),
+ gateway: "vpn.example.com:51820".into(),
+ allowed_ips: vec!["0.0.0.0/0".into()],
+ preshared_key: None,
+ persistent_keepalive: Some(25),
+ }],
+ dns: Some(vec!["1.1.1.1".into()]),
+ mtu: None,
+ uuid: None,
+ };
+
+ println!("Connecting to VPN...");
+ nm.connect_vpn(creds).await?;
+
+ let info = nm.get_vpn_info("ExampleVPN").await?;
+ println!("Connected! IP: {:?}", info.ip4_address);
+
+ Ok(())
+}
diff --git a/nmrs/examples/wifi_scan.rs b/nmrs/examples/wifi_scan.rs
new file mode 100644
index 00000000..5ee858ee
--- /dev/null
+++ b/nmrs/examples/wifi_scan.rs
@@ -0,0 +1,16 @@
+use nmrs::NetworkManager;
+
+#[tokio::main]
+async fn main() -> nmrs::Result<()> {
+ let nm = NetworkManager::new().await?;
+
+ println!("Scanning for WiFi networks...");
+ nm.scan_networks().await?;
+
+ let networks = nm.list_networks().await?;
+ for net in networks {
+ println!("{:30} {}%", net.ssid, net.strength.unwrap_or(0));
+ }
+
+ Ok(())
+}
diff --git a/nmrs/src/api/builders/mod.rs b/nmrs/src/api/builders/mod.rs
new file mode 100644
index 00000000..40a867a4
--- /dev/null
+++ b/nmrs/src/api/builders/mod.rs
@@ -0,0 +1,47 @@
+//! Connection builders for different network types.
+//!
+//! This module provides functions to construct NetworkManager connection settings
+//! dictionaries for various connection types. These settings are used with
+//! NetworkManager's D-Bus API to create and activate connections.
+//!
+//! # Available Builders
+//!
+//! - [`wifi`] - WiFi connection builders (WPA-PSK, WPA-EAP, Open)
+//! - [`vpn`] - VPN connection builders (WireGuard)
+//! - Ethernet builders (via [`build_ethernet_connection`])
+//!
+//! # When to Use These
+//!
+//! Most users should use the high-level [`NetworkManager`](crate::NetworkManager) API
+//! instead of calling these builders directly. These are exposed for advanced use cases
+//! where you need fine-grained control over connection settings.
+//!
+//! # Examples
+//!
+//! ```rust
+//! use nmrs::builders::{build_wifi_connection, build_ethernet_connection};
+//! use nmrs::{WifiSecurity, ConnectionOptions};
+//!
+//! let opts = ConnectionOptions {
+//! autoconnect: true,
+//! autoconnect_priority: Some(10),
+//! autoconnect_retries: Some(3),
+//! };
+//!
+//! // Build WiFi connection settings
+//! let wifi_settings = build_wifi_connection(
+//! "MyNetwork",
+//! &WifiSecurity::WpaPsk { psk: "password".into() },
+//! &opts
+//! );
+//!
+//! // Build Ethernet connection settings
+//! let eth_settings = build_ethernet_connection("eth0", &opts);
+//! ```
+
+pub mod vpn;
+pub mod wifi;
+
+// Re-export builder functions for convenience
+pub use vpn::build_wireguard_connection;
+pub use wifi::{build_ethernet_connection, build_wifi_connection};
diff --git a/nmrs/src/api/builders/vpn.rs b/nmrs/src/api/builders/vpn.rs
new file mode 100644
index 00000000..d308ded4
--- /dev/null
+++ b/nmrs/src/api/builders/vpn.rs
@@ -0,0 +1,819 @@
+//! VPN connection settings builders.
+//!
+//! This module provides functions to build NetworkManager settings dictionaries
+//! for VPN connections. Currently supports:
+//!
+//! - **WireGuard** - Modern, high-performance VPN protocol
+//!
+//! # Usage
+//!
+//! Most users should call [`NetworkManager::connect_vpn`][crate::NetworkManager::connect_vpn]
+//! instead of using these builders directly. This module is intended for
+//! advanced use cases where you need low-level control over the connection settings.
+//!
+//! # Example
+//!
+//! ```rust
+//! use nmrs::builders::build_wireguard_connection;
+//! use nmrs::{VpnCredentials, VpnType, WireGuardPeer, ConnectionOptions};
+//!
+//! let creds = VpnCredentials {
+//! vpn_type: VpnType::WireGuard,
+//! name: "MyVPN".into(),
+//! gateway: "vpn.example.com:51820".into(),
+//! // Valid WireGuard private key (44 chars base64)
+//! private_key: "YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=".into(),
+//! address: "10.0.0.2/24".into(),
+//! peers: vec![WireGuardPeer {
+//! // Valid WireGuard public key (44 chars base64)
+//! public_key: "HIgo9xNzJMWLKAShlKl6/bUT1VI9Q0SDBXGtLXkPFXc=".into(),
+//! gateway: "vpn.example.com:51820".into(),
+//! allowed_ips: vec!["0.0.0.0/0".into()],
+//! preshared_key: None,
+//! persistent_keepalive: Some(25),
+//! }],
+//! dns: Some(vec!["1.1.1.1".into()]),
+//! mtu: None,
+//! uuid: None,
+//! };
+//!
+//! let opts = ConnectionOptions {
+//! autoconnect: false,
+//! autoconnect_priority: None,
+//! autoconnect_retries: None,
+//! };
+//!
+//! let settings = build_wireguard_connection(&creds, &opts).unwrap();
+//! // Pass settings to NetworkManager's AddAndActivateConnection
+//! ```
+
+use std::collections::HashMap;
+use uuid::Uuid;
+use zvariant::Value;
+
+use crate::api::models::{ConnectionError, ConnectionOptions, VpnCredentials};
+
+/// Validates a WireGuard key (private or public).
+///
+/// WireGuard keys are 32-byte values encoded in base64, resulting in 44 characters
+/// (including padding).
+fn validate_wireguard_key(key: &str, key_type: &str) -> Result<(), ConnectionError> {
+ // Basic validation: should be non-empty and reasonable length
+ if key.trim().is_empty() {
+ return Err(ConnectionError::InvalidPrivateKey(format!(
+ "{} cannot be empty",
+ key_type
+ )));
+ }
+
+ // WireGuard keys are 32 bytes, base64 encoded = 44 chars with padding
+ // We'll be lenient and allow 43-45 characters
+ let len = key.trim().len();
+ if !(40..=50).contains(&len) {
+ return Err(ConnectionError::InvalidPrivateKey(format!(
+ "{} has invalid length: {} (expected ~44 characters)",
+ key_type, len
+ )));
+ }
+
+ // Check if it's valid base64 (contains only base64 characters)
+ let is_valid_base64 = key
+ .trim()
+ .chars()
+ .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '=');
+
+ if !is_valid_base64 {
+ return Err(ConnectionError::InvalidPrivateKey(format!(
+ "{} contains invalid base64 characters",
+ key_type
+ )));
+ }
+
+ Ok(())
+}
+
+/// Validates an IP address with CIDR notation (e.g., "10.0.0.2/24").
+fn validate_address(address: &str) -> Result<(String, u32), ConnectionError> {
+ let (ip, prefix) = address.split_once('/').ok_or_else(|| {
+ ConnectionError::InvalidAddress(format!(
+ "missing CIDR prefix (e.g., '10.0.0.2/24'): {}",
+ address
+ ))
+ })?;
+
+ // Validate IP address format (basic check)
+ if ip.trim().is_empty() {
+ return Err(ConnectionError::InvalidAddress(
+ "IP address cannot be empty".into(),
+ ));
+ }
+
+ // Parse CIDR prefix
+ let prefix: u32 = prefix
+ .parse()
+ .map_err(|_| ConnectionError::InvalidAddress(format!("invalid CIDR prefix: {}", prefix)))?;
+
+ // Validate prefix range (IPv4: 0-32, IPv6: 0-128)
+ // We'll accept up to 128 to support IPv6
+ if prefix > 128 {
+ return Err(ConnectionError::InvalidAddress(format!(
+ "CIDR prefix too large: {} (max 128)",
+ prefix
+ )));
+ }
+
+ // Basic IPv4 validation (if it contains dots)
+ if ip.contains('.') {
+ let octets: Vec<&str> = ip.split('.').collect();
+ if octets.len() != 4 {
+ return Err(ConnectionError::InvalidAddress(format!(
+ "invalid IPv4 address: {}",
+ ip
+ )));
+ }
+
+ for octet in octets {
+ let num: u32 = octet.parse().map_err(|_| {
+ ConnectionError::InvalidAddress(format!("invalid IPv4 octet: {}", octet))
+ })?;
+ if num > 255 {
+ return Err(ConnectionError::InvalidAddress(format!(
+ "IPv4 octet out of range: {}",
+ num
+ )));
+ }
+ }
+
+ if prefix > 32 {
+ return Err(ConnectionError::InvalidAddress(format!(
+ "IPv4 CIDR prefix too large: {} (max 32)",
+ prefix
+ )));
+ }
+ }
+
+ Ok((ip.to_string(), prefix))
+}
+
+/// Validates a VPN gateway endpoint (should be in "host:port" format).
+fn validate_gateway(gateway: &str) -> Result<(), ConnectionError> {
+ if gateway.trim().is_empty() {
+ return Err(ConnectionError::InvalidGateway(
+ "gateway cannot be empty".into(),
+ ));
+ }
+
+ // Should contain a colon for port
+ if !gateway.contains(':') {
+ return Err(ConnectionError::InvalidGateway(format!(
+ "gateway must be in 'host:port' format: {}",
+ gateway
+ )));
+ }
+
+ let parts: Vec<&str> = gateway.rsplitn(2, ':').collect();
+ if parts.len() != 2 {
+ return Err(ConnectionError::InvalidGateway(format!(
+ "invalid gateway format: {}",
+ gateway
+ )));
+ }
+
+ // Validate port
+ let port_str = parts[0];
+ let port: u16 = port_str.parse().map_err(|_| {
+ ConnectionError::InvalidGateway(format!("invalid port number: {}", port_str))
+ })?;
+
+ if port == 0 {
+ return Err(ConnectionError::InvalidGateway("port cannot be 0".into()));
+ }
+
+ Ok(())
+}
+
+/// Builds WireGuard VPN connection settings.
+///
+/// Returns a complete NetworkManager settings dictionary suitable for
+/// `AddAndActivateConnection`.
+///
+/// # Errors
+///
+/// - `ConnectionError::InvalidPeers` if no peers are provided
+/// - `ConnectionError::InvalidAddress` if the address is missing or malformed
+pub fn build_wireguard_connection(
+ creds: &VpnCredentials,
+ opts: &ConnectionOptions,
+) -> Result>>, ConnectionError> {
+ // Validate peers
+ if creds.peers.is_empty() {
+ return Err(ConnectionError::InvalidPeers("No peers provided".into()));
+ }
+
+ // Validate private key
+ validate_wireguard_key(&creds.private_key, "Private key")?;
+
+ // Validate gateway
+ validate_gateway(&creds.gateway)?;
+
+ // Validate address
+ let (ip, prefix) = validate_address(&creds.address)?;
+
+ // Validate each peer
+ for (i, peer) in creds.peers.iter().enumerate() {
+ validate_wireguard_key(&peer.public_key, &format!("Peer {} public key", i))?;
+ validate_gateway(&peer.gateway)?;
+
+ if peer.allowed_ips.is_empty() {
+ return Err(ConnectionError::InvalidPeers(format!(
+ "Peer {} has no allowed IPs",
+ i
+ )));
+ }
+ }
+
+ let mut conn = HashMap::new();
+
+ // [connection] section
+ let mut connection = HashMap::new();
+ connection.insert("type", Value::from("vpn"));
+ connection.insert("id", Value::from(creds.name.clone()));
+
+ let uuid = creds.uuid.unwrap_or_else(|| {
+ Uuid::new_v5(
+ &Uuid::NAMESPACE_DNS,
+ format!("wg:{}@{}", creds.name, creds.gateway).as_bytes(),
+ )
+ });
+ connection.insert("uuid", Value::from(uuid.to_string()));
+ connection.insert("autoconnect", Value::from(opts.autoconnect));
+
+ if let Some(p) = opts.autoconnect_priority {
+ connection.insert("autoconnect-priority", Value::from(p));
+ }
+ if let Some(r) = opts.autoconnect_retries {
+ connection.insert("autoconnect-retries", Value::from(r));
+ }
+
+ conn.insert("connection", connection);
+
+ // [vpn] section
+ let mut vpn = HashMap::new();
+ vpn.insert(
+ "service-type",
+ Value::from("org.freedesktop.NetworkManager.wireguard"),
+ );
+
+ // WireGuard-specific data
+ let mut data: HashMap> = HashMap::new();
+ data.insert("private-key".into(), Value::from(creds.private_key.clone()));
+
+ for (i, peer) in creds.peers.iter().enumerate() {
+ let prefix = format!("peer.{i}.");
+ data.insert(
+ format!("{prefix}public-key"),
+ Value::from(peer.public_key.clone()),
+ );
+ data.insert(
+ format!("{prefix}endpoint"),
+ Value::from(peer.gateway.clone()),
+ );
+ data.insert(
+ format!("{prefix}allowed-ips"),
+ Value::from(peer.allowed_ips.join(",")),
+ );
+
+ if let Some(psk) = &peer.preshared_key {
+ data.insert(format!("{prefix}preshared-key"), Value::from(psk.clone()));
+ }
+
+ if let Some(ka) = peer.persistent_keepalive {
+ data.insert(format!("{prefix}persistent-keepalive"), Value::from(ka));
+ }
+ }
+
+ vpn.insert("data", Value::from(data));
+ conn.insert("vpn", vpn);
+
+ // [ipv4] section
+ let mut ipv4 = HashMap::new();
+ ipv4.insert("method", Value::from("manual"));
+
+ // Use already validated address
+ let addresses = vec![vec![
+ Value::from(ip),
+ Value::from(prefix),
+ Value::from("0.0.0.0"),
+ ]];
+ ipv4.insert("address-data", Value::from(addresses));
+
+ if let Some(dns) = &creds.dns {
+ let dns_vec: Vec = dns.to_vec();
+ ipv4.insert("dns", Value::from(dns_vec));
+ }
+
+ if let Some(mtu) = creds.mtu {
+ ipv4.insert("mtu", Value::from(mtu));
+ }
+
+ conn.insert("ipv4", ipv4);
+
+ // [ipv6] section (required but typically ignored for WireGuard)
+ let mut ipv6 = HashMap::new();
+ ipv6.insert("method", Value::from("ignore"));
+ conn.insert("ipv6", ipv6);
+
+ Ok(conn)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::api::models::{VpnType, WireGuardPeer};
+
+ fn create_test_credentials() -> VpnCredentials {
+ VpnCredentials {
+ vpn_type: VpnType::WireGuard,
+ name: "TestVPN".into(),
+ gateway: "vpn.example.com:51820".into(),
+ private_key: "YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=".into(),
+ address: "10.0.0.2/24".into(),
+ peers: vec![WireGuardPeer {
+ public_key: "HIgo9xNzJMWLKAShlKl6/bUT1VI9Q0SDBXGtLXkPFXc=".into(),
+ gateway: "vpn.example.com:51820".into(),
+ allowed_ips: vec!["0.0.0.0/0".into()],
+ preshared_key: None,
+ persistent_keepalive: Some(25),
+ }],
+ dns: Some(vec!["1.1.1.1".into(), "8.8.8.8".into()]),
+ mtu: Some(1420),
+ uuid: None,
+ }
+ }
+
+ fn create_test_options() -> ConnectionOptions {
+ ConnectionOptions {
+ autoconnect: false,
+ autoconnect_priority: None,
+ autoconnect_retries: None,
+ }
+ }
+
+ #[test]
+ fn builds_wireguard_connection() {
+ let creds = create_test_credentials();
+ let opts = create_test_options();
+
+ let settings = build_wireguard_connection(&creds, &opts);
+ assert!(settings.is_ok());
+
+ let settings = settings.unwrap();
+ assert!(settings.contains_key("connection"));
+ assert!(settings.contains_key("vpn"));
+ assert!(settings.contains_key("ipv4"));
+ assert!(settings.contains_key("ipv6"));
+ }
+
+ #[test]
+ fn connection_section_has_correct_type() {
+ let creds = create_test_credentials();
+ let opts = create_test_options();
+
+ let settings = build_wireguard_connection(&creds, &opts).unwrap();
+ let connection = settings.get("connection").unwrap();
+
+ let conn_type = connection.get("type").unwrap();
+ assert_eq!(conn_type, &Value::from("vpn"));
+
+ let id = connection.get("id").unwrap();
+ assert_eq!(id, &Value::from("TestVPN"));
+ }
+
+ #[test]
+ fn vpn_section_has_wireguard_service_type() {
+ let creds = create_test_credentials();
+ let opts = create_test_options();
+
+ let settings = build_wireguard_connection(&creds, &opts).unwrap();
+ let vpn = settings.get("vpn").unwrap();
+
+ let service_type = vpn.get("service-type").unwrap();
+ assert_eq!(
+ service_type,
+ &Value::from("org.freedesktop.NetworkManager.wireguard")
+ );
+ }
+
+ #[test]
+ fn ipv4_section_is_manual() {
+ let creds = create_test_credentials();
+ let opts = create_test_options();
+
+ let settings = build_wireguard_connection(&creds, &opts).unwrap();
+ let ipv4 = settings.get("ipv4").unwrap();
+
+ let method = ipv4.get("method").unwrap();
+ assert_eq!(method, &Value::from("manual"));
+ }
+
+ #[test]
+ fn ipv6_section_is_ignored() {
+ let creds = create_test_credentials();
+ let opts = create_test_options();
+
+ let settings = build_wireguard_connection(&creds, &opts).unwrap();
+ let ipv6 = settings.get("ipv6").unwrap();
+
+ let method = ipv6.get("method").unwrap();
+ assert_eq!(method, &Value::from("ignore"));
+ }
+
+ #[test]
+ fn rejects_empty_peers() {
+ let mut creds = create_test_credentials();
+ creds.peers = vec![];
+ let opts = create_test_options();
+
+ let result = build_wireguard_connection(&creds, &opts);
+ assert!(result.is_err());
+ assert!(matches!(
+ result.unwrap_err(),
+ ConnectionError::InvalidPeers(_)
+ ));
+ }
+
+ #[test]
+ fn rejects_invalid_address_format() {
+ let mut creds = create_test_credentials();
+ creds.address = "invalid".into();
+ let opts = create_test_options();
+
+ let result = build_wireguard_connection(&creds, &opts);
+ assert!(result.is_err());
+ assert!(matches!(
+ result.unwrap_err(),
+ ConnectionError::InvalidAddress(_)
+ ));
+ }
+
+ #[test]
+ fn rejects_address_without_cidr() {
+ let mut creds = create_test_credentials();
+ creds.address = "10.0.0.2".into();
+ let opts = create_test_options();
+
+ let result = build_wireguard_connection(&creds, &opts);
+ assert!(result.is_err());
+ assert!(matches!(
+ result.unwrap_err(),
+ ConnectionError::InvalidAddress(_)
+ ));
+ }
+
+ #[test]
+ fn accepts_ipv6_address() {
+ let mut creds = create_test_credentials();
+ creds.address = "fd00::2/64".into();
+ let opts = create_test_options();
+
+ let result = build_wireguard_connection(&creds, &opts);
+ assert!(result.is_ok());
+ }
+
+ #[test]
+ fn handles_multiple_peers() {
+ let mut creds = create_test_credentials();
+ creds.peers.push(WireGuardPeer {
+ public_key: "xScVkH3fUGUVRvGLFcjkx+GGD7cf5eBVyN3Gh4FLjmI=".into(),
+ gateway: "peer2.example.com:51821".into(),
+ allowed_ips: vec!["192.168.0.0/16".into()],
+ preshared_key: Some("PSKABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklm=".into()),
+ persistent_keepalive: None,
+ });
+ let opts = create_test_options();
+
+ let result = build_wireguard_connection(&creds, &opts);
+ assert!(result.is_ok());
+ }
+
+ #[test]
+ fn handles_optional_dns() {
+ let mut creds = create_test_credentials();
+ creds.dns = None;
+ let opts = create_test_options();
+
+ let result = build_wireguard_connection(&creds, &opts);
+ assert!(result.is_ok());
+ }
+
+ #[test]
+ fn handles_optional_mtu() {
+ let mut creds = create_test_credentials();
+ creds.mtu = None;
+ let opts = create_test_options();
+
+ let result = build_wireguard_connection(&creds, &opts);
+ assert!(result.is_ok());
+ }
+
+ #[test]
+ fn includes_dns_when_provided() {
+ let creds = create_test_credentials();
+ let opts = create_test_options();
+
+ let settings = build_wireguard_connection(&creds, &opts).unwrap();
+ let ipv4 = settings.get("ipv4").unwrap();
+
+ assert!(ipv4.contains_key("dns"));
+ }
+
+ #[test]
+ fn includes_mtu_when_provided() {
+ let creds = create_test_credentials();
+ let opts = create_test_options();
+
+ let settings = build_wireguard_connection(&creds, &opts).unwrap();
+ let ipv4 = settings.get("ipv4").unwrap();
+
+ assert!(ipv4.contains_key("mtu"));
+ }
+
+ #[test]
+ fn respects_autoconnect_option() {
+ let creds = create_test_credentials();
+ let mut opts = create_test_options();
+ opts.autoconnect = true;
+
+ let settings = build_wireguard_connection(&creds, &opts).unwrap();
+ let connection = settings.get("connection").unwrap();
+
+ let autoconnect = connection.get("autoconnect").unwrap();
+ assert_eq!(autoconnect, &Value::from(true));
+ }
+
+ #[test]
+ fn includes_autoconnect_priority_when_provided() {
+ let creds = create_test_credentials();
+ let mut opts = create_test_options();
+ opts.autoconnect_priority = Some(10);
+
+ let settings = build_wireguard_connection(&creds, &opts).unwrap();
+ let connection = settings.get("connection").unwrap();
+
+ assert!(connection.contains_key("autoconnect-priority"));
+ }
+
+ #[test]
+ fn generates_uuid_when_not_provided() {
+ let creds = create_test_credentials();
+ let opts = create_test_options();
+
+ let settings = build_wireguard_connection(&creds, &opts).unwrap();
+ let connection = settings.get("connection").unwrap();
+
+ assert!(connection.contains_key("uuid"));
+ }
+
+ #[test]
+ fn uses_provided_uuid() {
+ let mut creds = create_test_credentials();
+ let test_uuid = uuid::Uuid::new_v4();
+ creds.uuid = Some(test_uuid);
+ let opts = create_test_options();
+
+ let settings = build_wireguard_connection(&creds, &opts).unwrap();
+ let connection = settings.get("connection").unwrap();
+
+ let uuid = connection.get("uuid").unwrap();
+ assert_eq!(uuid, &Value::from(test_uuid.to_string()));
+ }
+
+ #[test]
+ fn peer_with_preshared_key() {
+ let mut creds = create_test_credentials();
+ creds.peers[0].preshared_key = Some("PSKABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklm=".into());
+ let opts = create_test_options();
+
+ let result = build_wireguard_connection(&creds, &opts);
+ assert!(result.is_ok());
+ }
+
+ #[test]
+ fn peer_without_keepalive() {
+ let mut creds = create_test_credentials();
+ creds.peers[0].persistent_keepalive = None;
+ let opts = create_test_options();
+
+ let result = build_wireguard_connection(&creds, &opts);
+ assert!(result.is_ok());
+ }
+
+ #[test]
+ fn multiple_allowed_ips_for_peer() {
+ let mut creds = create_test_credentials();
+ creds.peers[0].allowed_ips =
+ vec!["0.0.0.0/0".into(), "::/0".into(), "192.168.1.0/24".into()];
+ let opts = create_test_options();
+
+ let result = build_wireguard_connection(&creds, &opts);
+ assert!(result.is_ok());
+ }
+
+ // Validation tests
+
+ #[test]
+ fn rejects_empty_private_key() {
+ let mut creds = create_test_credentials();
+ creds.private_key = "".into();
+ let opts = create_test_options();
+
+ let result = build_wireguard_connection(&creds, &opts);
+ assert!(result.is_err());
+ assert!(matches!(
+ result.unwrap_err(),
+ ConnectionError::InvalidPrivateKey(_)
+ ));
+ }
+
+ #[test]
+ fn rejects_short_private_key() {
+ let mut creds = create_test_credentials();
+ creds.private_key = "tooshort".into();
+ let opts = create_test_options();
+
+ let result = build_wireguard_connection(&creds, &opts);
+ assert!(result.is_err());
+ assert!(matches!(
+ result.unwrap_err(),
+ ConnectionError::InvalidPrivateKey(_)
+ ));
+ }
+
+ #[test]
+ fn rejects_invalid_private_key_characters() {
+ let mut creds = create_test_credentials();
+ creds.private_key = "this is not base64 encoded!!!!!!!!!!!!!!!!!!".into();
+ let opts = create_test_options();
+
+ let result = build_wireguard_connection(&creds, &opts);
+ assert!(result.is_err());
+ assert!(matches!(
+ result.unwrap_err(),
+ ConnectionError::InvalidPrivateKey(_)
+ ));
+ }
+
+ #[test]
+ fn rejects_empty_gateway() {
+ let mut creds = create_test_credentials();
+ creds.gateway = "".into();
+ let opts = create_test_options();
+
+ let result = build_wireguard_connection(&creds, &opts);
+ assert!(result.is_err());
+ assert!(matches!(
+ result.unwrap_err(),
+ ConnectionError::InvalidGateway(_)
+ ));
+ }
+
+ #[test]
+ fn rejects_gateway_without_port() {
+ let mut creds = create_test_credentials();
+ creds.gateway = "vpn.example.com".into();
+ let opts = create_test_options();
+
+ let result = build_wireguard_connection(&creds, &opts);
+ assert!(result.is_err());
+ assert!(matches!(
+ result.unwrap_err(),
+ ConnectionError::InvalidGateway(_)
+ ));
+ }
+
+ #[test]
+ fn rejects_gateway_with_invalid_port() {
+ let mut creds = create_test_credentials();
+ creds.gateway = "vpn.example.com:99999".into();
+ let opts = create_test_options();
+
+ let result = build_wireguard_connection(&creds, &opts);
+ assert!(result.is_err());
+ assert!(matches!(
+ result.unwrap_err(),
+ ConnectionError::InvalidGateway(_)
+ ));
+ }
+
+ #[test]
+ fn rejects_gateway_with_zero_port() {
+ let mut creds = create_test_credentials();
+ creds.gateway = "vpn.example.com:0".into();
+ let opts = create_test_options();
+
+ let result = build_wireguard_connection(&creds, &opts);
+ assert!(result.is_err());
+ assert!(matches!(
+ result.unwrap_err(),
+ ConnectionError::InvalidGateway(_)
+ ));
+ }
+
+ #[test]
+ fn rejects_invalid_ipv4_address() {
+ let mut creds = create_test_credentials();
+ creds.address = "999.999.999.999/24".into();
+ let opts = create_test_options();
+
+ let result = build_wireguard_connection(&creds, &opts);
+ assert!(result.is_err());
+ assert!(matches!(
+ result.unwrap_err(),
+ ConnectionError::InvalidAddress(_)
+ ));
+ }
+
+ #[test]
+ fn rejects_ipv4_with_invalid_prefix() {
+ let mut creds = create_test_credentials();
+ creds.address = "10.0.0.2/999".into();
+ let opts = create_test_options();
+
+ let result = build_wireguard_connection(&creds, &opts);
+ assert!(result.is_err());
+ assert!(matches!(
+ result.unwrap_err(),
+ ConnectionError::InvalidAddress(_)
+ ));
+ }
+
+ #[test]
+ fn rejects_peer_with_empty_allowed_ips() {
+ let mut creds = create_test_credentials();
+ creds.peers[0].allowed_ips = vec![];
+ let opts = create_test_options();
+
+ let result = build_wireguard_connection(&creds, &opts);
+ assert!(result.is_err());
+ assert!(matches!(
+ result.unwrap_err(),
+ ConnectionError::InvalidPeers(_)
+ ));
+ }
+
+ #[test]
+ fn rejects_peer_with_invalid_public_key() {
+ let mut creds = create_test_credentials();
+ creds.peers[0].public_key = "invalid!@#$key".into();
+ let opts = create_test_options();
+
+ let result = build_wireguard_connection(&creds, &opts);
+ assert!(result.is_err());
+ // Should get InvalidPrivateKey error (we use same validation for both)
+ assert!(matches!(
+ result.unwrap_err(),
+ ConnectionError::InvalidPrivateKey(_)
+ ));
+ }
+
+ #[test]
+ fn accepts_valid_ipv4_addresses() {
+ let test_cases = vec![
+ "10.0.0.2/24",
+ "192.168.1.100/32",
+ "172.16.0.1/16",
+ "1.1.1.1/8",
+ ];
+
+ for address in test_cases {
+ let mut creds = create_test_credentials();
+ creds.address = address.into();
+ let opts = create_test_options();
+
+ let result = build_wireguard_connection(&creds, &opts);
+ assert!(
+ result.is_ok(),
+ "Should accept valid IPv4 address: {}",
+ address
+ );
+ }
+ }
+
+ #[test]
+ fn accepts_standard_wireguard_ports() {
+ let test_cases = vec![
+ "vpn.example.com:51820",
+ "192.168.1.1:51821",
+ "test.local:12345",
+ ];
+
+ for gateway in test_cases {
+ let mut creds = create_test_credentials();
+ creds.gateway = gateway.into();
+ let opts = create_test_options();
+
+ let result = build_wireguard_connection(&creds, &opts);
+ assert!(result.is_ok(), "Should accept valid gateway: {}", gateway);
+ }
+ }
+}
diff --git a/nmrs/src/wifi_builders.rs b/nmrs/src/api/builders/wifi.rs
similarity index 99%
rename from nmrs/src/wifi_builders.rs
rename to nmrs/src/api/builders/wifi.rs
index 5bd19efa..3e8e817c 100644
--- a/nmrs/src/wifi_builders.rs
+++ b/nmrs/src/api/builders/wifi.rs
@@ -13,11 +13,10 @@
//! - `802-1x`: Enterprise authentication settings (for WPA-EAP)
//! - `ipv4` / `ipv6`: IP configuration (usually "auto" for DHCP)
-use models::ConnectionOptions;
use std::collections::HashMap;
use zvariant::Value;
-use crate::models::{self, EapMethod};
+use crate::api::models::{self, ConnectionOptions, EapMethod};
/// Converts a string to bytes for SSID encoding.
fn bytes(val: &str) -> Vec {
diff --git a/nmrs/src/api/mod.rs b/nmrs/src/api/mod.rs
new file mode 100644
index 00000000..86bb8a0b
--- /dev/null
+++ b/nmrs/src/api/mod.rs
@@ -0,0 +1,7 @@
+//! Public API module.
+//!
+//! This module contains the high-level user-facing API for the `nmrs` crate.
+
+pub mod builders;
+pub mod models;
+pub mod network_manager;
diff --git a/nmrs/src/models.rs b/nmrs/src/api/models.rs
similarity index 67%
rename from nmrs/src/models.rs
rename to nmrs/src/api/models.rs
index 8046373c..28f55caa 100644
--- a/nmrs/src/models.rs
+++ b/nmrs/src/api/models.rs
@@ -1,6 +1,7 @@
use serde::{Deserialize, Serialize};
use std::fmt::{Display, Formatter};
use thiserror::Error;
+use uuid::Uuid;
/// NetworkManager active connection state.
///
@@ -156,7 +157,7 @@ pub fn connection_state_reason_to_error(code: u32) -> ConnectionError {
ConnectionStateReason::IpConfigInvalid => ConnectionError::DhcpFailed,
// All other failures
- _ => ConnectionError::ConnectionFailed(reason),
+ _ => ConnectionError::ActivationFailed(reason),
}
}
@@ -221,42 +222,155 @@ pub enum StateReason {
}
/// Represents a Wi-Fi network discovered during a scan.
+///
+/// This struct contains information about a WiFi network that was discovered
+/// by NetworkManager during a scan operation.
+///
+/// # Examples
+///
+/// ```no_run
+/// use nmrs::NetworkManager;
+///
+/// # async fn example() -> nmrs::Result<()> {
+/// let nm = NetworkManager::new().await?;
+///
+/// // Scan for networks
+/// nm.scan_networks().await?;
+/// let networks = nm.list_networks().await?;
+///
+/// for net in networks {
+/// println!("SSID: {}", net.ssid);
+/// println!(" Signal: {}%", net.strength.unwrap_or(0));
+/// println!(" Secured: {}", net.secured);
+///
+/// if let Some(freq) = net.frequency {
+/// let band = if freq > 5000 { "5GHz" } else { "2.4GHz" };
+/// println!(" Band: {}", band);
+/// }
+/// }
+/// # Ok(())
+/// # }
+/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Network {
+ /// Device interface name (e.g., "wlan0")
pub device: String,
+ /// Network SSID (name)
pub ssid: String,
+ /// Access point MAC address (BSSID)
pub bssid: Option,
+ /// Signal strength (0-100)
pub strength: Option,
+ /// Frequency in MHz (e.g., 2437 for channel 6)
pub frequency: Option,
+ /// Whether the network requires authentication
pub secured: bool,
+ /// Whether the network uses WPA-PSK authentication
pub is_psk: bool,
+ /// Whether the network uses WPA-EAP (Enterprise) authentication
pub is_eap: bool,
}
-/// Detailed information about a connected Wi-Fi network.
+/// Detailed information about a Wi-Fi network.
+///
+/// Contains comprehensive information about a WiFi network, including
+/// connection status, signal quality, and technical details.
+///
+/// # Examples
+///
+/// ```no_run
+/// use nmrs::NetworkManager;
+///
+/// # async fn example() -> nmrs::Result<()> {
+/// let nm = NetworkManager::new().await?;
+/// let networks = nm.list_networks().await?;
+///
+/// if let Some(network) = networks.first() {
+/// let info = nm.show_details(network).await?;
+///
+/// println!("Network: {}", info.ssid);
+/// println!("Signal: {} {}", info.strength, info.bars);
+/// println!("Security: {}", info.security);
+/// println!("Status: {}", info.status);
+///
+/// if let Some(rate) = info.rate_mbps {
+/// println!("Speed: {} Mbps", rate);
+/// }
+/// }
+/// # Ok(())
+/// # }
+/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetworkInfo {
+ /// Network SSID (name)
pub ssid: String,
+ /// Access point MAC address (BSSID)
pub bssid: String,
+ /// Signal strength (0-100)
pub strength: u8,
+ /// Frequency in MHz
pub freq: Option,
+ /// WiFi channel number
pub channel: Option,
+ /// Operating mode (e.g., "infrastructure")
pub mode: String,
+ /// Connection speed in Mbps
pub rate_mbps: Option,
+ /// Visual signal strength representation (e.g., "▂▄▆█")
pub bars: String,
+ /// Security type description
pub security: String,
+ /// Connection status
pub status: String,
}
/// Represents a network device managed by NetworkManager.
+///
+/// A device can be a WiFi adapter, Ethernet interface, or other network hardware.
+///
+/// # Examples
+///
+/// ```no_run
+/// use nmrs::NetworkManager;
+///
+/// # async fn example() -> nmrs::Result<()> {
+/// let nm = NetworkManager::new().await?;
+/// let devices = nm.list_devices().await?;
+///
+/// for device in devices {
+/// println!("Interface: {}", device.interface);
+/// println!(" Type: {}", device.device_type);
+/// println!(" State: {}", device.state);
+/// println!(" MAC: {}", device.identity.current_mac);
+///
+/// if device.is_wireless() {
+/// println!(" This is a WiFi device");
+/// } else if device.is_wired() {
+/// println!(" This is an Ethernet device");
+/// }
+///
+/// if let Some(driver) = &device.driver {
+/// println!(" Driver: {}", driver);
+/// }
+/// }
+/// # Ok(())
+/// # }
+/// ```
#[derive(Debug, Clone)]
pub struct Device {
+ /// D-Bus object path
pub path: String,
+ /// Interface name (e.g., "wlan0", "eth0")
pub interface: String,
+ /// Device hardware identity (MAC addresses)
pub identity: DeviceIdentity,
+ /// Type of device (WiFi, Ethernet, etc.)
pub device_type: DeviceType,
+ /// Current device state
pub state: DeviceState,
+ /// Whether NetworkManager manages this device
pub managed: Option,
+ /// Kernel driver name
pub driver: Option,
}
@@ -268,41 +382,358 @@ pub struct DeviceIdentity {
}
/// EAP (Extensible Authentication Protocol) method options for Wi-Fi connections.
+#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EapMethod {
Peap, // PEAPv0/EAP-MSCHAPv2
Ttls, // EAP-TTLS
}
/// Phase 2 authentication methods for EAP connections.
+#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Phase2 {
Mschapv2,
Pap,
}
-/// EAP options for WPA-EAP Wi-Fi connections.
+/// EAP options for WPA-EAP (Enterprise) Wi-Fi connections.
+///
+/// Configuration for 802.1X authentication, commonly used in corporate
+/// and educational networks.
+///
+/// # Examples
+///
+/// ## PEAP with MSCHAPv2 (Common Corporate Setup)
+///
+/// ```rust
+/// use nmrs::{EapOptions, EapMethod, Phase2};
+///
+/// let opts = EapOptions {
+/// identity: "employee@company.com".into(),
+/// password: "my_password".into(),
+/// anonymous_identity: Some("anonymous@company.com".into()),
+/// domain_suffix_match: Some("company.com".into()),
+/// ca_cert_path: None,
+/// system_ca_certs: true, // Use system certificate store
+/// method: EapMethod::Peap,
+/// phase2: Phase2::Mschapv2,
+/// };
+/// ```
+///
+/// ## TTLS with PAP (Alternative Setup)
+///
+/// ```rust
+/// use nmrs::{EapOptions, EapMethod, Phase2};
+///
+/// let opts = EapOptions {
+/// identity: "student@university.edu".into(),
+/// password: "password".into(),
+/// anonymous_identity: None,
+/// domain_suffix_match: None,
+/// ca_cert_path: Some("file:///etc/ssl/certs/university-ca.pem".into()),
+/// system_ca_certs: false,
+/// method: EapMethod::Ttls,
+/// phase2: Phase2::Pap,
+/// };
+/// ```
+#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EapOptions {
+ /// User identity (usually email or username)
pub identity: String,
+ /// Password for authentication
pub password: String,
+ /// Anonymous outer identity (for privacy)
pub anonymous_identity: Option,
+ /// Domain to match against server certificate
pub domain_suffix_match: Option,
+ /// Path to CA certificate file (file:// URL)
pub ca_cert_path: Option,
+ /// Use system CA certificate store
pub system_ca_certs: bool,
+ /// EAP method (PEAP or TTLS)
pub method: EapMethod,
+ /// Phase 2 inner authentication method
pub phase2: Phase2,
}
/// Connection options for saved NetworkManager connections.
+///
+/// Controls how NetworkManager handles saved connection profiles,
+/// including automatic connection behavior.
+///
+/// # Examples
+///
+/// ```rust
+/// use nmrs::ConnectionOptions;
+///
+/// // Basic auto-connect
+/// let opts = ConnectionOptions {
+/// autoconnect: true,
+/// autoconnect_priority: None,
+/// autoconnect_retries: None,
+/// };
+///
+/// // High-priority connection with retry limit
+/// let opts_priority = ConnectionOptions {
+/// autoconnect: true,
+/// autoconnect_priority: Some(10), // Higher = more preferred
+/// autoconnect_retries: Some(3), // Retry up to 3 times
+/// };
+///
+/// // Manual connection only
+/// let opts_manual = ConnectionOptions {
+/// autoconnect: false,
+/// autoconnect_priority: None,
+/// autoconnect_retries: None,
+/// };
+/// ```
+#[derive(Debug, Clone)]
pub struct ConnectionOptions {
+ /// Whether to automatically connect when available
pub autoconnect: bool,
+ /// Priority for auto-connection (higher = more preferred)
pub autoconnect_priority: Option,
+ /// Maximum number of auto-connect retry attempts
pub autoconnect_retries: Option,
}
/// Wi-Fi connection security types.
+///
+/// Represents the authentication method for connecting to a WiFi network.
+///
+/// # Variants
+///
+/// - [`Open`](WifiSecurity::Open) - No authentication required (open network)
+/// - [`WpaPsk`](WifiSecurity::WpaPsk) - WPA/WPA2/WPA3 Personal (password-based)
+/// - [`WpaEap`](WifiSecurity::WpaEap) - WPA/WPA2 Enterprise (802.1X authentication)
+///
+/// # Examples
+///
+/// ## Open Network
+///
+/// ```rust
+/// use nmrs::WifiSecurity;
+///
+/// let security = WifiSecurity::Open;
+/// ```
+///
+/// ## Password-Protected Network
+///
+/// ```no_run
+/// use nmrs::{NetworkManager, WifiSecurity};
+///
+/// # async fn example() -> nmrs::Result<()> {
+/// let nm = NetworkManager::new().await?;
+///
+/// nm.connect("HomeWiFi", WifiSecurity::WpaPsk {
+/// psk: "my_secure_password".into()
+/// }).await?;
+/// # Ok(())
+/// # }
+/// ```
+///
+/// ## Enterprise Network (WPA-EAP)
+///
+/// ```no_run
+/// use nmrs::{NetworkManager, WifiSecurity, EapOptions, EapMethod, Phase2};
+///
+/// # async fn example() -> nmrs::Result<()> {
+/// let nm = NetworkManager::new().await?;
+///
+/// nm.connect("CorpWiFi", WifiSecurity::WpaEap {
+/// opts: EapOptions {
+/// identity: "user@company.com".into(),
+/// password: "password".into(),
+/// anonymous_identity: None,
+/// domain_suffix_match: Some("company.com".into()),
+/// ca_cert_path: None,
+/// system_ca_certs: true,
+/// method: EapMethod::Peap,
+/// phase2: Phase2::Mschapv2,
+/// }
+/// }).await?;
+/// # Ok(())
+/// # }
+/// ```
+#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WifiSecurity {
+ /// Open network (no authentication)
Open,
- WpaPsk { psk: String },
- WpaEap { opts: EapOptions },
+ /// WPA-PSK (password-based authentication)
+ WpaPsk {
+ /// Pre-shared key (password)
+ psk: String,
+ },
+ /// WPA-EAP (Enterprise authentication via 802.1X)
+ WpaEap {
+ /// EAP configuration options
+ opts: EapOptions,
+ },
+}
+
+/// VPN Connection type
+///
+/// Currently only WireGuard is supported.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum VpnType {
+ WireGuard,
+}
+
+/// VPN Credentials for establishing a VPN connection.
+///
+/// Stores the necessary information to configure and connect to a VPN.
+/// Currently supports WireGuard VPN connections.
+///
+/// # Fields
+///
+/// - `vpn_type`: The type of VPN (currently only WireGuard)
+/// - `name`: Unique identifier for the connection
+/// - `gateway`: VPN gateway endpoint (e.g., "vpn.example.com:51820")
+/// - `private_key`: Client's WireGuard private key
+/// - `address`: Client's IP address with CIDR notation (e.g., "10.0.0.2/24")
+/// - `peers`: List of WireGuard peers to connect to
+/// - `dns`: Optional DNS servers to use (e.g., ["1.1.1.1", "8.8.8.8"])
+/// - `mtu`: Optional Maximum Transmission Unit
+/// - `uuid`: Optional UUID for the connection (auto-generated if not provided)
+///
+/// # Example
+///
+/// ```rust
+/// use nmrs::{VpnCredentials, VpnType, WireGuardPeer};
+///
+/// let creds = VpnCredentials {
+/// vpn_type: VpnType::WireGuard,
+/// name: "HomeVPN".into(),
+/// gateway: "vpn.home.com:51820".into(),
+/// private_key: "aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789=".into(),
+/// address: "10.0.0.2/24".into(),
+/// peers: vec![WireGuardPeer {
+/// public_key: "server_public_key".into(),
+/// gateway: "vpn.home.com:51820".into(),
+/// allowed_ips: vec!["0.0.0.0/0".into()],
+/// preshared_key: None,
+/// persistent_keepalive: Some(25),
+/// }],
+/// dns: Some(vec!["1.1.1.1".into()]),
+/// mtu: None,
+/// uuid: None,
+/// };
+/// ```
+#[derive(Debug, Clone)]
+pub struct VpnCredentials {
+ pub vpn_type: VpnType,
+ pub name: String,
+ pub gateway: String,
+ pub private_key: String,
+ pub address: String,
+ pub peers: Vec,
+ pub dns: Option>,
+ pub mtu: Option,
+ pub uuid: Option,
+}
+
+/// WireGuard peer configuration.
+///
+/// Represents a single WireGuard peer (server) to connect to.
+///
+/// # Fields
+///
+/// - `public_key`: The peer's WireGuard public key
+/// - `gateway`: Peer endpoint in "host:port" format (e.g., "vpn.example.com:51820")
+/// - `allowed_ips`: List of IP ranges allowed through this peer (e.g., ["0.0.0.0/0"])
+/// - `preshared_key`: Optional pre-shared key for additional security
+/// - `persistent_keepalive`: Optional keepalive interval in seconds (e.g., 25)
+///
+/// # Example
+///
+/// ```rust
+/// use nmrs::WireGuardPeer;
+///
+/// let peer = WireGuardPeer {
+/// public_key: "aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789=".into(),
+/// gateway: "vpn.example.com:51820".into(),
+/// allowed_ips: vec!["0.0.0.0/0".into(), "::/0".into()],
+/// preshared_key: None,
+/// persistent_keepalive: Some(25),
+/// };
+/// ```
+#[derive(Debug, Clone)]
+pub struct WireGuardPeer {
+ pub public_key: String,
+ pub gateway: String,
+ pub allowed_ips: Vec,
+ pub preshared_key: Option,
+ pub persistent_keepalive: Option,
+}
+
+/// VPN Connection information.
+///
+/// Represents a VPN connection managed by NetworkManager, including both
+/// saved and active connections.
+///
+/// # Fields
+///
+/// - `name`: The connection name/identifier
+/// - `vpn_type`: The type of VPN (WireGuard, etc.)
+/// - `state`: Current connection state (for active connections)
+/// - `interface`: Network interface name (e.g., "wg0") when active
+///
+/// # Example
+///
+/// ```rust
+/// use nmrs::{VpnConnection, VpnType, DeviceState};
+///
+/// let vpn = VpnConnection {
+/// name: "WorkVPN".into(),
+/// vpn_type: VpnType::WireGuard,
+/// state: DeviceState::Activated,
+/// interface: Some("wg0".into()),
+/// };
+/// ```
+#[derive(Debug, Clone)]
+pub struct VpnConnection {
+ pub name: String,
+ pub vpn_type: VpnType,
+ pub state: DeviceState,
+ pub interface: Option,
+}
+
+/// Detailed VPN connection information and statistics.
+///
+/// Provides comprehensive information about an active VPN connection,
+/// including IP configuration and connection details.
+///
+/// # Limitations
+///
+/// - `ip6_address`: IPv6 address parsing is not currently implemented and will
+/// always return `None`. IPv4 addresses are fully supported.
+///
+/// # Example
+///
+/// ```rust
+/// 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()],
+/// };
+/// ```
+#[derive(Debug, Clone)]
+pub struct VpnConnectionInfo {
+ pub name: String,
+ pub vpn_type: VpnType,
+ pub state: DeviceState,
+ pub interface: Option,
+ pub gateway: Option,
+ pub ip4_address: Option,
+ /// IPv6 address (currently always `None` - IPv6 parsing not yet implemented)
+ pub ip6_address: Option,
+ pub dns_servers: Vec,
}
/// NetworkManager device types.
@@ -340,6 +771,63 @@ impl Device {
}
/// Errors that can occur during network operations.
+///
+/// This enum provides specific error types for different failure modes,
+/// making it easy to handle errors appropriately in your application.
+///
+/// # Examples
+///
+/// ## Basic Error Handling
+///
+/// ```no_run
+/// use nmrs::{NetworkManager, WifiSecurity, ConnectionError};
+///
+/// # async fn example() -> nmrs::Result<()> {
+/// let nm = NetworkManager::new().await?;
+///
+/// match nm.connect("MyNetwork", WifiSecurity::WpaPsk {
+/// psk: "password".into()
+/// }).await {
+/// Ok(_) => println!("Connected!"),
+/// Err(ConnectionError::AuthFailed) => {
+/// eprintln!("Wrong password");
+/// }
+/// Err(ConnectionError::NotFound) => {
+/// eprintln!("Network not in range");
+/// }
+/// Err(ConnectionError::Timeout) => {
+/// eprintln!("Connection timed out");
+/// }
+/// Err(e) => eprintln!("Error: {}", e),
+/// }
+/// # Ok(())
+/// # }
+/// ```
+///
+/// ## Retry Logic
+///
+/// ```no_run
+/// use nmrs::{NetworkManager, WifiSecurity, ConnectionError};
+///
+/// # async fn example() -> nmrs::Result<()> {
+/// let nm = NetworkManager::new().await?;
+///
+/// for attempt in 1..=3 {
+/// match nm.connect("MyNetwork", WifiSecurity::Open).await {
+/// Ok(_) => {
+/// println!("Connected on attempt {}", attempt);
+/// break;
+/// }
+/// Err(ConnectionError::Timeout) if attempt < 3 => {
+/// println!("Timeout, retrying...");
+/// continue;
+/// }
+/// Err(e) => return Err(e),
+/// }
+/// }
+/// # Ok(())
+/// # }
+/// ```
#[derive(Debug, Error)]
pub enum ConnectionError {
/// A D-Bus communication error occurred.
@@ -392,15 +880,43 @@ pub enum ConnectionError {
/// A general connection failure with a device state reason code.
#[error("connection failed: {0}")]
- Failed(StateReason),
+ DeviceFailed(StateReason),
/// A connection activation failure with a connection state reason.
#[error("connection activation failed: {0}")]
- ConnectionFailed(ConnectionStateReason),
+ ActivationFailed(ConnectionStateReason),
/// Invalid UTF-8 encountered in SSID.
#[error("invalid UTF-8 in SSID: {0}")]
InvalidUtf8(#[from] std::str::Utf8Error),
+
+ /// No VPN connection found
+ #[error("no VPN connection found")]
+ NoVpnConnection,
+
+ /// Invalid IP address or CIDR notation
+ #[error("invalid address: {0}")]
+ InvalidAddress(String),
+
+ /// Invalid VPN peer configuration
+ #[error("invalid peer configuration: {0}")]
+ InvalidPeers(String),
+
+ /// Invalid WireGuard private key format
+ #[error("invalid WireGuard private key: {0}")]
+ InvalidPrivateKey(String),
+
+ /// Invalid WireGuard public key format
+ #[error("invalid WireGuard public key: {0}")]
+ InvalidPublicKey(String),
+
+ /// Invalid VPN gateway format (should be host:port)
+ #[error("invalid VPN gateway: {0}")]
+ InvalidGateway(String),
+
+ /// VPN connection failed
+ #[error("VPN connection failed: {0}")]
+ VpnFailed(String),
}
/// NetworkManager device state reason codes.
@@ -547,7 +1063,7 @@ pub fn reason_to_error(code: u32) -> ConnectionError {
StateReason::SsidNotFound => ConnectionError::NotFound,
// All other failures
- _ => ConnectionError::Failed(reason),
+ _ => ConnectionError::DeviceFailed(reason),
}
}
@@ -798,7 +1314,7 @@ mod tests {
fn reason_to_error_generic_failure() {
// User disconnected maps to generic Failed
match reason_to_error(2) {
- ConnectionError::Failed(reason) => {
+ ConnectionError::DeviceFailed(reason) => {
assert_eq!(reason, StateReason::UserDisconnected);
}
_ => panic!("expected ConnectionError::Failed"),
@@ -829,7 +1345,10 @@ mod tests {
"connection stuck in state: config"
);
assert_eq!(
- format!("{}", ConnectionError::Failed(StateReason::CarrierChanged)),
+ format!(
+ "{}",
+ ConnectionError::DeviceFailed(StateReason::CarrierChanged)
+ ),
"connection failed: carrier changed"
);
}
@@ -977,7 +1496,7 @@ mod tests {
fn connection_state_reason_to_error_generic() {
// Other reasons map to ConnectionFailed
match connection_state_reason_to_error(2) {
- ConnectionError::ConnectionFailed(reason) => {
+ ConnectionError::ActivationFailed(reason) => {
assert_eq!(reason, ConnectionStateReason::UserDisconnected);
}
_ => panic!("expected ConnectionError::ConnectionFailed"),
@@ -989,7 +1508,7 @@ mod tests {
assert_eq!(
format!(
"{}",
- ConnectionError::ConnectionFailed(ConnectionStateReason::NoSecrets)
+ ConnectionError::ActivationFailed(ConnectionStateReason::NoSecrets)
),
"connection activation failed: no secrets (password) provided"
);
diff --git a/nmrs/src/api/network_manager.rs b/nmrs/src/api/network_manager.rs
new file mode 100644
index 00000000..aa9a2ca2
--- /dev/null
+++ b/nmrs/src/api/network_manager.rs
@@ -0,0 +1,441 @@
+use zbus::Connection;
+
+use crate::api::models::{Device, Network, NetworkInfo, WifiSecurity};
+use crate::core::connection::{connect, connect_wired, forget};
+use crate::core::connection_settings::{get_saved_connection_path, has_saved_connection};
+use crate::core::device::{list_devices, set_wifi_enabled, wait_for_wifi_ready, wifi_enabled};
+use crate::core::scan::{list_networks, scan_networks};
+use crate::core::vpn::{connect_vpn, disconnect_vpn, get_vpn_info, list_vpn_connections};
+use crate::models::{VpnConnection, VpnConnectionInfo, VpnCredentials};
+use crate::monitoring::device as device_monitor;
+use crate::monitoring::info::{current_connection_info, current_ssid, show_details};
+use crate::monitoring::network as network_monitor;
+use crate::Result;
+
+/// High-level interface to NetworkManager over D-Bus.
+///
+/// This is the main entry point for managing network connections on Linux systems.
+/// It provides a safe, async Rust API over NetworkManager's D-Bus interface.
+///
+/// # Creating an Instance
+///
+/// ```no_run
+/// use nmrs::NetworkManager;
+///
+/// # async fn example() -> nmrs::Result<()> {
+/// let nm = NetworkManager::new().await?;
+/// # Ok(())
+/// # }
+/// ```
+///
+/// # Capabilities
+///
+/// - **Device Management**: List devices, enable/disable WiFi
+/// - **Network Scanning**: Discover available WiFi networks
+/// - **Connection Management**: Connect to WiFi, Ethernet networks
+/// - **Profile Management**: Save, retrieve, and delete connection profiles
+/// - **Real-Time Monitoring**: Subscribe to network and device state changes
+///
+/// # Examples
+///
+/// ## Basic WiFi Connection
+///
+/// ```no_run
+/// use nmrs::{NetworkManager, WifiSecurity};
+///
+/// # async fn example() -> nmrs::Result<()> {
+/// let nm = NetworkManager::new().await?;
+///
+/// // Scan and list networks
+/// let networks = nm.list_networks().await?;
+/// for net in &networks {
+/// println!("{}: {}%", net.ssid, net.strength.unwrap_or(0));
+/// }
+///
+/// // Connect to a network
+/// nm.connect("MyNetwork", WifiSecurity::WpaPsk {
+/// psk: "password".into()
+/// }).await?;
+/// # Ok(())
+/// # }
+/// ```
+///
+/// ## Device Management
+///
+/// ```no_run
+/// use nmrs::NetworkManager;
+///
+/// # async fn example() -> nmrs::Result<()> {
+/// let nm = NetworkManager::new().await?;
+///
+/// // List all network devices
+/// let devices = nm.list_devices().await?;
+///
+/// // Control WiFi
+/// nm.set_wifi_enabled(false).await?; // Disable WiFi
+/// nm.set_wifi_enabled(true).await?; // Enable WiFi
+/// # Ok(())
+/// # }
+/// ```
+///
+/// ## Connection Profiles
+///
+/// ```no_run
+/// use nmrs::NetworkManager;
+///
+/// # async fn example() -> nmrs::Result<()> {
+/// let nm = NetworkManager::new().await?;
+///
+/// // Check for saved connection
+/// if nm.has_saved_connection("MyNetwork").await? {
+/// println!("Connection profile exists");
+///
+/// // Delete it
+/// nm.forget("MyNetwork").await?;
+/// }
+/// # Ok(())
+/// # }
+/// ```
+///
+/// # Thread Safety
+///
+/// `NetworkManager` is `Clone` and can be safely shared across async tasks.
+/// Each clone shares the same underlying D-Bus connection.
+#[derive(Clone)]
+pub struct NetworkManager {
+ conn: Connection,
+}
+
+impl NetworkManager {
+ /// Creates a new `NetworkManager` connected to the system D-Bus.
+ pub async fn new() -> Result {
+ let conn = Connection::system().await?;
+ Ok(Self { conn })
+ }
+
+ /// List all network devices managed by NetworkManager.
+ pub async fn list_devices(&self) -> Result> {
+ list_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?;
+ Ok(devices.into_iter().filter(|d| d.is_wireless()).collect())
+ }
+
+ /// List all wired (Ethernet) devices.
+ pub async fn list_wired_devices(&self) -> Result> {
+ let devices = list_devices(&self.conn).await?;
+ Ok(devices.into_iter().filter(|d| d.is_wired()).collect())
+ }
+
+ /// Lists all visible Wi-Fi networks.
+ pub async fn list_networks(&self) -> Result> {
+ list_networks(&self.conn).await
+ }
+
+ /// Connects to a Wi-Fi network with the given credentials.
+ ///
+ /// # Errors
+ ///
+ /// Returns `ConnectionError::NotFound` if the network is not visible,
+ /// `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
+ }
+
+ /// Connects to a wired (Ethernet) device.
+ ///
+ /// Finds the first available wired device and either activates an existing
+ /// saved connection or creates a new one. The connection will activate
+ /// when a cable is plugged in.
+ ///
+ /// # Errors
+ ///
+ /// Returns `ConnectionError::NoWiredDevice` if no wired device is found.
+ pub async fn connect_wired(&self) -> Result<()> {
+ connect_wired(&self.conn).await
+ }
+
+ /// Connects to a VPN using the provided credentials.
+ ///
+ /// Currently supports WireGuard VPN connections. The function checks for an
+ /// existing saved VPN connection by name. If found, it activates the saved
+ /// connection. If not found, it creates a new VPN connection with the provided
+ /// credentials.
+ ///
+ /// # Example
+ ///
+ /// ```no_run
+ /// use nmrs::{NetworkManager, VpnCredentials, VpnType, WireGuardPeer};
+ ///
+ /// # async fn example() -> nmrs::Result<()> {
+ /// let nm = NetworkManager::new().await?;
+ ///
+ /// let creds = VpnCredentials {
+ /// vpn_type: VpnType::WireGuard,
+ /// name: "MyVPN".into(),
+ /// gateway: "vpn.example.com:51820".into(),
+ /// private_key: "your_private_key".into(),
+ /// address: "10.0.0.2/24".into(),
+ /// peers: vec![WireGuardPeer {
+ /// public_key: "peer_public_key".into(),
+ /// gateway: "vpn.example.com:51820".into(),
+ /// allowed_ips: vec!["0.0.0.0/0".into()],
+ /// preshared_key: None,
+ /// persistent_keepalive: Some(25),
+ /// }],
+ /// dns: Some(vec!["1.1.1.1".into()]),
+ /// mtu: None,
+ /// uuid: None,
+ /// };
+ ///
+ /// nm.connect_vpn(creds).await?;
+ /// # Ok(())
+ /// # }
+ /// ```
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if:
+ /// - NetworkManager is not running or accessible
+ /// - The credentials are invalid or incomplete
+ /// - The VPN connection fails to activate
+ pub async fn connect_vpn(&self, creds: VpnCredentials) -> Result<()> {
+ connect_vpn(&self.conn, creds).await
+ }
+
+ /// Disconnects from an active VPN connection by name.
+ ///
+ /// Searches through active connections for a VPN matching the given name.
+ /// If found, deactivates the connection. If not found or already disconnected,
+ /// returns success.
+ ///
+ /// # Example
+ ///
+ /// ```no_run
+ /// use nmrs::NetworkManager;
+ ///
+ /// # async fn example() -> nmrs::Result<()> {
+ /// let nm = NetworkManager::new().await?;
+ /// nm.disconnect_vpn("MyVPN").await?;
+ /// # Ok(())
+ /// # }
+ /// ```
+ pub async fn disconnect_vpn(&self, name: &str) -> Result<()> {
+ disconnect_vpn(&self.conn, name).await
+ }
+
+ /// Lists all saved VPN connections.
+ ///
+ /// Returns a list of all VPN connection profiles saved in NetworkManager,
+ /// including their name, type, and current state. Only VPN connections with
+ /// recognized types (currently WireGuard) are returned.
+ ///
+ /// # Example
+ ///
+ /// ```no_run
+ /// use nmrs::NetworkManager;
+ ///
+ /// # async fn example() -> nmrs::Result<()> {
+ /// let nm = NetworkManager::new().await?;
+ /// let vpns = nm.list_vpn_connections().await?;
+ ///
+ /// for vpn in vpns {
+ /// println!("{}: {:?}", vpn.name, vpn.vpn_type);
+ /// }
+ /// # Ok(())
+ /// # }
+ /// ```
+ pub async fn list_vpn_connections(&self) -> Result> {
+ list_vpn_connections(&self.conn).await
+ }
+
+ /// Forgets (deletes) a saved VPN connection by name.
+ ///
+ /// Searches through saved connections for a VPN matching the given name.
+ /// If found, deletes the connection profile. If currently connected, the
+ /// VPN will be disconnected first before deletion.
+ ///
+ /// # Example
+ ///
+ /// ```no_run
+ /// use nmrs::NetworkManager;
+ ///
+ /// # async fn example() -> nmrs::Result<()> {
+ /// let nm = NetworkManager::new().await?;
+ /// nm.forget_vpn("MyVPN").await?;
+ /// # Ok(())
+ /// # }
+ /// ```
+ ///
+ /// # Errors
+ ///
+ /// Returns `ConnectionError::NoSavedConnection` if no VPN with the given
+ /// name is found.
+ pub async fn forget_vpn(&self, name: &str) -> Result<()> {
+ crate::core::vpn::forget_vpn(&self.conn, name).await
+ }
+
+ /// Gets detailed information about an active VPN connection.
+ ///
+ /// Retrieves comprehensive information about a VPN connection, including
+ /// IP configuration, DNS servers, gateway, interface, and connection state.
+ /// The VPN must be actively connected to retrieve this information.
+ ///
+ /// # Example
+ ///
+ /// ```no_run
+ /// use nmrs::NetworkManager;
+ ///
+ /// # async fn example() -> nmrs::Result<()> {
+ /// let nm = NetworkManager::new().await?;
+ /// let info = nm.get_vpn_info("MyVPN").await?;
+ ///
+ /// println!("VPN: {}", info.name);
+ /// println!("Interface: {:?}", info.interface);
+ /// println!("IP Address: {:?}", info.ip4_address);
+ /// println!("DNS Servers: {:?}", info.dns_servers);
+ /// println!("State: {:?}", info.state);
+ /// # Ok(())
+ /// # }
+ /// ```
+ ///
+ /// # Errors
+ ///
+ /// Returns `ConnectionError::NoVpnConnection` if the VPN is not found
+ /// or not currently active.
+ pub async fn get_vpn_info(&self, name: &str) -> Result {
+ get_vpn_info(&self.conn, name).await
+ }
+
+ /// Returns whether Wi-Fi is currently enabled.
+ pub async fn wifi_enabled(&self) -> Result {
+ wifi_enabled(&self.conn).await
+ }
+
+ /// Enables or disables Wi-Fi.
+ pub async fn set_wifi_enabled(&self, value: bool) -> Result<()> {
+ set_wifi_enabled(&self.conn, value).await
+ }
+
+ /// Waits for a Wi-Fi device to become ready (disconnected or activated).
+ pub async fn wait_for_wifi_ready(&self) -> Result<()> {
+ wait_for_wifi_ready(&self.conn).await
+ }
+
+ /// Triggers a Wi-Fi scan on all wireless devices.
+ pub async fn scan_networks(&self) -> Result<()> {
+ scan_networks(&self.conn).await
+ }
+
+ /// Returns the SSID of the currently connected network, if any.
+ #[must_use]
+ pub async fn current_ssid(&self) -> Option {
+ current_ssid(&self.conn).await
+ }
+
+ /// Returns the SSID and frequency of the current connection, if any.
+ #[must_use]
+ pub async fn current_connection_info(&self) -> Option<(String, Option)> {
+ current_connection_info(&self.conn).await
+ }
+
+ /// Returns detailed information about a specific network.
+ pub async fn show_details(&self, net: &Network) -> Result {
+ show_details(&self.conn, net).await
+ }
+
+ /// Returns whether a saved connection exists for the given SSID.
+ pub async fn has_saved_connection(&self, ssid: &str) -> Result {
+ has_saved_connection(&self.conn, ssid).await
+ }
+
+ /// Returns the D-Bus object path of a saved connection for the given SSID.
+ pub async fn get_saved_connection_path(
+ &self,
+ ssid: &str,
+ ) -> Result