diff --git a/.github/workflows/contributors.yml b/.github/workflows/contributors.yml new file mode 100644 index 00000000..dedb3a2c --- /dev/null +++ b/.github/workflows/contributors.yml @@ -0,0 +1,25 @@ +name: Contributors + +on: + workflow_dispatch: + push: + branches: + - master + +permissions: + contents: write + pull-requests: write + +jobs: + contributors: + runs-on: ubuntu-latest + steps: + - name: Update contributors in README + uses: akhilmhdh/contributors-readme-action@v2.3.11 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + readme_path: README.md + image_size: 100 + columns_per_row: 6 + commit_message: "docs: update contributors in README" diff --git a/Cargo.lock b/Cargo.lock index f6e3610d..24c779a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -963,7 +963,7 @@ dependencies = [ [[package]] name = "nmrs" -version = "2.4.0" +version = "3.0.0" dependencies = [ "async-trait", "base64", diff --git a/Cargo.toml b/Cargo.toml index 4c43d014..2c52bf51 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,6 @@ license = "MIT" repository = "https://github.com/cachebag/nmrs" [workspace.lints.rust] -unsafe_code = "forbid" # This shouldn't apply to nmrs-gui # missing_docs = "warn" unused = { level = "warn", priority = -1 } @@ -33,7 +32,7 @@ uuid = { version = "1.23.1", features = ["v4", "v5"] } futures = "0.3.32" futures-timer = "3.0.3" base64 = "0.22.1" -nmrs = { path = "nmrs", version = "2.2" } +nmrs = { path = "nmrs", version = "3.0" } gtk = { version = "0.11.2", package = "gtk4" } glib = "0.22.5" dirs = "6.0.0" diff --git a/README.md b/README.md index a060bdea..7d5e5d9f 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ async fn main() -> nmrs::Result<()> { let nm = NetworkManager::new().await?; // Scan for networks - let networks = nm.list_networks().await?; + let networks = nm.list_networks(None).await?; for net in networks { println!( @@ -70,7 +70,7 @@ async fn main() -> nmrs::Result<()> { let nm = NetworkManager::new().await?; // Connect to a network - nm.connect("MyNetwork", WifiSecurity::WpaPsk { + nm.connect("MyNetwork", None, WifiSecurity::WpaPsk { psk: "password123".into() }).await?; @@ -94,7 +94,7 @@ use nmrs::{NetworkManager, WifiSecurity, ConnectionError}; async fn main() -> nmrs::Result<()> { let nm = NetworkManager::new().await?; - match nm.connect("MyNetwork", WifiSecurity::WpaPsk { + match nm.connect("MyNetwork", None, WifiSecurity::WpaPsk { psk: "wrong_password".into() }).await { Ok(_) => println!("Connected successfully"), @@ -212,12 +212,12 @@ Edit `~/.config/nmrs/style.css` to customize the interface. There are also pre-d - [x] Active Connection - [x] Settings - [x] Settings Connection -- [ ] Agent Manager +- [x] Agent Manager - [ ] Checkpoint - [ ] DNS Manager - [ ] PPP -- [ ] Secret Agent -- [X] VPN Connection (WireGuard) +- [x] Secret Agent +- [x] VPN Connection (WireGuard + plugin VPNs) - [ ] VPN Plugin - [ ] Wi-Fi P2P - [ ] WiMAX NSP @@ -246,3 +246,34 @@ You may use, copy, modify, and distribute this software under the terms of eithe See the following files for full license texts: - [MIT License](./LICENSE-MIT) - [Apache License 2.0](./LICENSE-APACHE) + +## Contributors + +Thank you to everyone who has helped build, test, document, and review `nmrs`. + + + + + + + + + + + + + + + + + + + + + + + + + +
cachebag
cachebag
stoutes
stoutes
pluiee
pluiee
JonnieCache
JonnieCache
tristanmsct
tristanmsct
Rifat-R
Rifat-R
of-the-stars
of-the-stars
okhsunrog
okhsunrog
ruthwik-01
ruthwik-01
joncorv
joncorv
AK78gz
AK78gz
pwsandoval
pwsandoval
ritiek
ritiek
shubhsingh5901
shubhsingh5901
cinnamonstic
cinnamonstic
tuned-willow
tuned-willow
+ diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 2e44f7df..e0f82f1f 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -16,8 +16,10 @@ - [WPA-PSK Networks](./guide/wifi-wpa-psk.md) - [WPA-EAP (Enterprise)](./guide/wifi-enterprise.md) - [Hidden Networks](./guide/wifi-hidden.md) + - [Per-Device Wi-Fi Scoping](./guide/wifi-per-device.md) - [VPN Connections](./guide/vpn.md) - [WireGuard Setup](./guide/vpn-wireguard.md) + - [OpenVPN Setup](./guide/vpn-openvpn.md) - [VPN Management](./guide/vpn-management.md) - [Ethernet Management](./guide/ethernet.md) - [Bluetooth](./guide/bluetooth.md) @@ -40,6 +42,8 @@ - [WiFi Auto-Connect](./examples/wifi-auto-connect.md) - [Enterprise WiFi](./examples/enterprise-wifi.md) - [WireGuard VPN Client](./examples/wireguard-client.md) +- [OpenVPN Client](./examples/openvpn-client.md) +- [.ovpn File Import](./examples/ovpn-import.md) - [Network Monitor Dashboard](./examples/network-monitor.md) - [Connection Manager](./examples/connection-manager.md) diff --git a/docs/src/advanced/async-runtimes.md b/docs/src/advanced/async-runtimes.md index 8f884848..cd2036a0 100644 --- a/docs/src/advanced/async-runtimes.md +++ b/docs/src/advanced/async-runtimes.md @@ -12,7 +12,7 @@ use nmrs::NetworkManager; #[tokio::main] async fn main() -> nmrs::Result<()> { let nm = NetworkManager::new().await?; - let networks = nm.list_networks().await?; + let networks = nm.list_networks(None).await?; println!("{} networks found", networks.len()); Ok(()) } @@ -49,7 +49,7 @@ use nmrs::NetworkManager; #[async_std::main] async fn main() -> nmrs::Result<()> { let nm = NetworkManager::new().await?; - let networks = nm.list_networks().await?; + let networks = nm.list_networks(None).await?; println!("{} networks found", networks.len()); Ok(()) } @@ -69,7 +69,7 @@ use nmrs::NetworkManager; fn main() -> nmrs::Result<()> { smol::block_on(async { let nm = NetworkManager::new().await?; - let networks = nm.list_networks().await?; + let networks = nm.list_networks(None).await?; println!("{} networks found", networks.len()); Ok(()) }) @@ -93,7 +93,7 @@ use nmrs::NetworkManager; glib::MainContext::default().spawn_local(async { let nm = NetworkManager::new().await.unwrap(); - let networks = nm.list_networks().await.unwrap(); + let networks = nm.list_networks(None).await.unwrap(); for net in &networks { println!("{}: {}%", net.ssid, net.strength.unwrap_or(0)); } diff --git a/docs/src/advanced/dbus.md b/docs/src/advanced/dbus.md index 5ce8d026..d0ef978a 100644 --- a/docs/src/advanced/dbus.md +++ b/docs/src/advanced/dbus.md @@ -65,7 +65,7 @@ nmrs: nm.monitor_network_changes(callback) When connecting to a network, nmrs builds a settings dictionary and sends it via D-Bus: ``` -nmrs: nm.connect("MyWiFi", WifiSecurity::WpaPsk { psk: "..." }) +nmrs: nm.connect("MyWiFi", None, WifiSecurity::WpaPsk { psk: "..." }) → Build settings HashMap → D-Bus: AddAndActivateConnection(settings, device_path, specific_object) ← D-Bus: Active connection path diff --git a/docs/src/advanced/logging.md b/docs/src/advanced/logging.md index 63fad831..d360f38e 100644 --- a/docs/src/advanced/logging.md +++ b/docs/src/advanced/logging.md @@ -21,7 +21,7 @@ async fn main() -> nmrs::Result<()> { env_logger::init(); let nm = NetworkManager::new().await?; - nm.scan_networks().await?; + nm.scan_networks(None).await?; Ok(()) } diff --git a/docs/src/advanced/timeouts.md b/docs/src/advanced/timeouts.md index 03805a41..2a642538 100644 --- a/docs/src/advanced/timeouts.md +++ b/docs/src/advanced/timeouts.md @@ -98,7 +98,7 @@ let config = TimeoutConfig::new() let nm = NetworkManager::with_config(config).await?; -match nm.connect("SlowNetwork", WifiSecurity::Open).await { +match nm.connect("SlowNetwork", None, WifiSecurity::Open).await { Ok(_) => println!("Connected!"), Err(ConnectionError::Timeout) => { eprintln!("Connection timed out — try a longer timeout"); diff --git a/docs/src/api/builders.md b/docs/src/api/builders.md index 9e410cd6..278c747a 100644 --- a/docs/src/api/builders.md +++ b/docs/src/api/builders.md @@ -128,17 +128,14 @@ The `build()` method validates all fields and returns `Result nmrs::Result<()> { let nm = NetworkManager::new().await?; - nm.connect("MyWiFi", WifiSecurity::Open).await?; + nm.connect("MyWiFi", None, WifiSecurity::Open).await?; Ok(()) } ``` @@ -135,7 +152,7 @@ async fn connect() -> nmrs::Result<()> { ### Specific Error Handling ```rust -match nm.connect("MyWiFi", security).await { +match nm.connect("MyWiFi", None, security).await { Ok(_) => println!("Connected"), Err(ConnectionError::AuthFailed) => eprintln!("Wrong password"), Err(ConnectionError::NotFound) => eprintln!("Network not found"), @@ -151,7 +168,7 @@ use anyhow::{Context, Result}; async fn connect() -> Result<()> { let nm = NetworkManager::new().await .context("Failed to connect to NetworkManager")?; - nm.connect("MyWiFi", WifiSecurity::Open).await + nm.connect("MyWiFi", None, WifiSecurity::Open).await .context("Failed to connect to MyWiFi")?; Ok(()) } diff --git a/docs/src/api/models.md b/docs/src/api/models.md index d7470403..1bca2e16 100644 --- a/docs/src/api/models.md +++ b/docs/src/api/models.md @@ -82,6 +82,40 @@ pub struct Network { } ``` +### AccessPoint + +A single AP with per-BSSID details. + +```rust +pub struct AccessPoint { + pub ssid: String, + pub bssid: String, + pub strength: u8, + pub frequency_mhz: u32, + pub device: String, + // ... security flags and device state +} +``` + +### WifiDevice + +A Wi-Fi device discovered by `list_wifi_devices()`. + +```rust +pub struct WifiDevice { + pub interface: String, + pub mac: String, + pub state: DeviceState, + pub active_ssid: Option, +} +``` + +### WifiScope + +Per-interface operations scope returned by `nm.wifi("wlan1")`. + +Methods: `interface()`, `scan()`, `list_networks()`, `list_access_points()`, `connect(ssid, creds)`, `connect_to_bssid(ssid, bssid, creds)`, `disconnect()`, `set_enabled(bool)`, `forget(ssid)` + ### NetworkInfo Detailed network information from `show_details()`. @@ -141,13 +175,29 @@ pub enum EapMethod { Peap, Ttls } pub enum Phase2 { Mschapv2, Pap } ``` +## Radio / Airplane-Mode Models + +### RadioState + +```rust +pub struct RadioState { + pub enabled: bool, // software toggle + pub hardware_enabled: bool, // rfkill state +} +``` + +### AirplaneModeState + +Aggregated state across Wi-Fi, WWAN, and Bluetooth radios. Methods: `is_airplane_mode()`. + ## VPN Models -### VpnCredentials +### WireGuardConfig + +WireGuard VPN configuration (replaces the deprecated `VpnCredentials`). ```rust -pub struct VpnCredentials { - pub vpn_type: VpnType, +pub struct WireGuardConfig { pub name: String, pub gateway: String, pub private_key: String, @@ -159,7 +209,67 @@ pub struct VpnCredentials { } ``` -Constructors: `new(...)`, `builder()` +Constructor: `new(name, gateway, private_key, address, peers)` +Builder methods: `.with_dns(vec)`, `.with_mtu(u32)`, `.with_uuid(uuid)` + +### OpenVpnConfig + +OpenVPN configuration. + +```rust +pub struct OpenVpnConfig { + pub name: String, + pub remote: String, + pub port: u16, + pub tcp: bool, + pub auth_type: Option, + pub ca_cert: Option, + pub client_cert: Option, + pub client_key: Option, + pub username: Option, + pub password: Option, + pub compression: Option, + pub proxy: Option, + // ... many more TLS and routing fields +} +``` + +Constructor: `new(name, remote, port, tcp)` +Builder methods: `.with_auth_type()`, `.with_username()`, `.with_password()`, `.with_ca_cert()`, `.with_client_cert()`, `.with_client_key()`, `.with_dns()`, `.with_mtu()`, `.with_compression()`, `.with_proxy()`, `.with_tls_auth()`, `.with_tls_crypt()`, `.with_redirect_gateway()`, `.with_routes()`, and many more. + +### OpenVpnAuthType + +```rust +pub enum OpenVpnAuthType { Password, Tls, PasswordTls, StaticKey } +``` + +### OpenVpnCompression + +```rust +pub enum OpenVpnCompression { No, Lzo, Lz4, Lz4V2, Yes } +``` + +### OpenVpnProxy + +```rust +pub enum OpenVpnProxy { + Http { server, port, username, password, retry }, + Socks { server, port, retry }, +} +``` + +### VpnRoute + +```rust +pub struct VpnRoute { + pub dest: String, + pub prefix: u32, + pub next_hop: Option, + pub metric: Option, +} +``` + +Constructor: `new(dest, prefix)`. Builder methods: `.next_hop(gw)`, `.metric(m)`. ### WireGuardPeer @@ -173,25 +283,107 @@ pub struct WireGuardPeer { } ``` -### VpnConnection / VpnConnectionInfo +### VpnType + +Protocol-specific metadata decoded from NM settings (data-carrying enum): + +```rust +pub enum VpnType { + WireGuard { private_key, peer_public_key, endpoint, allowed_ips, ... }, + OpenVpn { remote, connection_type, user_name, ca, cert, key, ... }, + OpenConnect { gateway, user_name, protocol, ... }, + StrongSwan { address, method, user_name, ... }, + Pptp { gateway, user_name, ... }, + L2tp { gateway, user_name, ipsec_enabled, ... }, + Generic { service_type, data, secrets, ... }, +} +``` + +### VpnKind + +```rust +pub enum VpnKind { Plugin, WireGuard } +``` + +### VpnConnection + +A saved or active VPN connection with rich metadata. ```rust pub struct VpnConnection { + pub uuid: String, + pub id: String, pub name: String, pub vpn_type: VpnType, pub state: DeviceState, pub interface: Option, + pub active: bool, + pub user_name: Option, + pub password_flags: VpnSecretFlags, + pub service_type: String, + pub kind: VpnKind, } +``` +### VpnConnectionInfo + +Detailed active VPN information. + +```rust pub struct VpnConnectionInfo { pub name: String, - pub vpn_type: VpnType, + pub vpn_kind: VpnKind, pub state: DeviceState, pub interface: Option, pub gateway: Option, pub ip4_address: Option, pub ip6_address: Option, pub dns_servers: Vec, + pub details: Option, +} +``` + +### VpnDetails + +```rust +pub enum VpnDetails { + WireGuard { public_key, endpoint }, + OpenVpn { remote, port, protocol, cipher, auth, compression }, +} +``` + +## Saved Connection Models + +### SavedConnection + +Full decoded saved profile from `list_saved_connections()`. + +Fields: `uuid`, `id`, `connection_type`, `interface_name`, `autoconnect`, `timestamp`, `settings`. + +### SavedConnectionBrief + +Lightweight: `uuid`, `id`, `connection_type`. + +### SettingsPatch + +Partial update for `update_saved_connection`. + +## Connectivity Models + +### ConnectivityState + +```rust +pub enum ConnectivityState { Unknown, None, Portal, Limited, Full } +``` + +### ConnectivityReport + +```rust +pub struct ConnectivityReport { + pub state: ConnectivityState, + pub check_enabled: bool, + pub check_uri: Option, + pub captive_portal_url: Option, } ``` diff --git a/docs/src/api/network-manager.md b/docs/src/api/network-manager.md index 544c3322..cd5d018f 100644 --- a/docs/src/api/network-manager.md +++ b/docs/src/api/network-manager.md @@ -25,16 +25,41 @@ let config = nm.timeout_config(); | Method | Returns | Description | |--------|---------|-------------| -| `scan_networks()` | `Result<()>` | Trigger active Wi-Fi scan | -| `list_networks()` | `Result>` | List visible networks | -| `connect(ssid, security)` | `Result<()>` | Connect to a Wi-Fi network | -| `disconnect()` | `Result<()>` | Disconnect from current network | +| `scan_networks(interface)` | `Result<()>` | Trigger active Wi-Fi scan (`None` = all devices) | +| `list_networks(interface)` | `Result>` | List visible networks (`None` = all devices) | +| `list_access_points(interface)` | `Result>` | List individual APs by BSSID | +| `connect(ssid, interface, security)` | `Result<()>` | Connect to a Wi-Fi network | +| `connect_to_bssid(ssid, bssid, interface, security)` | `Result<()>` | Connect to a specific AP | +| `disconnect(interface)` | `Result<()>` | Disconnect from current network (`None` = first Wi-Fi device) | | `current_network()` | `Result>` | Get current Wi-Fi network | | `current_ssid()` | `Option` | Get current SSID | | `current_connection_info()` | `Option<(String, Option)>` | Get SSID + frequency | | `is_connected(ssid)` | `Result` | Check if connected to a specific network | | `show_details(network)` | `Result` | Get detailed network info | +## Per-Device Wi-Fi Methods + +| Method | Returns | Description | +|--------|---------|-------------| +| `list_wifi_devices()` | `Result>` | List all Wi-Fi devices | +| `wifi_device_by_interface(name)` | `Result` | Look up a Wi-Fi device by name | +| `wifi(interface)` | `WifiScope` | Build a scope pinned to one interface | +| `set_wifi_enabled(interface, bool)` | `Result<()>` | Enable/disable one Wi-Fi radio | + +## Radio / Airplane-Mode Methods + +| Method | Returns | Description | +|--------|---------|-------------| +| `wifi_state()` | `Result` | Software + hardware Wi-Fi state | +| `wwan_state()` | `Result` | Software + hardware WWAN state | +| `bluetooth_radio_state()` | `Result` | Software + hardware Bluetooth state | +| `airplane_mode_state()` | `Result` | Aggregated across all radios | +| `set_wireless_enabled(bool)` | `Result<()>` | Global Wi-Fi software toggle | +| `set_wwan_enabled(bool)` | `Result<()>` | Global WWAN toggle | +| `set_bluetooth_radio_enabled(bool)` | `Result<()>` | Toggle all BlueZ adapters | +| `set_airplane_mode(bool)` | `Result<()>` | Toggle all three radios | +| `wait_for_wifi_ready()` | `Result<()>` | Wait for Wi-Fi device to become ready | + ## Ethernet Methods | Method | Returns | Description | @@ -45,12 +70,26 @@ let config = nm.timeout_config(); | Method | Returns | Description | |--------|---------|-------------| -| `connect_vpn(creds)` | `Result<()>` | Connect to a VPN | +| `connect_vpn(config)` | `Result<()>` | Connect with a `WireGuardConfig` or `OpenVpnConfig` | +| `import_ovpn(path, user, pass)` | `Result<()>` | Import `.ovpn` file and connect | +| `connect_vpn_by_uuid(uuid)` | `Result<()>` | Activate a saved VPN by UUID | +| `connect_vpn_by_id(id)` | `Result<()>` | Activate a saved VPN by display name | | `disconnect_vpn(name)` | `Result<()>` | Disconnect a VPN by name | +| `disconnect_vpn_by_uuid(uuid)` | `Result<()>` | Disconnect a VPN by UUID | | `list_vpn_connections()` | `Result>` | List all saved VPNs | +| `active_vpn_connections()` | `Result>` | List only active VPNs | | `forget_vpn(name)` | `Result<()>` | Delete a saved VPN profile | | `get_vpn_info(name)` | `Result` | Get active VPN details | +## Connectivity Methods + +| Method | Returns | Description | +|--------|---------|-------------| +| `connectivity()` | `Result` | Current NM connectivity state | +| `check_connectivity()` | `Result` | Force a connectivity re-check | +| `connectivity_report()` | `Result` | Full report with captive portal URL | +| `captive_portal_url()` | `Result>` | Captive portal URL if in Portal state | + ## Bluetooth Methods | Method | Returns | Description | @@ -69,21 +108,19 @@ let config = nm.timeout_config(); | `get_device_by_interface(name)` | `Result` | Find device by interface name | | `is_connecting()` | `Result` | Check if any device is connecting | -## Wi-Fi Control Methods - -| Method | Returns | Description | -|--------|---------|-------------| -| `wifi_enabled()` | `Result` | Check if Wi-Fi is enabled | -| `set_wifi_enabled(bool)` | `Result<()>` | Enable/disable Wi-Fi | -| `wifi_hardware_enabled()` | `Result` | Check hardware radio state (rfkill) | -| `wait_for_wifi_ready()` | `Result<()>` | Wait for Wi-Fi device to become ready | - ## Connection Profile Methods | Method | Returns | Description | |--------|---------|-------------| -| `list_saved_connections()` | `Result>` | List all saved profiles | -| `has_saved_connection(ssid)` | `Result` | Check if a profile exists | +| `list_saved_connections()` | `Result>` | Full decode of all saved profiles | +| `list_saved_connections_brief()` | `Result>` | Lightweight profile listing | +| `list_saved_connection_ids()` | `Result>` | Just the profile names | +| `get_saved_connection(uuid)` | `Result` | Load one profile by UUID | +| `get_saved_connection_raw(uuid)` | `Result>` | Raw `GetSettings` map | +| `delete_saved_connection(uuid)` | `Result<()>` | Delete a profile by UUID | +| `update_saved_connection(uuid, patch)` | `Result<()>` | Merge a `SettingsPatch` into a profile | +| `reload_saved_connections()` | `Result<()>` | Re-read profiles from disk | +| `has_saved_connection(ssid)` | `Result` | Check if a Wi-Fi profile exists | | `get_saved_connection_path(ssid)` | `Result>` | Get profile D-Bus path | | `forget(ssid)` | `Result<()>` | Delete a Wi-Fi profile | diff --git a/docs/src/api/types.md b/docs/src/api/types.md index ccccc692..cec8d917 100644 --- a/docs/src/api/types.md +++ b/docs/src/api/types.md @@ -31,6 +31,9 @@ All public methods return `nmrs::Result`. |------|-------------| | `Network` | A discovered Wi-Fi network (SSID, signal, security flags) | | `NetworkInfo` | Detailed network information (channel, speed, bars) | +| `AccessPoint` | A single AP with BSSID, frequency, and security flags | +| `WifiDevice` | A Wi-Fi device with interface, MAC, state, and active SSID | +| `WifiScope` | Per-interface operations scope (from `nm.wifi("wlan1")`) | | `WifiSecurity` | Authentication type: `Open`, `WpaPsk`, `WpaEap` | | `EapOptions` | Enterprise Wi-Fi (802.1X) configuration | | `EapOptionsBuilder` | Builder for `EapOptions` | @@ -46,16 +49,48 @@ All public methods return `nmrs::Result`. | `DeviceType` | Device kind: `Wifi`, `Ethernet`, `Bluetooth`, `WifiP2P`, `Loopback`, `Other(u32)` | | `DeviceState` | Operational state: `Disconnected`, `Activated`, `Failed`, etc. | +## Radio / Airplane-Mode Types + +| Type | Description | +|------|-------------| +| `RadioState` | Combined software (`enabled`) and hardware (`hardware_enabled`) radio state | +| `AirplaneModeState` | Aggregated state across Wi-Fi, WWAN, and Bluetooth | + ## VPN Types | Type | Description | |------|-------------| -| `VpnType` | VPN protocol: `WireGuard` | -| `VpnCredentials` | Full VPN configuration for connecting | -| `VpnCredentialsBuilder` | Builder for `VpnCredentials` | +| `VpnConfig` | Sealed trait for VPN configurations | +| `VpnConfiguration` | Dispatch enum: `WireGuard(WireGuardConfig)` or `OpenVpn(OpenVpnConfig)` | +| `WireGuardConfig` | WireGuard VPN configuration | | `WireGuardPeer` | WireGuard peer configuration | -| `VpnConnection` | A saved/active VPN connection | -| `VpnConnectionInfo` | Detailed VPN info (IP, DNS, gateway) | +| `OpenVpnConfig` | OpenVPN configuration | +| `OpenVpnAuthType` | OpenVPN auth: `Password`, `Tls`, `PasswordTls`, `StaticKey` | +| `OpenVpnCompression` | Compression mode: `No`, `Lz4`, `Lz4V2`, `Yes` | +| `OpenVpnProxy` | Proxy: `Http { ... }`, `Socks { ... }` | +| `VpnRoute` | Static IPv4 route for split tunneling | +| `VpnType` | Protocol-specific metadata (data-carrying enum) | +| `VpnKind` | `Plugin` (OpenVPN, etc.) vs `WireGuard` | +| `VpnConnection` | A saved/active VPN connection with rich metadata | +| `VpnConnectionInfo` | Detailed active VPN info (IP, DNS, gateway, protocol details) | +| `VpnDetails` | Protocol-specific active connection details | +| `VpnCredentials` | **Deprecated** — use `WireGuardConfig` instead | + +## Connectivity Types + +| Type | Description | +|------|-------------| +| `ConnectivityState` | NM connectivity: `Full`, `Portal`, `Limited`, `None`, `Unknown` | +| `ConnectivityReport` | Full report with state, check URI, and captive portal URL | + +## Saved Connection Types + +| Type | Description | +|------|-------------| +| `SavedConnection` | Full decoded saved profile | +| `SavedConnectionBrief` | Lightweight profile (`uuid`, `id`, `type`) | +| `SettingsSummary` | Decoded settings within a profile | +| `SettingsPatch` | Partial update for `update_saved_connection` | ## Bluetooth Types @@ -88,6 +123,7 @@ All public methods return `nmrs::Result`. | `ConnectionBuilder` | Base connection settings builder | | `WifiConnectionBuilder` | Wi-Fi connection builder | | `WireGuardBuilder` | WireGuard VPN builder | +| `OpenVpnBuilder` | OpenVPN builder (also imports `.ovpn` files) | | `IpConfig` | IP address with CIDR prefix | | `Route` | Static route configuration | | `WifiBand` | Wi-Fi band: `Bg` (2.4 GHz), `A` (5 GHz) | @@ -99,11 +135,15 @@ nmrs re-exports commonly used types at the crate root for convenience: ```rust use nmrs::{ - NetworkManager, + NetworkManager, WifiScope, WifiSecurity, EapOptions, EapMethod, Phase2, - VpnCredentials, VpnType, WireGuardPeer, + WireGuardConfig, WireGuardPeer, + OpenVpnConfig, OpenVpnAuthType, + VpnConfig, VpnConfiguration, VpnType, VpnKind, TimeoutConfig, ConnectionOptions, ConnectionError, DeviceType, DeviceState, + RadioState, AirplaneModeState, + ConnectivityState, ConnectivityReport, }; ``` @@ -111,5 +151,5 @@ Less commonly used types are available through the `models` and `builders` modul ```rust use nmrs::models::{BluetoothIdentity, BluetoothNetworkRole, BluetoothDevice}; -use nmrs::builders::{ConnectionBuilder, WireGuardBuilder, IpConfig, Route}; +use nmrs::builders::{ConnectionBuilder, WireGuardBuilder, OpenVpnBuilder, IpConfig, Route}; ``` diff --git a/docs/src/appendix/faq.md b/docs/src/appendix/faq.md index 2d205834..fa2196a5 100644 --- a/docs/src/appendix/faq.md +++ b/docs/src/appendix/faq.md @@ -56,15 +56,19 @@ When nmrs connects to a network, NetworkManager saves the profile. On subsequent ### Which VPN protocols are supported? -Currently only WireGuard. OpenVPN support is planned and actively being developed. +WireGuard and OpenVPN. WireGuard uses NetworkManager's native kernel integration (no plugin needed). OpenVPN requires the `networkmanager-openvpn` plugin. ### Do I need the WireGuard kernel module? Yes. WireGuard is built into the Linux kernel since version 5.6. On older kernels, install the `wireguard` module. NetworkManager's WireGuard support requires NM 1.16+. +### Can I import a `.ovpn` file? + +Yes. Use `nm.import_ovpn("client.ovpn", Some("user"), Some("pass")).await?` to parse and activate an OpenVPN profile in one call. Inline certificates are extracted and persisted automatically. + ### Can I import a `.conf` WireGuard file? -Not directly. You need to extract the values from the config file and pass them to `VpnCredentials`. Direct `.conf` file import is not yet implemented. +Not directly. Extract the values from the config file and pass them to `WireGuardConfig::new()`. ## GUI diff --git a/docs/src/appendix/troubleshooting.md b/docs/src/appendix/troubleshooting.md index 11c0dff5..f21fe048 100644 --- a/docs/src/appendix/troubleshooting.md +++ b/docs/src/appendix/troubleshooting.md @@ -33,8 +33,8 @@ busctl list | grep NetworkManager **Solutions:** - Verify the SSID is spelled correctly (case-sensitive) -- Trigger a scan first: `nm.scan_networks().await?` -- Check if Wi-Fi is enabled: `nm.wifi_enabled().await?` +- Trigger a scan first: `nm.scan_networks(None).await?` +- Check if Wi-Fi is enabled: `nm.wifi_state().await?` returns a `RadioState` with `.enabled` and `.hardware_enabled` fields - Check if the network is in range - For hidden networks, the network won't appear in scans but should still connect diff --git a/docs/src/examples/connection-manager.md b/docs/src/examples/connection-manager.md index c5a0a975..dd475347 100644 --- a/docs/src/examples/connection-manager.md +++ b/docs/src/examples/connection-manager.md @@ -56,14 +56,14 @@ async fn main() -> nmrs::Result<()> { async fn scan(nm: &NetworkManager) { println!("Scanning..."); - match nm.scan_networks().await { + match nm.scan_networks(None).await { Ok(_) => println!("Scan complete"), Err(e) => eprintln!("Scan failed: {}", e), } } async fn list_networks(nm: &NetworkManager) { - match nm.list_networks().await { + match nm.list_networks(None).await { Ok(networks) => { println!("\n{:<5} {:<30} {:>6} {:>10}", "#", "SSID", "Signal", "Security"); @@ -98,7 +98,7 @@ async fn connect_wifi(nm: &NetworkManager) { }; println!("Connecting to '{}'...", ssid); - match nm.connect(ssid, security).await { + match nm.connect(ssid, None, security).await { Ok(_) => println!("Connected!"), Err(ConnectionError::AuthFailed) => eprintln!("Wrong password"), Err(ConnectionError::NotFound) => eprintln!("Network not found"), @@ -108,7 +108,7 @@ async fn connect_wifi(nm: &NetworkManager) { } async fn disconnect(nm: &NetworkManager) { - match nm.disconnect().await { + match nm.disconnect(None).await { Ok(_) => println!("Disconnected"), Err(e) => eprintln!("Error: {}", e), } @@ -144,8 +144,8 @@ async fn devices(nm: &NetworkManager) { async fn saved(nm: &NetworkManager) { match nm.list_saved_connections().await { Ok(connections) => { - for name in &connections { - println!(" {}", name); + for conn in &connections { + println!(" {} ({})", conn.id, conn.connection_type); } } Err(e) => eprintln!("Error: {}", e), diff --git a/docs/src/examples/enterprise-wifi.md b/docs/src/examples/enterprise-wifi.md index b96ce427..e7e6cfb6 100644 --- a/docs/src/examples/enterprise-wifi.md +++ b/docs/src/examples/enterprise-wifi.md @@ -48,7 +48,7 @@ async fn main() -> nmrs::Result<()> { println!("Connecting to enterprise network '{}'...", ssid); - match nm.connect(&ssid, WifiSecurity::WpaEap { opts: eap }).await { + match nm.connect(&ssid, None, WifiSecurity::WpaEap { opts: eap }).await { Ok(_) => { println!("Connected!"); diff --git a/docs/src/examples/network-monitor.md b/docs/src/examples/network-monitor.md index 4f191df8..2493e043 100644 --- a/docs/src/examples/network-monitor.md +++ b/docs/src/examples/network-monitor.md @@ -74,8 +74,8 @@ async fn print_status(nm: &NetworkManager) { } // Wi-Fi state - if let Ok(enabled) = nm.wifi_enabled().await { - println!("Wi-Fi enabled: {}", enabled); + if let Ok(state) = nm.wifi_state().await { + println!("Wi-Fi enabled: {}", state.enabled); } // Devices @@ -90,7 +90,7 @@ async fn print_status(nm: &NetworkManager) { } async fn print_networks(nm: &NetworkManager) { - if let Ok(networks) = nm.list_networks().await { + if let Ok(networks) = nm.list_networks(None).await { println!("Visible networks ({}):", networks.len()); for net in &networks { let security = if net.is_eap { @@ -125,6 +125,7 @@ cargo run --example network_monitor Connected to: HomeWiFi Wi-Fi enabled: true +Wi-Fi hardware enabled: true Devices: wlan0 — Wi-Fi [Activated] @@ -142,6 +143,7 @@ Visible networks (5): --- Device state changed --- Connected to: HomeWiFi Wi-Fi enabled: true +Wi-Fi hardware enabled: true Devices: wlan0 — Wi-Fi [Activated] diff --git a/docs/src/examples/openvpn-client.md b/docs/src/examples/openvpn-client.md new file mode 100644 index 00000000..fb629d0b --- /dev/null +++ b/docs/src/examples/openvpn-client.md @@ -0,0 +1,191 @@ +# OpenVPN Client + +This example demonstrates a complete OpenVPN client that authenticates with password+TLS, connects, displays VPN details, and cleanly disconnects. + +## Features + +- Builds OpenVPN config with password+TLS authentication +- Reads credentials from environment variables +- Connects and retrieves VPN details +- Displays IP configuration, DNS, and gateway +- Cleanly disconnects on completion + +## Code + +```rust +use nmrs::{NetworkManager, OpenVpnConfig, OpenVpnAuthType}; + +#[tokio::main] +async fn main() -> nmrs::Result<()> { + let nm = NetworkManager::new().await?; + + // Read credentials from environment + let remote = std::env::var("OVPN_REMOTE") + .unwrap_or_else(|_| "vpn.example.com".into()); + let port: u16 = std::env::var("OVPN_PORT") + .unwrap_or_else(|_| "1194".into()) + .parse() + .expect("OVPN_PORT must be a valid port number"); + let username = std::env::var("OVPN_USER") + .expect("Set OVPN_USER"); + let password = std::env::var("OVPN_PASS") + .expect("Set OVPN_PASS"); + let ca_path = std::env::var("OVPN_CA") + .unwrap_or_else(|_| "/etc/openvpn/ca.crt".into()); + let cert_path = std::env::var("OVPN_CERT") + .unwrap_or_else(|_| "/etc/openvpn/client.crt".into()); + let key_path = std::env::var("OVPN_KEY") + .unwrap_or_else(|_| "/etc/openvpn/client.key".into()); + + // Build OpenVPN config (password+TLS) + let config = OpenVpnConfig::new("CorpVPN", &remote, port, false) + .with_auth_type(OpenVpnAuthType::PasswordTls) + .with_username(&username) + .with_password(&password) + .with_ca_cert(&ca_path) + .with_client_cert(&cert_path) + .with_client_key(&key_path); + + // Connect + println!("Connecting to OpenVPN ({remote}:{port})..."); + nm.connect_vpn(config).await?; + println!("Connected!\n"); + + // Show VPN details + let info = nm.get_vpn_info("CorpVPN").await?; + println!("VPN Connection Details:"); + println!(" Name: {}", info.name); + println!(" Kind: {:?}", info.vpn_kind); + println!(" State: {:?}", info.state); + println!(" Interface: {:?}", info.interface); + println!(" Gateway: {:?}", info.gateway); + println!(" IPv4: {:?}", info.ip4_address); + println!(" IPv6: {:?}", info.ip6_address); + println!(" DNS: {:?}", info.dns_servers); + + if let Some(details) = &info.details { + println!(" Details: {:?}", details); + } + + // Wait for user input before disconnecting + println!("\nPress Enter to disconnect..."); + let mut input = String::new(); + std::io::stdin().read_line(&mut input).ok(); + + // Disconnect + nm.disconnect_vpn("CorpVPN").await?; + println!("VPN disconnected"); + + Ok(()) +} +``` + +## Running + +```bash +OVPN_REMOTE="vpn.example.com" \ +OVPN_PORT="1194" \ +OVPN_USER="alice" \ +OVPN_PASS="hunter2" \ +OVPN_CA="/etc/openvpn/ca.crt" \ +OVPN_CERT="/etc/openvpn/client.crt" \ +OVPN_KEY="/etc/openvpn/client.key" \ +cargo run --example openvpn_client +``` + +## Sample Output + +``` +Connecting to OpenVPN (vpn.example.com:1194)... +Connected! + +VPN Connection Details: + Name: CorpVPN + Kind: Plugin + State: Activated + Interface: Some("tun0") + Gateway: Some("vpn.example.com") + IPv4: Some("10.8.0.2") + IPv6: None + DNS: ["10.8.0.1"] + +Press Enter to disconnect... +``` + +## Error Handling + +```rust +use nmrs::{NetworkManager, OpenVpnConfig, OpenVpnAuthType, ConnectionError}; + +async fn connect_with_retry(nm: &NetworkManager) -> nmrs::Result<()> { + let config = OpenVpnConfig::new("CorpVPN", "vpn.example.com", 1194, false) + .with_auth_type(OpenVpnAuthType::PasswordTls) + .with_username("alice") + .with_password("hunter2") + .with_ca_cert("/etc/openvpn/ca.crt") + .with_client_cert("/etc/openvpn/client.crt") + .with_client_key("/etc/openvpn/client.key"); + + match nm.connect_vpn(config).await { + Ok(_) => { + println!("VPN connected"); + Ok(()) + } + Err(ConnectionError::AuthFailed) => { + eprintln!("Authentication failed — check username/password and certificates"); + Err(ConnectionError::AuthFailed) + } + Err(ConnectionError::Timeout) => { + eprintln!("Connection timed out — check server address and port"); + Err(ConnectionError::Timeout) + } + Err(ConnectionError::VpnFailed) => { + eprintln!("VPN activation failed — verify OpenVPN plugin is installed"); + Err(ConnectionError::VpnFailed) + } + Err(e) => { + eprintln!("Unexpected error: {e}"); + Err(e) + } + } +} +``` + +## TLS-Only Variation + +For certificate-only authentication (no username/password): + +```rust +use nmrs::{OpenVpnConfig, OpenVpnAuthType}; + +let config = OpenVpnConfig::new("TlsOnlyVPN", "vpn.example.com", 1194, false) + .with_auth_type(OpenVpnAuthType::Tls) + .with_ca_cert("/etc/openvpn/ca.crt") + .with_client_cert("/etc/openvpn/client.crt") + .with_client_key("/etc/openvpn/client.key"); + +nm.connect_vpn(config).await?; +``` + +## Password-Only Variation + +For username/password authentication without client certificates: + +```rust +use nmrs::{OpenVpnConfig, OpenVpnAuthType}; + +let config = OpenVpnConfig::new("PassVPN", "vpn.example.com", 1194, false) + .with_auth_type(OpenVpnAuthType::Password) + .with_username("alice") + .with_password("hunter2") + .with_ca_cert("/etc/openvpn/ca.crt"); + +nm.connect_vpn(config).await?; +``` + +## Next Steps + +- [`.ovpn` File Import](./ovpn-import.md) — import existing OpenVPN configurations +- [VPN Connections Guide](../guide/vpn.md) — comprehensive VPN overview +- [OpenVPN Setup](../guide/vpn-openvpn.md) — detailed OpenVPN configuration +- [WireGuard VPN Client](./wireguard-client.md) — WireGuard example diff --git a/docs/src/examples/ovpn-import.md b/docs/src/examples/ovpn-import.md new file mode 100644 index 00000000..442ea73c --- /dev/null +++ b/docs/src/examples/ovpn-import.md @@ -0,0 +1,179 @@ +# .ovpn File Import + +This example shows how to import an existing `.ovpn` configuration file into NetworkManager using nmrs. + +## Features + +- Import `.ovpn` files with a single method call +- Builder approach for more control over the import +- Automatic handling of inline certificates +- Error handling for parse failures + +## One-Liner Import + +The simplest way to import an `.ovpn` file: + +```rust +use nmrs::NetworkManager; + +#[tokio::main] +async fn main() -> nmrs::Result<()> { + let nm = NetworkManager::new().await?; + + // Import with credentials + nm.import_ovpn("client.ovpn", Some("alice"), Some("hunter2")).await?; + println!("VPN imported and connected!"); + + Ok(()) +} +``` + +If the `.ovpn` file uses certificate-only authentication, pass `None` for username and password: + +```rust +nm.import_ovpn("client.ovpn", None, None).await?; +``` + +## Builder Approach + +For more control over the import process, use `OpenVpnBuilder`: + +```rust +use nmrs::{NetworkManager, OpenVpnBuilder}; + +#[tokio::main] +async fn main() -> nmrs::Result<()> { + let nm = NetworkManager::new().await?; + + let config = OpenVpnBuilder::from_ovpn_file("client.ovpn")? + .username("alice") + .password("hunter2") + .build()?; + + nm.connect_vpn(config).await?; + println!("VPN connected!"); + + Ok(()) +} +``` + +The builder extracts remote, port, protocol, certificates, and other settings from the `.ovpn` file automatically. + +## Inline Certificates + +Many `.ovpn` files embed certificates directly rather than referencing external files: + +``` + +-----BEGIN CERTIFICATE----- +MIIBxTCCAWugAwIBAgIJAJ... +-----END CERTIFICATE----- + + + +-----BEGIN CERTIFICATE----- +MIICCzCCAZGgAwIBAgIRAP... +-----END CERTIFICATE----- + + + +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w... +-----END PRIVATE KEY----- + +``` + +`from_ovpn_file` handles these automatically — inline certificates are extracted and written to temporary files that NetworkManager can reference. No extra steps needed: + +```rust +let config = OpenVpnBuilder::from_ovpn_file("inline-certs.ovpn")? + .username("alice") + .password("hunter2") + .build()?; +``` + +## Error Handling + +Handle parse failures and missing fields gracefully: + +```rust +use nmrs::{OpenVpnBuilder, ConnectionError}; + +fn import_config(path: &str) -> nmrs::Result<()> { + let config = match OpenVpnBuilder::from_ovpn_file(path) { + Ok(builder) => builder.build()?, + Err(ConnectionError::InvalidConfig(msg)) => { + eprintln!("Failed to parse {path}: {msg}"); + return Err(ConnectionError::InvalidConfig(msg)); + } + Err(ConnectionError::NotFound) => { + eprintln!("File not found: {path}"); + return Err(ConnectionError::NotFound); + } + Err(e) => { + eprintln!("Import error: {e}"); + return Err(e); + } + }; + + println!("Successfully parsed configuration"); + Ok(()) +} +``` + +Common parse errors: + +| Error | Cause | +|-------|-------| +| `InvalidConfig` | Missing `remote` directive, malformed options, or invalid certificate data | +| `NotFound` | The `.ovpn` file does not exist at the given path | +| `AuthFailed` | Credentials required but not provided | + +## Complete Example + +```rust +use nmrs::{NetworkManager, OpenVpnBuilder}; + +#[tokio::main] +async fn main() -> nmrs::Result<()> { + let nm = NetworkManager::new().await?; + + let ovpn_path = std::env::args() + .nth(1) + .unwrap_or_else(|| "client.ovpn".into()); + + let username = std::env::var("OVPN_USER").ok(); + let password = std::env::var("OVPN_PASS").ok(); + + println!("Importing {ovpn_path}..."); + + // Builder approach for maximum control + let mut builder = OpenVpnBuilder::from_ovpn_file(&ovpn_path)?; + + if let Some(user) = &username { + builder = builder.username(user); + } + if let Some(pass) = &password { + builder = builder.password(pass); + } + + let config = builder.build()?; + nm.connect_vpn(config).await?; + + println!("Connected! Checking VPN info..."); + let vpns = nm.list_vpn_connections().await?; + for vpn in &vpns { + if vpn.active { + println!(" Active: {} ({:?})", vpn.name, vpn.vpn_type); + } + } + + Ok(()) +} +``` + +## Next Steps + +- [OpenVPN Client Example](./openvpn-client.md) — build OpenVPN config from scratch +- [VPN Connections Guide](../guide/vpn.md) — comprehensive VPN overview +- [OpenVPN Setup](../guide/vpn-openvpn.md) — detailed OpenVPN configuration diff --git a/docs/src/examples/wifi-auto-connect.md b/docs/src/examples/wifi-auto-connect.md index b5b15b3a..991a3c52 100644 --- a/docs/src/examples/wifi-auto-connect.md +++ b/docs/src/examples/wifi-auto-connect.md @@ -54,8 +54,8 @@ async fn main() -> nmrs::Result<()> { // Scan and list visible networks println!("Scanning for networks..."); - nm.scan_networks().await?; - let visible = nm.list_networks().await?; + nm.scan_networks(None).await?; + let visible = nm.list_networks(None).await?; let visible_ssids: HashMap<&str, &nmrs::Network> = visible .iter() @@ -71,7 +71,7 @@ async fn main() -> nmrs::Result<()> { net.strength.unwrap_or(0), ); - match nm.connect(&pref.ssid, pref.security.clone()).await { + match nm.connect(&pref.ssid, None, pref.security.clone()).await { Ok(_) => { println!("Connected to '{}'!", pref.ssid); return Ok(()); diff --git a/docs/src/examples/wifi-scanner.md b/docs/src/examples/wifi-scanner.md index f95d7a68..4860528e 100644 --- a/docs/src/examples/wifi-scanner.md +++ b/docs/src/examples/wifi-scanner.md @@ -31,7 +31,7 @@ async fn main() -> nmrs::Result<()> { print!("\x1B[2J\x1B[1;1H"); // Get networks - let mut networks = nm.list_networks().await?; + let mut networks = nm.list_networks(None).await?; // Sort by signal strength (strongest first) networks.sort_by(|a, b| { @@ -199,7 +199,7 @@ if let Ok(choice) = input.trim().parse::() { // Get password if needed match &selected.security { nmrs::WifiSecurity::Open => { - nm.connect(&selected.ssid, nmrs::WifiSecurity::Open).await?; + nm.connect(&selected.ssid, None, nmrs::WifiSecurity::Open).await?; println!("Connected to {}", selected.ssid); } _ => { @@ -208,7 +208,7 @@ if let Ok(choice) = input.trim().parse::() { let mut password = String::new(); io::stdin().read_line(&mut password)?; - nm.connect(&selected.ssid, nmrs::WifiSecurity::WpaPsk { + nm.connect(&selected.ssid, None, nmrs::WifiSecurity::WpaPsk { psk: password.trim().to_string() }).await?; println!("Connected to {}", selected.ssid); diff --git a/docs/src/examples/wireguard-client.md b/docs/src/examples/wireguard-client.md index 97427a5d..3bfb8d8a 100644 --- a/docs/src/examples/wireguard-client.md +++ b/docs/src/examples/wireguard-client.md @@ -4,7 +4,7 @@ This example demonstrates a complete WireGuard VPN client that connects, display ## Features -- Builds VPN credentials with the builder pattern +- Builds VPN configuration with `WireGuardConfig` - Connects and retrieves VPN details - Displays IP configuration and DNS - Cleanly disconnects on completion @@ -12,7 +12,7 @@ This example demonstrates a complete WireGuard VPN client that connects, display ## Code ```rust -use nmrs::{NetworkManager, VpnCredentials, WireGuardPeer}; +use nmrs::{NetworkManager, WireGuardConfig, WireGuardPeer}; #[tokio::main] async fn main() -> nmrs::Result<()> { @@ -38,37 +38,30 @@ async fn main() -> nmrs::Result<()> { ) .with_persistent_keepalive(25); - // Build credentials - let creds = VpnCredentials::builder() - .name("ExampleVPN") - .wireguard() - .gateway( - std::env::var("WG_ENDPOINT") - .unwrap_or_else(|_| "vpn.example.com:51820".into()), - ) - .private_key( - std::env::var("WG_PRIVATE_KEY") - .expect("Set WG_PRIVATE_KEY"), - ) - .address( - std::env::var("WG_ADDRESS") - .unwrap_or_else(|_| "10.0.0.2/24".into()), - ) - .add_peer(peer) - .with_dns(vec!["1.1.1.1".into(), "8.8.8.8".into()]) - .with_mtu(1420) - .build(); + // Build configuration + let config = WireGuardConfig::new( + "ExampleVPN", + std::env::var("WG_ENDPOINT") + .unwrap_or_else(|_| "vpn.example.com:51820".into()), + std::env::var("WG_PRIVATE_KEY") + .expect("Set WG_PRIVATE_KEY"), + std::env::var("WG_ADDRESS") + .unwrap_or_else(|_| "10.0.0.2/24".into()), + vec![peer], + ) + .with_dns(vec!["1.1.1.1".into(), "8.8.8.8".into()]) + .with_mtu(1420); // Connect println!("Connecting to VPN..."); - nm.connect_vpn(creds).await?; + nm.connect_vpn(config).await?; println!("Connected!\n"); // Show VPN details let info = nm.get_vpn_info("ExampleVPN").await?; println!("VPN Connection Details:"); println!(" Name: {}", info.name); - println!(" Type: {:?}", info.vpn_type); + println!(" Kind: {:?}", info.vpn_kind); println!(" State: {:?}", info.state); println!(" Interface: {:?}", info.interface); println!(" Gateway: {:?}", info.gateway); @@ -110,7 +103,7 @@ Connected! VPN Connection Details: Name: ExampleVPN - Type: WireGuard + Kind: WireGuard State: Activated Interface: Some("wg-examplevpn") Gateway: Some("vpn.example.com") @@ -153,13 +146,11 @@ let peer2 = WireGuardPeer::new( vec!["10.2.0.0/16".into()], ); -let creds = VpnCredentials::builder() - .name("MultiPeerVPN") - .wireguard() - .gateway("us-east.vpn.example.com:51820") - .private_key("client_private_key") - .address("10.0.0.2/24") - .add_peer(peer1) - .add_peer(peer2) - .build(); +let config = WireGuardConfig::new( + "MultiPeerVPN", + "us-east.vpn.example.com:51820", + "client_private_key", + "10.0.0.2/24", + vec![peer1, peer2], +); ``` diff --git a/docs/src/getting-started/quick-start.md b/docs/src/getting-started/quick-start.md index 42ad4188..accf4db6 100644 --- a/docs/src/getting-started/quick-start.md +++ b/docs/src/getting-started/quick-start.md @@ -30,7 +30,7 @@ async fn main() -> nmrs::Result<()> { let nm = NetworkManager::new().await?; // List all available networks - let networks = nm.list_networks().await?; + let networks = nm.list_networks(None).await?; // Print network information for network in networks { @@ -72,7 +72,7 @@ async fn main() -> nmrs::Result<()> { let nm = NetworkManager::new().await?; // Connect to a WPA-PSK protected network - nm.connect("MyHomeNetwork", WifiSecurity::WpaPsk { + nm.connect("MyHomeNetwork", None, WifiSecurity::WpaPsk { psk: "your_password_here".into() }).await?; @@ -98,7 +98,7 @@ use nmrs::{NetworkManager, WifiSecurity, ConnectionError}; async fn main() -> nmrs::Result<()> { let nm = NetworkManager::new().await?; - match nm.connect("MyNetwork", WifiSecurity::WpaPsk { + match nm.connect("MyNetwork", None, WifiSecurity::WpaPsk { psk: "password123".into() }).await { Ok(_) => println!("✓ Connected successfully"), @@ -158,7 +158,7 @@ use nmrs::NetworkManager; async fn main() -> nmrs::Result<()> { let nm = NetworkManager::new().await?; - let profiles = nm.list_connections().await?; + let profiles = nm.list_saved_connection_ids().await?; println!("Saved connections:"); for profile in profiles { @@ -207,7 +207,7 @@ async fn main() -> nmrs::Result<()> { let nm = NetworkManager::new().await?; println!("Scanning for networks...\n"); - let networks = nm.list_networks().await?; + let networks = nm.list_networks(None).await?; // Display networks with numbering for (i, net) in networks.iter().enumerate() { @@ -252,7 +252,7 @@ async fn main() -> nmrs::Result<()> { // Connect println!("Connecting to {}...", selected.ssid); - nm.connect(&selected.ssid, security).await?; + nm.connect(&selected.ssid, None, security).await?; println!("✓ Connected successfully!"); diff --git a/docs/src/guide/devices.md b/docs/src/guide/devices.md index da1a736d..6e8564bc 100644 --- a/docs/src/guide/devices.md +++ b/docs/src/guide/devices.md @@ -132,25 +132,24 @@ let bluetooth = nm.list_bluetooth_devices().await?; ## Wi-Fi Radio Control -Enable or disable the Wi-Fi radio globally: +Check and control the Wi-Fi radio globally: ```rust let nm = NetworkManager::new().await?; -// Check current state -let enabled = nm.wifi_enabled().await?; -println!("Wi-Fi enabled: {}", enabled); +// Check current state (software + hardware) +let state = nm.wifi_state().await?; +println!("Wi-Fi enabled: {}", state.enabled); +println!("Wi-Fi hardware enabled: {}", state.hardware_enabled); -// Check hardware switch (rfkill) -let hw_enabled = nm.wifi_hardware_enabled().await?; -println!("Wi-Fi hardware enabled: {}", hw_enabled); - -// Toggle Wi-Fi -nm.set_wifi_enabled(false).await?; // Disable -nm.set_wifi_enabled(true).await?; // Enable +// Global toggle +nm.set_wireless_enabled(false).await?; // Disable +nm.set_wireless_enabled(true).await?; // Enable ``` -> **Note:** `wifi_hardware_enabled()` reflects the rfkill state. If the hardware switch is off, enabling Wi-Fi via software will have no effect. +> **Note:** `wifi_state().hardware_enabled` reflects the rfkill state. If the hardware switch is off, enabling Wi-Fi via software will have no effect. + +For per-device Wi-Fi enable/disable, see [Per-Device Scoping](./wifi-per-device.md). ## Waiting for Wi-Fi Ready @@ -159,11 +158,11 @@ After enabling Wi-Fi, the device may take a moment to become ready: ```rust let nm = NetworkManager::new().await?; -nm.set_wifi_enabled(true).await?; +nm.set_wireless_enabled(true).await?; nm.wait_for_wifi_ready().await?; // Now safe to scan and connect -nm.scan_networks().await?; +nm.scan_networks(None).await?; ``` ## Finding a Device by Interface Name diff --git a/docs/src/guide/error-handling.md b/docs/src/guide/error-handling.md index a199d367..48e3fab5 100644 --- a/docs/src/guide/error-handling.md +++ b/docs/src/guide/error-handling.md @@ -23,6 +23,9 @@ All public API methods return `nmrs::Result`. | `MissingPassword` | Empty password provided | | `NoWifiDevice` | No Wi-Fi adapter found | | `WifiNotReady` | Wi-Fi device not ready in time | +| `WifiInterfaceNotFound` | Specified Wi-Fi interface doesn't exist | +| `NotAWifiDevice` | Interface exists but isn't Wi-Fi | +| `HardwareRadioKilled` | Hardware kill switch is on | | `NoWiredDevice` | No Ethernet adapter found | | `DhcpFailed` | Failed to obtain an IP address via DHCP | | `Timeout` | Operation timed out waiting for activation | @@ -41,6 +44,8 @@ All public API methods return `nmrs::Result`. |---------|-------------| | `NoVpnConnection` | VPN not found or not active | | `VpnFailed(String)` | VPN connection failed with details | +| `VpnIdAmbiguous` | Multiple VPNs share the same name | +| `IncompleteBuilder` | VPN builder missing required fields | | `InvalidPrivateKey(String)` | Bad WireGuard private key | | `InvalidPublicKey(String)` | Bad WireGuard public key | | `InvalidAddress(String)` | Bad IP address or CIDR notation | @@ -79,7 +84,7 @@ use nmrs::{NetworkManager, WifiSecurity}; #[tokio::main] async fn main() -> nmrs::Result<()> { let nm = NetworkManager::new().await?; - nm.connect("MyWiFi", WifiSecurity::Open).await?; + nm.connect("MyWiFi", None, WifiSecurity::Open).await?; Ok(()) } ``` @@ -93,7 +98,7 @@ use nmrs::{NetworkManager, WifiSecurity, ConnectionError}; let nm = NetworkManager::new().await?; -match nm.connect("MyWiFi", WifiSecurity::WpaPsk { +match nm.connect("MyWiFi", None, WifiSecurity::WpaPsk { psk: "password".into(), }).await { Ok(_) => println!("Connected!"), @@ -126,7 +131,7 @@ use nmrs::{NetworkManager, WifiSecurity, ConnectionError}; let nm = NetworkManager::new().await?; for attempt in 1..=3 { - match nm.connect("MyWiFi", WifiSecurity::WpaPsk { + match nm.connect("MyWiFi", None, WifiSecurity::WpaPsk { psk: "password".into(), }).await { Ok(_) => { @@ -168,11 +173,18 @@ use nmrs::NetworkManager; async fn connect() -> Result<()> { let nm = NetworkManager::new().await?; - nm.connect("MyWiFi", nmrs::WifiSecurity::Open).await?; + nm.connect("MyWiFi", None, nmrs::WifiSecurity::Open).await?; Ok(()) } ``` +### Radio / Airplane-mode Errors + +| Variant | Description | +|---------|-------------| +| `HardwareRadioKilled` | Hardware kill switch is on; Wi-Fi cannot be enabled until the switch is toggled | +| `BluezUnavailable` | Bluetooth D-Bus service (BlueZ) is not running or unreachable | + ## Non-Exhaustive `ConnectionError` is marked `#[non_exhaustive]`, which means new variants may be added in future versions without a breaking change. Always include a wildcard arm in match expressions: diff --git a/docs/src/guide/monitoring.md b/docs/src/guide/monitoring.md index 1347420a..d85bc325 100644 --- a/docs/src/guide/monitoring.md +++ b/docs/src/guide/monitoring.md @@ -144,7 +144,7 @@ async fn main() -> nmrs::Result<()> { loop { notify.notified().await; - let networks = nm.list_networks().await?; + let networks = nm.list_networks(None).await?; println!("Updated: {} networks visible", networks.len()); } } diff --git a/docs/src/guide/profiles.md b/docs/src/guide/profiles.md index 1c879e67..cf608920 100644 --- a/docs/src/guide/profiles.md +++ b/docs/src/guide/profiles.md @@ -12,15 +12,15 @@ async fn main() -> nmrs::Result<()> { let nm = NetworkManager::new().await?; let connections = nm.list_saved_connections().await?; - for name in &connections { - println!(" {}", name); + for conn in &connections { + println!(" {} ({})", conn.id, conn.connection_type); } Ok(()) } ``` -`list_saved_connections()` returns the names of all saved connection profiles across all connection types — Wi-Fi, Ethernet, VPN, and Bluetooth. +`list_saved_connections()` returns full `SavedConnection` objects for all saved connection profiles across all connection types — Wi-Fi, Ethernet, VPN, and Bluetooth. Each `SavedConnection` includes the profile `id` (name), `connection_type`, and other metadata. ## Checking for a Saved Connection @@ -46,12 +46,12 @@ When you call `connect()` with an SSID that has a saved profile, nmrs activates let nm = NetworkManager::new().await?; // First connection — credentials are required and saved -nm.connect("HomeWiFi", WifiSecurity::WpaPsk { +nm.connect("HomeWiFi", None, WifiSecurity::WpaPsk { psk: "password".into(), }).await?; // Later reconnection — saved profile is used, security parameter is ignored -nm.connect("HomeWiFi", WifiSecurity::Open).await?; +nm.connect("HomeWiFi", None, WifiSecurity::Open).await?; ``` ## Forgetting (Deleting) Connections diff --git a/docs/src/guide/vpn-management.md b/docs/src/guide/vpn-management.md index 3e9c30e2..36f8d6a0 100644 --- a/docs/src/guide/vpn-management.md +++ b/docs/src/guide/vpn-management.md @@ -1,6 +1,6 @@ # VPN Management -Once you've set up a WireGuard VPN connection, nmrs provides methods to list, inspect, disconnect, and remove VPN profiles. +Once you've set up a WireGuard or OpenVPN connection, nmrs provides methods to list, inspect, connect, disconnect, and remove VPN profiles. ## Listing VPN Connections @@ -13,13 +13,14 @@ async fn main() -> nmrs::Result<()> { let vpns = nm.list_vpn_connections().await?; for vpn in &vpns { - println!("{}: {:?} [{:?}]", + println!("{}: {:?} [{:?}] (active: {})", vpn.name, vpn.vpn_type, vpn.state, + vpn.active, ); if let Some(iface) = &vpn.interface { - println!(" Interface: {}", iface); + println!(" Interface: {iface}"); } } @@ -31,10 +32,26 @@ async fn main() -> nmrs::Result<()> { | Field | Type | Description | |-------|------|-------------| +| `uuid` | `String` | Connection UUID | +| `id` | `String` | Connection name (alias for `name`) | | `name` | `String` | Connection profile name | -| `vpn_type` | `VpnType` | VPN protocol (currently `WireGuard`) | +| `vpn_type` | `VpnType` | VPN protocol — a data-carrying enum with `WireGuard`, `OpenVpn`, and other variants | | `state` | `DeviceState` | Current state (`Activated`, `Disconnected`, etc.) | -| `interface` | `Option` | Network interface when active (e.g., `wg0`) | +| `interface` | `Option` | Network interface when active (e.g., `wg0`, `tun0`) | +| `active` | `bool` | Whether the connection is currently active | +| `kind` | `VpnKind` | `VpnKind::Plugin` (OpenVPN) or `VpnKind::WireGuard` | + +## Active VPN Connections + +Get only currently active VPN connections: + +```rust +let active = nm.active_vpn_connections().await?; + +for vpn in &active { + println!("Active: {} ({:?}) on {:?}", vpn.name, vpn.vpn_type, vpn.interface); +} +``` ## Getting VPN Details @@ -46,13 +63,17 @@ let nm = NetworkManager::new().await?; let info = nm.get_vpn_info("MyVPN").await?; println!("Name: {}", info.name); -println!("Type: {:?}", info.vpn_type); +println!("Kind: {:?}", info.vpn_kind); println!("State: {:?}", info.state); println!("Interface: {:?}", info.interface); println!("Gateway: {:?}", info.gateway); println!("IPv4: {:?}", info.ip4_address); println!("IPv6: {:?}", info.ip6_address); println!("DNS: {:?}", info.dns_servers); + +if let Some(details) = &info.details { + println!("Details: {:?}", details); +} ``` The `VpnConnectionInfo` struct provides: @@ -60,22 +81,42 @@ The `VpnConnectionInfo` struct provides: | Field | Type | Description | |-------|------|-------------| | `name` | `String` | Connection name | -| `vpn_type` | `VpnType` | VPN protocol | +| `vpn_kind` | `VpnKind` | `VpnKind::Plugin` or `VpnKind::WireGuard` | | `state` | `DeviceState` | Current state | | `interface` | `Option` | Interface name | | `gateway` | `Option` | VPN gateway address | | `ip4_address` | `Option` | Assigned IPv4 address | | `ip6_address` | `Option` | Assigned IPv6 address | | `dns_servers` | `Vec` | Active DNS servers | +| `details` | `Option` | Additional VPN-specific details | > **Note:** `get_vpn_info()` returns `ConnectionError::NoVpnConnection` if the VPN is not currently active. +## Connecting to a Saved VPN + +Reconnect to an existing VPN profile by name or UUID without rebuilding the config: + +```rust +let nm = NetworkManager::new().await?; + +// By profile name +nm.connect_vpn_by_id("MyVPN").await?; + +// By UUID +nm.connect_vpn_by_uuid("a1b2c3d4-e5f6-7890-abcd-ef1234567890").await?; +``` + ## Disconnecting a VPN ```rust let nm = NetworkManager::new().await?; +// By name nm.disconnect_vpn("MyVPN").await?; + +// By UUID +nm.disconnect_vpn_by_uuid("a1b2c3d4-e5f6-7890-abcd-ef1234567890").await?; + println!("VPN disconnected"); ``` @@ -97,7 +138,7 @@ println!("VPN profile deleted"); ## Complete Example ```rust -use nmrs::{NetworkManager, VpnCredentials, WireGuardPeer}; +use nmrs::{NetworkManager, WireGuardConfig, WireGuardPeer}; #[tokio::main] async fn main() -> nmrs::Result<()> { @@ -106,27 +147,26 @@ async fn main() -> nmrs::Result<()> { // List existing VPNs println!("Saved VPN connections:"); for vpn in nm.list_vpn_connections().await? { - println!(" {} ({:?}) - {:?}", vpn.name, vpn.vpn_type, vpn.state); + println!(" {} ({:?}) - {:?} [active: {}]", + vpn.name, vpn.vpn_type, vpn.state, vpn.active); } - // Connect + // Connect a new WireGuard VPN 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("ExampleVPN") - .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(); + let config = WireGuardConfig::new( + "ExampleVPN", + "vpn.example.com:51820", + "CLIENT_PRIVATE_KEY", + "10.0.0.2/24", + vec![peer], + ).with_dns(vec!["1.1.1.1".into()]); - nm.connect_vpn(creds).await?; + nm.connect_vpn(config).await?; // Show details let info = nm.get_vpn_info("ExampleVPN").await?; @@ -153,9 +193,12 @@ async fn main() -> nmrs::Result<()> { | `InvalidPrivateKey` | `connect_vpn` | Bad WireGuard key | | `InvalidAddress` | `connect_vpn` | Bad IP/CIDR | | `InvalidGateway` | `connect_vpn` | Bad endpoint format | +| `AuthFailed` | `connect_vpn` | OpenVPN authentication failed | +| `InvalidConfig` | `connect_vpn` | OpenVPN configuration error (missing certs, bad options) | ## Next Steps -- [WireGuard Setup](./vpn-wireguard.md) – credential configuration details -- [Error Handling](./error-handling.md) – comprehensive error reference -- [Real-Time Monitoring](./monitoring.md) – monitor VPN state changes +- [WireGuard Setup](./vpn-wireguard.md) — credential configuration details +- [OpenVPN Setup](./vpn-openvpn.md) — OpenVPN configuration details +- [Error Handling](./error-handling.md) — comprehensive error reference +- [Real-Time Monitoring](./monitoring.md) — monitor VPN state changes diff --git a/docs/src/guide/vpn-openvpn.md b/docs/src/guide/vpn-openvpn.md new file mode 100644 index 00000000..ed1d5412 --- /dev/null +++ b/docs/src/guide/vpn-openvpn.md @@ -0,0 +1,377 @@ +# OpenVPN Setup + +[OpenVPN](https://openvpn.net/) is a widely-deployed, battle-tested VPN protocol that uses TLS for key exchange and supports a variety of authentication methods. nmrs provides full OpenVPN support through the NetworkManager OpenVPN plugin, letting you create, import, and manage OpenVPN connections programmatically. + +## Prerequisites + +- NetworkManager 1.2+ +- The OpenVPN plugin for NetworkManager: + - Fedora / RHEL: `NetworkManager-openvpn` + - Debian / Ubuntu: `network-manager-openvpn` + - Arch Linux: `networkmanager-openvpn` +- OpenVPN certificates and/or credentials from your VPN provider + +## Quick Start + +Connect to an OpenVPN server using password + TLS certificate authentication: + +```rust +use nmrs::{NetworkManager, OpenVpnConfig, OpenVpnAuthType}; + +#[tokio::main] +async fn main() -> nmrs::Result<()> { + let nm = NetworkManager::new().await?; + + let config = OpenVpnConfig::new("CorpVPN", "vpn.example.com", 1194, false) + .with_auth_type(OpenVpnAuthType::PasswordTls) + .with_ca_cert("/etc/openvpn/ca.crt") + .with_client_cert("/etc/openvpn/client.crt") + .with_client_key("/etc/openvpn/client.key") + .with_username("alice") + .with_password("hunter2") + .with_dns(vec!["1.1.1.1".into()]); + + nm.connect_vpn(config).await?; + + println!("VPN connected!"); + Ok(()) +} +``` + +## Authentication Types + +OpenVPN supports four authentication modes, selected with `OpenVpnAuthType`: + +| Variant | Description | Required Fields | +|---------|-------------|-----------------| +| `Password` | Username/password only | `username` | +| `Tls` | TLS certificate only | `ca_cert`, `client_cert`, `client_key` | +| `PasswordTls` | Password + TLS certificates | `username`, `ca_cert`, `client_cert`, `client_key` | +| `StaticKey` | Pre-shared static key | (static key file via TLS auth) | + +### Password Authentication + +```rust +use nmrs::{OpenVpnConfig, OpenVpnAuthType}; + +let config = OpenVpnConfig::new("SimpleVPN", "vpn.example.com", 1194, false) + .with_auth_type(OpenVpnAuthType::Password) + .with_username("alice") + .with_password("secret") + .with_ca_cert("/etc/openvpn/ca.crt"); +``` + +### TLS Certificate Authentication + +```rust +use nmrs::{OpenVpnConfig, OpenVpnAuthType}; + +let config = OpenVpnConfig::new("CertVPN", "vpn.example.com", 1194, false) + .with_auth_type(OpenVpnAuthType::Tls) + .with_ca_cert("/etc/openvpn/ca.crt") + .with_client_cert("/etc/openvpn/client.crt") + .with_client_key("/etc/openvpn/client.key"); +``` + +If the client key is encrypted: + +```rust +let config = config.with_key_password("keyfile-passphrase"); +``` + +### Password + TLS Authentication + +The most common configuration for corporate VPNs — the server verifies both your certificate and your credentials: + +```rust +use nmrs::{OpenVpnConfig, OpenVpnAuthType}; + +let config = OpenVpnConfig::new("CorpVPN", "vpn.example.com", 443, true) + .with_auth_type(OpenVpnAuthType::PasswordTls) + .with_ca_cert("/etc/openvpn/ca.crt") + .with_client_cert("/etc/openvpn/client.crt") + .with_client_key("/etc/openvpn/client.key") + .with_username("alice") + .with_password("secret"); +``` + +## Configuration Reference + +| Field | Builder Method | Required | Description | +|-------|---------------|----------|-------------| +| `name` | constructor | Yes | Connection profile name | +| `remote` | constructor | Yes | Server hostname or IP | +| `port` | constructor | Yes | Server port (typically 1194 or 443) | +| `tcp` | constructor | Yes | `true` for TCP, `false` for UDP | +| `auth_type` | `with_auth_type` | No | Authentication mode (see above) | +| `ca_cert` | `with_ca_cert` | No* | Path to CA certificate | +| `client_cert` | `with_client_cert` | No* | Path to client certificate | +| `client_key` | `with_client_key` | No* | Path to client private key | +| `key_password` | `with_key_password` | No | Password for encrypted key file | +| `username` | `with_username` | No* | Username for password auth | +| `password` | `with_password` | No | Password for password auth | +| `cipher` | `with_cipher` | No | Data channel cipher (e.g. `"AES-256-GCM"`) | +| `auth` | `with_auth` | No | HMAC digest algorithm (e.g. `"SHA256"`) | +| `dns` | `with_dns` | No | DNS servers while connected | +| `mtu` | `with_mtu` | No | MTU size | +| `uuid` | `with_uuid` | No | Custom UUID (auto-generated if omitted) | +| `compression` | `with_compression` | No | Compression mode (see below) | +| `proxy` | `with_proxy` | No | Proxy configuration | +| `redirect_gateway` | `with_redirect_gateway` | No | Full tunnel (`false` by default) | +| `routes` | `with_routes` | No | Split tunnel routes | + +\* Required depending on the chosen `auth_type`. + +## Importing .ovpn Files + +If you already have an `.ovpn` configuration file, you can import it directly. + +### High-Level Import + +The simplest approach — import and connect in one call: + +```rust +use nmrs::NetworkManager; + +#[tokio::main] +async fn main() -> nmrs::Result<()> { + let nm = NetworkManager::new().await?; + + nm.import_ovpn("corp.ovpn", Some("alice"), Some("secret")).await?; + + println!("Connected via imported .ovpn profile"); + Ok(()) +} +``` + +`import_ovpn` parses the file, creates a NetworkManager connection profile, and activates it. The connection name defaults to the filename stem (e.g., `corp` from `corp.ovpn`). + +### Builder-Based Import + +For more control, use `OpenVpnBuilder::from_ovpn_file` to parse the file into a builder, then customise before connecting: + +```rust +use nmrs::builders::OpenVpnBuilder; +use nmrs::NetworkManager; + +#[tokio::main] +async fn main() -> nmrs::Result<()> { + let nm = NetworkManager::new().await?; + + let config = OpenVpnBuilder::from_ovpn_file("corp.ovpn")? + .username("alice") + .dns(vec!["1.1.1.1".into()]) + .mtu(1400) + .remote_cert_tls("server") + .build()?; + + nm.connect_vpn(config).await?; + + Ok(()) +} +``` + +You can also parse from a string with `OpenVpnBuilder::from_ovpn_str(content, name)` if the configuration is fetched from a remote source. + +## TLS Hardening + +OpenVPN's TLS layer can be hardened with several options. These are independent of the authentication type and can be combined. + +### TLS Auth + +Adds an HMAC firewall to the control channel, providing DoS protection. Both sides must share the same static key and agree on direction: + +```rust +let config = config + .with_tls_auth("/etc/openvpn/ta.key", Some(1)); +``` + +### TLS-Crypt + +Encrypts **and** authenticates the entire control channel with a pre-shared key — stronger than `tls-auth` because the TLS handshake itself is hidden: + +```rust +let config = config + .with_tls_crypt("/etc/openvpn/tls-crypt.key"); +``` + +### TLS-Crypt-v2 + +Per-client key wrapping, allowing the server to issue unique keys to each client while retaining the benefits of TLS-Crypt: + +```rust +let config = config + .with_tls_crypt_v2("/etc/openvpn/client-tls-crypt-v2.key"); +``` + +> **Note:** `tls-auth`, `tls-crypt`, and `tls-crypt-v2` are mutually exclusive. Use only one. + +### Certificate Verification + +Verify the server's certificate identity to prevent man-in-the-middle attacks: + +```rust +use nmrs::OpenVpnConfig; + +let config = OpenVpnConfig::new("SecureVPN", "vpn.example.com", 1194, false) + .with_auth_type(nmrs::OpenVpnAuthType::Tls) + .with_ca_cert("/etc/openvpn/ca.crt") + .with_client_cert("/etc/openvpn/client.crt") + .with_client_key("/etc/openvpn/client.key") + .with_remote_cert_tls("server") + .with_verify_x509_name("vpn.example.com", "name"); +``` + +| Method | Purpose | +|--------|---------| +| `with_remote_cert_tls("server")` | Require the remote cert to have server (TLS Web Server) usage | +| `with_verify_x509_name(name, type)` | Verify the CN or subject of the server certificate | +| `with_tls_version_min("1.2")` | Enforce minimum TLS version | +| `with_tls_version_max("1.3")` | Cap the maximum TLS version | +| `with_tls_cipher(suite)` | Restrict control-channel cipher suites | + +## Split Tunneling + +By default, `redirect_gateway` is `false` — only traffic matching explicit routes goes through the VPN. + +### Full Tunnel + +Route all traffic through the VPN: + +```rust +let config = config.with_redirect_gateway(true); +``` + +### Split Tunnel with Routes + +Route only specific networks through the VPN using `VpnRoute`: + +```rust +use nmrs::{OpenVpnConfig, OpenVpnAuthType, VpnRoute}; + +let config = OpenVpnConfig::new("OfficeVPN", "vpn.example.com", 1194, false) + .with_auth_type(OpenVpnAuthType::Tls) + .with_ca_cert("/etc/openvpn/ca.crt") + .with_client_cert("/etc/openvpn/client.crt") + .with_client_key("/etc/openvpn/client.key") + .with_routes(vec![ + VpnRoute::new("10.0.0.0", 8), + VpnRoute::new("192.168.1.0", 24).next_hop("10.0.0.1").metric(100), + ]); +``` + +| `VpnRoute` Method | Description | +|--------------------|-------------| +| `VpnRoute::new(dest, prefix)` | Destination network and CIDR prefix length | +| `.next_hop(gateway)` | Optional gateway for the route | +| `.metric(m)` | Optional route metric (lower = higher priority) | + +## Compression + +OpenVPN supports several compression algorithms, but **compression is disabled by default for security reasons**. + +```rust +use nmrs::OpenVpnCompression; + +let config = config.with_compression(OpenVpnCompression::No); +``` + +| Variant | Description | +|---------|-------------| +| `No` | Disabled (recommended default) | +| `Lzo` | LZO compression (deprecated) | +| `Lz4` | LZ4 compression | +| `Lz4V2` | LZ4 v2 compression | +| `Yes` | Adaptive compression | + +> **Security Warning:** Enabling compression on an OpenVPN tunnel that carries TLS traffic (HTTPS, etc.) exposes the connection to the [VORACLE attack](https://openvpn.net/security-advisory/the-voracle-attack-vulnerability/). An attacker who can observe encrypted VPN traffic and induce the victim to visit attacker-controlled content can recover plaintext via compression oracle side-channels. **Leave compression disabled unless you have a specific need and understand the risk.** + +## Proxy Support + +Route OpenVPN traffic through an HTTP or SOCKS proxy: + +### HTTP Proxy + +```rust +use nmrs::OpenVpnProxy; + +let config = config.with_proxy(OpenVpnProxy::Http { + server: "proxy.example.com".into(), + port: 8080, + username: Some("proxyuser".into()), + password: Some("proxypass".into()), + retry: true, +}); +``` + +### SOCKS Proxy + +```rust +use nmrs::OpenVpnProxy; + +let config = config.with_proxy(OpenVpnProxy::Socks { + server: "socks.example.com".into(), + port: 1080, + retry: false, +}); +``` + +When using a proxy, TCP mode (`tcp: true` in the constructor) is typically required. + +## Error Handling + +Handle OpenVPN-specific errors: + +```rust +use nmrs::{ConnectionError, NetworkManager, OpenVpnConfig, OpenVpnAuthType}; + +#[tokio::main] +async fn main() -> nmrs::Result<()> { + let nm = NetworkManager::new().await?; + + let config = OpenVpnConfig::new("CorpVPN", "vpn.example.com", 1194, false) + .with_auth_type(OpenVpnAuthType::PasswordTls) + .with_ca_cert("/etc/openvpn/ca.crt") + .with_client_cert("/etc/openvpn/client.crt") + .with_client_key("/etc/openvpn/client.key") + .with_username("alice") + .with_password("secret"); + + match nm.connect_vpn(config).await { + Ok(()) => println!("VPN connected"), + + Err(ConnectionError::VpnFailed(msg)) => { + eprintln!("OpenVPN activation failed: {msg}"); + } + + Err(ConnectionError::AuthFailed) => { + eprintln!("Authentication failed — check username/password and certificates"); + } + + Err(ConnectionError::Timeout) => { + eprintln!("Connection timed out — verify server address and port"); + } + + Err(ConnectionError::InvalidGateway(msg)) => { + eprintln!("Bad server address: {msg}"); + } + + Err(e) => eprintln!("Unexpected error: {e}"), + } + + Ok(()) +} +``` + +| Error | Cause | +|-------|-------| +| `VpnFailed` | Plugin missing, config rejected, or activation failed | +| `AuthFailed` | Bad username/password or certificate rejected | +| `Timeout` | Server unreachable or handshake timed out | +| `InvalidGateway` | Empty or invalid remote address | + +## Next Steps + +- [VPN Connections](./vpn.md) – VPN overview and general operations +- [VPN Management](./vpn-management.md) – list, disconnect, and remove VPN profiles +- [Error Handling](./error-handling.md) – comprehensive error reference diff --git a/docs/src/guide/vpn-wireguard.md b/docs/src/guide/vpn-wireguard.md index debf758f..0ef0bd19 100644 --- a/docs/src/guide/vpn-wireguard.md +++ b/docs/src/guide/vpn-wireguard.md @@ -11,7 +11,7 @@ ## Quick Start ```rust -use nmrs::{NetworkManager, VpnCredentials, VpnType, WireGuardPeer}; +use nmrs::{NetworkManager, WireGuardConfig, WireGuardPeer}; #[tokio::main] async fn main() -> nmrs::Result<()> { @@ -23,8 +23,7 @@ async fn main() -> nmrs::Result<()> { vec!["0.0.0.0/0".into()], ).with_persistent_keepalive(25); - let creds = VpnCredentials::new( - VpnType::WireGuard, + let config = WireGuardConfig::new( "MyVPN", "vpn.example.com:51820", "CLIENT_PRIVATE_KEY_BASE64", @@ -32,7 +31,7 @@ async fn main() -> nmrs::Result<()> { vec![peer], ).with_dns(vec!["1.1.1.1".into()]); - nm.connect_vpn(creds).await?; + nm.connect_vpn(config).await?; println!("VPN connected!"); Ok(()) @@ -51,11 +50,10 @@ async fn main() -> nmrs::Result<()> { | **DNS** | DNS servers to use while the VPN is active | | **Persistent Keepalive** | Seconds between keepalive packets (helps with NAT traversal) | -## VpnCredentials Fields +## WireGuardConfig Fields | Field | Required | Description | |-------|----------|-------------| -| `vpn_type` | Yes | Must be `VpnType::WireGuard` | | `name` | Yes | Connection profile name | | `gateway` | Yes | Server endpoint (`host:port`) | | `private_key` | Yes | Client private key (base64) | @@ -65,12 +63,10 @@ async fn main() -> nmrs::Result<()> { | `mtu` | No | MTU size (typical: 1420) | | `uuid` | No | Custom UUID (auto-generated if omitted) | -## Building Credentials - -### Direct Constructor +## Building Configuration ```rust -use nmrs::{VpnCredentials, VpnType, WireGuardPeer}; +use nmrs::{WireGuardConfig, WireGuardPeer}; let peer = WireGuardPeer::new( "HIgo9xNzJMWLKAShlKl6/bUT1VI9Q0SDBXGtLXkPFXc=", @@ -79,8 +75,7 @@ let peer = WireGuardPeer::new( ).with_persistent_keepalive(25) .with_preshared_key("OPTIONAL_PSK_BASE64"); -let creds = VpnCredentials::new( - VpnType::WireGuard, +let config = WireGuardConfig::new( "HomeVPN", "vpn.example.com:51820", "YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=", @@ -90,31 +85,6 @@ let creds = VpnCredentials::new( .with_mtu(1420); ``` -### Builder Pattern - -The builder pattern avoids positional parameter confusion: - -```rust -use nmrs::{VpnCredentials, WireGuardPeer}; - -let peer = WireGuardPeer::new( - "HIgo9xNzJMWLKAShlKl6/bUT1VI9Q0SDBXGtLXkPFXc=", - "vpn.example.com:51820", - vec!["0.0.0.0/0".into()], -).with_persistent_keepalive(25); - -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) - .with_dns(vec!["1.1.1.1".into()]) - .with_mtu(1420) - .build(); -``` - ## WireGuardPeer Configuration | Field | Required | Description | @@ -183,5 +153,6 @@ Invalid parameters produce specific error variants: ## Next Steps - [VPN Management](./vpn-management.md) – list, disconnect, and remove VPN connections +- [OpenVPN Setup](./vpn-openvpn.md) – set up OpenVPN connections - [Custom Timeouts](../advanced/timeouts.md) – adjust VPN connection timeouts - [Error Handling](./error-handling.md) – handle VPN-specific errors diff --git a/docs/src/guide/vpn.md b/docs/src/guide/vpn.md index 1edcedae..96a382d9 100644 --- a/docs/src/guide/vpn.md +++ b/docs/src/guide/vpn.md @@ -1,188 +1,115 @@ # VPN Connections -nmrs provides full support for WireGuard VPN connections through NetworkManager. This guide covers everything you need to know about managing VPNs with nmrs. +nmrs provides full support for WireGuard and OpenVPN connections through NetworkManager. This guide covers everything you need to know about managing VPNs with nmrs. ## Overview VPN support includes: -- **WireGuard** - Modern, fast, secure VPN protocol -- **Profile Management** - Save and reuse VPN configurations -- **Connection Control** - Connect, disconnect, monitor VPN status -- **Multiple Peers** - Support for multiple WireGuard peers -- **Custom DNS** - Override DNS servers for VPN connections -- **MTU Configuration** - Optimize packet sizes +- **WireGuard** — Modern, fast, secure VPN protocol (native NetworkManager integration) +- **OpenVPN** — Widely deployed VPN protocol (via NetworkManager OpenVPN plugin) +- **`.ovpn` Import** — Import existing OpenVPN configuration files +- **Profile Management** — Save and reuse VPN configurations +- **Connection Control** — Connect, disconnect, monitor VPN status +- **Multiple Peers** — Support for multiple WireGuard peers +- **Custom DNS** — Override DNS servers for VPN connections +- **MTU Configuration** — Optimize packet sizes -## Quick Start - -Basic WireGuard VPN connection: +## WireGuard Quick Start ```rust -use nmrs::{NetworkManager, VpnCredentials, VpnType, WireGuardPeer}; +use nmrs::{NetworkManager, WireGuardConfig, WireGuardPeer}; #[tokio::main] async fn main() -> nmrs::Result<()> { let nm = NetworkManager::new().await?; - - // Create a WireGuard peer + let peer = WireGuardPeer::new( - "server_public_key_here", + "server_public_key", "vpn.example.com:51820", - vec!["0.0.0.0/0".into()], // Route all traffic through VPN + vec!["0.0.0.0/0".into()], ).with_persistent_keepalive(25); - - // Create VPN credentials - let creds = VpnCredentials::new( - VpnType::WireGuard, + + let config = WireGuardConfig::new( "MyVPN", "vpn.example.com:51820", - "your_private_key_here", - "10.0.0.2/24", // Your VPN IP + "your_private_key", + "10.0.0.2/24", vec![peer], ).with_dns(vec!["1.1.1.1".into()]); - - // Connect - nm.connect_vpn(creds).await?; - println!("Connected to VPN!"); - - Ok(()) -} -``` - -## VPN Credentials -The `VpnCredentials` struct contains all necessary VPN configuration: + nm.connect_vpn(config).await?; + println!("Connected to WireGuard VPN!"); -```rust -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, + Ok(()) } ``` -### Creating Credentials +## OpenVPN Quick Start ```rust -use nmrs::{VpnCredentials, VpnType, WireGuardPeer}; +use nmrs::{NetworkManager, OpenVpnConfig, OpenVpnAuthType}; -let peer = WireGuardPeer::new( - "base64_public_key", - "vpn.example.com:51820", - vec!["0.0.0.0/0".into()], -); +#[tokio::main] +async fn main() -> nmrs::Result<()> { + let nm = NetworkManager::new().await?; -let creds = VpnCredentials::new( - VpnType::WireGuard, - "WorkVPN", // Connection name - "vpn.example.com:51820", // Gateway - "base64_private_key", // Your private key - "10.0.0.2/24", // Your VPN IP address - vec![peer], // WireGuard peers -); -``` + let config = OpenVpnConfig::new("CorpVPN", "vpn.example.com", 1194, false) + .with_auth_type(OpenVpnAuthType::PasswordTls) + .with_username("user") + .with_password("secret") + .with_ca_cert("/etc/openvpn/ca.crt") + .with_client_cert("/etc/openvpn/client.crt") + .with_client_key("/etc/openvpn/client.key"); -### With Custom DNS + nm.connect_vpn(config).await?; + println!("Connected to OpenVPN!"); -```rust -let creds = VpnCredentials::new( - VpnType::WireGuard, - "MyVPN", - "vpn.example.com:51820", - "private_key", - "10.0.0.2/24", - vec![peer], -).with_dns(vec![ - "1.1.1.1".into(), - "8.8.8.8".into(), -]); -``` - -### With Custom MTU - -```rust -let creds = creds.with_mtu(1420); // Standard WireGuard MTU + Ok(()) +} ``` -## WireGuard Peers +## `.ovpn` File Import -Each WireGuard connection can have multiple peers: +Import an existing OpenVPN configuration file directly: ```rust -pub struct WireGuardPeer { - pub public_key: String, - pub gateway: String, - pub allowed_ips: Vec, - pub preshared_key: Option, - pub persistent_keepalive: Option, -} +nm.import_ovpn("client.ovpn", Some("user"), Some("secret")).await?; ``` -### Creating Peers +For certificate-only configs that don't require credentials: ```rust -use nmrs::WireGuardPeer; - -// Basic peer -let peer = WireGuardPeer::new( - "peer_public_key", - "vpn.example.com:51820", - vec!["0.0.0.0/0".into()], -); +nm.import_ovpn("client.ovpn", None, None).await?; +``` -// Peer with keepalive -let peer = WireGuardPeer::new( - "peer_public_key", - "vpn.example.com:51820", - vec!["0.0.0.0/0".into()], -).with_persistent_keepalive(25); +See the [`.ovpn` Import Example](../examples/ovpn-import.md) for builder-based import and inline certificate handling. -// Peer with preshared key -let peer = peer.with_preshared_key("base64_preshared_key"); -``` +## VPN Operations -### Multiple Peers +### Connect ```rust -let peer1 = WireGuardPeer::new( - "peer1_public_key", - "vpn1.example.com:51820", - vec!["10.0.0.0/8".into()], -); +// WireGuard +nm.connect_vpn(wireguard_config).await?; -let peer2 = WireGuardPeer::new( - "peer2_public_key", - "vpn2.example.com:51820", - vec!["192.168.0.0/16".into()], -); - -let creds = VpnCredentials::new( - VpnType::WireGuard, - "MultiPeerVPN", - "vpn1.example.com:51820", - "private_key", - "10.0.0.2/24", - vec![peer1, peer2], // Multiple peers -); +// OpenVPN +nm.connect_vpn(openvpn_config).await?; ``` -## VPN Operations +### Connect by Name or UUID -### Connect to VPN +Reconnect to a saved VPN profile without rebuilding the config: ```rust -nm.connect_vpn(creds).await?; +nm.connect_vpn_by_id("MyVPN").await?; +nm.connect_vpn_by_uuid("a1b2c3d4-e5f6-...").await?; ``` -### Disconnect from VPN +### Disconnect ```rust nm.disconnect_vpn("MyVPN").await?; +nm.disconnect_vpn_by_uuid("a1b2c3d4-e5f6-...").await?; ``` ### List VPN Connections @@ -190,10 +117,36 @@ nm.disconnect_vpn("MyVPN").await?; ```rust let vpns = nm.list_vpn_connections().await?; -for vpn in vpns { - println!("Name: {}", vpn.name); - println!("Type: {:?}", vpn.vpn_type); - println!("State: {:?}", vpn.state); +for vpn in &vpns { + println!("{} ({:?}) — active: {}", vpn.name, vpn.vpn_type, vpn.active); + if let Some(iface) = &vpn.interface { + println!(" Interface: {iface}"); + } +} +``` + +`list_vpn_connections()` returns `Vec` with fields: + +| Field | Type | Description | +|-------|------|-------------| +| `uuid` | `String` | Connection UUID | +| `id` | `String` | Connection name (alias for `name`) | +| `name` | `String` | Connection profile name | +| `vpn_type` | `VpnType` | VPN protocol (`WireGuard`, `OpenVpn`, etc.) | +| `state` | `DeviceState` | Current state (`Activated`, `Disconnected`, etc.) | +| `interface` | `Option` | Network interface when active | +| `active` | `bool` | Whether the connection is currently active | +| `kind` | `VpnKind` | `VpnKind::Plugin` (OpenVPN) or `VpnKind::WireGuard` | + +### Active VPN Connections + +Get only currently active VPN connections: + +```rust +let active = nm.active_vpn_connections().await?; + +for vpn in &active { + println!("Active: {} ({:?})", vpn.name, vpn.vpn_type); } ``` @@ -202,45 +155,39 @@ for vpn in vpns { ```rust let info = nm.get_vpn_info("MyVPN").await?; -println!("VPN State: {:?}", info.state); -if let Some(ip) = info.ip4_address { - println!("VPN IP: {}", ip); -} -if let Some(device) = info.device { - println!("Device: {}", device); +println!("Name: {}", info.name); +println!("Kind: {:?}", info.vpn_kind); +println!("State: {:?}", info.state); +println!("Interface: {:?}", info.interface); +println!("Gateway: {:?}", info.gateway); +println!("IPv4: {:?}", info.ip4_address); +println!("IPv6: {:?}", info.ip6_address); +println!("DNS: {:?}", info.dns_servers); + +if let Some(details) = &info.details { + println!("Details: {:?}", details); } ``` -### Check if VPN is Active +### Remove a VPN Profile ```rust -let vpns = nm.list_vpn_connections().await?; -let active = vpns.iter().any(|v| { - matches!(v.state, nmrs::models::ActiveConnectionState::Activated) -}); - -if active { - println!("VPN is active"); -} else { - println!("VPN is not active"); -} +nm.forget_vpn("MyVPN").await?; ``` ## Routing Configuration -### Route All Traffic - -Send all traffic through the VPN: +### Route All Traffic (WireGuard) ```rust let peer = WireGuardPeer::new( "public_key", "vpn.example.com:51820", - vec!["0.0.0.0/0".into()], // All IPv4 + vec!["0.0.0.0/0".into()], ); ``` -### Split Tunnel +### Split Tunnel (WireGuard) Route only specific networks through VPN: @@ -249,8 +196,8 @@ let peer = WireGuardPeer::new( "public_key", "vpn.example.com:51820", vec![ - "10.0.0.0/8".into(), // Private network - "192.168.0.0/16".into(), // Another private network + "10.0.0.0/8".into(), + "192.168.0.0/16".into(), ], ); ``` @@ -262,90 +209,87 @@ let peer = WireGuardPeer::new( "public_key", "vpn.example.com:51820", vec![ - "0.0.0.0/0".into(), // All IPv4 - "::/0".into(), // All IPv6 + "0.0.0.0/0".into(), + "::/0".into(), ], ); ``` ## Error Handling -Handle VPN-specific errors: - ```rust use nmrs::ConnectionError; -match nm.connect_vpn(creds).await { +match nm.connect_vpn(config).await { Ok(_) => println!("VPN connected"), - + Err(ConnectionError::AuthFailed) => { - eprintln!("VPN authentication failed - check keys"); + eprintln!("Authentication failed — check keys or credentials"); } - + Err(ConnectionError::Timeout) => { - eprintln!("VPN connection timed out - check gateway"); + eprintln!("Connection timed out — check gateway address"); + } + + Err(ConnectionError::VpnFailed) => { + eprintln!("VPN activation failed — check plugin or config"); } - + Err(ConnectionError::NotFound) => { eprintln!("VPN gateway not reachable"); } - - Err(e) => eprintln!("VPN error: {}", e), + + Err(e) => eprintln!("VPN error: {e}"), } ``` ## Complete Example -Here's a complete VPN client: - ```rust -use nmrs::{NetworkManager, VpnCredentials, VpnType, WireGuardPeer}; +use nmrs::{NetworkManager, WireGuardConfig, WireGuardPeer}; #[tokio::main] async fn main() -> nmrs::Result<()> { let nm = NetworkManager::new().await?; - + // Check if already connected - let vpns = nm.list_vpn_connections().await?; - if let Some(active_vpn) = vpns.iter().find(|v| { - matches!(v.state, nmrs::models::ActiveConnectionState::Activated) - }) { - println!("Already connected to: {}", active_vpn.name); + let active = nm.active_vpn_connections().await?; + if let Some(vpn) = active.first() { + println!("Already connected to: {}", vpn.name); return Ok(()); } - + // Create WireGuard configuration let peer = WireGuardPeer::new( std::env::var("WG_PUBLIC_KEY")?, std::env::var("WG_ENDPOINT")?, vec!["0.0.0.0/0".into()], ).with_persistent_keepalive(25); - - let creds = VpnCredentials::new( - VpnType::WireGuard, + + let config = WireGuardConfig::new( "AutoVPN", - std::env::var("WG_ENDPOINT")?, - std::env::var("WG_PRIVATE_KEY")?, - std::env::var("WG_ADDRESS")?, + &std::env::var("WG_ENDPOINT")?, + &std::env::var("WG_PRIVATE_KEY")?, + &std::env::var("WG_ADDRESS")?, vec![peer], ).with_dns(vec!["1.1.1.1".into(), "8.8.8.8".into()]); - + // Connect println!("Connecting to VPN..."); - nm.connect_vpn(creds).await?; - + nm.connect_vpn(config).await?; + // Verify connection let info = nm.get_vpn_info("AutoVPN").await?; println!("Connected! VPN IP: {:?}", info.ip4_address); - + // Keep connection alive println!("Press Ctrl+C to disconnect..."); tokio::signal::ctrl_c().await?; - + // Disconnect nm.disconnect_vpn("AutoVPN").await?; println!("Disconnected from VPN"); - + Ok(()) } ``` @@ -354,38 +298,40 @@ async fn main() -> nmrs::Result<()> { For more advanced VPN usage, see: -- [WireGuard Setup](./vpn-wireguard.md) - Detailed WireGuard guide -- [VPN Management](./vpn-management.md) - Managing VPN profiles -- [Examples](../examples/wireguard-client.md) - Complete VPN client example +- [WireGuard Setup](./vpn-wireguard.md) — Detailed WireGuard configuration +- [OpenVPN Setup](./vpn-openvpn.md) — Detailed OpenVPN configuration +- [VPN Management](./vpn-management.md) — Managing VPN profiles +- [WireGuard Client Example](../examples/wireguard-client.md) — Complete WireGuard example +- [OpenVPN Client Example](../examples/openvpn-client.md) — Complete OpenVPN example +- [`.ovpn` Import Example](../examples/ovpn-import.md) — Import `.ovpn` files ## Security Best Practices -1. **Never hardcode keys** - Use environment variables or secure storage -2. **Rotate keys regularly** - Update WireGuard keys periodically -3. **Use preshared keys** - Add extra layer of security with PSK -4. **Verify endpoints** - Ensure gateway addresses are correct -5. **Monitor connection** - Check VPN status regularly +1. **Never hardcode keys or passwords** — Use environment variables or secure storage +2. **Rotate keys regularly** — Update WireGuard keys periodically +3. **Use preshared keys** — Add extra layer of security with PSK (WireGuard) +4. **Protect certificates** — Store OpenVPN certs with restrictive file permissions (`chmod 600`) +5. **Use TLS authentication** — Prefer `PasswordTls` or `Tls` over `Password` alone for OpenVPN +6. **Verify endpoints** — Ensure gateway addresses are correct +7. **Monitor connection** — Check VPN status regularly ## Troubleshooting ### VPN Won't Connect ```rust -// Check if WireGuard is available -// NetworkManager should handle this automatically - -// Verify your credentials are correct -println!("Gateway: {}", creds.gateway); -println!("Address: {}", creds.address); -// Don't print private keys! +let vpns = nm.list_vpn_connections().await?; +for vpn in &vpns { + println!("{}: {:?} (active: {})", vpn.name, vpn.state, vpn.active); +} ``` -### Connection Drops +### Connection Drops (WireGuard) Use persistent keepalive: ```rust -let peer = peer.with_persistent_keepalive(25); // Send keepalive every 25s +let peer = peer.with_persistent_keepalive(25); ``` ### DNS Not Working @@ -393,14 +339,31 @@ let peer = peer.with_persistent_keepalive(25); // Send keepalive every 25s Explicitly set DNS servers: ```rust -let creds = creds.with_dns(vec![ +let config = config.with_dns(vec![ "1.1.1.1".into(), "8.8.8.8".into(), ]); ``` +### OpenVPN Plugin Not Found + +Ensure the NetworkManager OpenVPN plugin is installed: + +```bash +# Arch Linux +sudo pacman -S networkmanager-openvpn + +# Debian/Ubuntu +sudo apt install network-manager-openvpn + +# Fedora +sudo dnf install NetworkManager-openvpn +``` + ## Next Steps - [WireGuard Setup Guide](./vpn-wireguard.md) +- [OpenVPN Setup Guide](./vpn-openvpn.md) - [VPN Management](./vpn-management.md) -- [Complete VPN Client Example](../examples/wireguard-client.md) +- [OpenVPN Client Example](../examples/openvpn-client.md) +- [WireGuard Client Example](../examples/wireguard-client.md) diff --git a/docs/src/guide/wifi-connecting.md b/docs/src/guide/wifi-connecting.md index 28cf90b3..cc85c90b 100644 --- a/docs/src/guide/wifi-connecting.md +++ b/docs/src/guide/wifi-connecting.md @@ -14,10 +14,10 @@ async fn main() -> nmrs::Result<()> { let nm = NetworkManager::new().await?; // Open network (no password) - nm.connect("CafeWiFi", WifiSecurity::Open).await?; + nm.connect("CafeWiFi", None, WifiSecurity::Open).await?; // WPA-PSK network (password) - nm.connect("HomeWiFi", WifiSecurity::WpaPsk { + nm.connect("HomeWiFi", None, WifiSecurity::WpaPsk { psk: "my_password".into(), }).await?; @@ -83,7 +83,7 @@ if nm.is_connecting().await? { return Ok(()); } -nm.connect("HomeWiFi", WifiSecurity::WpaPsk { +nm.connect("HomeWiFi", None, WifiSecurity::WpaPsk { psk: "password".into(), }).await?; ``` @@ -94,7 +94,7 @@ nm.connect("HomeWiFi", WifiSecurity::WpaPsk { let nm = NetworkManager::new().await?; // Disconnect from the current Wi-Fi network -nm.disconnect().await?; +nm.disconnect(None).await?; ``` `disconnect()` deactivates the current wireless connection and waits for the device to reach the `Disconnected` state. If no connection is active, it returns `Ok(())`. @@ -112,7 +112,7 @@ if nm.has_saved_connection("HomeWiFi").await? { } // Connect using saved profile (WifiSecurity value is ignored if profile exists) -nm.connect("HomeWiFi", WifiSecurity::Open).await?; +nm.connect("HomeWiFi", None, WifiSecurity::Open).await?; ``` See [Connection Profiles](./profiles.md) for more on managing saved connections. @@ -126,7 +126,7 @@ use nmrs::{NetworkManager, WifiSecurity, ConnectionError}; let nm = NetworkManager::new().await?; -match nm.connect("MyNetwork", WifiSecurity::WpaPsk { +match nm.connect("MyNetwork", None, WifiSecurity::WpaPsk { psk: "password".into(), }).await { Ok(_) => println!("Connected!"), diff --git a/docs/src/guide/wifi-enterprise.md b/docs/src/guide/wifi-enterprise.md index 58dd7574..c47f7200 100644 --- a/docs/src/guide/wifi-enterprise.md +++ b/docs/src/guide/wifi-enterprise.md @@ -15,7 +15,7 @@ async fn main() -> nmrs::Result<()> { .with_method(EapMethod::Peap) .with_phase2(Phase2::Mschapv2); - nm.connect("CorpWiFi", WifiSecurity::WpaEap { opts: eap }).await?; + nm.connect("CorpWiFi", None, WifiSecurity::WpaEap { opts: eap }).await?; println!("Connected to enterprise WiFi!"); Ok(()) @@ -165,7 +165,7 @@ async fn main() -> nmrs::Result<()> { .system_ca_certs(true) .build(); - nm.connect("CorpNetwork", WifiSecurity::WpaEap { + nm.connect("CorpNetwork", None, WifiSecurity::WpaEap { opts: eap, }).await?; diff --git a/docs/src/guide/wifi-hidden.md b/docs/src/guide/wifi-hidden.md index 2af2ec87..1daa263b 100644 --- a/docs/src/guide/wifi-hidden.md +++ b/docs/src/guide/wifi-hidden.md @@ -14,10 +14,10 @@ async fn main() -> nmrs::Result<()> { let nm = NetworkManager::new().await?; // Hidden open network - nm.connect("HiddenCafe", WifiSecurity::Open).await?; + nm.connect("HiddenCafe", None, WifiSecurity::Open).await?; // Hidden WPA-PSK network - nm.connect("SecretLab", WifiSecurity::WpaPsk { + nm.connect("SecretLab", None, WifiSecurity::WpaPsk { psk: "lab_password".into(), }).await?; @@ -49,7 +49,7 @@ let eap = EapOptions::new("user@company.com", "password") .with_phase2(Phase2::Mschapv2) .with_system_ca_certs(true); -nm.connect("HiddenCorpNet", WifiSecurity::WpaEap { +nm.connect("HiddenCorpNet", None, WifiSecurity::WpaEap { opts: eap, }).await?; ``` diff --git a/docs/src/guide/wifi-per-device.md b/docs/src/guide/wifi-per-device.md new file mode 100644 index 00000000..b76316de --- /dev/null +++ b/docs/src/guide/wifi-per-device.md @@ -0,0 +1,220 @@ +# Per-Device Wi-Fi Scoping + +Many machines have more than one Wi-Fi radio — a built-in card plus a USB dongle, a laptop in a dock with a secondary adapter, or an IoT gateway with dual radios on different bands. By default, nmrs routes every Wi-Fi operation through whichever device NetworkManager returns first. That works on single-radio systems, but on multi-radio setups you need to control *which* adapter scans, connects, or gets disabled. + +nmrs 3.0 introduces per-device scoping so you can target a specific interface by name. + +## Listing Wi-Fi Devices + +Start by discovering the available radios: + +```rust +use nmrs::NetworkManager; + +#[tokio::main] +async fn main() -> nmrs::Result<()> { + let nm = NetworkManager::new().await?; + + let devices = nm.list_wifi_devices().await?; + for dev in &devices { + println!("{} ({})", dev.interface, dev.mac); + println!(" State: {:?}", dev.state); + if let Some(ssid) = &dev.active_ssid { + println!(" Connected to: {}", ssid); + } + } + + Ok(()) +} +``` + +Each `WifiDevice` contains: + +| Field | Type | Description | +|-------|------|-------------| +| `interface` | `String` | Interface name (`wlan0`, `wlp2s0`, …) | +| `mac` | `String` | Hardware MAC address | +| `state` | `DeviceState` | Current operational state | +| `active_ssid` | `Option` | SSID of the active connection, if any | + +You can also look up a single device directly: + +```rust +let dev = nm.wifi_device_by_interface("wlan1").await?; +println!("{} is {:?}", dev.interface, dev.state); +``` + +## The WifiScope Pattern + +The most ergonomic way to work with a specific radio is `WifiScope`. Call `nm.wifi("wlan1")` to get a scope pinned to that interface, then chain operations without repeating the interface name: + +```rust +use nmrs::{NetworkManager, WifiSecurity}; + +#[tokio::main] +async fn main() -> nmrs::Result<()> { + let nm = NetworkManager::new().await?; + + let scope = nm.wifi("wlan1"); + + scope.scan().await?; + let networks = scope.list_networks().await?; + for net in &networks { + println!("{} ({}%)", net.ssid, net.strength.unwrap_or(0)); + } + + scope.connect("HomeWiFi", WifiSecurity::WpaPsk { + psk: "hunter2".into(), + }).await?; + + Ok(()) +} +``` + +`WifiScope` delegates to `NetworkManager` under the hood but locks every call to a single interface. The available methods are: + +| Method | Description | +|--------|-------------| +| `scope.interface()` | Returns the interface name this scope is pinned to | +| `scope.scan()` | Trigger a scan on this device | +| `scope.list_networks()` | List networks visible to this device | +| `scope.list_access_points()` | List raw access points (including duplicates per BSSID) | +| `scope.connect(ssid, creds)` | Connect through this device | +| `scope.connect_to_bssid(ssid, bssid, creds)` | Connect to a specific BSSID through this device | +| `scope.disconnect()` | Disconnect this device | +| `scope.set_enabled(bool)` | Enable or disable this device | +| `scope.forget(ssid)` | Remove a saved connection from this device | + +Because the interface is already captured, none of these methods take an interface parameter. + +### BSSID targeting + +When the same SSID is broadcast by multiple access points, use `connect_to_bssid` to force a specific one: + +```rust +let scope = nm.wifi("wlan0"); + +let aps = scope.list_access_points().await?; +if let Some(best) = aps.iter().max_by_key(|ap| ap.strength.unwrap_or(0)) { + scope.connect_to_bssid( + &best.ssid, + &best.hwaddress.as_deref().unwrap_or_default(), + WifiSecurity::WpaPsk { psk: "password".into() }, + ).await?; +} +``` + +## Per-Interface vs Global Operations + +nmrs distinguishes between operations that target one device and operations that affect the entire Wi-Fi subsystem. + +| Operation | Per-device | Global | +|-----------|-----------|--------| +| Enable/disable radio | `nm.set_wifi_enabled("wlan1", true)` | `nm.set_wireless_enabled(false)` | +| Scan | `nm.scan_networks(Some("wlan1"))` | `nm.scan_networks(None)` (scans all) | +| List networks | `nm.list_networks(Some("wlan1"))` | `nm.list_networks(None)` (merges all) | +| Connect | `nm.connect("ssid", Some("wlan1"), creds)` | `nm.connect("ssid", None, creds)` | +| Disconnect | `nm.disconnect(Some("wlan1"))` | `nm.disconnect(None)` (all devices) | + +When you pass `None`, nmrs falls back to the original behavior: pick the first Wi-Fi device for single-device operations, or aggregate across all devices for scans and listings. + +## Per-Device Enable/Disable + +There are two distinct toggles: + +- **`set_wireless_enabled(bool)`** flips NetworkManager's global `WirelessEnabled` property. This affects *every* Wi-Fi radio on the system — equivalent to airplane-mode for Wi-Fi. +- **`set_wifi_enabled(interface, bool)`** targets a single radio. It sets `Autoconnect = false` and disconnects the device (to disable) or re-enables autoconnect (to enable). The rest of the system's Wi-Fi radios are unaffected. + +```rust +let nm = NetworkManager::new().await?; + +// Disable only the USB dongle +nm.set_wifi_enabled("wlan1", false).await?; + +// The built-in radio stays online +let dev = nm.wifi_device_by_interface("wlan0").await?; +assert_ne!(dev.state, nmrs::DeviceState::Unavailable); +``` + +Using `WifiScope`: + +```rust +let scope = nm.wifi("wlan1"); +scope.set_enabled(false).await?; +``` + +> **Note:** `set_wifi_enabled` is *not* the same as the global `set_wireless_enabled`. The global toggle controls the NM-level `WirelessEnabled` property (equivalent to `nmcli radio wifi off`), while per-device disable works through the device's autoconnect and disconnect mechanism. + +## Direct Method Approach + +If you don't want a `WifiScope`, every Wi-Fi method on `NetworkManager` accepts an optional interface name. Pass `None` for single-radio behavior or `Some("wlan1")` to target a device: + +```rust +use nmrs::{NetworkManager, WifiSecurity}; + +#[tokio::main] +async fn main() -> nmrs::Result<()> { + let nm = NetworkManager::new().await?; + + // Scan on a specific interface + nm.scan_networks(Some("wlan1")).await?; + + // List networks from a specific interface + let networks = nm.list_networks(Some("wlan1")).await?; + + // Connect through a specific interface + nm.connect("OfficeWiFi", Some("wlan1"), WifiSecurity::WpaPsk { + psk: "secret".into(), + }).await?; + + // Disconnect a specific interface + nm.disconnect(Some("wlan1")).await?; + + // Or use None to get the default (first device) behavior + nm.scan_networks(None).await?; + nm.connect("HomeWiFi", None, WifiSecurity::Open).await?; + + Ok(()) +} +``` + +## Error Handling + +Two error variants are specific to per-device scoping: + +```rust +use nmrs::ConnectionError; + +let nm = NetworkManager::new().await?; + +match nm.wifi_device_by_interface("wlan99").await { + Ok(dev) => println!("Found: {}", dev.interface), + Err(ConnectionError::WifiInterfaceNotFound { interface }) => { + eprintln!("No Wi-Fi device named '{}'", interface); + } + Err(e) => eprintln!("Unexpected error: {}", e), +} +``` + +| Variant | Meaning | +|---------|---------| +| `WifiInterfaceNotFound { interface }` | No network device with that name exists | +| `NotAWifiDevice { interface }` | The interface exists but is not a Wi-Fi device (e.g., `eth0`) | + +`NotAWifiDevice` fires when you pass a valid interface name that belongs to an Ethernet or Bluetooth adapter: + +```rust +match nm.wifi_device_by_interface("eth0").await { + Err(ConnectionError::NotAWifiDevice { interface }) => { + eprintln!("'{}' is not a Wi-Fi interface", interface); + } + _ => {} +} +``` + +## Next Steps + +- [WiFi Management](./wifi.md) — general Wi-Fi operations (scanning, security types, connection options) +- [Device Management](./devices.md) — listing and inspecting all device types +- [Real-Time Monitoring](./monitoring.md) — subscribe to device state changes +- [Error Handling](./error-handling.md) — full error variant reference diff --git a/docs/src/guide/wifi-scanning.md b/docs/src/guide/wifi-scanning.md index 1fc1cddb..f67d1a07 100644 --- a/docs/src/guide/wifi-scanning.md +++ b/docs/src/guide/wifi-scanning.md @@ -14,7 +14,7 @@ async fn main() -> nmrs::Result<()> { let nm = NetworkManager::new().await?; // Trigger an active scan on all wireless devices - nm.scan_networks().await?; + nm.scan_networks(None).await?; Ok(()) } @@ -33,7 +33,7 @@ use nmrs::NetworkManager; async fn main() -> nmrs::Result<()> { let nm = NetworkManager::new().await?; - let networks = nm.list_networks().await?; + let networks = nm.list_networks(None).await?; for net in &networks { println!("{:30} {}%", net.ssid, net.strength.unwrap_or(0)); } @@ -70,7 +70,7 @@ use nmrs::NetworkManager; async fn main() -> nmrs::Result<()> { let nm = NetworkManager::new().await?; - let networks = nm.list_networks().await?; + let networks = nm.list_networks(None).await?; if let Some(network) = networks.first() { let info = nm.show_details(network).await?; @@ -108,8 +108,8 @@ use nmrs::NetworkManager; async fn main() -> nmrs::Result<()> { let nm = NetworkManager::new().await?; - nm.scan_networks().await?; - let networks = nm.list_networks().await?; + nm.scan_networks(None).await?; + let networks = nm.list_networks(None).await?; for net in &networks { let security = if net.is_eap { diff --git a/docs/src/guide/wifi-wpa-psk.md b/docs/src/guide/wifi-wpa-psk.md index 2d37062d..829ff1dc 100644 --- a/docs/src/guide/wifi-wpa-psk.md +++ b/docs/src/guide/wifi-wpa-psk.md @@ -11,7 +11,7 @@ use nmrs::{NetworkManager, WifiSecurity}; async fn main() -> nmrs::Result<()> { let nm = NetworkManager::new().await?; - nm.connect("HomeWiFi", WifiSecurity::WpaPsk { + nm.connect("HomeWiFi", None, WifiSecurity::WpaPsk { psk: "my_secure_password".into(), }).await?; @@ -42,7 +42,7 @@ async fn main() -> nmrs::Result<()> { let password = std::env::var("WIFI_PASSWORD") .expect("Set WIFI_PASSWORD environment variable"); - nm.connect("HomeWiFi", WifiSecurity::WpaPsk { + nm.connect("HomeWiFi", None, WifiSecurity::WpaPsk { psk: password, }).await?; @@ -60,7 +60,7 @@ let nm = NetworkManager::new().await?; if nm.has_saved_connection("HomeWiFi").await? { // Saved profile exists; password is stored in it. // The WifiSecurity value is ignored when a saved profile exists. - nm.connect("HomeWiFi", WifiSecurity::Open).await?; + nm.connect("HomeWiFi", None, WifiSecurity::Open).await?; } ``` @@ -81,7 +81,7 @@ use nmrs::{NetworkManager, WifiSecurity, ConnectionError}; let nm = NetworkManager::new().await?; -match nm.connect("HomeWiFi", WifiSecurity::WpaPsk { +match nm.connect("HomeWiFi", None, WifiSecurity::WpaPsk { psk: "password".into(), }).await { Ok(_) => println!("Connected!"), diff --git a/docs/src/guide/wifi.md b/docs/src/guide/wifi.md index cb1a7b77..8593d66e 100644 --- a/docs/src/guide/wifi.md +++ b/docs/src/guide/wifi.md @@ -23,10 +23,10 @@ async fn main() -> nmrs::Result<()> { let nm = NetworkManager::new().await?; // Scan for networks - let networks = nm.list_networks().await?; + let networks = nm.list_networks(None).await?; // Connect to WPA-PSK network - nm.connect("MyWiFi", WifiSecurity::WpaPsk { + nm.connect("MyWiFi", None, WifiSecurity::WpaPsk { psk: "password".into() }).await?; @@ -36,7 +36,7 @@ async fn main() -> nmrs::Result<()> { } // Disconnect - nm.disconnect().await?; + nm.disconnect(None).await?; Ok(()) } @@ -51,7 +51,7 @@ nmrs supports all major WiFi security protocols: No authentication required: ```rust -nm.connect("FreeWiFi", WifiSecurity::Open).await?; +nm.connect("FreeWiFi", None, WifiSecurity::Open).await?; ``` ### WPA-PSK (Personal) @@ -59,7 +59,7 @@ nm.connect("FreeWiFi", WifiSecurity::Open).await?; Password-based authentication: ```rust -nm.connect("HomeWiFi", WifiSecurity::WpaPsk { +nm.connect("HomeWiFi", None, WifiSecurity::WpaPsk { psk: "your_password".into() }).await?; ``` @@ -76,7 +76,7 @@ let eap_opts = EapOptions::new("user@company.com", "password") .with_phase2(Phase2::Mschapv2) .with_domain_suffix_match("company.com"); -nm.connect("CorpWiFi", WifiSecurity::WpaEap { +nm.connect("CorpWiFi", None, WifiSecurity::WpaEap { opts: eap_opts }).await?; ``` @@ -98,7 +98,7 @@ pub struct Network { Example usage: ```rust -let networks = nm.list_networks().await?; +let networks = nm.list_networks(None).await?; for net in networks { println!("SSID: {}", net.ssid); @@ -144,14 +144,15 @@ Enable or disable WiFi hardware: ```rust // Disable WiFi (airplane mode) -nm.set_wifi_enabled(false).await?; +nm.set_wireless_enabled(false).await?; // Enable WiFi -nm.set_wifi_enabled(true).await?; +nm.set_wireless_enabled(true).await?; // Check WiFi status -let enabled = nm.is_wifi_enabled().await?; -println!("WiFi is {}", if enabled { "enabled" } else { "disabled" }); +let state = nm.wifi_state().await?; +println!("WiFi is {}", if state.enabled { "enabled" } else { "disabled" }); +println!("Hardware switch is {}", if state.hardware_enabled { "on" } else { "off" }); ``` ## Network Scanning @@ -160,13 +161,13 @@ Trigger a fresh scan: ```rust // Request a scan (may take a few seconds) -nm.request_scan().await?; +nm.scan_networks(None).await?; // Wait a moment for scan to complete tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; // Get updated results -let networks = nm.list_networks().await?; +let networks = nm.list_networks(None).await?; ``` ## Detecting Connection State @@ -182,11 +183,9 @@ if let Some(ssid) = nm.current_ssid().await { } // Get detailed network info -if let Some(info) = nm.current_network_info().await? { - println!("SSID: {}", info.ssid); - println!("IP: {:?}", info.ip4_address); - println!("Gateway: {:?}", info.gateway); - println!("DNS: {:?}", info.dns); +if let Some(network) = nm.current_network().await? { + println!("SSID: {}", network.ssid); + println!("Signal: {}%", network.strength.unwrap_or(0)); } ``` @@ -197,7 +196,7 @@ WiFi operations can fail for various reasons. Handle them gracefully: ```rust use nmrs::ConnectionError; -match nm.connect("Network", WifiSecurity::WpaPsk { +match nm.connect("Network", None, WifiSecurity::WpaPsk { psk: "pass".into() }).await { Ok(_) => println!("Connected!"), @@ -255,6 +254,7 @@ nm.monitor_device_changes(|| { - [WPA-EAP (Enterprise)](./wifi-enterprise.md) - Enterprise WiFi - [Hidden Networks](./wifi-hidden.md) - Connecting to hidden SSIDs - [Error Handling](./error-handling.md) - Comprehensive error guide +- [Per-Device Scoping](./wifi-per-device.md) - Multi-radio, per-interface operations ## Best Practices @@ -263,14 +263,14 @@ nm.monitor_device_changes(|| { ```rust // Good - reuse the same instance let nm = NetworkManager::new().await?; -nm.list_networks().await?; -nm.connect("WiFi", WifiSecurity::Open).await?; +nm.list_networks(None).await?; +nm.connect("WiFi", None, WifiSecurity::Open).await?; // Avoid - creating multiple instances let nm1 = NetworkManager::new().await?; -nm1.list_networks().await?; +nm1.list_networks(None).await?; let nm2 = NetworkManager::new().await?; // Unnecessary -nm2.connect("WiFi", WifiSecurity::Open).await?; +nm2.connect("WiFi", None, WifiSecurity::Open).await?; ``` ### 2. Handle Signal Strength @@ -290,7 +290,7 @@ if let Some(strength) = network.strength { use tokio::time::{timeout, Duration}; // Wrap operations in timeouts -match timeout(Duration::from_secs(30), nm.connect("WiFi", security)).await { +match timeout(Duration::from_secs(30), nm.connect("WiFi", None, security)).await { Ok(Ok(_)) => println!("Connected"), Ok(Err(e)) => eprintln!("Connection failed: {}", e), Err(_) => eprintln!("Operation timed out"), diff --git a/docs/src/introduction.md b/docs/src/introduction.md index a35e43b3..500393b6 100644 --- a/docs/src/introduction.md +++ b/docs/src/introduction.md @@ -7,7 +7,7 @@ Welcome to the **nmrs** documentation! This guide will help you understand and u **nmrs** is a high-level, async Rust API for [NetworkManager](https://networkmanager.dev/) over [D-Bus](https://dbus.freedesktop.org/doc/dbus-specification.html). It provides: - **Simple WiFi Management** - Scan, connect, and manage wireless networks -- **VPN Support** - Full WireGuard VPN integration +- **VPN Support** - WireGuard and OpenVPN VPN support - **Ethernet Control** - Manage wired network connections - **Bluetooth** - Connect to Bluetooth network devices - **Real-Time Monitoring** - Event-driven network state updates @@ -52,13 +52,13 @@ async fn main() -> nmrs::Result<()> { let nm = NetworkManager::new().await?; // Scan for networks - let networks = nm.list_networks().await?; + let networks = nm.list_networks(None).await?; for net in networks { println!("{} - {}%", net.ssid, net.strength.unwrap_or(0)); } // Connect to a network - nm.connect("MyWiFi", WifiSecurity::WpaPsk { + nm.connect("MyWiFi", None, WifiSecurity::WpaPsk { psk: "password123".into() }).await?; diff --git a/nmrs-gui/src/ui/connect.rs b/nmrs-gui/src/ui/connect.rs index b932771a..bf365312 100644 --- a/nmrs-gui/src/ui/connect.rs +++ b/nmrs-gui/src/ui/connect.rs @@ -205,7 +205,7 @@ fn draw_connect_modal( }; debug!("Calling nm.connect() for '{ssid}'"); - match nm.connect(&ssid, creds).await { + match nm.connect(&ssid, None, creds).await { Ok(_) => { debug!("nm.connect() succeeded!"); status.set_text("✓ Connected!"); diff --git a/nmrs-gui/src/ui/header.rs b/nmrs-gui/src/ui/header.rs index a5144f9f..e830779e 100644 --- a/nmrs-gui/src/ui/header.rs +++ b/nmrs-gui/src/ui/header.rs @@ -180,7 +180,7 @@ pub fn build_header( ctx.stack.set_visible_child_name("loading"); clear_children(&list_container); - match ctx.nm.wifi_enabled().await { + match ctx.nm.wifi_state().await.map(|s| s.enabled) { Ok(enabled) => { wifi_switch.set_active(enabled); if enabled { @@ -207,7 +207,7 @@ pub fn build_header( glib::MainContext::default().spawn_local(async move { clear_children(&list_container); - if let Err(err) = ctx.nm.set_wifi_enabled(sw.is_active()).await { + if let Err(err) = ctx.nm.set_wireless_enabled(sw.is_active()).await { ctx.status.set_text(&format!("Error setting Wi-Fi: {err}")); return; } @@ -310,7 +310,7 @@ pub async fn refresh_networks( wireless_header.set_margin_start(12); list_container.append(&wireless_header); - if let Err(err) = ctx.nm.scan_networks().await { + if let Err(err) = ctx.nm.scan_networks(None).await { ctx.status.set_text(&format!("Scan failed: {err}")); is_scanning.set(false); return; @@ -318,7 +318,7 @@ pub async fn refresh_networks( let mut last_len = 0; for _ in 0..5 { - let nets = ctx.nm.list_networks().await.unwrap_or_default(); + let nets = ctx.nm.list_networks(None).await.unwrap_or_default(); if nets.len() == last_len && last_len > 0 { break; } @@ -326,7 +326,7 @@ pub async fn refresh_networks( glib::timeout_future_seconds(1).await; } - match ctx.nm.list_networks().await { + match ctx.nm.list_networks(None).await { Ok(mut nets) => { let current_conn = ctx.nm.current_connection_info().await; let (current_ssid, current_band) = if let Some((ssid, freq)) = current_conn { @@ -460,7 +460,7 @@ pub async fn refresh_networks_no_scan( wireless_header.set_margin_start(12); list_container.append(&wireless_header); - match ctx.nm.list_networks().await { + match ctx.nm.list_networks(None).await { Ok(mut nets) => { let current_conn = ctx.nm.current_connection_info().await; let (current_ssid, current_band) = if let Some((ssid, freq)) = current_conn { diff --git a/nmrs-gui/src/ui/networks.rs b/nmrs-gui/src/ui/networks.rs index 8de7f5a3..072e3f2a 100644 --- a/nmrs-gui/src/ui/networks.rs +++ b/nmrs-gui/src/ui/networks.rs @@ -132,7 +132,7 @@ impl NetworkRowController { status_c.set_text(&format!("Connecting to {}...", ssid_c)); window_c.set_sensitive(false); let creds = WifiSecurity::WpaPsk { psk: "".into() }; - match nm_c.connect(&ssid_c, creds).await { + match nm_c.connect(&ssid_c, None, creds).await { Ok(_) => { status_c.set_text(""); on_success_c(); @@ -153,7 +153,7 @@ impl NetworkRowController { status_c.set_text(&format!("Connecting to {}...", ssid_c)); window_c.set_sensitive(false); let creds = WifiSecurity::Open; - match nm_c.connect(&ssid_c, creds).await { + match nm_c.connect(&ssid_c, None, creds).await { Ok(_) => { status_c.set_text(""); on_success_c(); diff --git a/nmrs/CHANGELOG.md b/nmrs/CHANGELOG.md index 630e0169..5f5d2cad 100644 --- a/nmrs/CHANGELOG.md +++ b/nmrs/CHANGELOG.md @@ -3,21 +3,40 @@ All notable changes to the `nmrs` crate will be documented in this file. ## [Unreleased] + +## [3.0.0] - 2026-04-24 ### Added -- `nmrs::agent` module: NetworkManager secret agent for credential prompting over D-Bus (`SecretAgent`, `SecretAgentBuilder`, `SecretAgentHandle`, `SecretRequest`, `SecretResponder`, `SecretSetting`, `SecretAgentFlags`, `SecretAgentCapabilities`, `CancelReason`, `SecretStoreEvent`) -- `VpnConfig` trait and `WireGuardConfig`; `NetworkManager::connect_vpn` accepts `VpnConfig` implementors; `VpnCredentials` deprecated with compatibility bridges ([#303](https://github.com/cachebag/nmrs/pull/303)) +- `ConnectionError::IncompleteBuilder` for builders missing required fields ([#350](https://github.com/cachebag/nmrs/issues/350)) +- `nmrs::agent` module: NetworkManager secret agent for credential prompting over D-Bus (`SecretAgent`, `SecretAgentBuilder`, `SecretAgentHandle`, `SecretRequest`, `SecretResponder`, `SecretSetting`, `SecretAgentFlags`, `SecretAgentCapabilities`, `CancelReason`, `SecretStoreEvent`) ([#370](https://github.com/cachebag/nmrs/pull/370)) +- `AccessPoint` model preserving per-AP BSSID, frequency, security flags, and device state; `list_access_points(interface)` for full AP enumeration ([#373](https://github.com/cachebag/nmrs/pull/373)) +- Airplane-mode surface: `RadioState`, `AirplaneModeState`, `wifi_state()`, `wwan_state()`, `bluetooth_radio_state()`, `airplane_mode_state()`, `set_wireless_enabled()`, `set_wwan_enabled()`, `set_bluetooth_radio_enabled()`, `set_airplane_mode()` ([#372](https://github.com/cachebag/nmrs/pull/372)) +- Kernel rfkill awareness: hardware kill switch state via `/sys/class/rfkill` ([#372](https://github.com/cachebag/nmrs/pull/372)) +- `HardwareRadioKilled` and `BluezUnavailable` error variants ([#372](https://github.com/cachebag/nmrs/pull/372)) +- Per-Wi-Fi-device scoping: `WifiDevice` model, `list_wifi_devices()`, `wifi_device_by_interface()`, `WifiScope` builder via `nm.wifi("wlan1")`, `set_wifi_enabled(interface, bool)` for per-radio enable/disable ([#375](https://github.com/cachebag/nmrs/pull/375)) +- `WifiInterfaceNotFound` and `NotAWifiDevice` error variants ([#375](https://github.com/cachebag/nmrs/pull/375)) +- Saved profile enumeration: `SavedConnection`, `SavedConnectionBrief`, `SettingsSummary`, `SettingsPatch`, `WifiSecuritySummary`, `WifiKeyMgmt`, `VpnSecretFlags`; `list_saved_connections()`, `list_saved_connections_brief()`, `list_saved_connection_ids()`, `get_saved_connection()`, `get_saved_connection_raw()`, `delete_saved_connection()`, `update_saved_connection()`, `reload_saved_connections()`; D-Bus proxies `NMSettingsProxy` / `NMSettingsConnectionProxy`; example `saved_list` ([#376](https://github.com/cachebag/nmrs/pull/376)) +- Connectivity state surface: `ConnectivityState`, `ConnectivityReport`, `connectivity()`, `check_connectivity()`, `connectivity_report()`, `captive_portal_url()`; `ConnectivityCheckDisabled` error variant ([#377](https://github.com/cachebag/nmrs/pull/377)) +- Generic VPN support: `VpnType` now carries protocol-specific metadata for OpenVPN, OpenConnect, strongSwan, PPTP, L2TP, and a `Generic` catch-all; `VpnKind` (Plugin vs WireGuard); `VpnConnection` enriched with `uuid`, `active`, `user_name`, `password_flags`, `service_type`; `connect_vpn_by_uuid()`, `connect_vpn_by_id()`, `disconnect_vpn_by_uuid()`, `active_vpn_connections()` ([#378](https://github.com/cachebag/nmrs/pull/378)) ### Changed +- `VpnCredentialsBuilder::build()` and `EapOptionsBuilder::build()` return `Result` (no panics on missing fields); `VpnCredentialsBuilder` may return `ConnectionError::InvalidPeers` when no peers are set ([#350](https://github.com/cachebag/nmrs/issues/350)) +- `VpnType` is now a data-carrying enum; the old tag enum is renamed to `VpnKind`. `VpnConfig::vpn_type()` renamed to `vpn_kind()`. `VpnConnectionInfo.vpn_type` renamed to `vpn_kind`. ([#378](https://github.com/cachebag/nmrs/pull/378)) +- `list_saved_connections()` now returns `Vec` (full decode + summaries). Use `list_saved_connection_ids()` for the previous `Vec` behavior (connection `id` names only). ([#376](https://github.com/cachebag/nmrs/pull/376)) +- `connect`, `connect_to_bssid`, `disconnect`, `scan_networks`, and `list_networks` now take an `interface: Option<&str>` parameter. Pass `None` to preserve previous behavior, or `Some("wlan1")` to scope to a specific Wi-Fi interface. For an ergonomic per-interface API, use `nm.wifi("wlan1")` to obtain a `WifiScope`. ([#375](https://github.com/cachebag/nmrs/pull/375)) +- `set_wifi_enabled` now requires an `interface: &str` argument and toggles only that radio (via `Device.Autoconnect` + `Device.Disconnect()`). For the global wireless killswitch use `set_wireless_enabled(bool)`. ([#375](https://github.com/cachebag/nmrs/pull/375)) +- `VpnConfig` trait and `WireGuardConfig`; `NetworkManager::connect_vpn` accepts `VpnConfig` implementors; `VpnCredentials` deprecated with compatibility bridges ([#303](https://github.com/cachebag/nmrs/pull/303)) - Introduce `VpnConfig` trait and refactor `connect_vpn` signature ([#303](https://github.com/cachebag/nmrs/pull/303)) - OpenVPN connection settings model expansion ([#309](https://github.com/cachebag/nmrs/pull/309)) - Multi-VPN plumbing: `detect_vpn_type()`, `VpnType::OpenVpn`, and shared detection across connect, disconnect, and list VPN flows ([#311](https://github.com/cachebag/nmrs/pull/311)) -- `.ovpn` profile lexer and parser for translating OpenVPN configs toward NetworkManager ([#314](https://github.com/cachebag/nmrs/pull/314)) +- `.ovpn` profile lexer/parser and auth-user-pass inference for translating OpenVPN configs toward NetworkManager ([#314](https://github.com/cachebag/nmrs/pull/314), [#340](https://github.com/cachebag/nmrs/pull/340)) - Unit tests and parser refactors for `.ovpn` parsing ([#316](https://github.com/cachebag/nmrs/pull/316)) -- OpenVPN builder: compression, proxy, and `build_openvpn_connection()` ([#315](https://github.com/cachebag/nmrs/pull/315)) +- OpenVPN builder, validation, compression, proxy, routing, resilience, TLS hardening, import, cert-store, and `VpnDetails` support ([#315](https://github.com/cachebag/nmrs/pull/315), [#323](https://github.com/cachebag/nmrs/pull/323), [#326](https://github.com/cachebag/nmrs/pull/326), [#345](https://github.com/cachebag/nmrs/pull/345), [#346](https://github.com/cachebag/nmrs/pull/346), [#347](https://github.com/cachebag/nmrs/pull/347), [#348](https://github.com/cachebag/nmrs/pull/348), [#349](https://github.com/cachebag/nmrs/pull/349)) - `VpnConfiguration` to dispatch WireGuard vs OpenVPN; `connect_vpn` wired to the OpenVPN builder ([#322](https://github.com/cachebag/nmrs/pull/322)) - Support for specifying Bluetooth adapter in `BluetoothIdentity` ([#267](https://github.com/cachebag/nmrs/pull/267)) ### Fixed +- Wi-Fi `ensure_disconnected` no longer deactivates every active connection (VPN, wired, other radios); only the target Wi-Fi device is torn down. VPN disconnect, Wi-Fi/Bluetooth `Device::Disconnect` D-Bus failures propagate instead of being swallowed ([#351](https://github.com/cachebag/nmrs/issues/351)) +- OpenVPN settings decoding now uses D-Bus `Dict` values for `vpn.data` / `vpn.secrets` and extracts gateways from `vpn.data` correctly ([#337](https://github.com/cachebag/nmrs/pull/337), [#344](https://github.com/cachebag/nmrs/pull/344)) - Line-accurate source locations for `.ovpn` directives and blocks ([#318](https://github.com/cachebag/nmrs/pull/318)) - `key_direction` when nested under `tls_auth` and as a standalone directive ([#320](https://github.com/cachebag/nmrs/pull/320)) @@ -212,7 +231,8 @@ All notable changes to the `nmrs` crate will be documented in this file. [2.2.0]: https://github.com/cachebag/nmrs/compare/nmrs-v1.2.0...nmrs-v2.2.0 [2.3.0]: https://github.com/cachebag/nmrs/compare/nmrs-v2.2.0...nmrs-v2.3.0 [2.4.0]: https://github.com/cachebag/nmrs/compare/nmrs-v1.2.0...nmrs-v2.4.0 -[Unreleased]: https://github.com/cachebag/nmrs/compare/nmrs-v2.4.0...HEAD +[3.0.0]: https://github.com/cachebag/nmrs/compare/nmrs-v1.2.0...nmrs-v3.0.0 +[Unreleased]: https://github.com/cachebag/nmrs/compare/nmrs-v3.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 @@ -221,4 +241,4 @@ All notable changes to the `nmrs` crate will be documented in this file. [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 +[0.1.0-beta]: https://github.com/cachebag/nmrs/releases/tag/v0.1.0-beta \ No newline at end of file diff --git a/nmrs/Cargo.toml b/nmrs/Cargo.toml index aca499d4..39dabfc1 100644 --- a/nmrs/Cargo.toml +++ b/nmrs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nmrs" -version = "2.4.0" +version = "3.0.0" authors = ["Akrm Al-Hakimi "] edition.workspace = true rust-version = "1.94.0" @@ -47,3 +47,27 @@ path = "examples/vpn_connect.rs" [[example]] name = "secret_agent" path = "examples/secret_agent.rs" + +[[example]] +name = "airplane_mode" +path = "examples/airplane_mode.rs" + +[[example]] +name = "ap_list" +path = "examples/ap_list.rs" + +[[example]] +name = "multi_wifi" +path = "examples/multi_wifi.rs" + +[[example]] +name = "saved_list" +path = "examples/saved_list.rs" + +[[example]] +name = "connectivity" +path = "examples/connectivity.rs" + +[[example]] +name = "vpn_list" +path = "examples/vpn_list.rs" diff --git a/nmrs/README.md b/nmrs/README.md index 9e4da015..52e2761f 100644 --- a/nmrs/README.md +++ b/nmrs/README.md @@ -13,12 +13,15 @@ Rust bindings for NetworkManager via D-Bus. ## Features - **WiFi Management**: Connect to WPA-PSK, WPA-EAP, and open networks -- **VPN Support**: WireGuard VPN connections with full configuration +- **VPN Support**: WireGuard, OpenVPN, OpenConnect, strongSwan, PPTP, L2TP, and generic plugin VPNs with rich enumeration and UUID-based activation - **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 +- **Network Discovery**: Scan and list available access points with per-BSSID detail and security capabilities +- **Per-Interface Scoping**: Target specific Wi-Fi radios on multi-NIC systems via `nm.wifi("wlan1")` or `Option<&str>` interface arguments +- **Profile Management**: List saved profiles with decoded summaries (`list_saved_connections`), raw settings, UUID-based delete/update, plus create/query/delete helpers - **Real-Time Monitoring**: Signal-based network and device state change notifications - **Secret Agent**: Respond to NetworkManager credential prompts via an async stream API +- **Airplane Mode**: Toggle Wi-Fi, WWAN, and Bluetooth radios with rfkill hardware awareness +- **Connectivity**: Query NM's connectivity state, force re-checks, and detect captive-portal URLs - **Typed Errors**: Structured error types with specific failure reasons - **Fully Async**: Built on `zbus` with async/await throughout @@ -26,7 +29,7 @@ Rust bindings for NetworkManager via D-Bus. ```toml [dependencies] -nmrs = "2.0.0" +nmrs = "3.0.0" ``` or ```bash @@ -46,16 +49,21 @@ use nmrs::{NetworkManager, WifiSecurity}; async fn main() -> nmrs::Result<()> { let nm = NetworkManager::new().await?; - // List networks - let networks = nm.list_networks().await?; + // List networks (None = all Wi-Fi devices, or pass Some("wlan1") to scope) + let networks = nm.list_networks(None).await?; for net in &networks { println!("{} - Signal: {}%", net.ssid, net.strength.unwrap_or(0)); } - - // Connect to WPA-PSK network - nm.connect("MyNetwork", WifiSecurity::WpaPsk { + + // Connect to a WPA-PSK network on the first Wi-Fi device + nm.connect("MyNetwork", None, WifiSecurity::WpaPsk { psk: "password".into() }).await?; + + // Or scope every operation to a specific radio + let wlan1 = nm.wifi("wlan1"); + wlan1.scan().await?; + wlan1.connect("Guest", WifiSecurity::Open).await?; // Check current connection if let Some(ssid) = nm.current_ssid().await { @@ -69,32 +77,28 @@ async fn main() -> nmrs::Result<()> { ### WireGuard VPN ```rust -use nmrs::{NetworkManager, VpnCredentials, VpnType, WireGuardPeer}; +use nmrs::{NetworkManager, WireGuardConfig, 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, - }; + let peer = WireGuardPeer::new( + "server_public_key", + "vpn.example.com:51820", + vec!["0.0.0.0/0".into()], + ).with_persistent_keepalive(25); + + let config = WireGuardConfig::new( + "WorkVPN", + "vpn.example.com:51820", + "your_private_key_here", + "10.0.0.2/24", + vec![peer], + ).with_dns(vec!["1.1.1.1".into()]); // Connect to VPN - nm.connect_vpn(creds).await?; + nm.connect_vpn(config).await?; // Get connection details let info = nm.get_vpn_info("WorkVPN").await?; @@ -116,7 +120,7 @@ use nmrs::{NetworkManager, WifiSecurity, EapOptions, EapMethod, Phase2}; async fn main() -> nmrs::Result<()> { let nm = NetworkManager::new().await?; - nm.connect("CorpNetwork", WifiSecurity::WpaEap { + nm.connect("CorpNetwork", None, WifiSecurity::WpaEap { opts: EapOptions { identity: "user@company.com".into(), password: "password".into(), @@ -150,9 +154,9 @@ async fn main() -> nmrs::Result<()> { println!("{}: {} ({})", device.interface, device.device_type, device.state); } - // Control WiFi radio - nm.set_wifi_enabled(false).await?; - nm.set_wifi_enabled(true).await?; + // Control the global Wi-Fi radio + nm.set_wireless_enabled(false).await?; + nm.set_wireless_enabled(true).await?; Ok(()) } @@ -213,7 +217,7 @@ All operations return `Result` with specific variants: ```rust use nmrs::{NetworkManager, WifiSecurity, ConnectionError}; -match nm.connect("MyNetwork", WifiSecurity::WpaPsk { +match nm.connect("MyNetwork", None, WifiSecurity::WpaPsk { psk: "wrong".into() }).await { Ok(_) => println!("Connected"), diff --git a/nmrs/examples/airplane_mode.rs b/nmrs/examples/airplane_mode.rs new file mode 100644 index 00000000..be855130 --- /dev/null +++ b/nmrs/examples/airplane_mode.rs @@ -0,0 +1,18 @@ +use nmrs::NetworkManager; + +#[tokio::main] +async fn main() -> nmrs::Result<()> { + let nm = NetworkManager::new().await?; + + println!("before: {:#?}", nm.airplane_mode_state().await?); + + nm.set_airplane_mode(true).await?; + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + + println!("during: {:#?}", nm.airplane_mode_state().await?); + + nm.set_airplane_mode(false).await?; + println!("after: {:#?}", nm.airplane_mode_state().await?); + + Ok(()) +} diff --git a/nmrs/examples/ap_list.rs b/nmrs/examples/ap_list.rs new file mode 100644 index 00000000..d3fa835c --- /dev/null +++ b/nmrs/examples/ap_list.rs @@ -0,0 +1,22 @@ +use nmrs::NetworkManager; + +#[tokio::main] +async fn main() -> nmrs::Result<()> { + let nm = NetworkManager::new().await?; + let mut aps = nm.list_access_points(None).await?; + aps.sort_by_key(|ap| std::cmp::Reverse(ap.strength)); + + for ap in &aps { + let active = if ap.is_active { "*" } else { " " }; + println!( + "{active} {:>3}% {:<24} {} {} MHz {:?}", + ap.strength, + ap.ssid, + ap.bssid, + ap.frequency_mhz, + ap.security.preferred_connect_type() + ); + } + + Ok(()) +} diff --git a/nmrs/examples/connectivity.rs b/nmrs/examples/connectivity.rs new file mode 100644 index 00000000..788209ed --- /dev/null +++ b/nmrs/examples/connectivity.rs @@ -0,0 +1,20 @@ +use nmrs::NetworkManager; + +#[tokio::main] +async fn main() -> nmrs::Result<()> { + let nm = NetworkManager::new().await?; + let report = nm.connectivity_report().await?; + + println!("state: {:?}", report.state); + println!("check enabled: {}", report.check_enabled); + println!("check uri: {:?}", report.check_uri); + println!("captive portal: {:?}", report.captive_portal_url); + + if report.state == nmrs::ConnectivityState::Portal + && let Some(url) = report.captive_portal_url + { + println!("-> open {url} in your browser to authenticate"); + } + + Ok(()) +} diff --git a/nmrs/examples/custom_timeouts.rs b/nmrs/examples/custom_timeouts.rs index ae0e0512..90a52377 100644 --- a/nmrs/examples/custom_timeouts.rs +++ b/nmrs/examples/custom_timeouts.rs @@ -29,6 +29,7 @@ async fn main() -> nmrs::Result<()> { println!("\nConnecting to network..."); nm.connect( "MyNetwork", + None, WifiSecurity::WpaPsk { psk: std::env::var("WIFI_PASSWORD").unwrap_or_else(|_| "password".to_string()), }, diff --git a/nmrs/examples/multi_wifi.rs b/nmrs/examples/multi_wifi.rs new file mode 100644 index 00000000..f82d7b42 --- /dev/null +++ b/nmrs/examples/multi_wifi.rs @@ -0,0 +1,59 @@ +//! Per-Wi-Fi-device enumeration and scoped operations. +//! +//! Lists every Wi-Fi interface NetworkManager manages, then triggers a +//! scan and prints the visible SSIDs on each radio independently. +//! Useful on laptops with USB Wi-Fi dongles or docks with a second adapter. +//! +//! Run with: `cargo run --example multi_wifi` + +use nmrs::NetworkManager; + +#[tokio::main] +async fn main() -> nmrs::Result<()> { + let nm = NetworkManager::new().await?; + + let radios = nm.list_wifi_devices().await?; + if radios.is_empty() { + println!("No Wi-Fi devices found."); + return Ok(()); + } + + println!("Found {} Wi-Fi radio(s):", radios.len()); + for r in &radios { + println!( + " {:<10} {} state={:?} active={}{}", + r.interface, + r.hw_address, + r.state, + r.is_active, + r.active_ssid + .as_ref() + .map(|s| format!(" ssid={s}")) + .unwrap_or_default(), + ); + } + + for r in &radios { + let scope = nm.wifi(&r.interface); + println!("\n[{}] scanning...", r.interface); + + if let Err(e) = scope.scan().await { + eprintln!("[{}] scan failed: {e}", r.interface); + continue; + } + // Give NM a moment to populate scan results. + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + + let nets = scope.list_networks().await?; + for n in nets { + println!( + " {:>3}% {:<32} ({} BSSIDs)", + n.strength.unwrap_or(0), + n.ssid, + n.bssids.len(), + ); + } + } + + Ok(()) +} diff --git a/nmrs/examples/saved_list.rs b/nmrs/examples/saved_list.rs new file mode 100644 index 00000000..20d0e55a --- /dev/null +++ b/nmrs/examples/saved_list.rs @@ -0,0 +1,74 @@ +//! List all saved NetworkManager connection profiles with decoded summaries. +//! +//! Run: `cargo run --example saved_list` +//! +//! Secrets (Wi-Fi PSK, VPN passwords, etc.) are not shown — only non-secret +//! settings returned by `GetSettings`. + +use nmrs::{NetworkManager, SettingsSummary}; + +#[tokio::main] +async fn main() -> nmrs::Result<()> { + let nm = NetworkManager::new().await?; + + let mut profiles = nm.list_saved_connections().await?; + profiles.sort_by(|a, b| a.id.cmp(&b.id)); + + for c in profiles { + print!("{:<32} {:<22} {}", c.id, c.connection_type, c.uuid); + if !c.autoconnect { + print!(" [manual]"); + } + if c.unsaved { + print!(" [unsaved]"); + } + println!(); + + match &c.summary { + SettingsSummary::Wifi { + ssid, + security, + hidden, + .. + } => { + println!(" ssid={ssid:?} hidden={hidden} security={security:?}"); + } + SettingsSummary::Vpn { + service_type, + user_name, + data_keys, + .. + } => { + println!(" vpn={service_type} user={user_name:?} data_keys={data_keys:?}"); + } + SettingsSummary::WireGuard { + peer_count, + first_peer_endpoint, + listen_port, + .. + } => { + println!( + " wireguard listen={listen_port:?} peers={peer_count} endpoint={first_peer_endpoint:?}" + ); + } + SettingsSummary::Ethernet { mac_address, .. } => { + println!(" ethernet mac={mac_address:?}"); + } + SettingsSummary::Gsm { apn, .. } => { + println!(" gsm apn={apn:?}"); + } + SettingsSummary::Bluetooth { bdaddr, bt_type } => { + println!(" bluetooth {bdaddr} type={bt_type}"); + } + SettingsSummary::Cdma { number, .. } => { + println!(" cdma number={number:?}"); + } + SettingsSummary::Other { sections } => { + println!(" other sections={sections:?}"); + } + _ => println!(" (additional summary variant)"), + } + } + + Ok(()) +} diff --git a/nmrs/examples/vpn_connect.rs b/nmrs/examples/vpn_connect.rs index 28fbb64a..dedc030b 100644 --- a/nmrs/examples/vpn_connect.rs +++ b/nmrs/examples/vpn_connect.rs @@ -1,8 +1,8 @@ /// 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}; +/// This example demonstrates creating a `WireGuardConfig`, +/// the preferred API for configuring VPN connections. +use nmrs::{NetworkManager, WireGuardConfig, WireGuardPeer}; #[tokio::main] async fn main() -> nmrs::Result<()> { @@ -16,16 +16,14 @@ async fn main() -> nmrs::Result<()> { ) .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(); + let creds = WireGuardConfig::new( + "ExampleVPN", + "vpn.example.com:51820", + std::env::var("WG_PRIVATE_KEY").expect("Set WG_PRIVATE_KEY env var"), + "10.0.0.2/24", + vec![peer], + ) + .with_dns(vec!["1.1.1.1".into()]); println!("Connecting to VPN..."); nm.connect_vpn(creds).await?; diff --git a/nmrs/examples/vpn_list.rs b/nmrs/examples/vpn_list.rs new file mode 100644 index 00000000..13e02c4d --- /dev/null +++ b/nmrs/examples/vpn_list.rs @@ -0,0 +1,45 @@ +use nmrs::NetworkManager; + +#[tokio::main] +async fn main() -> nmrs::Result<()> { + let nm = NetworkManager::new().await?; + let vpns = nm.list_vpn_connections().await?; + + println!( + "{:<20} {:<38} {:<16} {:<12} active", + "id", "uuid", "type", "user" + ); + println!("{}", "-".repeat(90)); + + for vpn in &vpns { + let type_label = match &vpn.vpn_type { + nmrs::VpnType::WireGuard { .. } => "wireguard".to_string(), + nmrs::VpnType::OpenVpn { + connection_type, .. + } => { + format!( + "openvpn/{}", + connection_type + .map(|ct| format!("{ct:?}")) + .unwrap_or_default() + ) + } + nmrs::VpnType::OpenConnect { .. } => "openconnect".to_string(), + nmrs::VpnType::StrongSwan { .. } => "strongswan".to_string(), + nmrs::VpnType::Pptp { .. } => "pptp".to_string(), + nmrs::VpnType::L2tp { .. } => "l2tp".to_string(), + nmrs::VpnType::Generic { service_type, .. } => service_type.clone(), + _ => "(unknown)".to_string(), + }; + + let user = vpn.user_name.as_deref().unwrap_or("(n/a)"); + let active_icon = if vpn.active { "●" } else { "○" }; + + println!( + "{:<20} {:<38} {:<16} {:<12} {}", + vpn.id, vpn.uuid, type_label, user, active_icon + ); + } + + Ok(()) +} diff --git a/nmrs/examples/wifi_enterprise.rs b/nmrs/examples/wifi_enterprise.rs index e931d6b8..efcbc70e 100644 --- a/nmrs/examples/wifi_enterprise.rs +++ b/nmrs/examples/wifi_enterprise.rs @@ -17,12 +17,12 @@ async fn main() -> nmrs::Result<()> { .anonymous_identity("anonymous@company.com") .domain_suffix_match("company.com") .system_ca_certs(true) - .build(); + .build()?; let security = WifiSecurity::WpaEap { opts: eap_opts }; println!("Connecting to enterprise WiFi network..."); - nm.connect("CorpNetwork", security).await?; + nm.connect("CorpNetwork", None, security).await?; println!("Successfully connected to enterprise WiFi!"); diff --git a/nmrs/examples/wifi_scan.rs b/nmrs/examples/wifi_scan.rs index 19788373..ba76e6d5 100644 --- a/nmrs/examples/wifi_scan.rs +++ b/nmrs/examples/wifi_scan.rs @@ -6,9 +6,9 @@ async fn main() -> nmrs::Result<()> { let nm = NetworkManager::new().await?; println!("Scanning for WiFi networks..."); - nm.scan_networks().await?; + nm.scan_networks(None).await?; - let networks = nm.list_networks().await?; + let networks = nm.list_networks(None).await?; for net in networks { println!("{:30} {}%", net.ssid, net.strength.unwrap_or(0)); } diff --git a/nmrs/src/api/builders/mod.rs b/nmrs/src/api/builders/mod.rs index d40aad4b..5035d57d 100644 --- a/nmrs/src/api/builders/mod.rs +++ b/nmrs/src/api/builders/mod.rs @@ -20,7 +20,7 @@ //! //! ```ignore //! use nmrs::builders::{build_wifi_connection, build_wireguard_connection, build_ethernet_connection}; -//! use nmrs::{WifiSecurity, ConnectionOptions, VpnCredentials, VpnType, WireGuardPeer}; +//! use nmrs::{WifiSecurity, ConnectionOptions, VpnCredentials, VpnKind, WireGuardPeer}; //! //! let opts = ConnectionOptions { //! autoconnect: true, @@ -45,7 +45,7 @@ //! }; //! //! let creds = VpnCredentials { -//! vpn_type: VpnType::WireGuard, +//! vpn_type: VpnKind::WireGuard, //! name: "MyVPN".into(), //! gateway: "vpn.example.com:51820".into(), //! private_key: "YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=".into(), @@ -70,6 +70,7 @@ pub mod bluetooth; pub mod connection_builder; +pub mod openvpn_builder; pub mod vpn; pub mod wifi; pub mod wifi_builder; @@ -77,10 +78,11 @@ pub mod wireguard_builder; // Re-export core builder types pub use connection_builder::{ConnectionBuilder, IpConfig, Route}; +pub use openvpn_builder::OpenVpnBuilder; pub use wifi_builder::{WifiBand, WifiConnectionBuilder, WifiMode}; pub use wireguard_builder::WireGuardBuilder; // Re-export builder functions for convenience pub use bluetooth::build_bluetooth_connection; -pub use vpn::build_wireguard_connection; +pub use vpn::{build_openvpn_connection, build_wireguard_connection}; pub use wifi::{build_ethernet_connection, build_wifi_connection}; diff --git a/nmrs/src/api/builders/openvpn_builder.rs b/nmrs/src/api/builders/openvpn_builder.rs new file mode 100644 index 00000000..e54a71ca --- /dev/null +++ b/nmrs/src/api/builders/openvpn_builder.rs @@ -0,0 +1,1102 @@ +//! OpenVPN connection builder with validation. +//! +//! Provides a type-safe builder API for constructing [`OpenVpnConfig`] with +//! validation of required fields and auth-type-specific requirements at +//! build time. +//! +//! Unlike [`super::vpn::build_wireguard_connection`] which returns NM-ready +//! D-Bus settings directly, this builder produces an [`OpenVpnConfig`] domain +//! struct. Use [`super::vpn::build_openvpn_connection`] to convert it into +//! NetworkManager connection settings. + +use std::path::Path; + +use uuid::Uuid; + +use crate::api::models::{ + ConnectionError, OpenVpnAuthType, OpenVpnCompression, OpenVpnConfig, OpenVpnProxy, VpnRoute, + vpn_route_from_parser, +}; +use crate::core::ovpn_parser::parser::{self, CertSource, OvpnFile}; +use crate::util::cert_store::store_inline_cert; +use crate::util::validation::validate_connection_name; + +/// Builder for OpenVPN connections. +/// +/// Validates at build time: +/// - `remote` must be set and non-empty +/// - `auth_type` must be set +/// - `Password` or `PasswordTls`: `username` required +/// - `Tls` or `PasswordTls`: `ca_cert`, `client_cert`, `client_key` required +/// - port must be 1–65535 +/// +/// # Example +/// +/// ```rust +/// use nmrs::builders::OpenVpnBuilder; +/// use nmrs::OpenVpnAuthType; +/// +/// let config = OpenVpnBuilder::new("CorpVPN") +/// .remote("vpn.example.com") +/// .port(1194) +/// .auth_type(OpenVpnAuthType::Tls) +/// .ca_cert("/etc/openvpn/ca.crt") +/// .client_cert("/etc/openvpn/client.crt") +/// .client_key("/etc/openvpn/client.key") +/// .build() +/// .expect("Failed to build OpenVPN config"); +/// ``` +#[non_exhaustive] +#[derive(Debug)] +pub struct OpenVpnBuilder { + name: String, + remote: Option, + port: Option, + tcp: bool, + auth_type: Option, + auth: Option, + cipher: Option, + dns: Option>, + mtu: Option, + uuid: Option, + ca_cert: Option, + client_cert: Option, + client_key: Option, + key_password: Option, + username: Option, + password: Option, + compression: Option, + proxy: Option, + tls_auth_key: Option, + tls_auth_direction: Option, + tls_crypt: Option, + tls_crypt_v2: Option, + tls_version_min: Option, + tls_version_max: Option, + tls_cipher: Option, + remote_cert_tls: Option, + verify_x509_name: Option<(String, String)>, + crl_verify: Option, + redirect_gateway: bool, + routes: Vec, + ping: Option, + ping_exit: Option, + ping_restart: Option, + reneg_seconds: Option, + connect_timeout: Option, + data_ciphers: Option, + data_ciphers_fallback: Option, + ncp_disable: bool, +} + +impl OpenVpnBuilder { + /// Creates a new OpenVPN connection builder. + #[must_use] + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + remote: None, + port: None, + tcp: false, + auth_type: None, + auth: None, + cipher: None, + dns: None, + mtu: None, + uuid: None, + ca_cert: None, + client_cert: None, + client_key: None, + key_password: None, + username: None, + password: None, + compression: None, + proxy: None, + tls_auth_key: None, + tls_auth_direction: None, + tls_crypt: None, + tls_crypt_v2: None, + tls_version_min: None, + tls_version_max: None, + tls_cipher: None, + remote_cert_tls: None, + verify_x509_name: None, + crl_verify: None, + redirect_gateway: false, + routes: Vec::new(), + ping: None, + ping_exit: None, + ping_restart: None, + reneg_seconds: None, + connect_timeout: None, + data_ciphers: None, + data_ciphers_fallback: None, + ncp_disable: false, + } + } + + /// Creates a builder pre-populated from a `.ovpn` file on disk. + /// + /// Reads the file, parses it, extracts inline certificates (persisting them + /// via the cert store), and pre-populates the builder. The connection name + /// defaults to the file stem (e.g. `"corp"` for `corp.ovpn`). + /// + /// The caller can override any field before calling [`build()`](Self::build). + /// + /// # Errors + /// + /// - `ConnectionError::VpnFailed` if the file cannot be read + /// - `ConnectionError::ParseError` if the `.ovpn` content is malformed + /// - `ConnectionError::InvalidGateway` if no `remote` directive is found + pub fn from_ovpn_file(path: impl AsRef) -> Result { + let path = path.as_ref(); + let content = std::fs::read_to_string(path).map_err(|e| { + ConnectionError::VpnFailed(format!("failed to read {}: {e}", path.display())) + })?; + let name = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("openvpn") + .to_string(); + Self::from_ovpn_str(&content, name) + } + + /// Creates a builder pre-populated from `.ovpn` file content. + /// + /// Parses the content, extracts inline certificates (persisting them via + /// the cert store under `name`), and pre-populates the builder. + /// + /// The caller can override any field before calling [`build()`](Self::build). + /// + /// # Errors + /// + /// - `ConnectionError::ParseError` if the content is malformed + /// - `ConnectionError::InvalidGateway` if no `remote` directive is found + /// - `ConnectionError::VpnFailed` if inline cert storage fails + pub fn from_ovpn_str(content: &str, name: impl Into) -> Result { + let name = name.into(); + let ovpn = parser::parse_ovpn(content)?; + Self::from_parsed(ovpn, name) + } + + /// Populates a builder from a parsed `OvpnFile`, resolving inline certs. + fn from_parsed(f: OvpnFile, name: String) -> Result { + use crate::core::ovpn_parser::parser::{AllowCompress, Compress}; + + let first_remote = f + .remotes + .into_iter() + .next() + .ok_or_else(|| ConnectionError::InvalidGateway("no remote in .ovpn file".into()))?; + + let tcp = first_remote + .proto + .as_deref() + .map(|p: &str| p.starts_with("tcp")) + .unwrap_or_else(|| { + f.proto + .as_deref() + .map(|p: &str| p.starts_with("tcp")) + .unwrap_or(false) + }); + + let routes: Vec = f + .routes + .into_iter() + .map(vpn_route_from_parser) + .collect::>()?; + + let redirect_gateway = f.redirect_gateway.is_some(); + + let data_ciphers = if f.data_ciphers.is_empty() { + None + } else { + Some(f.data_ciphers.join(":")) + }; + + let compression = match (f.compress, f.allow_compress) { + (Some(Compress::Algorithm(ref s)), _) => Some(match s.as_str() { + "lz4" => OpenVpnCompression::Lz4, + "lz4-v2" => OpenVpnCompression::Lz4V2, + _ => OpenVpnCompression::Yes, + }), + (Some(Compress::Stub | Compress::StubV2), _) => Some(OpenVpnCompression::No), + (None, Some(AllowCompress::No)) => Some(OpenVpnCompression::No), + _ => None, + }; + + let resolve_cert = + |src: CertSource, cert_type: &str, conn: &str| -> Result { + match src { + CertSource::File(p) => Ok(p), + CertSource::Inline(pem) => { + let path = store_inline_cert(conn, cert_type, &pem)?; + Ok(path.to_string_lossy().into_owned()) + } + } + }; + + let ca_cert = f.ca.map(|s| resolve_cert(s, "ca", &name)).transpose()?; + let client_cert = f.cert.map(|s| resolve_cert(s, "cert", &name)).transpose()?; + let client_key = f.key.map(|s| resolve_cert(s, "key", &name)).transpose()?; + + let has_client_cert_pair = client_cert.is_some() && client_key.is_some(); + let auth_type = match (f.auth_user_pass, has_client_cert_pair) { + (true, true) => Some(OpenVpnAuthType::PasswordTls), + (true, false) => Some(OpenVpnAuthType::Password), + (false, true) => Some(OpenVpnAuthType::Tls), + (false, false) => None, + }; + + let (tls_auth_key, tls_auth_direction) = match f.tls_auth { + Some(ta) => { + let path = resolve_cert(ta.source, "ta", &name)?; + (Some(path), ta.key_direction) + } + None => (None, None), + }; + + let tls_crypt = f + .tls_crypt + .map(|s| resolve_cert(s, "tls-crypt", &name)) + .transpose()?; + + Ok(Self { + name, + remote: Some(first_remote.host), + port: first_remote.port, + tcp, + auth_type, + auth: f.auth, + cipher: f.cipher, + dns: None, + mtu: None, + uuid: None, + ca_cert, + client_cert, + client_key, + key_password: None, + username: None, + password: None, + compression, + proxy: None, + tls_auth_key, + tls_auth_direction, + tls_crypt, + tls_crypt_v2: None, + tls_version_min: None, + tls_version_max: None, + tls_cipher: None, + remote_cert_tls: None, + verify_x509_name: None, + crl_verify: None, + redirect_gateway, + routes, + ping: None, + ping_exit: None, + ping_restart: None, + reneg_seconds: None, + connect_timeout: None, + data_ciphers, + data_ciphers_fallback: None, + ncp_disable: false, + }) + } + + /// Sets the remote server hostname or IP address. + #[must_use] + pub fn remote(mut self, remote: impl Into) -> Self { + self.remote = Some(remote.into()); + self + } + + /// Sets the remote server port (1–65535). + #[must_use] + pub fn port(mut self, port: u16) -> Self { + self.port = Some(port); + self + } + + /// Use TCP instead of UDP. + #[must_use] + pub fn tcp(mut self, tcp: bool) -> Self { + self.tcp = tcp; + self + } + + /// Sets the authentication type. + #[must_use] + pub fn auth_type(mut self, auth_type: OpenVpnAuthType) -> Self { + self.auth_type = Some(auth_type); + self + } + + /// Sets the HMAC digest algorithm (e.g. "SHA256"). + #[must_use] + pub fn auth(mut self, auth: impl Into) -> Self { + self.auth = Some(auth.into()); + self + } + + /// Sets the data channel cipher (e.g. "AES-256-GCM"). + #[must_use] + pub fn cipher(mut self, cipher: impl Into) -> Self { + self.cipher = Some(cipher.into()); + self + } + + /// Sets DNS servers for the connection. + #[must_use] + pub fn dns(mut self, servers: Vec) -> Self { + self.dns = Some(servers); + self + } + + /// Sets the MTU size. + #[must_use] + pub fn mtu(mut self, mtu: u32) -> Self { + self.mtu = Some(mtu); + self + } + + /// Sets a specific UUID for the connection. + #[must_use] + pub fn uuid(mut self, uuid: Uuid) -> Self { + self.uuid = Some(uuid); + self + } + + /// Sets the CA certificate path. + #[must_use] + pub fn ca_cert(mut self, path: impl Into) -> Self { + self.ca_cert = Some(path.into()); + self + } + + /// Sets the client certificate path. + #[must_use] + pub fn client_cert(mut self, path: impl Into) -> Self { + self.client_cert = Some(path.into()); + self + } + + /// Sets the client private key path. + #[must_use] + pub fn client_key(mut self, path: impl Into) -> Self { + self.client_key = Some(path.into()); + self + } + + /// Sets the password for an encrypted private key. + #[must_use] + pub fn key_password(mut self, password: impl Into) -> Self { + self.key_password = Some(password.into()); + self + } + + /// Sets the username for password authentication. + #[must_use] + pub fn username(mut self, username: impl Into) -> Self { + self.username = Some(username.into()); + self + } + + /// Sets the password for password authentication. + #[must_use] + pub fn password(mut self, password: impl Into) -> Self { + self.password = Some(password.into()); + self + } + + /// Sets the compression algorithm. + /// + /// # Security Warning + /// + /// Some compression modes are subject to the VORACLE vulnerability. + /// See [`OpenVpnCompression`] for details and recommendations. + #[must_use] + pub fn compression(mut self, compression: OpenVpnCompression) -> Self { + self.compression = Some(compression); + self + } + + /// Sets the proxy configuration. + #[must_use] + pub fn proxy(mut self, proxy: OpenVpnProxy) -> Self { + self.proxy = Some(proxy); + self + } + + /// Sets the TLS authentication key path and optional direction. + #[must_use] + pub fn tls_auth(mut self, key_path: impl Into, direction: Option) -> Self { + self.tls_auth_key = Some(key_path.into()); + self.tls_auth_direction = direction; + self + } + + /// Sets the TLS-Crypt key path. + #[must_use] + pub fn tls_crypt(mut self, key_path: impl Into) -> Self { + self.tls_crypt = Some(key_path.into()); + self + } + + /// Sets the TLS-Crypt-v2 key path. + #[must_use] + pub fn tls_crypt_v2(mut self, key_path: impl Into) -> Self { + self.tls_crypt_v2 = Some(key_path.into()); + self + } + + /// Sets the minimum TLS protocol version. + #[must_use] + pub fn tls_version_min(mut self, version: impl Into) -> Self { + self.tls_version_min = Some(version.into()); + self + } + + /// Sets the maximum TLS protocol version. + #[must_use] + pub fn tls_version_max(mut self, version: impl Into) -> Self { + self.tls_version_max = Some(version.into()); + self + } + + /// Sets the control channel TLS cipher suites. + #[must_use] + pub fn tls_cipher(mut self, cipher: impl Into) -> Self { + self.tls_cipher = Some(cipher.into()); + self + } + + /// Requires the remote certificate to be of a specific type. + #[must_use] + pub fn remote_cert_tls(mut self, cert_type: impl Into) -> Self { + self.remote_cert_tls = Some(cert_type.into()); + self + } + + /// Sets X.509 name verification for the remote certificate. + #[must_use] + pub fn verify_x509_name( + mut self, + name: impl Into, + name_type: impl Into, + ) -> Self { + self.verify_x509_name = Some((name.into(), name_type.into())); + self + } + + /// Sets the path to a Certificate Revocation List. + #[must_use] + pub fn crl_verify(mut self, path: impl Into) -> Self { + self.crl_verify = Some(path.into()); + self + } + + /// When true, the profile may become the default IPv4 route. + #[must_use] + pub fn redirect_gateway(mut self, redirect: bool) -> Self { + self.redirect_gateway = redirect; + self + } + + /// Sets static IPv4 routes for split tunneling. + #[must_use] + pub fn routes(mut self, routes: Vec) -> Self { + self.routes = routes; + self + } + + /// Sets OpenVPN `ping` (seconds). + #[must_use] + pub fn ping(mut self, seconds: u32) -> Self { + self.ping = Some(seconds); + self + } + + /// Sets OpenVPN `ping-exit` (seconds). + #[must_use] + pub fn ping_exit(mut self, seconds: u32) -> Self { + self.ping_exit = Some(seconds); + self + } + + /// Sets OpenVPN `ping-restart` (seconds). + #[must_use] + pub fn ping_restart(mut self, seconds: u32) -> Self { + self.ping_restart = Some(seconds); + self + } + + /// Sets TLS renegotiation period (`reneg-sec`, seconds). + #[must_use] + pub fn reneg_seconds(mut self, seconds: u32) -> Self { + self.reneg_seconds = Some(seconds); + self + } + + /// Sets initial connection timeout (`connect-timeout`, seconds). + #[must_use] + pub fn connect_timeout(mut self, seconds: u32) -> Self { + self.connect_timeout = Some(seconds); + self + } + + /// Sets negotiable data ciphers (colon-separated). + #[must_use] + pub fn data_ciphers(mut self, ciphers: impl Into) -> Self { + self.data_ciphers = Some(ciphers.into()); + self + } + + /// Sets `data-ciphers-fallback`. + #[must_use] + pub fn data_ciphers_fallback(mut self, cipher: impl Into) -> Self { + self.data_ciphers_fallback = Some(cipher.into()); + self + } + + /// When true, disables NCP (`ncp-disable`). + #[must_use] + pub fn ncp_disable(mut self, disable: bool) -> Self { + self.ncp_disable = disable; + self + } + + /// Builds and validates the `OpenVpnConfig`. + /// + /// # Errors + /// + /// - `ConnectionError::InvalidGateway` if `remote` is not set or empty + /// - `ConnectionError::InvalidGateway` if `port` is 0 + /// - `ConnectionError::VpnFailed` if `auth_type` is not set + /// - `ConnectionError::VpnFailed` if `username` is required but missing + /// - `ConnectionError::VpnFailed` if TLS certs are required but missing + #[must_use = "the validated OpenVPN config should be used to build connection settings"] + pub fn build(self) -> Result { + validate_connection_name(&self.name)?; + + let remote = self + .remote + .ok_or_else(|| ConnectionError::InvalidGateway("remote must be set".into()))?; + if remote.trim().is_empty() { + return Err(ConnectionError::InvalidGateway( + "remote must not be empty".into(), + )); + } + + // Validate port + let port = self.port.unwrap_or(1194); + if port == 0 { + return Err(ConnectionError::InvalidGateway( + "port must be between 1 and 65535".into(), + )); + } + + // Validate auth_type + let auth_type = self + .auth_type + .ok_or_else(|| ConnectionError::VpnFailed("auth_type must be set".into()))?; + + // auth_type-specific validation + match &auth_type { + OpenVpnAuthType::Password | OpenVpnAuthType::PasswordTls if self.username.is_none() => { + return Err(ConnectionError::VpnFailed( + "username is required for Password and PasswordTls auth".into(), + )); + } + _ => {} + } + + if matches!(auth_type, OpenVpnAuthType::StaticKey) { + return Err(ConnectionError::VpnFailed( + "StaticKey auth validation is not yet implemented".into(), + )); + } + + match &auth_type { + OpenVpnAuthType::Tls | OpenVpnAuthType::PasswordTls => { + if self.ca_cert.is_none() { + return Err(ConnectionError::VpnFailed( + "ca_cert is required for Tls and PasswordTls auth".into(), + )); + } + if self.client_cert.is_none() { + return Err(ConnectionError::VpnFailed( + "client_cert is required for Tls and PasswordTls auth".into(), + )); + } + if self.client_key.is_none() { + return Err(ConnectionError::VpnFailed( + "client_key is required for Tls and PasswordTls auth".into(), + )); + } + } + _ => {} + } + + Ok(OpenVpnConfig { + name: self.name, + remote, + port, + tcp: self.tcp, + auth_type: Some(auth_type), + auth: self.auth, + cipher: self.cipher, + dns: self.dns, + mtu: self.mtu, + uuid: self.uuid, + ca_cert: self.ca_cert, + client_cert: self.client_cert, + client_key: self.client_key, + key_password: self.key_password, + username: self.username, + password: self.password, + compression: self.compression, + proxy: self.proxy, + tls_auth_key: self.tls_auth_key, + tls_auth_direction: self.tls_auth_direction, + tls_crypt: self.tls_crypt, + tls_crypt_v2: self.tls_crypt_v2, + tls_version_min: self.tls_version_min, + tls_version_max: self.tls_version_max, + tls_cipher: self.tls_cipher, + remote_cert_tls: self.remote_cert_tls, + verify_x509_name: self.verify_x509_name, + crl_verify: self.crl_verify, + redirect_gateway: self.redirect_gateway, + routes: self.routes, + ping: self.ping, + ping_exit: self.ping_exit, + ping_restart: self.ping_restart, + reneg_seconds: self.reneg_seconds, + connect_timeout: self.connect_timeout, + data_ciphers: self.data_ciphers, + data_ciphers_fallback: self.data_ciphers_fallback, + ncp_disable: self.ncp_disable, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tls_builder() -> OpenVpnBuilder { + OpenVpnBuilder::new("TestVPN") + .remote("vpn.example.com") + .port(1194) + .auth_type(OpenVpnAuthType::Tls) + .ca_cert("/etc/openvpn/ca.crt") + .client_cert("/etc/openvpn/client.crt") + .client_key("/etc/openvpn/client.key") + } + + fn password_builder() -> OpenVpnBuilder { + OpenVpnBuilder::new("TestVPN") + .remote("vpn.example.com") + .port(1194) + .auth_type(OpenVpnAuthType::Password) + .username("user") + } + + #[test] + fn builds_tls_connection() { + let config = tls_builder().build(); + assert!(config.is_ok()); + let config = config.unwrap(); + assert_eq!(config.name, "TestVPN"); + assert_eq!(config.remote, "vpn.example.com"); + assert_eq!(config.port, 1194); + assert!(!config.tcp); + } + + #[test] + fn builds_password_connection() { + let config = password_builder().build(); + assert!(config.is_ok()); + } + + #[test] + fn builds_password_tls_connection() { + let config = OpenVpnBuilder::new("TestVPN") + .remote("vpn.example.com") + .auth_type(OpenVpnAuthType::PasswordTls) + .username("user") + .ca_cert("/etc/openvpn/ca.crt") + .client_cert("/etc/openvpn/client.crt") + .client_key("/etc/openvpn/client.key") + .build(); + assert!(config.is_ok()); + } + + #[test] + fn rejects_static_key_unimplemented() { + let result = OpenVpnBuilder::new("TestVPN") + .remote("vpn.example.com") + .auth_type(OpenVpnAuthType::StaticKey) + .build(); + assert!(matches!(result.unwrap_err(), ConnectionError::VpnFailed(_))); + } + + #[test] + fn defaults_port_to_1194() { + let config = tls_builder().build().unwrap(); + assert_eq!(config.port, 1194); + } + + #[test] + fn sets_tcp_flag() { + let config = tls_builder().tcp(true).build().unwrap(); + assert!(config.tcp); + } + + #[test] + fn sets_optional_fields() { + let config = tls_builder() + .auth("SHA256") + .cipher("AES-256-GCM") + .mtu(1400) + .dns(vec!["1.1.1.1".into()]) + .build() + .unwrap(); + assert_eq!(config.auth, Some("SHA256".into())); + assert_eq!(config.cipher, Some("AES-256-GCM".into())); + assert_eq!(config.mtu, Some(1400)); + assert!(config.dns.is_some()); + } + + #[test] + fn sets_compression() { + let config = tls_builder() + .compression(OpenVpnCompression::Lz4V2) + .build() + .unwrap(); + assert_eq!(config.compression, Some(OpenVpnCompression::Lz4V2)); + } + + #[test] + fn sets_proxy() { + let config = tls_builder() + .proxy(OpenVpnProxy::Http { + server: "proxy.example.com".into(), + port: 8080, + username: None, + password: None, + retry: false, + }) + .build() + .unwrap(); + assert!(config.proxy.is_some()); + } + + #[test] + fn rejects_empty_name() { + let result = OpenVpnBuilder::new("") + .remote("vpn.example.com") + .auth_type(OpenVpnAuthType::Tls) + .ca_cert("/etc/openvpn/ca.crt") + .client_cert("/etc/openvpn/client.crt") + .client_key("/etc/openvpn/client.key") + .build(); + assert!(result.is_err()); + } + + #[test] + fn requires_remote() { + let result = OpenVpnBuilder::new("TestVPN") + .auth_type(OpenVpnAuthType::Tls) + .ca_cert("/etc/openvpn/ca.crt") + .client_cert("/etc/openvpn/client.crt") + .client_key("/etc/openvpn/client.key") + .build(); + assert!(matches!( + result.unwrap_err(), + ConnectionError::InvalidGateway(_) + )); + } + + #[test] + fn rejects_empty_remote() { + let result = OpenVpnBuilder::new("TestVPN") + .remote("") + .auth_type(OpenVpnAuthType::Tls) + .ca_cert("/etc/openvpn/ca.crt") + .client_cert("/etc/openvpn/client.crt") + .client_key("/etc/openvpn/client.key") + .build(); + assert!(matches!( + result.unwrap_err(), + ConnectionError::InvalidGateway(_) + )); + } + + #[test] + fn rejects_zero_port() { + let result = tls_builder().port(0).build(); + assert!(matches!( + result.unwrap_err(), + ConnectionError::InvalidGateway(_) + )); + } + + #[test] + fn requires_auth_type() { + let result = OpenVpnBuilder::new("TestVPN") + .remote("vpn.example.com") + .build(); + assert!(matches!(result.unwrap_err(), ConnectionError::VpnFailed(_))); + } + + #[test] + fn requires_username_for_password_auth() { + let result = OpenVpnBuilder::new("TestVPN") + .remote("vpn.example.com") + .auth_type(OpenVpnAuthType::Password) + .build(); + assert!(matches!(result.unwrap_err(), ConnectionError::VpnFailed(_))); + } + + #[test] + fn requires_username_for_password_tls_auth() { + let result = OpenVpnBuilder::new("TestVPN") + .remote("vpn.example.com") + .auth_type(OpenVpnAuthType::PasswordTls) + .ca_cert("/etc/openvpn/ca.crt") + .client_cert("/etc/openvpn/client.crt") + .client_key("/etc/openvpn/client.key") + .build(); + assert!(matches!(result.unwrap_err(), ConnectionError::VpnFailed(_))); + } + + #[test] + fn requires_ca_cert_for_tls_auth() { + let result = OpenVpnBuilder::new("TestVPN") + .remote("vpn.example.com") + .auth_type(OpenVpnAuthType::Tls) + .client_cert("/etc/openvpn/client.crt") + .client_key("/etc/openvpn/client.key") + .build(); + assert!(matches!(result.unwrap_err(), ConnectionError::VpnFailed(_))); + } + + #[test] + fn requires_client_cert_for_tls_auth() { + let result = OpenVpnBuilder::new("TestVPN") + .remote("vpn.example.com") + .auth_type(OpenVpnAuthType::Tls) + .ca_cert("/etc/openvpn/ca.crt") + .client_key("/etc/openvpn/client.key") + .build(); + assert!(matches!(result.unwrap_err(), ConnectionError::VpnFailed(_))); + } + + #[test] + fn requires_client_key_for_tls_auth() { + let result = OpenVpnBuilder::new("TestVPN") + .remote("vpn.example.com") + .auth_type(OpenVpnAuthType::Tls) + .ca_cert("/etc/openvpn/ca.crt") + .client_cert("/etc/openvpn/client.crt") + .build(); + assert!(matches!(result.unwrap_err(), ConnectionError::VpnFailed(_))); + } + + // --- from_ovpn_str tests --- + + use std::sync::Mutex; + + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + fn with_fake_xdg(f: impl FnOnce() -> R) -> R { + let _g = ENV_LOCK.lock().unwrap(); + let base = std::env::temp_dir().join(format!("nmrs-ovpn-test-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&base).unwrap(); + unsafe { std::env::set_var("XDG_DATA_HOME", &base) }; + let out = f(); + unsafe { std::env::remove_var("XDG_DATA_HOME") }; + let _ = std::fs::remove_dir_all(&base); + out + } + + #[test] + fn from_ovpn_str_basic_tls_file_certs() { + let ovpn = "\ +remote vpn.example.com 1194 udp +ca /etc/openvpn/ca.crt +cert /etc/openvpn/client.crt +key /etc/openvpn/client.key +"; + let builder = OpenVpnBuilder::from_ovpn_str(ovpn, "test-tls").unwrap(); + let config = builder.build().unwrap(); + assert_eq!(config.remote, "vpn.example.com"); + assert_eq!(config.port, 1194); + assert!(!config.tcp); + assert_eq!(config.auth_type, Some(OpenVpnAuthType::Tls)); + assert_eq!(config.ca_cert, Some("/etc/openvpn/ca.crt".into())); + assert_eq!(config.client_cert, Some("/etc/openvpn/client.crt".into())); + assert_eq!(config.client_key, Some("/etc/openvpn/client.key".into())); + } + + #[test] + fn from_ovpn_str_password_auth() { + let ovpn = "remote vpn.example.com 443 tcp\nauth-user-pass\n"; + let builder = OpenVpnBuilder::from_ovpn_str(ovpn, "test-pw") + .unwrap() + .username("user"); + let config = builder.build().unwrap(); + assert_eq!(config.auth_type, Some(OpenVpnAuthType::Password)); + assert!(config.tcp); + assert_eq!(config.port, 443); + } + + #[test] + fn from_ovpn_str_inline_certs_stored() { + with_fake_xdg(|| { + let ovpn = "\ +remote vpn.example.com 1194 + +-----BEGIN CERTIFICATE----- +FAKECA +-----END CERTIFICATE----- + + +-----BEGIN CERTIFICATE----- +FAKECERT +-----END CERTIFICATE----- + + +-----BEGIN PRIVATE KEY----- +FAKEKEY +-----END PRIVATE KEY----- + +"; + let builder = OpenVpnBuilder::from_ovpn_str(ovpn, "inline-test").unwrap(); + let config = builder.build().unwrap(); + assert_eq!(config.auth_type, Some(OpenVpnAuthType::Tls)); + + let ca = config.ca_cert.unwrap(); + assert!( + std::path::Path::new(&ca).exists(), + "CA cert should be written to disk: {ca}" + ); + assert!(config.client_cert.is_some()); + assert!(config.client_key.is_some()); + }); + } + + #[test] + fn from_ovpn_str_tls_auth_with_direction() { + let ovpn = "\ +remote vpn.example.com 1194 +ca /etc/openvpn/ca.crt +cert /etc/openvpn/client.crt +key /etc/openvpn/client.key +tls-auth /etc/openvpn/ta.key 1 +"; + let builder = OpenVpnBuilder::from_ovpn_str(ovpn, "test-ta").unwrap(); + let config = builder.build().unwrap(); + assert_eq!(config.tls_auth_key, Some("/etc/openvpn/ta.key".into())); + assert_eq!(config.tls_auth_direction, Some(1)); + } + + #[test] + fn from_ovpn_str_inline_tls_auth() { + with_fake_xdg(|| { + let ovpn = "\ +remote vpn.example.com 1194 +ca /etc/openvpn/ca.crt +cert /etc/openvpn/client.crt +key /etc/openvpn/client.key +key-direction 0 + +-----BEGIN OpenVPN Static key V1----- +FAKEKEY +-----END OpenVPN Static key V1----- + +"; + let builder = OpenVpnBuilder::from_ovpn_str(ovpn, "inline-ta").unwrap(); + let config = builder.build().unwrap(); + assert!(config.tls_auth_key.is_some()); + assert_eq!(config.tls_auth_direction, Some(0)); + }); + } + + #[test] + fn from_ovpn_str_compression_lz4() { + let ovpn = "\ +remote vpn.example.com 1194 +ca /etc/openvpn/ca.crt +cert /etc/openvpn/client.crt +key /etc/openvpn/client.key +compress lz4-v2 +"; + let builder = OpenVpnBuilder::from_ovpn_str(ovpn, "test-comp").unwrap(); + let config = builder.build().unwrap(); + assert_eq!(config.compression, Some(OpenVpnCompression::Lz4V2)); + } + + #[test] + fn from_ovpn_str_cipher_and_auth() { + let ovpn = "\ +remote vpn.example.com 1194 +ca /etc/openvpn/ca.crt +cert /etc/openvpn/client.crt +key /etc/openvpn/client.key +cipher AES-256-GCM +auth SHA256 +"; + let builder = OpenVpnBuilder::from_ovpn_str(ovpn, "test-cipher").unwrap(); + let config = builder.build().unwrap(); + assert_eq!(config.cipher, Some("AES-256-GCM".into())); + assert_eq!(config.auth, Some("SHA256".into())); + } + + #[test] + fn from_ovpn_str_caller_can_override() { + let ovpn = "\ +remote vpn.example.com 1194 +ca /etc/openvpn/ca.crt +cert /etc/openvpn/client.crt +key /etc/openvpn/client.key +"; + let config = OpenVpnBuilder::from_ovpn_str(ovpn, "test-override") + .unwrap() + .port(443) + .tcp(true) + .dns(vec!["1.1.1.1".into()]) + .build() + .unwrap(); + assert_eq!(config.port, 443); + assert!(config.tcp); + assert!(config.dns.is_some()); + } + + #[test] + fn from_ovpn_str_no_remote_fails() { + let ovpn = "cipher AES-256-GCM\n"; + let result = OpenVpnBuilder::from_ovpn_str(ovpn, "test-fail"); + assert!(matches!( + result.unwrap_err(), + ConnectionError::InvalidGateway(_) + )); + } + + #[test] + fn from_ovpn_file_reads_and_parses() { + let dir = std::env::temp_dir().join(format!("nmrs-ovpn-file-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("corp.ovpn"); + std::fs::write(&path, "remote vpn.corp.com 1194\nauth-user-pass\n").unwrap(); + + let builder = OpenVpnBuilder::from_ovpn_file(&path).unwrap(); + assert_eq!(builder.name, "corp"); + let config = builder.username("user").build().unwrap(); + assert_eq!(config.remote, "vpn.corp.com"); + assert_eq!(config.auth_type, Some(OpenVpnAuthType::Password)); + + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/nmrs/src/api/builders/vpn.rs b/nmrs/src/api/builders/vpn.rs index 8cda4508..fe93338e 100644 --- a/nmrs/src/api/builders/vpn.rs +++ b/nmrs/src/api/builders/vpn.rs @@ -1,9 +1,10 @@ //! VPN connection settings builders. //! //! This module provides functions to build NetworkManager settings dictionaries -//! for VPN connections. Currently supports: +//! for VPN connections. Supports: //! -//! - **WireGuard** - Modern, high-performance VPN protocol +//! - **WireGuard** — Modern, high-performance VPN protocol +//! - **OpenVPN** — Widely-used open-source VPN protocol (via NM plugin) //! //! # Usage //! @@ -40,7 +41,7 @@ //! //! ```rust //! use nmrs::builders::build_wireguard_connection; -//! use nmrs::{VpnCredentials, VpnType, WireGuardPeer, ConnectionOptions}; +//! use nmrs::{VpnCredentials, VpnKind, WireGuardPeer, ConnectionOptions}; //! //! let peer = WireGuardPeer::new( //! "HIgo9xNzJMWLKAShlKl6/bUT1VI9Q0SDBXGtLXkPFXc=", @@ -49,7 +50,7 @@ //! ).with_persistent_keepalive(25); //! //! let creds = VpnCredentials::new( -//! VpnType::WireGuard, +//! VpnKind::WireGuard, //! "MyVPN", //! "vpn.example.com:51820", //! "YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=", @@ -62,12 +63,16 @@ //! let settings = build_wireguard_connection(&creds, &opts).unwrap(); //! // Pass settings to NetworkManager's AddAndActivateConnection //! ``` +#![allow(deprecated)] use std::collections::HashMap; -use zvariant::Value; +use zvariant::{Dict, Value, signature}; use super::wireguard_builder::WireGuardBuilder; -use crate::api::models::{ConnectionError, ConnectionOptions, VpnCredentials}; +use crate::api::models::{ + ConnectionError, ConnectionOptions, OpenVpnAuthType, OpenVpnCompression, OpenVpnConfig, + OpenVpnProxy, VpnCredentials, +}; /// Builds WireGuard VPN connection settings. /// @@ -83,6 +88,7 @@ use crate::api::models::{ConnectionError, ConnectionOptions, VpnCredentials}; /// /// This function is maintained for backward compatibility. For new code, /// consider using `WireGuardBuilder` for a more ergonomic API. +#[must_use = "the connection settings must be passed to NetworkManager"] pub fn build_wireguard_connection( creds: &VpnCredentials, opts: &ConnectionOptions, @@ -108,10 +114,294 @@ pub fn build_wireguard_connection( builder.build() } +/// Converts a list of string key-value pairs into a `zvariant::Dict` with +/// D-Bus signature `a{ss}`, which NetworkManager requires for `vpn.data` +/// and `vpn.secrets`. +fn string_pairs_to_dict( + pairs: Vec<(String, String)>, +) -> Result, ConnectionError> { + let sig = signature!("s"); + let mut dict = Dict::new(&sig, &sig); + for (k, v) in pairs { + dict.append(Value::from(k), Value::from(v)).map_err(|e| { + ConnectionError::VpnFailed(format!("failed to append VPN setting: {e}")) + })?; + } + Ok(dict) +} + +/// Builds OpenVPN connection settings for NetworkManager. +/// +/// Returns a settings dictionary suitable for `AddAndActivateConnection`. +/// OpenVPN uses the NM VPN plugin model: `connection.type = "vpn"` with +/// `vpn.service-type = "org.freedesktop.NetworkManager.openvpn"`. +/// All config lives in the flat `vpn.data` dict. +/// +/// # Errors +/// +/// - `ConnectionError::InvalidGateway` if `remote` is empty +/// - `ConnectionError::InvalidAddress` if a proxy port is zero +#[must_use = "the connection settings must be passed to NetworkManager"] +pub fn build_openvpn_connection( + config: &OpenVpnConfig, + opts: &ConnectionOptions, +) -> Result>>, ConnectionError> { + if config.remote.is_empty() { + return Err(ConnectionError::InvalidGateway( + "OpenVPN remote must not be empty".into(), + )); + } + + let uuid = config.uuid.unwrap_or_else(uuid::Uuid::new_v4).to_string(); + + let mut connection: HashMap<&'static str, Value<'static>> = HashMap::new(); + connection.insert("type", Value::from("vpn")); + connection.insert("id", Value::from(config.name.clone())); + connection.insert("uuid", Value::from(uuid)); + connection.insert("autoconnect", Value::from(opts.autoconnect)); + if let Some(p) = opts.autoconnect_priority { + connection.insert("autoconnect-priority", Value::from(p)); + } + + let mut vpn_data: Vec<(String, String)> = Vec::new(); + + let remote = format!("{}:{}", config.remote, config.port); + + vpn_data.push(("remote".into(), remote)); + + let connection_type = match config.auth_type { + Some(OpenVpnAuthType::Password) => "password", + Some(OpenVpnAuthType::Tls) => "tls", + Some(OpenVpnAuthType::PasswordTls) => "password-tls", + Some(OpenVpnAuthType::StaticKey) => "static-key", + None => "tls", + }; + vpn_data.push(("connection-type".into(), connection_type.into())); + + if config.tcp { + vpn_data.push(("proto-tcp".into(), "yes".into())); + } + + if let Some(ref username) = config.username { + vpn_data.push(("username".into(), username.clone())); + } + if let Some(ref auth) = config.auth { + vpn_data.push(("auth".into(), auth.clone())); + } + if let Some(ref cipher) = config.cipher { + vpn_data.push(("cipher".into(), cipher.clone())); + } + if let Some(mtu) = config.mtu { + vpn_data.push(("tunnel-mtu".into(), mtu.to_string())); + } + + // certs + if let Some(ref ca) = config.ca_cert { + vpn_data.push(("ca".into(), ca.clone())); + } + if let Some(ref cert) = config.client_cert { + vpn_data.push(("cert".into(), cert.clone())); + } + if let Some(ref key) = config.client_key { + vpn_data.push(("key".into(), key.clone())); + } + + if let Some(ref compression) = config.compression { + #[allow(deprecated)] + match compression { + OpenVpnCompression::No => { + vpn_data.push(("compress".into(), "no".into())); + } + OpenVpnCompression::Lzo => { + vpn_data.push(("comp-lzo".into(), "yes".into())); + } + OpenVpnCompression::Lz4 => { + vpn_data.push(("compress".into(), "lz4".into())); + } + OpenVpnCompression::Lz4V2 => { + vpn_data.push(("compress".into(), "lz4-v2".into())); + } + OpenVpnCompression::Yes => { + vpn_data.push(("compress".into(), "yes".into())); + } + } + } + + // TLS hardening options + if let Some(ref key) = config.tls_auth_key { + vpn_data.push(("tls-auth".into(), key.clone())); + if let Some(dir) = config.tls_auth_direction { + vpn_data.push(("ta-dir".into(), dir.to_string())); + } + } + // FIXME: surely, there must be a better way to do this + if let Some(ref key) = config.tls_crypt { + vpn_data.push(("tls-crypt".into(), key.clone())); + } + if let Some(ref key) = config.tls_crypt_v2 { + vpn_data.push(("tls-crypt-v2".into(), key.clone())); + } + if let Some(ref ver) = config.tls_version_min { + vpn_data.push(("tls-version-min".into(), ver.clone())); + } + if let Some(ref ver) = config.tls_version_max { + vpn_data.push(("tls-version-max".into(), ver.clone())); + } + if let Some(ref cipher) = config.tls_cipher { + vpn_data.push(("tls-cipher".into(), cipher.clone())); + } + if let Some(ref cert_type) = config.remote_cert_tls { + vpn_data.push(("remote-cert-tls".into(), cert_type.clone())); + } + if let Some((ref name, ref name_type)) = config.verify_x509_name { + vpn_data.push(("verify-x509-name".into(), name.clone())); + vpn_data.push(("verify-x509-type".into(), name_type.clone())); + } + if let Some(ref path) = config.crl_verify { + vpn_data.push(("crl-verify".into(), path.clone())); + } + + if let Some(v) = config.ping { + vpn_data.push(("ping".into(), v.to_string())); + } + if let Some(v) = config.ping_exit { + vpn_data.push(("ping-exit".into(), v.to_string())); + } + if let Some(v) = config.ping_restart { + vpn_data.push(("ping-restart".into(), v.to_string())); + } + if let Some(v) = config.reneg_seconds { + vpn_data.push(("reneg-sec".into(), v.to_string())); + } + if let Some(v) = config.connect_timeout { + vpn_data.push(("connect-timeout".into(), v.to_string())); + } + if let Some(ref s) = config.data_ciphers { + vpn_data.push(("data-ciphers".into(), s.clone())); + } + if let Some(ref s) = config.data_ciphers_fallback { + vpn_data.push(("data-ciphers-fallback".into(), s.clone())); + } + if config.ncp_disable { + vpn_data.push(("ncp-disable".into(), "yes".into())); + } + // holy moly + + if let Some(ref proxy) = config.proxy { + match proxy { + OpenVpnProxy::Http { + server, + port, + username, + password, + retry, + } => { + if *port == 0 { + return Err(ConnectionError::InvalidAddress( + "proxy port must not be zero".into(), + )); + } + vpn_data.push(("proxy-type".into(), "http".into())); + vpn_data.push(("proxy-server".into(), server.clone())); + vpn_data.push(("proxy-port".into(), port.to_string())); + vpn_data.push(( + "proxy-retry".into(), + if *retry { "yes" } else { "no" }.into(), + )); + if let Some(u) = username { + vpn_data.push(("http-proxy-username".into(), u.clone())); + } + if let Some(p) = password { + vpn_data.push(("http-proxy-password".into(), p.clone())); + } + } + OpenVpnProxy::Socks { + server, + port, + retry, + } => { + if *port == 0 { + return Err(ConnectionError::InvalidAddress( + "proxy port must not be zero".into(), + )); + } + vpn_data.push(("proxy-type".into(), "socks".into())); + vpn_data.push(("proxy-server".into(), server.clone())); + vpn_data.push(("proxy-port".into(), port.to_string())); + vpn_data.push(( + "proxy-retry".into(), + if *retry { "yes" } else { "no" }.into(), + )); + } + } + } + + let data_dict = string_pairs_to_dict(vpn_data)?; + + let mut vpn_secrets: Vec<(String, String)> = Vec::new(); + if let Some(ref password) = config.password { + vpn_secrets.push(("password".into(), password.clone())); + } + if let Some(ref key_password) = config.key_password { + vpn_secrets.push(("cert-pass".into(), key_password.clone())); + } + + let mut vpn: HashMap<&'static str, Value<'static>> = HashMap::new(); + vpn.insert( + "service-type", + Value::from("org.freedesktop.NetworkManager.openvpn"), + ); + vpn.insert("data", Value::from(data_dict)); + if !vpn_secrets.is_empty() { + vpn.insert("secrets", Value::from(string_pairs_to_dict(vpn_secrets)?)); + } + + let mut ipv4: HashMap<&'static str, Value<'static>> = HashMap::new(); + ipv4.insert("method", Value::from("auto")); + if config.redirect_gateway { + ipv4.insert("never-default", Value::from(false)); + } + if !config.routes.is_empty() { + let route_data: Vec>> = config + .routes + .iter() + .map(|route| { + let mut route_dict = HashMap::new(); + route_dict.insert("dest".to_string(), Value::from(route.dest.clone())); + route_dict.insert("prefix".to_string(), Value::from(route.prefix)); + if let Some(ref nh) = route.next_hop { + route_dict.insert("next-hop".to_string(), Value::from(nh.clone())); + } + if let Some(m) = route.metric { + route_dict.insert("metric".to_string(), Value::from(m)); + } + route_dict + }) + .collect(); + ipv4.insert("route-data", Value::from(route_data)); + } + if let Some(dns) = &config.dns { + let dns_array: Vec = dns.iter().map(|s| Value::from(s.clone())).collect(); + ipv4.insert("dns", Value::from(dns_array)); + } + + let mut ipv6: HashMap<&'static str, Value<'static>> = HashMap::new(); + ipv6.insert("method", Value::from("ignore")); + + let mut settings = HashMap::new(); + settings.insert("connection", connection); + settings.insert("vpn", vpn); + settings.insert("ipv4", ipv4); + settings.insert("ipv6", ipv6); + + Ok(settings) +} #[cfg(test)] mod tests { use super::*; - use crate::api::models::{VpnType, WireGuardPeer}; + use crate::api::models::{ + OpenVpnCompression, OpenVpnConfig, OpenVpnProxy, VpnKind, WireGuardPeer, + }; fn create_test_credentials() -> VpnCredentials { let peer = WireGuardPeer::new( @@ -122,7 +412,7 @@ mod tests { .with_persistent_keepalive(25); VpnCredentials::new( - VpnType::WireGuard, + VpnKind::WireGuard, "TestVPN", "vpn.example.com:51820", "YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=", @@ -598,4 +888,437 @@ mod tests { assert!(result.is_ok(), "Should accept valid gateway: {}", gateway); } } + + // --- OpenVPN tests --- + fn create_openvpn_config() -> OpenVpnConfig { + OpenVpnConfig::new("TestOpenVPN", "vpn.example.com", 1194, false) + .with_ca_cert("/etc/openvpn/ca.crt") + .with_client_cert("/etc/openvpn/client.crt") + .with_client_key("/etc/openvpn/client.key") + } + + #[test] + fn builds_openvpn_connection() { + let config = create_openvpn_config(); + let opts = create_test_options(); + let result = build_openvpn_connection(&config, &opts); + assert!(result.is_ok()); + let settings = result.unwrap(); + assert!(settings.contains_key("connection")); + assert!(settings.contains_key("vpn")); + assert!(settings.contains_key("ipv4")); + assert!(settings.contains_key("ipv6")); + } + + #[test] + fn openvpn_connection_type_is_vpn() { + let config = create_openvpn_config(); + let opts = create_test_options(); + let settings = build_openvpn_connection(&config, &opts).unwrap(); + let conn = settings.get("connection").unwrap(); + assert_eq!(conn.get("type").unwrap(), &Value::from("vpn")); + } + + #[test] + fn openvpn_service_type_is_correct() { + let config = create_openvpn_config(); + let opts = create_test_options(); + let settings = build_openvpn_connection(&config, &opts).unwrap(); + let vpn = settings.get("vpn").unwrap(); + assert_eq!( + vpn.get("service-type").unwrap(), + &Value::from("org.freedesktop.NetworkManager.openvpn") + ); + } + + #[test] + fn openvpn_rejects_empty_remote() { + let mut config = create_openvpn_config(); + config.remote = "".into(); + let opts = create_test_options(); + let result = build_openvpn_connection(&config, &opts); + assert!(matches!( + result.unwrap_err(), + ConnectionError::InvalidGateway(_) + )); + } + + #[test] + fn openvpn_compression_no() { + let config = create_openvpn_config().with_compression(OpenVpnCompression::No); + let opts = create_test_options(); + let settings = build_openvpn_connection(&config, &opts).unwrap(); + let vpn = settings.get("vpn").unwrap(); + // vpn.data is packed — just assert the section exists and no error + assert!(vpn.contains_key("data")); + } + #[allow(deprecated)] + #[test] + fn openvpn_compression_lzo() { + let config = create_openvpn_config().with_compression(OpenVpnCompression::Lzo); + let opts = create_test_options(); + assert!(build_openvpn_connection(&config, &opts).is_ok()); + } + + #[test] + fn openvpn_compression_lz4() { + let config = create_openvpn_config().with_compression(OpenVpnCompression::Lz4); + let opts = create_test_options(); + assert!(build_openvpn_connection(&config, &opts).is_ok()); + } + + #[test] + fn openvpn_compression_lz4v2() { + let config = create_openvpn_config().with_compression(OpenVpnCompression::Lz4V2); + let opts = create_test_options(); + assert!(build_openvpn_connection(&config, &opts).is_ok()); + } + + #[test] + fn openvpn_compression_yes() { + let config = create_openvpn_config().with_compression(OpenVpnCompression::Yes); + let opts = create_test_options(); + assert!(build_openvpn_connection(&config, &opts).is_ok()); + } + + #[test] + fn openvpn_http_proxy() { + let config = create_openvpn_config().with_proxy(OpenVpnProxy::Http { + server: "proxy.example.com".into(), + port: 8080, + username: Some("user".into()), + password: Some("pass".into()), + retry: true, + }); + let opts = create_test_options(); + assert!(build_openvpn_connection(&config, &opts).is_ok()); + } + + #[test] + fn openvpn_http_proxy_no_credentials() { + let config = create_openvpn_config().with_proxy(OpenVpnProxy::Http { + server: "proxy.example.com".into(), + port: 3128, + username: None, + password: None, + retry: false, + }); + let opts = create_test_options(); + assert!(build_openvpn_connection(&config, &opts).is_ok()); + } + + #[test] + fn openvpn_socks_proxy() { + let config = create_openvpn_config().with_proxy(OpenVpnProxy::Socks { + server: "socks.example.com".into(), + port: 1080, + retry: false, + }); + let opts = create_test_options(); + assert!(build_openvpn_connection(&config, &opts).is_ok()); + } + + #[test] + fn openvpn_proxy_rejects_zero_port_http() { + let config = create_openvpn_config().with_proxy(OpenVpnProxy::Http { + server: "proxy.example.com".into(), + port: 0, + username: None, + password: None, + retry: false, + }); + let opts = create_test_options(); + assert!(matches!( + build_openvpn_connection(&config, &opts).unwrap_err(), + ConnectionError::InvalidAddress(_) + )); + } + + #[test] + fn openvpn_proxy_rejects_zero_port_socks() { + let config = create_openvpn_config().with_proxy(OpenVpnProxy::Socks { + server: "socks.example.com".into(), + port: 0, + retry: false, + }); + let opts = create_test_options(); + assert!(matches!( + build_openvpn_connection(&config, &opts).unwrap_err(), + ConnectionError::InvalidAddress(_) + )); + } + + #[test] + fn openvpn_with_dns() { + let config = create_openvpn_config().with_dns(vec!["1.1.1.1".into(), "8.8.8.8".into()]); + let opts = create_test_options(); + let settings = build_openvpn_connection(&config, &opts).unwrap(); + let ipv4 = settings.get("ipv4").unwrap(); + assert!(ipv4.contains_key("dns")); + } + + #[test] + fn openvpn_tcp_emits_proto_tcp() { + let config = OpenVpnConfig::new("TcpVPN", "vpn.example.com", 443, true); + let opts = create_test_options(); + let settings = build_openvpn_connection(&config, &opts).unwrap(); + let vpn = settings.get("vpn").unwrap(); + assert!(vpn.contains_key("data")); + } + + #[test] + fn openvpn_vpn_data_has_dict_signature() { + let config = create_openvpn_config(); + let opts = create_test_options(); + let settings = build_openvpn_connection(&config, &opts).unwrap(); + let vpn = settings.get("vpn").unwrap(); + let data = vpn.get("data").unwrap(); + assert_eq!( + data.value_signature().to_string(), + "a{ss}", + "vpn.data must be a{{ss}} for NetworkManager" + ); + } + + fn get_vpn_data_value( + settings: &HashMap<&str, HashMap<&str, Value>>, + key: &str, + ) -> Option { + let vpn = settings.get("vpn")?; + let data = vpn.get("data")?; + if let Value::Dict(dict) = data { + let val: String = dict.get::(&Value::from(key)).ok()??; + return Some(val); + } + None + } + + #[test] + fn openvpn_vpn_secrets_has_dict_signature() { + let config = create_openvpn_config() + .with_auth_type(OpenVpnAuthType::Password) + .with_username("user") + .with_password("secret"); + let opts = create_test_options(); + let settings = build_openvpn_connection(&config, &opts).unwrap(); + let vpn = settings.get("vpn").unwrap(); + let secrets = vpn.get("secrets").unwrap(); + assert_eq!( + secrets.value_signature().to_string(), + "a{ss}", + "vpn.secrets must be a{{ss}} for NetworkManager" + ); + } + + #[test] + fn openvpn_tls_auth_key_and_direction() { + let config = create_openvpn_config().with_tls_auth("/etc/openvpn/ta.key", Some(1)); + let opts = create_test_options(); + let settings = build_openvpn_connection(&config, &opts).unwrap(); + assert_eq!( + get_vpn_data_value(&settings, "tls-auth").as_deref(), + Some("/etc/openvpn/ta.key") + ); + assert_eq!( + get_vpn_data_value(&settings, "ta-dir").as_deref(), + Some("1") + ); + } + + #[test] + fn openvpn_tls_auth_key_without_direction() { + let config = create_openvpn_config().with_tls_auth("/etc/openvpn/ta.key", None); + let opts = create_test_options(); + let settings = build_openvpn_connection(&config, &opts).unwrap(); + assert_eq!( + get_vpn_data_value(&settings, "tls-auth").as_deref(), + Some("/etc/openvpn/ta.key") + ); + assert!(get_vpn_data_value(&settings, "ta-dir").is_none()); + } + + #[test] + fn openvpn_tls_crypt() { + let config = create_openvpn_config().with_tls_crypt("/etc/openvpn/tls-crypt.key"); + let opts = create_test_options(); + let settings = build_openvpn_connection(&config, &opts).unwrap(); + assert_eq!( + get_vpn_data_value(&settings, "tls-crypt").as_deref(), + Some("/etc/openvpn/tls-crypt.key") + ); + } + + #[test] + fn openvpn_tls_crypt_v2() { + let config = create_openvpn_config().with_tls_crypt_v2("/etc/openvpn/tls-crypt-v2.key"); + let opts = create_test_options(); + let settings = build_openvpn_connection(&config, &opts).unwrap(); + assert_eq!( + get_vpn_data_value(&settings, "tls-crypt-v2").as_deref(), + Some("/etc/openvpn/tls-crypt-v2.key") + ); + } + + #[test] + fn openvpn_tls_version_min() { + let config = create_openvpn_config().with_tls_version_min("1.2"); + let opts = create_test_options(); + let settings = build_openvpn_connection(&config, &opts).unwrap(); + assert_eq!( + get_vpn_data_value(&settings, "tls-version-min").as_deref(), + Some("1.2") + ); + } + + #[test] + fn openvpn_tls_version_max() { + let config = create_openvpn_config().with_tls_version_max("1.3"); + let opts = create_test_options(); + let settings = build_openvpn_connection(&config, &opts).unwrap(); + assert_eq!( + get_vpn_data_value(&settings, "tls-version-max").as_deref(), + Some("1.3") + ); + } + + #[test] + fn openvpn_tls_cipher() { + let config = + create_openvpn_config().with_tls_cipher("TLS-ECDHE-RSA-WITH-AES-256-GCM-SHA384"); + let opts = create_test_options(); + let settings = build_openvpn_connection(&config, &opts).unwrap(); + assert_eq!( + get_vpn_data_value(&settings, "tls-cipher").as_deref(), + Some("TLS-ECDHE-RSA-WITH-AES-256-GCM-SHA384") + ); + } + + #[test] + fn openvpn_remote_cert_tls() { + let config = create_openvpn_config().with_remote_cert_tls("server"); + let opts = create_test_options(); + let settings = build_openvpn_connection(&config, &opts).unwrap(); + assert_eq!( + get_vpn_data_value(&settings, "remote-cert-tls").as_deref(), + Some("server") + ); + } + + #[test] + fn openvpn_verify_x509_name() { + let config = create_openvpn_config().with_verify_x509_name("vpn.example.com", "name"); + let opts = create_test_options(); + let settings = build_openvpn_connection(&config, &opts).unwrap(); + assert_eq!( + get_vpn_data_value(&settings, "verify-x509-name").as_deref(), + Some("vpn.example.com") + ); + assert_eq!( + get_vpn_data_value(&settings, "verify-x509-type").as_deref(), + Some("name") + ); + } + + #[test] + fn openvpn_crl_verify() { + let config = create_openvpn_config().with_crl_verify("/etc/openvpn/crl.pem"); + let opts = create_test_options(); + let settings = build_openvpn_connection(&config, &opts).unwrap(); + assert_eq!( + get_vpn_data_value(&settings, "crl-verify").as_deref(), + Some("/etc/openvpn/crl.pem") + ); + } + + #[test] + fn openvpn_tls_options_absent_by_default() { + let config = create_openvpn_config(); + let opts = create_test_options(); + let settings = build_openvpn_connection(&config, &opts).unwrap(); + assert!(get_vpn_data_value(&settings, "tls-auth").is_none()); + assert!(get_vpn_data_value(&settings, "ta-dir").is_none()); + assert!(get_vpn_data_value(&settings, "tls-crypt").is_none()); + assert!(get_vpn_data_value(&settings, "tls-crypt-v2").is_none()); + assert!(get_vpn_data_value(&settings, "tls-version-min").is_none()); + assert!(get_vpn_data_value(&settings, "tls-version-max").is_none()); + assert!(get_vpn_data_value(&settings, "tls-cipher").is_none()); + assert!(get_vpn_data_value(&settings, "remote-cert-tls").is_none()); + assert!(get_vpn_data_value(&settings, "verify-x509-name").is_none()); + assert!(get_vpn_data_value(&settings, "crl-verify").is_none()); + } + + #[test] + fn openvpn_resilience_keys_in_vpn_data() { + let config = create_openvpn_config() + .with_ping(10) + .with_ping_exit(60) + .with_ping_restart(120) + .with_reneg_seconds(3600) + .with_connect_timeout(30); + let opts = create_test_options(); + let settings = build_openvpn_connection(&config, &opts).unwrap(); + assert_eq!(get_vpn_data_value(&settings, "ping").as_deref(), Some("10")); + assert_eq!( + get_vpn_data_value(&settings, "ping-exit").as_deref(), + Some("60") + ); + assert_eq!( + get_vpn_data_value(&settings, "ping-restart").as_deref(), + Some("120") + ); + assert_eq!( + get_vpn_data_value(&settings, "reneg-sec").as_deref(), + Some("3600") + ); + assert_eq!( + get_vpn_data_value(&settings, "connect-timeout").as_deref(), + Some("30") + ); + } + + #[test] + fn openvpn_data_ciphers_and_ncp_disable() { + let config = create_openvpn_config() + .with_data_ciphers("AES-256-GCM:AES-128-GCM") + .with_data_ciphers_fallback("AES-256-GCM") + .with_ncp_disable(true); + let opts = create_test_options(); + let settings = build_openvpn_connection(&config, &opts).unwrap(); + assert_eq!( + get_vpn_data_value(&settings, "data-ciphers").as_deref(), + Some("AES-256-GCM:AES-128-GCM") + ); + assert_eq!( + get_vpn_data_value(&settings, "data-ciphers-fallback").as_deref(), + Some("AES-256-GCM") + ); + assert_eq!( + get_vpn_data_value(&settings, "ncp-disable").as_deref(), + Some("yes") + ); + } + + #[test] + fn openvpn_ipv4_route_data() { + use crate::api::models::VpnRoute; + let config = create_openvpn_config() + .with_routes(vec![VpnRoute::new("10.0.0.0", 24).next_hop("192.168.1.1")]); + let opts = create_test_options(); + let settings = build_openvpn_connection(&config, &opts).unwrap(); + let ipv4 = settings.get("ipv4").unwrap(); + let rd = ipv4.get("route-data").unwrap(); + let Value::Array(arr) = rd else { + panic!("route-data must be an array"); + }; + assert_eq!(arr.iter().count(), 1, "expected one static route"); + } + + #[test] + fn openvpn_redirect_gateway_sets_never_default() { + let config = create_openvpn_config().with_redirect_gateway(true); + let opts = create_test_options(); + let settings = build_openvpn_connection(&config, &opts).unwrap(); + let ipv4 = settings.get("ipv4").unwrap(); + assert_eq!(ipv4.get("never-default"), Some(&Value::from(false))); + } } diff --git a/nmrs/src/api/builders/wifi_builder.rs b/nmrs/src/api/builders/wifi_builder.rs index 0acff2b1..4851bb58 100644 --- a/nmrs/src/api/builders/wifi_builder.rs +++ b/nmrs/src/api/builders/wifi_builder.rs @@ -10,6 +10,7 @@ use super::connection_builder::ConnectionBuilder; use crate::api::models::{self, ConnectionOptions, EapMethod}; /// WiFi band selection. +#[non_exhaustive] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WifiBand { /// 2.4 GHz band diff --git a/nmrs/src/api/builders/wireguard_builder.rs b/nmrs/src/api/builders/wireguard_builder.rs index b675ec4f..8a72120f 100644 --- a/nmrs/src/api/builders/wireguard_builder.rs +++ b/nmrs/src/api/builders/wireguard_builder.rs @@ -36,6 +36,7 @@ use crate::api::models::{ConnectionError, ConnectionOptions, WireGuardPeer}; /// .build() /// .expect("Failed to build WireGuard connection"); /// ``` +#[non_exhaustive] pub struct WireGuardBuilder { inner: ConnectionBuilder, name: String, @@ -185,6 +186,7 @@ impl WireGuardBuilder { /// - `ConnectionError::InvalidAddress` if address is missing or invalid /// - `ConnectionError::InvalidPeers` if no peers are configured or peer validation fails /// - `ConnectionError::InvalidGateway` if any peer gateway is invalid + #[must_use = "the connection settings must be passed to NetworkManager"] pub fn build( mut self, ) -> Result>>, ConnectionError> { diff --git a/nmrs/src/api/mod.rs b/nmrs/src/api/mod.rs index 86bb8a0b..e0da5502 100644 --- a/nmrs/src/api/mod.rs +++ b/nmrs/src/api/mod.rs @@ -5,3 +5,4 @@ pub mod builders; pub mod models; pub mod network_manager; +pub mod wifi_scope; diff --git a/nmrs/src/api/models/access_point.rs b/nmrs/src/api/models/access_point.rs new file mode 100644 index 00000000..bc92c264 --- /dev/null +++ b/nmrs/src/api/models/access_point.rs @@ -0,0 +1,410 @@ +//! Per-AP model preserving BSSID and per-device state. +//! +//! [`AccessPoint`] represents a single Wi-Fi access point seen by a specific +//! wireless device, preserving the BSSID and all NM-reported properties. +//! Use [`list_access_points`](crate::NetworkManager::list_access_points) to +//! enumerate them; use [`list_networks`](crate::NetworkManager::list_networks) +//! for the deduplicated SSID-grouped view. + +use std::fmt; + +use serde::{Deserialize, Serialize}; +use zvariant::OwnedObjectPath; + +use super::DeviceState; + +/// A single Wi-Fi access point reported by NetworkManager. +/// +/// Unlike [`Network`](super::Network), which groups APs sharing an SSID, +/// each `AccessPoint` corresponds to one BSSID and carries per-device state. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq)] +pub struct AccessPoint { + /// D-Bus path of this access point object. + pub path: OwnedObjectPath, + /// D-Bus path of the wireless device that sees this AP. + pub device_path: OwnedObjectPath, + /// Interface name of the device (e.g. `"wlan0"`). + pub interface: String, + /// SSID decoded as UTF-8, or `""` for hidden networks. + pub ssid: String, + /// Raw SSID bytes for non-UTF-8 SSIDs. + pub ssid_bytes: Vec, + /// BSSID in `"XX:XX:XX:XX:XX:XX"` format. + pub bssid: String, + /// Operating frequency in MHz. + pub frequency_mhz: u32, + /// Maximum supported bitrate in Kbit/s. + pub max_bitrate_kbps: u32, + /// Signal strength percentage (0–100). + pub strength: u8, + /// AP operating mode. + pub mode: ApMode, + /// Decoded security capabilities. + pub security: SecurityFeatures, + /// Monotonic seconds since boot when last seen, or `None` if never. + pub last_seen_secs: Option, + /// `true` if this AP is the active connection on `device_path`. + pub is_active: bool, + /// State of the wireless device at enumeration time (not live). + pub device_state: DeviceState, +} + +/// Wi-Fi access point operating mode. +#[non_exhaustive] +#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)] +pub enum ApMode { + /// Ad-hoc (IBSS) network. + Adhoc, + /// Infrastructure (managed) mode — the most common. + Infrastructure, + /// Access point (hotspot) mode. + Ap, + /// Mesh mode. + Mesh, + /// Unknown or unrecognised NM mode value. + Unknown(u32), +} + +impl From for ApMode { + fn from(value: u32) -> Self { + match value { + 1 => Self::Adhoc, + 2 => Self::Infrastructure, + 3 => Self::Ap, + 4 => Self::Mesh, + other => Self::Unknown(other), + } + } +} + +impl fmt::Display for ApMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Adhoc => write!(f, "Ad-Hoc"), + Self::Infrastructure => write!(f, "Infrastructure"), + Self::Ap => write!(f, "AP"), + Self::Mesh => write!(f, "Mesh"), + Self::Unknown(v) => write!(f, "Unknown({v})"), + } + } +} + +/// Decoded security capabilities of an access point. +/// +/// Derived from NetworkManager's `Flags`, `WpaFlags`, and `RsnFlags` properties +/// using the `NM80211ApFlags` and `NM80211ApSecurityFlags` bitmask values: +/// +/// | Flag constant | Value | Field(s) | +/// |---|---|---| +/// | `NM_802_11_AP_FLAGS_PRIVACY` | `0x1` | `privacy` | +/// | `NM_802_11_AP_FLAGS_WPS` | `0x2` | `wps` | +/// | `PAIR_WEP40` | `0x1` | `wep40` | +/// | `PAIR_WEP104` | `0x2` | `wep104` | +/// | `PAIR_TKIP` | `0x4` | `tkip` | +/// | `PAIR_CCMP` | `0x8` | `ccmp` | +/// | `KEY_MGMT_PSK` | `0x100` | `psk` | +/// | `KEY_MGMT_802_1X` | `0x200` | `eap` | +/// | `KEY_MGMT_SAE` | `0x400` | `sae` | +/// | `KEY_MGMT_OWE` | `0x800` | `owe` | +/// | `KEY_MGMT_OWE_TM` | `0x1000` | `owe_transition_mode` | +/// | `KEY_MGMT_EAP_SUITE_B_192` | `0x2000` | `eap_suite_b_192` | +#[non_exhaustive] +#[derive(Debug, Clone, Copy, Eq, PartialEq, Default, Serialize, Deserialize)] +pub struct SecurityFeatures { + /// AP advertises privacy (WEP or higher). + pub privacy: bool, + /// WPS (Wi-Fi Protected Setup) is available. + pub wps: bool, + + /// Pre-shared key authentication (WPA/WPA2-Personal). + pub psk: bool, + /// 802.1X / EAP authentication (WPA/WPA2-Enterprise). + pub eap: bool, + /// Simultaneous Authentication of Equals (WPA3-Personal). + pub sae: bool, + /// Opportunistic Wireless Encryption. + pub owe: bool, + /// OWE transition mode (mixed open + OWE). + pub owe_transition_mode: bool, + /// EAP Suite B 192-bit (WPA3-Enterprise 192-bit). + pub eap_suite_b_192: bool, + + /// Pairwise WEP-40 cipher. + pub wep40: bool, + /// Pairwise WEP-104 cipher. + pub wep104: bool, + /// TKIP cipher. + pub tkip: bool, + /// CCMP (AES) cipher. + pub ccmp: bool, +} + +impl SecurityFeatures { + /// Returns `true` if no security mechanism is advertised. + #[must_use] + pub fn is_open(&self) -> bool { + !self.privacy + && !self.psk + && !self.eap + && !self.sae + && !self.owe + && !self.owe_transition_mode + && !self.eap_suite_b_192 + && !self.wep40 + && !self.wep104 + } + + /// Returns `true` if enterprise authentication is available. + #[must_use] + pub fn is_enterprise(&self) -> bool { + self.eap || self.eap_suite_b_192 + } + + /// Returns `true` if WPA3 (SAE or OWE) is available. + #[must_use] + pub fn is_wpa3(&self) -> bool { + self.sae || self.owe + } + + /// Returns the preferred connection type for this security profile. + #[must_use] + pub fn preferred_connect_type(&self) -> ConnectType { + if self.eap || self.eap_suite_b_192 { + ConnectType::Eap + } else if self.sae { + ConnectType::Sae + } else if self.owe || self.owe_transition_mode { + ConnectType::Owe + } else if self.psk { + ConnectType::Psk + } else { + ConnectType::Open + } + } +} + +/// Preferred connection type derived from [`SecurityFeatures`]. +#[non_exhaustive] +#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)] +pub enum ConnectType { + /// Open (no authentication). + Open, + /// Pre-shared key (WPA/WPA2-Personal). + Psk, + /// SAE (WPA3-Personal). + Sae, + /// 802.1X / EAP (Enterprise). + Eap, + /// Opportunistic Wireless Encryption. + Owe, +} + +impl fmt::Display for ConnectType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Open => write!(f, "Open"), + Self::Psk => write!(f, "PSK"), + Self::Sae => write!(f, "SAE"), + Self::Eap => write!(f, "EAP"), + Self::Owe => write!(f, "OWE"), + } + } +} + +// NM80211ApFlags +const AP_FLAGS_PRIVACY: u32 = 0x1; +const AP_FLAGS_WPS: u32 = 0x2; + +// NM80211ApSecurityFlags (applied to both WpaFlags and RsnFlags) +const SEC_PAIR_WEP40: u32 = 0x1; +const SEC_PAIR_WEP104: u32 = 0x2; +const SEC_PAIR_TKIP: u32 = 0x4; +const SEC_PAIR_CCMP: u32 = 0x8; +const SEC_KEY_MGMT_PSK: u32 = 0x100; +const SEC_KEY_MGMT_802_1X: u32 = 0x200; +const SEC_KEY_MGMT_SAE: u32 = 0x400; +const SEC_KEY_MGMT_OWE: u32 = 0x800; +const SEC_KEY_MGMT_OWE_TM: u32 = 0x1000; +const SEC_KEY_MGMT_EAP_SUITE_B_192: u32 = 0x2000; + +/// Decodes NM's AP flag triplet into a [`SecurityFeatures`]. +/// +/// `flags` is `NM80211ApFlags`, `wpa` and `rsn` are `NM80211ApSecurityFlags` +/// from the `WpaFlags` and `RsnFlags` AP properties respectively. +pub(crate) fn decode_security(flags: u32, wpa: u32, rsn: u32) -> SecurityFeatures { + let combined = wpa | rsn; + SecurityFeatures { + privacy: (flags & AP_FLAGS_PRIVACY) != 0, + wps: (flags & AP_FLAGS_WPS) != 0, + psk: (combined & SEC_KEY_MGMT_PSK) != 0, + eap: (combined & SEC_KEY_MGMT_802_1X) != 0, + sae: (combined & SEC_KEY_MGMT_SAE) != 0, + owe: (combined & SEC_KEY_MGMT_OWE) != 0, + owe_transition_mode: (combined & SEC_KEY_MGMT_OWE_TM) != 0, + eap_suite_b_192: (combined & SEC_KEY_MGMT_EAP_SUITE_B_192) != 0, + wep40: (combined & SEC_PAIR_WEP40) != 0, + wep104: (combined & SEC_PAIR_WEP104) != 0, + tkip: (combined & SEC_PAIR_TKIP) != 0, + ccmp: (combined & SEC_PAIR_CCMP) != 0, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decode_open_network() { + let sec = decode_security(0, 0, 0); + assert!(sec.is_open()); + assert!(!sec.is_enterprise()); + assert!(!sec.is_wpa3()); + assert_eq!(sec.preferred_connect_type(), ConnectType::Open); + } + + #[test] + fn decode_wep40() { + let sec = decode_security(AP_FLAGS_PRIVACY, SEC_PAIR_WEP40, 0); + assert!(!sec.is_open()); + assert!(sec.privacy); + assert!(sec.wep40); + assert_eq!(sec.preferred_connect_type(), ConnectType::Open); + } + + #[test] + fn decode_wep104() { + let sec = decode_security(AP_FLAGS_PRIVACY, SEC_PAIR_WEP104, 0); + assert!(sec.wep104); + assert!(sec.privacy); + } + + #[test] + fn decode_wpa_tkip_psk() { + let sec = decode_security(AP_FLAGS_PRIVACY, SEC_PAIR_TKIP | SEC_KEY_MGMT_PSK, 0); + assert!(sec.psk); + assert!(sec.tkip); + assert!(!sec.ccmp); + assert_eq!(sec.preferred_connect_type(), ConnectType::Psk); + } + + #[test] + fn decode_wpa2_ccmp_psk() { + let sec = decode_security(AP_FLAGS_PRIVACY, 0, SEC_PAIR_CCMP | SEC_KEY_MGMT_PSK); + assert!(sec.psk); + assert!(sec.ccmp); + assert!(!sec.tkip); + assert_eq!(sec.preferred_connect_type(), ConnectType::Psk); + } + + #[test] + fn decode_wpa2_enterprise() { + let sec = decode_security(AP_FLAGS_PRIVACY, 0, SEC_PAIR_CCMP | SEC_KEY_MGMT_802_1X); + assert!(sec.eap); + assert!(sec.ccmp); + assert!(sec.is_enterprise()); + assert_eq!(sec.preferred_connect_type(), ConnectType::Eap); + } + + #[test] + fn decode_wpa3_sae() { + let sec = decode_security(AP_FLAGS_PRIVACY, 0, SEC_PAIR_CCMP | SEC_KEY_MGMT_SAE); + assert!(sec.sae); + assert!(sec.ccmp); + assert!(sec.is_wpa3()); + assert_eq!(sec.preferred_connect_type(), ConnectType::Sae); + } + + #[test] + fn decode_owe() { + let sec = decode_security(0, 0, SEC_PAIR_CCMP | SEC_KEY_MGMT_OWE); + assert!(sec.owe); + assert!(sec.is_wpa3()); + assert_eq!(sec.preferred_connect_type(), ConnectType::Owe); + } + + #[test] + fn decode_owe_transition() { + let sec = decode_security(0, 0, SEC_KEY_MGMT_OWE_TM); + assert!(sec.owe_transition_mode); + assert_eq!(sec.preferred_connect_type(), ConnectType::Owe); + } + + #[test] + fn decode_eap_suite_b_192() { + let sec = decode_security( + AP_FLAGS_PRIVACY, + 0, + SEC_PAIR_CCMP | SEC_KEY_MGMT_EAP_SUITE_B_192, + ); + assert!(sec.eap_suite_b_192); + assert!(sec.is_enterprise()); + assert_eq!(sec.preferred_connect_type(), ConnectType::Eap); + } + + #[test] + fn decode_wps_flag() { + let sec = decode_security(AP_FLAGS_WPS, 0, 0); + assert!(sec.wps); + } + + #[test] + fn decode_mixed_wpa_wpa2() { + let sec = decode_security( + AP_FLAGS_PRIVACY, + SEC_PAIR_TKIP | SEC_KEY_MGMT_PSK, + SEC_PAIR_CCMP | SEC_KEY_MGMT_PSK, + ); + assert!(sec.psk); + assert!(sec.tkip); + assert!(sec.ccmp); + } + + #[test] + fn ap_mode_from_u32() { + assert_eq!(ApMode::from(1), ApMode::Adhoc); + assert_eq!(ApMode::from(2), ApMode::Infrastructure); + assert_eq!(ApMode::from(3), ApMode::Ap); + assert_eq!(ApMode::from(4), ApMode::Mesh); + assert_eq!(ApMode::from(99), ApMode::Unknown(99)); + } + + #[test] + fn connect_type_display() { + assert_eq!(ConnectType::Open.to_string(), "Open"); + assert_eq!(ConnectType::Psk.to_string(), "PSK"); + assert_eq!(ConnectType::Sae.to_string(), "SAE"); + assert_eq!(ConnectType::Eap.to_string(), "EAP"); + assert_eq!(ConnectType::Owe.to_string(), "OWE"); + } + + #[test] + fn security_features_default_is_open() { + let sec = SecurityFeatures::default(); + assert!(sec.is_open()); + } + + #[test] + fn eap_prioritized_over_psk() { + let sec = SecurityFeatures { + psk: true, + eap: true, + ccmp: true, + privacy: true, + ..Default::default() + }; + assert_eq!(sec.preferred_connect_type(), ConnectType::Eap); + } + + #[test] + fn sae_prioritized_over_psk() { + let sec = SecurityFeatures { + psk: true, + sae: true, + ccmp: true, + privacy: true, + ..Default::default() + }; + assert_eq!(sec.preferred_connect_type(), ConnectType::Sae); + } +} diff --git a/nmrs/src/api/models/bluetooth.rs b/nmrs/src/api/models/bluetooth.rs index eb8fe4f7..430e8f8e 100644 --- a/nmrs/src/api/models/bluetooth.rs +++ b/nmrs/src/api/models/bluetooth.rs @@ -40,6 +40,8 @@ pub struct BluetoothIdentity { pub bdaddr: String, /// Bluetooth device type (DUN or PANU) pub bt_device_type: BluetoothNetworkRole, + /// Optional Bluetooth adapter name (e.g., "hci1"). Defaults to "hci0". + pub adapter: Option, } impl BluetoothIdentity { @@ -73,6 +75,32 @@ impl BluetoothIdentity { Ok(Self { bdaddr, bt_device_type, + adapter: None, + }) + } + + /// Creates a new `BluetoothIdentity` with a specific adapter. + /// + /// # Arguments + /// + /// * `bdaddr` - Bluetooth MAC address (e.g., "00:1A:7D:DA:71:13") + /// * `bt_device_type` - Bluetooth network role (PanU or Dun) + /// * `adapter` - Bluetooth adapter name (e.g., "hci1") + /// + /// # Errors + /// + /// Returns a `ConnectionError` if the provided `bdaddr` is not a + /// valid Bluetooth MAC address format. + pub fn with_adapter( + bdaddr: String, + bt_device_type: BluetoothNetworkRole, + adapter: String, + ) -> Result { + validate_bluetooth_address(&bdaddr)?; + Ok(Self { + bdaddr, + bt_device_type, + adapter: Some(adapter), }) } } diff --git a/nmrs/src/api/models/connectivity.rs b/nmrs/src/api/models/connectivity.rs new file mode 100644 index 00000000..a6ad7e6c --- /dev/null +++ b/nmrs/src/api/models/connectivity.rs @@ -0,0 +1,129 @@ +//! Connectivity state and captive-portal awareness. +//! +//! NetworkManager periodically (or on demand via +//! [`crate::NetworkManager::check_connectivity`]) probes a well-known URL to +//! determine whether the host has actual internet access or is behind a captive +//! portal. The result is exposed as a [`ConnectivityState`]. +//! +//! UIs should watch for [`ConnectivityState::Portal`] and prompt the user to +//! open their browser at the captive portal URL (see +//! [`crate::NetworkManager::captive_portal_url`]). + +use std::fmt; + +/// NM's `NMConnectivityState` enum. +#[non_exhaustive] +#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)] +pub enum ConnectivityState { + /// NM has not checked yet. + Unknown, + /// No network connection at all. + None, + /// Connected behind a captive portal. + Portal, + /// Connected but no internet (upstream unreachable). + Limited, + /// Connected and internet-reachable. + Full, +} + +impl ConnectivityState { + /// `true` only when the host has verified internet connectivity. + #[must_use] + pub fn is_usable_for_internet(self) -> bool { + matches!(self, Self::Full) + } + + /// `true` when NM detected a captive portal. + #[must_use] + pub fn is_captive(self) -> bool { + matches!(self, Self::Portal) + } +} + +impl From for ConnectivityState { + fn from(v: u32) -> Self { + match v { + 0 => Self::Unknown, + 1 => Self::None, + 2 => Self::Portal, + 3 => Self::Limited, + 4 => Self::Full, + _ => Self::Unknown, + } + } +} + +impl From for u32 { + fn from(s: ConnectivityState) -> u32 { + match s { + ConnectivityState::Unknown => 0, + ConnectivityState::None => 1, + ConnectivityState::Portal => 2, + ConnectivityState::Limited => 3, + ConnectivityState::Full => 4, + } + } +} + +impl fmt::Display for ConnectivityState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Unknown => write!(f, "unknown"), + Self::None => write!(f, "none"), + Self::Portal => write!(f, "portal"), + Self::Limited => write!(f, "limited"), + Self::Full => write!(f, "full"), + } + } +} + +/// Snapshot of NM's connectivity subsystem. +/// +/// Returned by [`crate::NetworkManager::connectivity_report`]. +#[non_exhaustive] +#[derive(Debug, Clone)] +pub struct ConnectivityReport { + /// Current connectivity state. + pub state: ConnectivityState, + /// Whether NM is allowed to probe. + pub check_enabled: bool, + /// URL NM probes when checking (may be empty if disabled). + pub check_uri: Option, + /// Captive-portal URL detected by NM, if state is [`ConnectivityState::Portal`]. + /// `None` when NM has not filled in the URL or the NM version doesn't expose it. + pub captive_portal_url: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip_all() { + for code in 0..=4 { + let s = ConnectivityState::from(code); + assert_eq!(u32::from(s), code); + } + } + + #[test] + fn out_of_range_maps_to_unknown() { + assert_eq!(ConnectivityState::from(99), ConnectivityState::Unknown); + } + + #[test] + fn is_captive() { + assert!(ConnectivityState::Portal.is_captive()); + assert!(!ConnectivityState::Full.is_captive()); + assert!(!ConnectivityState::None.is_captive()); + } + + #[test] + fn is_usable() { + assert!(ConnectivityState::Full.is_usable_for_internet()); + assert!(!ConnectivityState::Portal.is_usable_for_internet()); + assert!(!ConnectivityState::Limited.is_usable_for_internet()); + assert!(!ConnectivityState::Unknown.is_usable_for_internet()); + } +} diff --git a/nmrs/src/api/models/device.rs b/nmrs/src/api/models/device.rs index e6c92adf..94f72140 100644 --- a/nmrs/src/api/models/device.rs +++ b/nmrs/src/api/models/device.rs @@ -1,5 +1,7 @@ use std::fmt::{Display, Formatter}; +use zvariant::OwnedObjectPath; + /// Represents a network device managed by NetworkManager. /// /// A device can be a WiFi adapter, Ethernet interface, or other network hardware. @@ -59,6 +61,39 @@ pub struct Device { // pub speed: Option, } +/// A Wi-Fi device summary returned by +/// [`list_wifi_devices`](crate::NetworkManager::list_wifi_devices). +/// +/// Use this on multi-radio machines (laptops with USB dongles, docks with a +/// second wireless adapter, etc.) to discover the available interfaces and +/// pick one to scope subsequent operations to. Pair with +/// [`NetworkManager::wifi`](crate::NetworkManager::wifi) for ergonomic +/// per-interface calls. +#[non_exhaustive] +#[derive(Debug, Clone)] +pub struct WifiDevice { + /// D-Bus object path of the device. + pub path: OwnedObjectPath, + /// Interface name (e.g. `"wlan0"`). + pub interface: String, + /// Current MAC address (may be randomized). + pub hw_address: String, + /// Permanent (factory-burned) MAC, if NM exposes it. + pub permanent_hw_address: Option, + /// Kernel driver name, if available. + pub driver: Option, + /// Current device state. + pub state: DeviceState, + /// Whether NetworkManager manages this device. + pub managed: bool, + /// Whether NM will autoconnect known networks on this device. + pub autoconnect: bool, + /// `true` if the device currently has an active access point. + pub is_active: bool, + /// SSID of the currently active AP, if any. + pub active_ssid: Option, +} + /// Represents the hardware identity of a network device. /// /// Contains MAC addresses that uniquely identify the device. The permanent diff --git a/nmrs/src/api/models/error.rs b/nmrs/src/api/models/error.rs index 2b9bc8fc..4c724f9a 100644 --- a/nmrs/src/api/models/error.rs +++ b/nmrs/src/api/models/error.rs @@ -1,5 +1,7 @@ use thiserror::Error; +use crate::core::ovpn_parser::error::OvpnParseError; + use super::connection_state::ConnectionStateReason; use super::state_reason::StateReason; @@ -18,7 +20,7 @@ use super::state_reason::StateReason; /// # async fn example() -> nmrs::Result<()> { /// let nm = NetworkManager::new().await?; /// -/// match nm.connect("MyNetwork", WifiSecurity::WpaPsk { +/// match nm.connect("MyNetwork", None, WifiSecurity::WpaPsk { /// psk: "password".into() /// }).await { /// Ok(_) => println!("Connected!"), @@ -46,7 +48,7 @@ use super::state_reason::StateReason; /// let nm = NetworkManager::new().await?; /// /// for attempt in 1..=3 { -/// match nm.connect("MyNetwork", WifiSecurity::Open).await { +/// match nm.connect("MyNetwork", None, WifiSecurity::Open).await { /// Ok(_) => { /// println!("Connected on attempt {}", attempt); /// break; @@ -112,6 +114,22 @@ pub enum ConnectionError { #[error("no saved connection for network")] NoSavedConnection, + /// No saved profile with the given UUID. + #[error("saved connection '{0}' not found")] + SavedConnectionNotFound(String), + + /// Saved profile settings are missing required keys or are inconsistent. + #[error("saved connection malformed: {0}")] + MalformedSavedConnection(String), + + /// A public builder was missing a required field. + #[error("incomplete builder: {0}")] + IncompleteBuilder(String), + + /// NM's connectivity checks are disabled; `check_connectivity` cannot run. + #[error("connectivity checks are disabled in NetworkManager")] + ConnectivityCheckDisabled, + /// An empty password was provided for the requested network. #[error("no password was provided")] MissingPassword, @@ -132,6 +150,14 @@ pub enum ConnectionError { #[error("no VPN connection found")] NoVpnConnection, + /// VPN connection not found by UUID or name. + #[error("VPN connection '{0}' not found")] + VpnNotFound(String), + + /// Multiple VPN connections share the same display name. + #[error("multiple VPN connections named '{0}', use UUID")] + VpnIdAmbiguous(String), + /// Invalid IP address or CIDR notation #[error("invalid address: {0}")] InvalidAddress(String), @@ -182,4 +208,43 @@ pub enum ConnectionError { /// A secret agent is already registered under this identifier. #[error("secret agent already registered under this identifier")] AgentAlreadyRegistered, + + /// An error occured while parsing a configuration + #[error("error while parsing a configuration: {0}")] + ParseError(OvpnParseError), + + /// Access point with the given SSID and BSSID was not found. + #[error("access point for SSID '{ssid}' with BSSID '{bssid}' not found")] + ApBssidNotFound { + /// SSID that was searched for. + ssid: String, + /// BSSID that was searched for. + bssid: String, + }, + + /// Invalid BSSID format. + #[error("invalid BSSID format: '{0}' (expected XX:XX:XX:XX:XX:XX)")] + InvalidBssid(String), + + /// Interface exists but is not a Wi-Fi device. + #[error("interface '{interface}' is not a Wi-Fi device")] + NotAWifiDevice { + /// The interface name that was checked. + interface: String, + }, + + /// No Wi-Fi device with the given interface name. + #[error("no Wi-Fi device named '{interface}'")] + WifiInterfaceNotFound { + /// The interface name that was searched for. + interface: String, + }, + + /// A radio is hardware-disabled via rfkill. + #[error("radio is hardware-disabled (rfkill)")] + HardwareRadioKilled, + + /// The BlueZ Bluetooth stack is unavailable. + #[error("bluetooth stack unavailable: {0}")] + BluezUnavailable(String), } diff --git a/nmrs/src/api/models/mod.rs b/nmrs/src/api/models/mod.rs index c5f1b030..f5ca15b7 100644 --- a/nmrs/src/api/models/mod.rs +++ b/nmrs/src/api/models/mod.rs @@ -1,21 +1,33 @@ +pub(crate) mod access_point; mod bluetooth; mod config; mod connection_state; +mod connectivity; mod device; mod error; +mod openvpn; +mod radio; +mod saved_connection; mod state_reason; mod vpn; mod wifi; +mod wireguard; #[cfg(test)] #[path = "tests.rs"] mod tests; +pub use access_point::*; pub use bluetooth::*; pub use config::*; pub use connection_state::*; +pub use connectivity::*; pub use device::*; pub use error::*; +pub use openvpn::*; +pub use radio::*; +pub use saved_connection::*; pub use state_reason::*; pub use vpn::*; pub use wifi::*; +pub use wireguard::*; diff --git a/nmrs/src/api/models/openvpn.rs b/nmrs/src/api/models/openvpn.rs new file mode 100644 index 00000000..76cc41a6 --- /dev/null +++ b/nmrs/src/api/models/openvpn.rs @@ -0,0 +1,791 @@ +#![allow(deprecated)] + +use super::vpn::{VpnConfig, VpnKind}; +use crate::api::models::error::ConnectionError; +use std::convert::TryFrom; +use std::net::Ipv4Addr; +use uuid::Uuid; + +/// A static IPv4 route for OpenVPN split tunneling. +/// +/// Serialized to NetworkManager `ipv4.route-data`. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VpnRoute { + /// Destination network (e.g. `10.0.0.0`). + pub dest: String, + /// CIDR prefix length (0–32). + pub prefix: u32, + /// Optional gateway (`next-hop` in NM). + pub next_hop: Option, + /// Optional route metric. + pub metric: Option, +} + +impl VpnRoute { + /// Creates a route to `dest`/`prefix`. + #[must_use] + pub fn new(dest: impl Into, prefix: u32) -> Self { + Self { + dest: dest.into(), + prefix, + next_hop: None, + metric: None, + } + } + + /// Sets the gateway for this route. + #[must_use] + pub fn next_hop(mut self, gateway: impl Into) -> Self { + self.next_hop = Some(gateway.into()); + self + } + + /// Sets the route metric. + #[must_use] + pub fn metric(mut self, metric: u32) -> Self { + self.metric = Some(metric); + self + } +} + +/// OpenVPN authentication type. +/// +/// Specifies how the client authenticates with the OpenVPN server. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OpenVpnAuthType { + /// Username/password authentication only. + Password, + /// TLS certificate authentication only. + Tls, + /// Both password and TLS certificate authentication. + PasswordTls, + /// Static key authentication (pre-shared key). + StaticKey, +} + +/// OpenVPN connection configuration. +/// +/// Stores the necessary information to configure and connect to an OpenVPN server. +/// +/// # Example +/// +/// ```rust +/// use nmrs::{OpenVpnConfig, OpenVpnAuthType}; +/// +/// let config = OpenVpnConfig::new("MyVPN", "vpn.example.com", 1194, false) +/// .with_auth_type(OpenVpnAuthType::PasswordTls) +/// .with_username("user") +/// .with_password("secret") +/// .with_ca_cert("/path/to/ca.crt") +/// .with_dns(vec!["1.1.1.1".into()]); +/// ``` +#[non_exhaustive] +#[derive(Debug, Clone)] +pub struct OpenVpnConfig { + /// Connection name. + pub name: String, + /// Remote server hostname or IP. + pub remote: String, + /// Remote server port (default: 1194). + pub port: u16, + /// Use TCP instead of UDP. + pub tcp: bool, + /// Authentication type. + pub auth_type: Option, + /// HMAC digest algorithm (e.g., "SHA256"). + pub auth: Option, + /// Data channel cipher (e.g., "AES-256-GCM"). + pub cipher: Option, + /// DNS servers to use when connected. + pub dns: Option>, + /// MTU size. + pub mtu: Option, + /// Connection UUID. + pub uuid: Option, + /// Path to CA certificate. + pub ca_cert: Option, + /// Path to client certificate. + pub client_cert: Option, + /// Path to client private key. + pub client_key: Option, + /// Password for encrypted private key. + pub key_password: Option, + /// Username for password authentication. + pub username: Option, + /// Password for password authentication. + pub password: Option, + /// Compression algorithm. See [`OpenVpnCompression`] for security considerations. + pub compression: Option, + /// Proxy configuration. + pub proxy: Option, + /// Path to TLS authentication (HMAC firewall) key file. + pub tls_auth_key: Option, + /// TLS auth direction (`0` or `1`). Only meaningful when `tls_auth_key` is set. + pub tls_auth_direction: Option, + /// Path to TLS-Crypt key file (encrypt+authenticate control channel). + pub tls_crypt: Option, + /// Path to TLS-Crypt-v2 key file (per-client TLS-Crypt). + pub tls_crypt_v2: Option, + /// Minimum TLS version (e.g. "1.2"). + pub tls_version_min: Option, + /// Maximum TLS version (e.g. "1.3"). + pub tls_version_max: Option, + /// Control channel TLS cipher suites (e.g. "TLS-ECDHE-RSA-WITH-AES-256-GCM-SHA384"). + pub tls_cipher: Option, + /// Require remote certificate to be of a specific type ("server" or "client"). + pub remote_cert_tls: Option, + /// X.509 name verification: `(name, type)` where type is e.g. "name", "subject", + /// or "name-prefix". + pub verify_x509_name: Option<(String, String)>, + /// Path to a Certificate Revocation List file. + pub crl_verify: Option, + /// When true, this profile may become the default route (full tunnel / `redirect-gateway`). + /// + /// Maps to `ipv4.never-default = false` in NetworkManager when set. + pub redirect_gateway: bool, + /// Static IPv4 routes for split tunneling (`ipv4.route-data`). + pub routes: Vec, + /// OpenVPN `ping` interval in seconds. + pub ping: Option, + /// OpenVPN `ping-exit` seconds. + pub ping_exit: Option, + /// OpenVPN `ping-restart` seconds. + pub ping_restart: Option, + /// TLS renegotiation period (`reneg-sec`). + pub reneg_seconds: Option, + /// Initial connection timeout in seconds (`connect-timeout`). + pub connect_timeout: Option, + /// Negotiable data ciphers list (`data-ciphers`), colon-separated. + pub data_ciphers: Option, + /// Fallback data cipher (`data-ciphers-fallback`). + pub data_ciphers_fallback: Option, + /// When true, disables NCP (`ncp-disable`). + pub ncp_disable: bool, +} + +impl OpenVpnConfig { + /// Creates a new `OpenVpnConfig` with required fields. + pub fn new(name: impl Into, remote: impl Into, port: u16, tcp: bool) -> Self { + Self { + name: name.into(), + remote: remote.into(), + port, + tcp, + auth_type: None, + auth: None, + cipher: None, + dns: None, + mtu: None, + uuid: None, + ca_cert: None, + client_cert: None, + client_key: None, + key_password: None, + username: None, + password: None, + compression: None, + proxy: None, + tls_auth_key: None, + tls_auth_direction: None, + tls_crypt: None, + tls_crypt_v2: None, + tls_version_min: None, + tls_version_max: None, + tls_cipher: None, + remote_cert_tls: None, + verify_x509_name: None, + crl_verify: None, + redirect_gateway: false, + routes: Vec::new(), + ping: None, + ping_exit: None, + ping_restart: None, + reneg_seconds: None, + connect_timeout: None, + data_ciphers: None, + data_ciphers_fallback: None, + ncp_disable: false, + } + } + + /// Sets the authentication type. + #[must_use] + pub fn with_auth_type(mut self, auth_type: OpenVpnAuthType) -> Self { + self.auth_type = Some(auth_type); + self + } + + /// Sets the HMAC digest algorithm. + #[must_use] + pub fn with_auth(mut self, auth: impl Into) -> Self { + self.auth = Some(auth.into()); + self + } + + /// Sets the data channel cipher. + #[must_use] + pub fn with_cipher(mut self, cipher: impl Into) -> Self { + self.cipher = Some(cipher.into()); + self + } + + /// Sets the DNS servers to use when connected. + #[must_use] + pub fn with_dns(mut self, dns: Vec) -> Self { + self.dns = Some(dns); + self + } + + /// Sets the MTU (Maximum Transmission Unit) size. + #[must_use] + pub fn with_mtu(mut self, mtu: u32) -> Self { + self.mtu = Some(mtu); + self + } + + /// Sets the UUID for the connection. + #[must_use] + pub fn with_uuid(mut self, uuid: Uuid) -> Self { + self.uuid = Some(uuid); + self + } + + /// Sets the CA certificate path. + #[must_use] + pub fn with_ca_cert(mut self, path: impl Into) -> Self { + self.ca_cert = Some(path.into()); + self + } + + /// Sets the client certificate path. + #[must_use] + pub fn with_client_cert(mut self, path: impl Into) -> Self { + self.client_cert = Some(path.into()); + self + } + + /// Sets the client private key path. + #[must_use] + pub fn with_client_key(mut self, path: impl Into) -> Self { + self.client_key = Some(path.into()); + self + } + + /// Sets the password for an encrypted private key. + #[must_use] + pub fn with_key_password(mut self, password: impl Into) -> Self { + self.key_password = Some(password.into()); + self + } + + /// Sets the username for password authentication. + #[must_use] + pub fn with_username(mut self, username: impl Into) -> Self { + self.username = Some(username.into()); + self + } + + /// Sets the password for password authentication. + #[must_use] + pub fn with_password(mut self, password: impl Into) -> Self { + self.password = Some(password.into()); + self + } + + /// Sets the compression algorithm. + /// + /// # Security Warning + /// + /// Some compression modes are subject to the VORACLE vulnerability. + /// See [`OpenVpnCompression`] for details and recommendations. + #[must_use] + pub fn with_compression(mut self, compression: OpenVpnCompression) -> Self { + self.compression = Some(compression); + self + } + + /// Sets the proxy configuration. + #[must_use] + pub fn with_proxy(mut self, proxy: OpenVpnProxy) -> Self { + self.proxy = Some(proxy); + self + } + + /// Sets the TLS authentication key path and optional direction. + /// + /// The `--tls-auth` option adds an HMAC firewall to the control channel, + /// providing an additional layer of DoS protection. + #[must_use] + pub fn with_tls_auth(mut self, key_path: impl Into, direction: Option) -> Self { + self.tls_auth_key = Some(key_path.into()); + self.tls_auth_direction = direction; + self + } + + /// Sets the TLS-Crypt key path. + /// + /// Encrypts and authenticates the control channel with a pre-shared key, + /// providing stronger protection than `--tls-auth`. + #[must_use] + pub fn with_tls_crypt(mut self, key_path: impl Into) -> Self { + self.tls_crypt = Some(key_path.into()); + self + } + + /// Sets the TLS-Crypt-v2 key path (per-client key wrapping). + #[must_use] + pub fn with_tls_crypt_v2(mut self, key_path: impl Into) -> Self { + self.tls_crypt_v2 = Some(key_path.into()); + self + } + + /// Sets the minimum TLS protocol version (e.g. "1.2"). + #[must_use] + pub fn with_tls_version_min(mut self, version: impl Into) -> Self { + self.tls_version_min = Some(version.into()); + self + } + + /// Sets the maximum TLS protocol version (e.g. "1.3"). + #[must_use] + pub fn with_tls_version_max(mut self, version: impl Into) -> Self { + self.tls_version_max = Some(version.into()); + self + } + + /// Sets the allowed control channel TLS cipher suites. + #[must_use] + pub fn with_tls_cipher(mut self, cipher: impl Into) -> Self { + self.tls_cipher = Some(cipher.into()); + self + } + + /// Requires the remote certificate to be of a specific type ("server" or "client"). + #[must_use] + pub fn with_remote_cert_tls(mut self, cert_type: impl Into) -> Self { + self.remote_cert_tls = Some(cert_type.into()); + self + } + + /// Sets X.509 name verification for the remote certificate. + /// + /// `name_type` is one of "name", "subject", or "name-prefix". + #[must_use] + pub fn with_verify_x509_name( + mut self, + name: impl Into, + name_type: impl Into, + ) -> Self { + self.verify_x509_name = Some((name.into(), name_type.into())); + self + } + + /// Sets the path to a Certificate Revocation List for peer verification. + #[must_use] + pub fn with_crl_verify(mut self, path: impl Into) -> Self { + self.crl_verify = Some(path.into()); + self + } + + /// When true, the connection may become the default IPv4 route (full tunnel). + #[must_use] + pub fn with_redirect_gateway(mut self, redirect: bool) -> Self { + self.redirect_gateway = redirect; + self + } + + /// Replaces static IPv4 routes for split tunneling. + #[must_use] + pub fn with_routes(mut self, routes: Vec) -> Self { + self.routes = routes; + self + } + + /// Sets the OpenVPN `ping` interval (seconds). + #[must_use] + pub fn with_ping(mut self, seconds: u32) -> Self { + self.ping = Some(seconds); + self + } + + /// Sets OpenVPN `ping-exit` (seconds). + #[must_use] + pub fn with_ping_exit(mut self, seconds: u32) -> Self { + self.ping_exit = Some(seconds); + self + } + + /// Sets OpenVPN `ping-restart` (seconds). + #[must_use] + pub fn with_ping_restart(mut self, seconds: u32) -> Self { + self.ping_restart = Some(seconds); + self + } + + /// Sets TLS renegotiation period (`reneg-sec`, seconds). + #[must_use] + pub fn with_reneg_seconds(mut self, seconds: u32) -> Self { + self.reneg_seconds = Some(seconds); + self + } + + /// Sets initial connection timeout (`connect-timeout`, seconds). + #[must_use] + pub fn with_connect_timeout(mut self, seconds: u32) -> Self { + self.connect_timeout = Some(seconds); + self + } + + /// Sets negotiable data ciphers (colon-separated, e.g. `AES-256-GCM:AES-128-GCM`). + #[must_use] + pub fn with_data_ciphers(mut self, ciphers: impl Into) -> Self { + self.data_ciphers = Some(ciphers.into()); + self + } + + /// Sets the fallback data cipher (`data-ciphers-fallback`). + #[must_use] + pub fn with_data_ciphers_fallback(mut self, cipher: impl Into) -> Self { + self.data_ciphers_fallback = Some(cipher.into()); + self + } + + /// When true, disables NCP cipher negotiation (`ncp-disable`). + #[must_use] + pub fn with_ncp_disable(mut self, disable: bool) -> Self { + self.ncp_disable = disable; + self + } +} + +fn ipv4_netmask_to_prefix(netmask: Ipv4Addr) -> u32 { + let mut prefix = 0u32; + for byte in netmask.octets() { + if byte == 0xff { + prefix += 8; + } else if byte == 0 { + break; + } else { + let mut b = byte; + while b & 0x80 != 0 { + prefix += 1; + b <<= 1; + } + break; + } + } + prefix +} + +pub(crate) fn vpn_route_from_parser( + r: crate::core::ovpn_parser::parser::Route, +) -> Result { + let dest = r.network.to_string(); + let prefix = r.netmask.map(ipv4_netmask_to_prefix).unwrap_or(32); + if prefix > 32 { + return Err(ConnectionError::InvalidAddress(format!( + "invalid route netmask for destination {dest}" + ))); + } + let next_hop = r.gateway.map(|g| g.to_string()); + Ok(VpnRoute { + dest, + prefix, + next_hop, + metric: None, + }) +} + +impl TryFrom for OpenVpnConfig { + type Error = ConnectionError; + + fn try_from(f: crate::core::ovpn_parser::parser::OvpnFile) -> Result { + use crate::core::ovpn_parser::parser::{AllowCompress, CertSource, Compress}; + + let first_remote = f + .remotes + .into_iter() + .next() + .ok_or_else(|| ConnectionError::InvalidGateway("no remote in .ovpn file".into()))?; + + let tcp = first_remote + .proto + .as_deref() + .map(|p: &str| p.starts_with("tcp")) + .unwrap_or_else(|| { + f.proto + .as_deref() + .map(|p: &str| p.starts_with("tcp")) + .unwrap_or(false) + }); + + let compression = match (f.compress, f.allow_compress) { + (Some(Compress::Algorithm(ref s)), _) => Some(match s.as_str() { + "lz4" => OpenVpnCompression::Lz4, + "lz4-v2" => OpenVpnCompression::Lz4V2, + _ => OpenVpnCompression::Yes, + }), + (Some(Compress::Stub | Compress::StubV2), _) => Some(OpenVpnCompression::No), + (None, Some(AllowCompress::No)) => Some(OpenVpnCompression::No), + _ => None, + }; + + // Client certificate auth needs both cert and key; one alone is incomplete. + let has_client_cert_pair = f.cert.is_some() && f.key.is_some(); + let auth_type = match (f.auth_user_pass, has_client_cert_pair) { + (true, true) => Some(OpenVpnAuthType::PasswordTls), + (true, false) => Some(OpenVpnAuthType::Password), + (false, true) => Some(OpenVpnAuthType::Tls), + (false, false) => None, + }; + + let cert_path = |src: CertSource, field: &str| -> Result { + match src { + CertSource::File(p) => Ok(p), + CertSource::Inline(_) => Err(ConnectionError::VpnFailed(format!( + "inline <{field}> blocks require OpenVpnBuilder::from_ovpn_file() \ + or from_ovpn_str() which persists them via the cert store; \ + TryFrom cannot handle inline certs" + ))), + } + }; + + let routes: Vec = f + .routes + .into_iter() + .map(vpn_route_from_parser) + .collect::>()?; + + let redirect_gateway = f.redirect_gateway.is_some(); + + let data_ciphers = if f.data_ciphers.is_empty() { + None + } else { + Some(f.data_ciphers.join(":")) + }; + + Ok(OpenVpnConfig { + name: String::new(), + remote: first_remote.host, + port: first_remote.port.unwrap_or(1194), + tcp, + auth_type, + auth: f.auth, + cipher: f.cipher, + dns: None, + mtu: None, + uuid: None, + ca_cert: f.ca.map(|s| cert_path(s, "ca")).transpose()?, + client_cert: f.cert.map(|s| cert_path(s, "cert")).transpose()?, + client_key: f.key.map(|s| cert_path(s, "key")).transpose()?, + key_password: None, + username: None, + password: None, + compression, + proxy: None, + tls_auth_key: None, + tls_auth_direction: None, + tls_crypt: None, + tls_crypt_v2: None, + tls_version_min: None, + tls_version_max: None, + tls_cipher: None, + remote_cert_tls: None, + verify_x509_name: None, + crl_verify: None, + redirect_gateway, + routes, + ping: None, + ping_exit: None, + ping_restart: None, + reneg_seconds: None, + connect_timeout: None, + data_ciphers, + data_ciphers_fallback: None, + ncp_disable: false, + }) + } +} + +impl super::vpn::sealed::Sealed for OpenVpnConfig {} + +impl VpnConfig for OpenVpnConfig { + fn vpn_kind(&self) -> VpnKind { + VpnKind::Plugin + } + + fn name(&self) -> &str { + &self.name + } + + fn dns(&self) -> Option<&[String]> { + self.dns.as_deref() + } + + fn mtu(&self) -> Option { + self.mtu + } + + fn uuid(&self) -> Option { + self.uuid + } +} + +/// Compression algorithm for OpenVPN connections. +/// +/// Maps to the NM `compress` and `comp-lzo` keys in the `vpn.data` dict. +/// +/// # Security Warning +/// +/// Compression is generally discouraged due to the VORACLE vulnerability, +/// where compression oracles can be exploited to recover plaintext from +/// encrypted tunnels. OpenVPN 2.5+ defaults to `--allow-compression no`. +/// Prefer [`No`](OpenVpnCompression::No) unless you have a specific need +/// and understand the risk. See . +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OpenVpnCompression { + /// Disable compression explicitly. Recommended default. + /// + /// Maps to `compress no` in the NM `vpn.data` dict. + No, + + /// LZO compression. + /// + /// Maps to `comp-lzo yes` in the NM `vpn.data` dict. + /// + /// # Security Warning + /// + /// Subject to the VORACLE vulnerability. See [`OpenVpnCompression`] docs. + /// + /// # Deprecation + /// + /// `comp-lzo` is deprecated upstream in OpenVPN in favour of the newer + /// `compress` directive. Use [`Lz4V2`](OpenVpnCompression::Lz4V2) if + /// you need compression, or [`No`](OpenVpnCompression::No) to disable it. + #[deprecated(note = "comp-lzo is deprecated upstream. Use Lz4V2 or No instead.")] + Lzo, + + /// LZ4 compression. + /// + /// Maps to `compress lz4` in the NM `vpn.data` dict. + /// + /// # Security Warning + /// + /// Subject to the VORACLE vulnerability. See [`OpenVpnCompression`] docs. + Lz4, + + /// LZ4 v2 compression. + /// + /// Maps to `compress lz4-v2` in the NM `vpn.data` dict. + /// + /// # Security Warning + /// + /// Subject to the VORACLE vulnerability. See [`OpenVpnCompression`] docs. + Lz4V2, + + /// Adaptive compression — algorithm negotiated at runtime. + /// + /// Maps to `compress yes` in the NM `vpn.data` dict. + /// + /// # Security Warning + /// + /// Subject to the VORACLE vulnerability. See [`OpenVpnCompression`] docs. + Yes, +} + +/// Proxy configuration for OpenVPN connections. +/// +/// Maps to the NM `proxy-type`, `proxy-server`, `proxy-port`, +/// `proxy-retry`, `http-proxy-username`, and `http-proxy-password` keys. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OpenVpnProxy { + /// HTTP proxy. + Http { + server: String, + port: u16, + username: Option, + password: Option, + retry: bool, + }, + /// SOCKS proxy. + Socks { + server: String, + port: u16, + retry: bool, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::ovpn_parser::parser::parse_ovpn; + + fn ovpn_with_remote(extra: &str) -> String { + format!("remote vpn.example.com 1194 udp\n{extra}") + } + + #[test] + fn try_from_auth_user_pass_with_file_certs_infers_password_tls() { + let input = ovpn_with_remote( + "auth-user-pass\ncert /etc/openvpn/client.crt\nkey /etc/openvpn/client.key", + ); + let ovpn = parse_ovpn(&input).unwrap(); + let config = OpenVpnConfig::try_from(ovpn).unwrap(); + assert_eq!(config.auth_type, Some(OpenVpnAuthType::PasswordTls)); + } + + #[test] + fn try_from_auth_user_pass_without_certs_infers_password() { + let input = ovpn_with_remote("auth-user-pass"); + let ovpn = parse_ovpn(&input).unwrap(); + let config = OpenVpnConfig::try_from(ovpn).unwrap(); + assert_eq!(config.auth_type, Some(OpenVpnAuthType::Password)); + } + + #[test] + fn try_from_no_auth_user_pass_with_file_certs_infers_tls() { + let input = ovpn_with_remote("cert /etc/openvpn/client.crt\nkey /etc/openvpn/client.key"); + let ovpn = parse_ovpn(&input).unwrap(); + let config = OpenVpnConfig::try_from(ovpn).unwrap(); + assert_eq!(config.auth_type, Some(OpenVpnAuthType::Tls)); + } + + #[test] + fn try_from_no_auth_user_pass_no_certs_infers_none() { + let input = ovpn_with_remote(""); + let ovpn = parse_ovpn(&input).unwrap(); + let config = OpenVpnConfig::try_from(ovpn).unwrap(); + assert_eq!(config.auth_type, None); + } + + #[test] + fn try_from_inline_cert_returns_error() { + let input = ovpn_with_remote("\nCERTPEM\n\n\nKEYPEM\n"); + let ovpn = parse_ovpn(&input).unwrap(); + let result = OpenVpnConfig::try_from(ovpn); + assert!( + result.is_err(), + "inline certs should be rejected by TryFrom" + ); + } + + #[test] + fn try_from_cert_only_without_auth_user_pass_does_not_infer_tls() { + let input = ovpn_with_remote("cert /etc/openvpn/client.crt"); + let ovpn = parse_ovpn(&input).unwrap(); + let config = OpenVpnConfig::try_from(ovpn).unwrap(); + assert_eq!(config.auth_type, None); + } + + #[test] + fn try_from_cert_only_with_auth_user_pass_infers_password_not_password_tls() { + let input = ovpn_with_remote("auth-user-pass\ncert /etc/openvpn/client.crt"); + let ovpn = parse_ovpn(&input).unwrap(); + let config = OpenVpnConfig::try_from(ovpn).unwrap(); + assert_eq!(config.auth_type, Some(OpenVpnAuthType::Password)); + } +} diff --git a/nmrs/src/api/models/radio.rs b/nmrs/src/api/models/radio.rs new file mode 100644 index 00000000..99053cad --- /dev/null +++ b/nmrs/src/api/models/radio.rs @@ -0,0 +1,128 @@ +//! Radio and airplane-mode state types. +//! +//! NetworkManager tracks both a software-enabled flag (controlled via D-Bus) +//! and a hardware-enabled flag (reflecting the kernel rfkill state) for each +//! radio. [`RadioState`] captures both, and [`AirplaneModeState`] aggregates +//! Wi-Fi, WWAN, and Bluetooth into a single snapshot. + +/// Software and hardware enabled state for a single radio. +/// +/// `enabled` reflects the user-facing toggle (can be written via D-Bus). +/// `hardware_enabled` reflects the kernel rfkill state and cannot be changed +/// from userspace — if `false`, setting `enabled = true` is accepted by NM +/// but the radio remains off until hardware is unkilled. +#[non_exhaustive] +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub struct RadioState { + /// Software-enabled: can the user turn this radio on via NM? + pub enabled: bool, + /// Hardware-enabled: is rfkill allowing this radio? + /// If `false`, `enabled = true` is a no-op until hardware is unkilled. + pub hardware_enabled: bool, +} + +impl RadioState { + /// Creates a new `RadioState`. + #[must_use] + pub fn new(enabled: bool, hardware_enabled: bool) -> Self { + Self { + enabled, + hardware_enabled, + } + } +} + +/// Aggregated radio state for all radios that `nmrs` can control. +/// +/// Returned by [`NetworkManager::airplane_mode_state`](crate::NetworkManager::airplane_mode_state). +#[non_exhaustive] +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub struct AirplaneModeState { + /// Wi-Fi radio state. + pub wifi: RadioState, + /// WWAN (mobile broadband) radio state. + pub wwan: RadioState, + /// Bluetooth radio state (sourced from BlueZ + rfkill). + pub bluetooth: RadioState, +} + +impl AirplaneModeState { + /// Creates a new `AirplaneModeState`. + #[must_use] + pub fn new(wifi: RadioState, wwan: RadioState, bluetooth: RadioState) -> Self { + Self { + wifi, + wwan, + bluetooth, + } + } + + /// Returns `true` if every radio `nmrs` can control is software-disabled. + /// + /// This is the "airplane mode is on" state — all radios off. + #[must_use] + pub fn is_airplane_mode(&self) -> bool { + !self.wifi.enabled && !self.wwan.enabled && !self.bluetooth.enabled + } + + /// Returns `true` if any radio has its hardware kill switch active. + #[must_use] + pub fn any_hardware_killed(&self) -> bool { + !self.wifi.hardware_enabled + || !self.wwan.hardware_enabled + || !self.bluetooth.hardware_enabled + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_off_is_airplane_mode() { + let state = AirplaneModeState::new( + RadioState::new(false, true), + RadioState::new(false, true), + RadioState::new(false, true), + ); + assert!(state.is_airplane_mode()); + assert!(!state.any_hardware_killed()); + } + + #[test] + fn any_on_is_not_airplane_mode() { + let state = AirplaneModeState::new( + RadioState::new(true, true), + RadioState::new(false, true), + RadioState::new(false, true), + ); + assert!(!state.is_airplane_mode()); + } + + #[test] + fn hardware_killed_detected() { + let state = AirplaneModeState::new( + RadioState::new(true, true), + RadioState::new(true, false), + RadioState::new(true, true), + ); + assert!(state.any_hardware_killed()); + } + + #[test] + fn no_hardware_kill() { + let state = AirplaneModeState::new( + RadioState::new(false, true), + RadioState::new(false, true), + RadioState::new(false, true), + ); + assert!(!state.any_hardware_killed()); + } + + #[test] + fn radio_state_new() { + let rs = RadioState::new(true, false); + assert!(rs.enabled); + assert!(!rs.hardware_enabled); + } +} diff --git a/nmrs/src/api/models/saved_connection.rs b/nmrs/src/api/models/saved_connection.rs new file mode 100644 index 00000000..5580faac --- /dev/null +++ b/nmrs/src/api/models/saved_connection.rs @@ -0,0 +1,216 @@ +//! Saved NetworkManager connection profiles with decoded settings summaries. +//! +//! Use [`crate::NetworkManager::list_saved_connections`] to enumerate every +//! profile NM knows about (Wi-Fi, Ethernet, VPN, WireGuard, mobile, Bluetooth). +//! Secrets (PSK, EAP passwords, VPN tokens) are **not** included in +//! [`SavedConnection`] — NetworkManager only returns them via +//! [`GetSecrets`](https://networkmanager.dev/docs/api/latest/gdbus-org.freedesktop.NetworkManager.Settings.Connection.html#gdbus-method-org-freedesktop-NetworkManager-Settings-Connection.GetSecrets) +//! when a [secret agent](crate::agent) is registered. See feature `01-secret-agent`. + +use std::collections::HashMap; + +use zvariant::{OwnedObjectPath, OwnedValue}; + +/// Full saved profile with a structured [`SettingsSummary`]. +#[non_exhaustive] +#[derive(Debug, Clone)] +pub struct SavedConnection { + /// D-Bus object path of the settings connection. + pub path: OwnedObjectPath, + /// Connection UUID (`connection.uuid`). + pub uuid: String, + /// Human-visible name (`connection.id`). + pub id: String, + /// NM connection type string (`connection.type`), e.g. `802-11-wireless`. + pub connection_type: String, + /// Bound interface, if any (`connection.interface-name`). + pub interface_name: Option, + /// Whether NM may auto-activate this profile (`connection.autoconnect`). + pub autoconnect: bool, + /// Autoconnect priority (`connection.autoconnect-priority`). + pub autoconnect_priority: i32, + /// Last activation time as Unix seconds (`connection.timestamp`), or `0` if never. + pub timestamp_unix: u64, + /// `connection.permissions` user strings, if present. + pub permissions: Vec, + /// In-memory-only profile not yet written to disk. + pub unsaved: bool, + /// On-disk keyfile path when saved. + pub filename: Option, + /// Decoded type-specific fields (no secrets). + pub summary: SettingsSummary, +} + +/// Cheap listing: path plus `connection` identity fields only (still one `GetSettings` per profile). +#[non_exhaustive] +#[derive(Debug, Clone)] +pub struct SavedConnectionBrief { + /// D-Bus object path. + pub path: OwnedObjectPath, + /// `connection.uuid`. + pub uuid: String, + /// `connection.id`. + pub id: String, + /// `connection.type`. + pub connection_type: String, +} + +/// Partial update merged via [`crate::NetworkManager::update_saved_connection`]. +#[non_exhaustive] +#[derive(Debug, Default, Clone)] +pub struct SettingsPatch { + /// When `Some`, sets `connection.autoconnect`. + pub autoconnect: Option, + /// When `Some`, sets `connection.autoconnect-priority`. + pub autoconnect_priority: Option, + /// When `Some`, sets `connection.id`. + pub id: Option, + /// `Some(Some(name))` sets `interface-name`; `Some(None)` clears it (best-effort empty string). + pub interface_name: Option>, + /// Merged after the fields above; section → key → value. Overwrites keys present. + pub raw_overlay: Option>>, +} + +/// NM `password-flags` / `psk-flags` style bitmask (subset used for summaries). +#[non_exhaustive] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +pub struct VpnSecretFlags(pub u32); + +impl VpnSecretFlags { + /// `NM_SETTING_SECRET_FLAG_AGENT_OWNED`. + pub const AGENT_OWNED: u32 = 0x1; + + /// True if the secret is expected to be provided by an agent. + #[must_use] + pub fn agent_owned(self) -> bool { + self.0 & Self::AGENT_OWNED != 0 + } +} + +/// Wi-Fi key management style from `802-11-wireless-security.key-mgmt`. +#[non_exhaustive] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum WifiKeyMgmt { + /// Open or no key management string. + None, + /// WEP (legacy). + Wep, + /// WPA-PSK (`wpa-psk`, `wpa-none`, …). + WpaPsk, + /// WPA-EAP / 802.1X. + WpaEap, + /// SAE (WPA3-Personal). + Sae, + /// OWE. + Owe, + /// OWE transition mode. + OweTransitionMode, +} + +/// Non-secret Wi-Fi security hints for UI / filtering. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WifiSecuritySummary { + /// Derived key management style. + pub key_mgmt: WifiKeyMgmt, + /// `psk` key exists in non-secret settings. + pub has_psk_field: bool, + /// `psk-flags` has [`VpnSecretFlags::AGENT_OWNED`]. + pub psk_agent_owned: bool, + /// EAP method names from `802-1x.eap`. + pub eap_methods: Vec, +} + +/// Decoded summary for the connection `type` (and related sections). +#[non_exhaustive] +#[derive(Debug, Clone)] +pub enum SettingsSummary { + /// `802-11-wireless` — SSID and security hints (no PSK / EAP secrets). + Wifi { + /// Decoded SSID (hidden networks may be empty). + ssid: String, + /// `mode` string from settings: `infrastructure`, `ap`, `adhoc`, … + mode: Option, + /// Present when a security block exists (`802-11-wireless-security` / `802-1x`). + security: Option, + /// `band` if set (`a` / `bg`). + band: Option, + /// `channel` if set. + channel: Option, + /// `bssid` MAC string if set. + bssid: Option, + /// `hidden` property. + hidden: bool, + /// `mac-address-randomization` if set. + mac_randomization: Option, + }, + /// `802-3-ethernet`. + Ethernet { + /// `mac-address` string if set. + mac_address: Option, + /// `auto-negotiate`. + auto_negotiate: Option, + /// `speed` in Mbps. + speed_mbps: Option, + /// `mtu`. + mtu: Option, + }, + /// Generic `vpn` connection (non-WireGuard service types). + Vpn { + /// `vpn.service-type` (e.g. OpenVPN plugin name). + service_type: String, + /// `vpn.user-name`. + user_name: Option, + /// `vpn.password-flags`. + password_flags: VpnSecretFlags, + /// Keys present in `vpn.data` (values omitted). + data_keys: Vec, + /// `vpn.persistent` when present. + persistent: bool, + }, + /// Native WireGuard or VPN plugin pointing at WireGuard. + WireGuard { + /// `listen-port`. + listen_port: Option, + /// `mtu`. + mtu: Option, + /// `fwmark`. + fwmark: Option, + /// Number of peer dicts under `wireguard.peers`. + peer_count: usize, + /// `endpoint` of the first peer, if any. + first_peer_endpoint: Option, + }, + /// `gsm` mobile broadband. + Gsm { + /// `apn`. + apn: Option, + /// `username`. + user_name: Option, + /// `password-flags`. + password_flags: u32, + /// `pin-flags`. + pin_flags: u32, + }, + /// `cdma` mobile broadband. + Cdma { + /// `number`. + number: Option, + /// `username`. + user_name: Option, + /// `password-flags`. + password_flags: u32, + }, + /// `bluetooth`. + Bluetooth { + /// Bluetooth MAC / bdaddr. + bdaddr: String, + /// `type` (`panu`, `dun`, …). + bt_type: String, + }, + /// Any other `connection.type` — lists settings section names only. + Other { + /// Keys from the top-level settings dict (`connection`, `ipv4`, …). + sections: Vec, + }, +} diff --git a/nmrs/src/api/models/tests.rs b/nmrs/src/api/models/tests.rs index 9dde4f82..dea38bab 100644 --- a/nmrs/src/api/models/tests.rs +++ b/nmrs/src/api/models/tests.rs @@ -1,3 +1,5 @@ +#![allow(deprecated)] + use std::time::Duration; use uuid::Uuid; @@ -9,6 +11,7 @@ use super::error::*; use super::state_reason::*; use super::vpn::*; use super::wifi::*; +use super::wireguard::*; use crate::api::models::DeviceType; #[test] @@ -601,10 +604,11 @@ fn test_vpn_credentials_builder_basic() { .private_key("YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=") .address("10.0.0.2/24") .add_peer(peer) - .build(); + .build() + .unwrap(); assert_eq!(creds.name, "TestVPN"); - assert_eq!(creds.vpn_type, VpnType::WireGuard); + assert_eq!(creds.vpn_type, VpnKind::WireGuard); assert_eq!(creds.gateway, "vpn.example.com:51820"); assert_eq!( creds.private_key, @@ -616,6 +620,92 @@ fn test_vpn_credentials_builder_basic() { assert!(creds.mtu.is_none()); } +#[test] +fn test_wireguard_config_basic() { + let peer = WireGuardPeer::new( + "HIgo9xNzJMWLKAShlKl6/bUT1VI9Q0SDBXGtLXkPFXc=", + "vpn.example.com:51820", + vec!["0.0.0.0/0".into()], + ); + + let config = WireGuardConfig::new( + "TestVPN", + "vpn.example.com:51820", + "YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=", + "10.0.0.2/24", + vec![peer], + ); + + assert_eq!(config.name, "TestVPN"); + assert_eq!(config.gateway, "vpn.example.com:51820"); + assert_eq!( + config.private_key, + "YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=" + ); + assert_eq!(config.address, "10.0.0.2/24"); + assert_eq!(config.peers.len(), 1); + assert!(config.dns.is_none()); + assert!(config.mtu.is_none()); +} + +#[test] +fn test_wireguard_config_implements_vpn_config() { + let uuid = Uuid::new_v4(); + let config = WireGuardConfig::new( + "TestVPN", + "vpn.example.com:51820", + "private_key", + "10.0.0.2/24", + vec![WireGuardPeer::new( + "public_key", + "vpn.example.com:51820", + vec!["0.0.0.0/0".into()], + )], + ) + .with_dns(vec!["1.1.1.1".into(), "8.8.8.8".into()]) + .with_mtu(1420) + .with_uuid(uuid); + + let vpn_config: &dyn VpnConfig = &config; + + assert_eq!(vpn_config.vpn_kind(), VpnKind::WireGuard); + assert_eq!(vpn_config.name(), "TestVPN"); + assert_eq!( + vpn_config.dns(), + Some(["1.1.1.1".to_string(), "8.8.8.8".to_string()].as_slice()) + ); + assert_eq!(vpn_config.mtu(), Some(1420)); + assert_eq!(vpn_config.uuid(), Some(uuid)); +} + +#[test] +fn test_wireguard_config_roundtrips_through_vpn_credentials() { + let config = WireGuardConfig::new( + "TestVPN", + "vpn.example.com:51820", + "private_key", + "10.0.0.2/24", + vec![WireGuardPeer::new( + "public_key", + "vpn.example.com:51820", + vec!["0.0.0.0/0".into()], + )], + ) + .with_dns(vec!["1.1.1.1".into()]) + .with_mtu(1420); + + let legacy: VpnCredentials = config.clone().into(); + let roundtrip = WireGuardConfig::from(legacy); + + assert_eq!(roundtrip.name, config.name); + assert_eq!(roundtrip.gateway, config.gateway); + assert_eq!(roundtrip.private_key, config.private_key); + assert_eq!(roundtrip.address, config.address); + assert_eq!(roundtrip.peers.len(), config.peers.len()); + assert_eq!(roundtrip.dns, config.dns); + assert_eq!(roundtrip.mtu, config.mtu); +} + #[test] fn test_vpn_credentials_builder_with_optionals() { let peer = WireGuardPeer::new( @@ -635,7 +725,8 @@ fn test_vpn_credentials_builder_with_optionals() { .with_dns(vec!["1.1.1.1".into(), "8.8.8.8".into()]) .with_mtu(1420) .with_uuid(uuid) - .build(); + .build() + .unwrap(); assert_eq!(creds.dns, Some(vec!["1.1.1.1".into(), "8.8.8.8".into()])); assert_eq!(creds.mtu, Some(1420)); @@ -659,7 +750,8 @@ fn test_vpn_credentials_builder_multiple_peers() { .address("10.0.0.2/24") .add_peer(peer1) .add_peer(peer2) - .build(); + .build() + .unwrap(); assert_eq!(creds.peers.len(), 2); } @@ -678,49 +770,53 @@ fn test_vpn_credentials_builder_peers_method() { .private_key("private_key") .address("10.0.0.2/24") .peers(peers) - .build(); + .build() + .unwrap(); 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()]); - let _ = VpnCredentials::builder() + let err = VpnCredentials::builder() .wireguard() .gateway("vpn.example.com:51820") .private_key("private_key") .address("10.0.0.2/24") .add_peer(peer) - .build(); + .build() + .unwrap_err(); + assert!(matches!(err, ConnectionError::IncompleteBuilder(_))); } #[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()]); - let _ = VpnCredentials::builder() + let err = VpnCredentials::builder() .name("TestVPN") .gateway("vpn.example.com:51820") .private_key("private_key") .address("10.0.0.2/24") .add_peer(peer) - .build(); + .build() + .unwrap_err(); + assert!(matches!(err, ConnectionError::IncompleteBuilder(_))); } #[test] -#[should_panic(expected = "at least one peer is required")] fn test_vpn_credentials_builder_missing_peers() { - let _ = VpnCredentials::builder() + let err = VpnCredentials::builder() .name("TestVPN") .wireguard() .gateway("vpn.example.com:51820") .private_key("private_key") .address("10.0.0.2/24") - .build(); + .build() + .unwrap_err(); + assert!(matches!(err, ConnectionError::InvalidPeers(_))); } #[test] @@ -730,7 +826,8 @@ fn test_eap_options_builder_basic() { .password("password") .method(EapMethod::Peap) .phase2(Phase2::Mschapv2) - .build(); + .build() + .unwrap(); assert_eq!(opts.identity, "user@example.com"); assert_eq!(opts.password, "password"); @@ -753,7 +850,8 @@ fn test_eap_options_builder_with_optionals() { .domain_suffix_match("company.com") .ca_cert_path("file:///etc/ssl/certs/ca.pem") .system_ca_certs(true) - .build(); + .build() + .unwrap(); assert_eq!(opts.identity, "user@company.com"); assert_eq!(opts.password, "password"); @@ -779,7 +877,8 @@ fn test_eap_options_builder_peap_mschapv2() { .method(EapMethod::Peap) .phase2(Phase2::Mschapv2) .system_ca_certs(true) - .build(); + .build() + .unwrap(); assert_eq!(opts.method, EapMethod::Peap); assert_eq!(opts.phase2, Phase2::Mschapv2); @@ -794,7 +893,8 @@ fn test_eap_options_builder_ttls_pap() { .method(EapMethod::Ttls) .phase2(Phase2::Pap) .ca_cert_path("file:///etc/ssl/certs/university.pem") - .build(); + .build() + .unwrap(); assert_eq!(opts.method, EapMethod::Ttls); assert_eq!(opts.phase2, Phase2::Pap); @@ -805,43 +905,47 @@ fn test_eap_options_builder_ttls_pap() { } #[test] -#[should_panic(expected = "identity is required")] fn test_eap_options_builder_missing_identity() { - let _ = EapOptions::builder() + let err = EapOptions::builder() .password("password") .method(EapMethod::Peap) .phase2(Phase2::Mschapv2) - .build(); + .build() + .unwrap_err(); + assert!(matches!(err, ConnectionError::IncompleteBuilder(_))); } #[test] -#[should_panic(expected = "password is required")] fn test_eap_options_builder_missing_password() { - let _ = EapOptions::builder() + let err = EapOptions::builder() .identity("user@example.com") .method(EapMethod::Peap) .phase2(Phase2::Mschapv2) - .build(); + .build() + .unwrap_err(); + assert!(matches!(err, ConnectionError::IncompleteBuilder(_))); } #[test] -#[should_panic(expected = "method is required")] fn test_eap_options_builder_missing_method() { - let _ = EapOptions::builder() + let err = EapOptions::builder() .identity("user@example.com") .password("password") .phase2(Phase2::Mschapv2) - .build(); + .build() + .unwrap_err(); + assert!(matches!(err, ConnectionError::IncompleteBuilder(_))); } #[test] -#[should_panic(expected = "phase2 is required")] fn test_eap_options_builder_missing_phase2() { - let _ = EapOptions::builder() + let err = EapOptions::builder() .identity("user@example.com") .password("password") .method(EapMethod::Peap) - .build(); + .build() + .unwrap_err(); + assert!(matches!(err, ConnectionError::IncompleteBuilder(_))); } #[test] @@ -853,7 +957,7 @@ fn test_vpn_credentials_builder_equivalence_to_new() { ); let creds_new = VpnCredentials::new( - VpnType::WireGuard, + VpnKind::WireGuard, "TestVPN", "vpn.example.com:51820", "private_key", @@ -868,7 +972,8 @@ fn test_vpn_credentials_builder_equivalence_to_new() { .private_key("private_key") .address("10.0.0.2/24") .add_peer(peer) - .build(); + .build() + .unwrap(); assert_eq!(creds_new.name, creds_builder.name); assert_eq!(creds_new.vpn_type, creds_builder.vpn_type); @@ -889,7 +994,8 @@ fn test_eap_options_builder_equivalence_to_new() { .password("password") .method(EapMethod::Peap) .phase2(Phase2::Mschapv2) - .build(); + .build() + .unwrap(); assert_eq!(opts_new.identity, opts_builder.identity); assert_eq!(opts_new.password, opts_builder.password); diff --git a/nmrs/src/api/models/vpn.rs b/nmrs/src/api/models/vpn.rs index 4859d677..8fe0f034 100644 --- a/nmrs/src/api/models/vpn.rs +++ b/nmrs/src/api/models/vpn.rs @@ -1,518 +1,310 @@ -use uuid::Uuid; +//! VPN connection types and configuration traits. +//! +//! `nmrs` treats both NM plugin-based VPNs (`connection.type = "vpn"`) and +//! kernel-level WireGuard tunnels (`connection.type = "wireguard"`) as VPN +//! connections. [`VpnKind`] distinguishes the two, while [`VpnType`] carries +//! protocol-specific metadata decoded from NM settings. + +use std::collections::HashMap; use super::device::DeviceState; +use super::openvpn::OpenVpnConfig; +use super::saved_connection::VpnSecretFlags; +use super::wireguard::WireGuardConfig; +use uuid::Uuid; -/// VPN connection type. -/// -/// Identifies the VPN protocol/technology used for the connection. -/// Currently only WireGuard is supported. +pub(crate) mod sealed { + pub trait Sealed {} +} + +/// Whether a VPN connection is a NM-plugin VPN or kernel WireGuard. #[non_exhaustive] -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum VpnType { - /// WireGuard - modern, high-performance VPN protocol. +#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)] +pub enum VpnKind { + /// NM VPN plugin (OpenVPN, strongSwan, OpenConnect, PPTP, L2TP, …). + Plugin, + /// Kernel-level WireGuard tunnel. 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 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()]); -/// ``` +/// OpenVPN authentication/connection type. #[non_exhaustive] -#[derive(Debug, Clone)] -pub struct VpnCredentials { - /// The type of VPN (currently only WireGuard). - pub vpn_type: VpnType, - /// Unique name for the connection profile. - pub name: String, - /// VPN gateway endpoint (e.g., "vpn.example.com:51820"). - pub gateway: String, - /// Client's WireGuard private key (base64 encoded). - pub private_key: String, - /// Client's IP address with CIDR notation (e.g., "10.0.0.2/24"). - pub address: String, - /// List of WireGuard peers to connect to. - pub peers: Vec, - /// Optional DNS servers to use when connected. - pub dns: Option>, - /// Optional Maximum Transmission Unit size. - pub mtu: Option, - /// Optional UUID for the connection (auto-generated if not provided). - pub uuid: Option, +#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)] +pub enum OpenVpnConnectionType { + /// Pure TLS certificate authentication. + Tls, + /// Static pre-shared key. + StaticKey, + /// Username/password only. + Password, + /// Username/password + TLS certificate. + PasswordTls, } -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(); - /// ``` - #[must_use] - pub fn builder() -> VpnCredentialsBuilder { - VpnCredentialsBuilder::default() - } - - /// Sets the DNS servers to use when connected. +impl OpenVpnConnectionType { + /// Parse from NM's `data.connection-type` string. #[must_use] - pub fn with_dns(mut self, dns: Vec) -> Self { - self.dns = Some(dns); - self - } - - /// Sets the MTU (Maximum Transmission Unit) size. - #[must_use] - pub fn with_mtu(mut self, mtu: u32) -> Self { - self.mtu = Some(mtu); - self - } - - /// Sets the UUID for the connection. - #[must_use] - pub fn with_uuid(mut self, uuid: Uuid) -> Self { - self.uuid = Some(uuid); - self + pub fn from_nm_str(s: &str) -> Option { + match s { + "tls" => Some(Self::Tls), + "static-key" => Some(Self::StaticKey), + "password" => Some(Self::Password), + "password-tls" => Some(Self::PasswordTls), + _ => None, + } } } -/// 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 +/// Protocol-specific VPN metadata decoded from NM saved settings. /// -/// ```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, +/// Returned by [`VpnConnection::vpn_type`] to describe a saved VPN profile. +/// Each variant carries the fields an applet typically needs to render a VPN +/// list entry. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq)] +pub enum VpnType { + /// Kernel WireGuard tunnel. + WireGuard { + /// Interface private key (often agent-owned and absent). + private_key: Option, + /// First peer's public key. + peer_public_key: Option, + /// First peer's `endpoint` (e.g. `"vpn.example.com:51820"`). + endpoint: Option, + /// First peer's allowed-ips list. + allowed_ips: Vec, + /// First peer's persistent keepalive (seconds). + persistent_keepalive: Option, + }, + /// OpenVPN (NM plugin `org.freedesktop.NetworkManager.openvpn`). + OpenVpn { + /// Remote server address. + remote: Option, + /// Authentication/connection type. + connection_type: Option, + /// VPN-level user name. + user_name: Option, + /// CA certificate path. + ca: Option, + /// Client certificate path. + cert: Option, + /// Client key path. + key: Option, + /// TLS-auth key path. + ta: Option, + /// Password secret flags. + password_flags: VpnSecretFlags, + }, + /// OpenConnect (Cisco AnyConnect / Juniper / GlobalProtect / Pulse). + OpenConnect { + /// Gateway hostname. + gateway: Option, + /// VPN-level user name. + user_name: Option, + /// Protocol variant (`"anyconnect"`, `"nc"`, `"gp"`, `"pulse"`). + protocol: Option, + /// Password secret flags. + password_flags: VpnSecretFlags, + }, + /// strongSwan (IPSec/IKEv2). + StrongSwan { + /// Gateway address. + address: Option, + /// Auth method (`"eap"`, `"key"`, `"agent"`, `"smartcard"`). + method: Option, + /// VPN-level user name. + user_name: Option, + /// Certificate path. + certificate: Option, + /// Password secret flags. + password_flags: VpnSecretFlags, + }, + /// PPTP VPN. + Pptp { + /// Gateway hostname. + gateway: Option, + /// VPN-level user name. + user_name: Option, + /// Password secret flags. + password_flags: VpnSecretFlags, + }, + /// L2TP VPN. + L2tp { + /// Gateway hostname. + gateway: Option, + /// VPN-level user name. + user_name: Option, + /// Password secret flags. + password_flags: VpnSecretFlags, + /// Whether IPSec encapsulation is enabled. + ipsec_enabled: bool, + }, + /// Catch-all for VPN plugins nmrs doesn't model first-class. + Generic { + /// NM VPN plugin D-Bus service name. + service_type: String, + /// Raw `vpn.data` key-value pairs. + data: HashMap, + /// Raw `vpn.secrets` key-value pairs (often empty without agent). + secrets: HashMap, + /// VPN-level user name. + user_name: Option, + /// Password secret flags. + password_flags: VpnSecretFlags, + }, } -impl VpnCredentialsBuilder { - /// Sets the VPN type to WireGuard. - /// - /// Currently, WireGuard is the only supported VPN type. - #[must_use] - 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. - #[must_use] - 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. - #[must_use] - 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"). - #[must_use] - pub fn gateway(mut self, gateway: impl Into) -> Self { - self.gateway = Some(gateway.into()); - self - } +/// VPN connection configuration +/// +/// Type-safe wrapper for VPN configurations that enables protocol dispatch. +#[non_exhaustive] +#[derive(Debug, Clone)] +pub enum VpnConfiguration { + /// WireGuard VPN configuration. + WireGuard(WireGuardConfig), + /// OpenVPN configuration + OpenVpn(Box), +} - /// Sets the client's WireGuard private key. - /// - /// The private key should be base64 encoded. - #[must_use] - pub fn private_key(mut self, private_key: impl Into) -> Self { - self.private_key = Some(private_key.into()); - self +impl From for VpnConfiguration { + fn from(config: WireGuardConfig) -> Self { + Self::WireGuard(config) } +} - /// 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 - #[must_use] - pub fn address(mut self, address: impl Into) -> Self { - self.address = Some(address.into()); - self +impl From for VpnConfiguration { + fn from(config: OpenVpnConfig) -> Self { + Self::OpenVpn(Box::new(config)) } +} - /// Adds a WireGuard peer to the connection. - /// - /// Multiple peers can be added by calling this method multiple times. - #[must_use] - pub fn add_peer(mut self, peer: WireGuardPeer) -> Self { - self.peers.push(peer); - self - } +impl sealed::Sealed for VpnConfiguration {} - /// Sets all WireGuard peers at once. - /// - /// This replaces any previously added peers. - #[must_use] - pub fn peers(mut self, peers: Vec) -> Self { - self.peers = peers; - self +impl VpnConfig for VpnConfiguration { + fn vpn_kind(&self) -> VpnKind { + match self { + Self::WireGuard(_) => VpnKind::WireGuard, + Self::OpenVpn(_) => VpnKind::Plugin, + } } - /// 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()]); - /// ``` - #[must_use] - pub fn with_dns(mut self, dns: Vec) -> Self { - self.dns = Some(dns); - self + fn name(&self) -> &str { + match self { + Self::WireGuard(c) => &c.name, + Self::OpenVpn(c) => &c.name, + } } - /// Sets the MTU (Maximum Transmission Unit) size. - /// - /// Typical values are 1420 for WireGuard over standard networks. - #[must_use] - pub fn with_mtu(mut self, mtu: u32) -> Self { - self.mtu = Some(mtu); - self + fn dns(&self) -> Option<&[String]> { + match self { + Self::WireGuard(c) => c.dns.as_deref(), + Self::OpenVpn(c) => c.dns.as_deref(), + } } - /// Sets a specific UUID for the connection. - /// - /// If not set, NetworkManager will generate one automatically. - #[must_use] - pub fn with_uuid(mut self, uuid: Uuid) -> Self { - self.uuid = Some(uuid); - self + fn mtu(&self) -> Option { + match self { + Self::WireGuard(c) => c.mtu, + Self::OpenVpn(c) => c.mtu, + } } - /// 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(); - /// ``` - #[must_use] - 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, + fn uuid(&self) -> Option { + match self { + Self::WireGuard(c) => c.uuid, + Self::OpenVpn(c) => c.uuid, } } } -/// WireGuard peer configuration. -/// -/// Represents a single WireGuard peer (server) to connect to. +/// Common metadata shared by VPN connection configurations. /// -/// # 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::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). - pub public_key: String, - /// Peer endpoint in "host:port" format. - pub gateway: String, - /// IP ranges to route through this peer (e.g., ["0.0.0.0/0"]). - pub allowed_ips: Vec, - /// Optional pre-shared key for additional security. - pub preshared_key: Option, - /// Optional keepalive interval in seconds (e.g., 25). - pub persistent_keepalive: Option, -} +/// This trait is sealed and cannot be implemented outside of this crate. +/// Use [`WireGuardConfig`], [`OpenVpnConfig`], or [`VpnConfiguration`] instead. +pub trait VpnConfig: sealed::Sealed + Send + Sync + std::fmt::Debug { + /// Returns whether this is a plugin VPN or kernel WireGuard. + fn vpn_kind(&self) -> VpnKind; -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, - } - } + /// Returns the connection name. + fn name(&self) -> &str; - /// Sets the pre-shared key for additional security. - #[must_use] - pub fn with_preshared_key(mut self, psk: impl Into) -> Self { - self.preshared_key = Some(psk.into()); - self - } + /// Returns the configured DNS servers, if any. + fn dns(&self) -> Option<&[String]>; - /// Sets the persistent keepalive interval in seconds. - #[must_use] - pub fn with_persistent_keepalive(mut self, interval: u32) -> Self { - self.persistent_keepalive = Some(interval); - self - } + /// Returns the configured MTU, if any. + fn mtu(&self) -> Option; + + /// Returns the configured UUID, if any. + fn uuid(&self) -> Option; } -/// VPN Connection information. -/// -/// Represents a VPN connection managed by NetworkManager, including both -/// saved and active connections. +/// A saved or active VPN connection with rich metadata. /// -/// # 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 +/// Returned by [`crate::NetworkManager::list_vpn_connections`]. /// /// # Example /// /// ```no_run -/// # use nmrs::{VpnConnection, VpnType, DeviceState}; -/// # // This struct is returned by the library, not constructed directly +/// # use nmrs::{VpnConnection, VpnKind}; /// # let vpn: VpnConnection = todo!(); -/// println!("VPN: {}, State: {:?}", vpn.name, vpn.state); +/// println!("{} ({:?}) active={}", vpn.id, vpn.kind, vpn.active); /// ``` #[non_exhaustive] #[derive(Debug, Clone)] pub struct VpnConnection { - /// The connection name/identifier. + /// NM connection UUID. + pub uuid: String, + /// Connection display name (`connection.id`). + pub id: String, + /// Alias for `id` (backward compat). pub name: String, - /// The type of VPN (WireGuard, etc.). + /// Protocol-specific decoded settings. pub vpn_type: VpnType, - /// Current connection state. + /// Current device/active-connection state. pub state: DeviceState, - /// Network interface name when active (e.g., "wg0"). + /// Network interface name when active. pub interface: Option, + /// Whether this VPN is currently activated. + pub active: bool, + /// VPN-level user name (from `vpn.user-name`). + pub user_name: Option, + /// Password secret flags. + pub password_flags: VpnSecretFlags, + /// Raw NM `vpn.service-type` string (empty for WireGuard). + pub service_type: String, + /// Plugin-based vs kernel WireGuard. + pub kind: VpnKind, +} + +/// Protocol-specific details for an active VPN connection. +/// +/// Provides configuration details extracted from the NetworkManager connection +/// profile, varying by VPN type. +#[non_exhaustive] +#[derive(Debug, Clone)] +pub enum VpnDetails { + /// WireGuard-specific connection details. + WireGuard { + /// The local interface's public key. + public_key: Option, + /// The peer endpoint (e.g. "vpn.example.com:51820"). + endpoint: Option, + }, + /// OpenVPN-specific connection details. + OpenVpn { + /// Remote server address (e.g. "vpn.example.com:1194"). + remote: String, + /// Remote server port. + port: u16, + /// Transport protocol ("udp" or "tcp"). + protocol: String, + /// Data channel cipher (e.g. "AES-256-GCM"). + cipher: Option, + /// HMAC digest algorithm (e.g. "SHA256"). + auth: Option, + /// Compression mode if enabled (e.g. "lz4-v2"). + compression: Option, + }, } /// Detailed VPN connection information and statistics. @@ -523,23 +315,19 @@ pub struct VpnConnection { /// # Example /// /// ```no_run -/// # use nmrs::{VpnConnectionInfo, VpnType, DeviceState}; -/// # // This struct is returned by the library, not constructed directly +/// # use nmrs::{VpnConnectionInfo, VpnKind, DeviceState}; /// # let info: VpnConnectionInfo = todo!(); /// if let Some(ip) = &info.ip4_address { /// println!("VPN IPv4: {}", ip); /// } -/// if let Some(ip) = &info.ip6_address { -/// println!("VPN IPv6: {}", ip); -/// } /// ``` #[non_exhaustive] #[derive(Debug, Clone)] pub struct VpnConnectionInfo { /// The connection name/identifier. pub name: String, - /// The type of VPN (WireGuard, etc.). - pub vpn_type: VpnType, + /// Plugin vs WireGuard. + pub vpn_kind: VpnKind, /// Current connection state. pub state: DeviceState, /// Network interface name when active (e.g., "wg0"). @@ -552,4 +340,6 @@ pub struct VpnConnectionInfo { pub ip6_address: Option, /// DNS servers configured for this VPN. pub dns_servers: Vec, + /// Protocol-specific connection details, if available. + pub details: Option, } diff --git a/nmrs/src/api/models/wifi.rs b/nmrs/src/api/models/wifi.rs index 335ab835..8646a5e9 100644 --- a/nmrs/src/api/models/wifi.rs +++ b/nmrs/src/api/models/wifi.rs @@ -1,5 +1,8 @@ use serde::{Deserialize, Serialize}; +use super::access_point::SecurityFeatures; +use super::error::ConnectionError; + /// Represents a Wi-Fi network discovered during a scan. /// /// This struct contains information about a WiFi network that was discovered @@ -13,9 +16,9 @@ use serde::{Deserialize, Serialize}; /// # async fn example() -> nmrs::Result<()> { /// let nm = NetworkManager::new().await?; /// -/// // Scan for networks -/// nm.scan_networks().await?; -/// let networks = nm.list_networks().await?; +/// // Scan for networks (None = all Wi-Fi devices) +/// nm.scan_networks(None).await?; +/// let networks = nm.list_networks(None).await?; /// /// for net in networks { /// println!("SSID: {}", net.ssid); @@ -55,6 +58,21 @@ pub struct Network { pub ip4_address: Option, /// Assigned IPv6 address with CIDR notation (only present when connected) pub ip6_address: Option, + /// BSSID of the strongest AP for this SSID. + #[serde(default)] + pub best_bssid: String, + /// All known BSSIDs for this SSID, strongest first. + #[serde(default)] + pub bssids: Vec, + /// `true` if this network is currently active (connected). + #[serde(default)] + pub is_active: bool, + /// `true` if a saved connection profile exists for this SSID. + #[serde(default)] + pub known: bool, + /// Decoded security capabilities from NM flag triplet. + #[serde(default)] + pub security_features: SecurityFeatures, } /// Detailed information about a Wi-Fi network. @@ -69,7 +87,7 @@ pub struct Network { /// /// # async fn example() -> nmrs::Result<()> { /// let nm = NetworkManager::new().await?; -/// let networks = nm.list_networks().await?; +/// let networks = nm.list_networks(None).await?; /// /// if let Some(network) = networks.first() { /// let info = nm.show_details(network).await?; @@ -248,7 +266,8 @@ impl EapOptions { /// .phase2(Phase2::Mschapv2) /// .domain_suffix_match("company.com") /// .system_ca_certs(true) - /// .build(); + /// .build() + /// .expect("all required fields set"); /// ``` #[must_use] pub fn builder() -> EapOptionsBuilder { @@ -318,7 +337,8 @@ impl EapOptions { /// .anonymous_identity("anonymous@company.com") /// .domain_suffix_match("company.com") /// .system_ca_certs(true) -/// .build(); +/// .build() +/// .expect("all required fields set"); /// ``` /// /// ## TTLS with PAP @@ -332,7 +352,8 @@ impl EapOptions { /// .method(EapMethod::Ttls) /// .phase2(Phase2::Pap) /// .ca_cert_path("file:///etc/ssl/certs/university-ca.pem") -/// .build(); +/// .build() +/// .expect("all required fields set"); /// ``` #[derive(Debug, Default)] pub struct EapOptionsBuilder { @@ -480,13 +501,10 @@ impl EapOptionsBuilder { /// Builds the `EapOptions` from the configured values. /// - /// # Panics + /// # Errors /// - /// 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)) + /// Returns [`ConnectionError::IncompleteBuilder`](crate::ConnectionError::IncompleteBuilder) + /// if any required field is missing. /// /// # Examples /// @@ -498,24 +516,35 @@ impl EapOptionsBuilder { /// .password("password") /// .method(EapMethod::Peap) /// .phase2(Phase2::Mschapv2) - /// .build(); + /// .build() + /// .expect("all required fields set"); /// ``` - #[must_use] - pub fn build(self) -> EapOptions { - EapOptions { - identity: self - .identity - .expect("identity is required (use .identity())"), - password: self - .password - .expect("password is required (use .password())"), + #[must_use = "use the EAP options with WifiSecurity::WpaEap or handle the error"] + pub fn build(self) -> Result { + Ok(EapOptions { + identity: self.identity.ok_or_else(|| { + ConnectionError::IncompleteBuilder( + "EAP identity is required (use .identity())".into(), + ) + })?, + password: self.password.ok_or_else(|| { + ConnectionError::IncompleteBuilder( + "EAP password is required (use .password())".into(), + ) + })?, 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())"), - } + method: self.method.ok_or_else(|| { + ConnectionError::IncompleteBuilder("EAP method is required (use .method())".into()) + })?, + phase2: self.phase2.ok_or_else(|| { + ConnectionError::IncompleteBuilder( + "EAP phase 2 method is required (use .phase2())".into(), + ) + })?, + }) } } @@ -547,7 +576,7 @@ impl EapOptionsBuilder { /// # async fn example() -> nmrs::Result<()> { /// let nm = NetworkManager::new().await?; /// -/// nm.connect("HomeWiFi", WifiSecurity::WpaPsk { +/// nm.connect("HomeWiFi", None, WifiSecurity::WpaPsk { /// psk: "my_secure_password".into() /// }).await?; /// # Ok(()) @@ -568,7 +597,7 @@ impl EapOptionsBuilder { /// .with_method(EapMethod::Peap) /// .with_phase2(Phase2::Mschapv2); /// -/// nm.connect("CorpWiFi", WifiSecurity::WpaEap { +/// nm.connect("CorpWiFi", None, WifiSecurity::WpaEap { /// opts: eap_opts /// }).await?; /// # Ok(()) @@ -618,16 +647,26 @@ impl Network { /// this method keeps the strongest signal and combines security flags. /// Used internally during network scanning to deduplicate results. pub fn merge_ap(&mut self, other: &Network) { + if let Some(ref b) = other.bssid + && !self.bssids.contains(b) + { + self.bssids.push(b.clone()); + } + if other.strength.unwrap_or(0) > self.strength.unwrap_or(0) { self.strength = other.strength; self.frequency = other.frequency; self.bssid = other.bssid.clone(); + self.best_bssid = other.best_bssid.clone(); + self.security_features = other.security_features; } self.secured |= other.secured; self.is_psk |= other.is_psk; self.is_eap |= other.is_eap; self.is_hotspot |= other.is_hotspot; + self.is_active |= other.is_active; + self.known |= other.known; if self.ip4_address.is_none() { self.ip4_address.clone_from(&other.ip4_address); @@ -659,6 +698,11 @@ mod network_merge_tests { is_hotspot: false, ip4_address: Some("192.168.1.5/24".into()), ip6_address: Some("fe80::1/64".into()), + best_bssid: "aa:aa:aa:aa:aa:aa".into(), + bssids: vec!["aa:aa:aa:aa:aa:aa".into()], + is_active: true, + known: false, + security_features: Default::default(), }; let stronger = Network { device: String::new(), @@ -672,12 +716,20 @@ mod network_merge_tests { is_hotspot: false, ip4_address: None, ip6_address: None, + best_bssid: "bb:bb:bb:bb:bb:bb".into(), + bssids: vec!["bb:bb:bb:bb:bb:bb".into()], + is_active: false, + known: false, + security_features: Default::default(), }; weaker_connected.merge_ap(&stronger); assert_eq!(weaker_connected.strength, Some(90)); assert_eq!(weaker_connected.bssid, Some("bb:bb:bb:bb:bb:bb".into())); + assert_eq!(weaker_connected.best_bssid, "bb:bb:bb:bb:bb:bb"); assert_eq!(weaker_connected.ip4_address, Some("192.168.1.5/24".into())); assert_eq!(weaker_connected.ip6_address, Some("fe80::1/64".into())); assert_eq!(weaker_connected.device, "wlan0"); + assert!(weaker_connected.is_active); + assert_eq!(weaker_connected.bssids.len(), 2); } } diff --git a/nmrs/src/api/models/wireguard.rs b/nmrs/src/api/models/wireguard.rs new file mode 100644 index 00000000..94c5032c --- /dev/null +++ b/nmrs/src/api/models/wireguard.rs @@ -0,0 +1,629 @@ +#![allow(deprecated)] + +use super::error::ConnectionError; +use super::vpn::{VpnConfig, VpnKind}; +use uuid::Uuid; + +/// WireGuard configuration for establishing a VPN connection. +/// +/// Stores the necessary information to configure and connect to a VPN. +/// +/// # Fields +/// +/// - `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::{WireGuardConfig, WireGuardPeer}; +/// +/// let peer = WireGuardPeer::new( +/// "server_public_key", +/// "vpn.home.com:51820", +/// vec!["0.0.0.0/0".into()], +/// ).with_persistent_keepalive(25); +/// +/// let config = WireGuardConfig::new( +/// "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 WireGuardConfig { + /// Unique name for the connection profile. + pub name: String, + /// VPN gateway endpoint (e.g., "vpn.example.com:51820"). + pub gateway: String, + /// Client's WireGuard private key (base64 encoded). + pub private_key: String, + /// Client's IP address with CIDR notation (e.g., "10.0.0.2/24"). + pub address: String, + /// List of WireGuard peers to connect to. + pub peers: Vec, + /// Optional DNS servers to use when connected. + pub dns: Option>, + /// Optional Maximum Transmission Unit size. + pub mtu: Option, + /// Optional UUID for the connection (auto-generated if not provided). + pub uuid: Option, +} + +impl WireGuardConfig { + /// Creates new `WireGuardConfig` with the required fields. + /// + /// # Examples + /// + /// ```rust + /// use nmrs::{WireGuardConfig, WireGuardPeer}; + /// + /// let peer = WireGuardPeer::new( + /// "server_public_key", + /// "vpn.example.com:51820", + /// vec!["0.0.0.0/0".into()], + /// ); + /// + /// let config = WireGuardConfig::new( + /// "MyVPN", + /// "vpn.example.com:51820", + /// "client_private_key", + /// "10.0.0.2/24", + /// vec![peer], + /// ); + /// ``` + pub fn new( + name: impl Into, + gateway: impl Into, + private_key: impl Into, + address: impl Into, + peers: Vec, + ) -> Self { + Self { + name: name.into(), + gateway: gateway.into(), + private_key: private_key.into(), + address: address.into(), + peers, + dns: None, + mtu: None, + uuid: None, + } + } + + /// Sets the DNS servers to use when connected. + #[must_use] + pub fn with_dns(mut self, dns: Vec) -> Self { + self.dns = Some(dns); + self + } + + /// Sets the MTU (Maximum Transmission Unit) size. + #[must_use] + pub fn with_mtu(mut self, mtu: u32) -> Self { + self.mtu = Some(mtu); + self + } + + /// Sets the UUID for the connection. + #[must_use] + pub fn with_uuid(mut self, uuid: Uuid) -> Self { + self.uuid = Some(uuid); + self + } +} + +impl super::vpn::sealed::Sealed for WireGuardConfig {} + +impl VpnConfig for WireGuardConfig { + fn vpn_kind(&self) -> VpnKind { + VpnKind::WireGuard + } + + fn name(&self) -> &str { + &self.name + } + + fn dns(&self) -> Option<&[String]> { + self.dns.as_deref() + } + + fn mtu(&self) -> Option { + self.mtu + } + + fn uuid(&self) -> Option { + self.uuid + } +} + +impl From for VpnCredentials { + fn from(config: WireGuardConfig) -> Self { + Self { + vpn_type: VpnKind::WireGuard, + name: config.name, + gateway: config.gateway, + private_key: config.private_key, + address: config.address, + peers: config.peers, + dns: config.dns, + mtu: config.mtu, + uuid: config.uuid, + } + } +} + +impl From for WireGuardConfig { + fn from(config: VpnCredentials) -> Self { + Self { + name: config.name, + gateway: config.gateway, + private_key: config.private_key, + address: config.address, + peers: config.peers, + dns: config.dns, + mtu: config.mtu, + uuid: config.uuid, + } + } +} + +/// Legacy VPN credentials for establishing a VPN connection. +/// +/// Prefer [`WireGuardConfig`] for new WireGuard connections. +#[deprecated(note = "Use WireGuardConfig instead.")] +#[non_exhaustive] +#[derive(Debug, Clone)] +pub struct VpnCredentials { + /// The type of VPN (currently only WireGuard). + pub vpn_type: VpnKind, + /// Unique name for the connection profile. + pub name: String, + /// VPN gateway endpoint (e.g., "vpn.example.com:51820"). + pub gateway: String, + /// Client's WireGuard private key (base64 encoded). + pub private_key: String, + /// Client's IP address with CIDR notation (e.g., "10.0.0.2/24"). + pub address: String, + /// List of WireGuard peers to connect to. + pub peers: Vec, + /// Optional DNS servers to use when connected. + pub dns: Option>, + /// Optional Maximum Transmission Unit size. + pub mtu: Option, + /// Optional UUID for the connection (auto-generated if not provided). + pub uuid: Option, +} + +impl VpnCredentials { + /// Creates new `VpnCredentials` with the required fields. + /// + /// Prefer [`WireGuardConfig::new`] for new code. + /// + /// # Examples + /// + /// ```rust + /// use nmrs::{VpnCredentials, VpnKind, WireGuardPeer}; + /// + /// let peer = WireGuardPeer::new( + /// "server_public_key", + /// "vpn.example.com:51820", + /// vec!["0.0.0.0/0".into()], + /// ); + /// + /// let creds = VpnCredentials::new( + /// VpnKind::WireGuard, + /// "MyVPN", + /// "vpn.example.com:51820", + /// "client_private_key", + /// "10.0.0.2/24", + /// vec![peer], + /// ); + /// ``` + pub fn new( + vpn_type: VpnKind, + 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. + #[must_use] + pub fn builder() -> VpnCredentialsBuilder { + VpnCredentialsBuilder::default() + } + + /// Sets the DNS servers to use when connected. + #[must_use] + pub fn with_dns(mut self, dns: Vec) -> Self { + self.dns = Some(dns); + self + } + + /// Sets the MTU (Maximum Transmission Unit) size. + #[must_use] + pub fn with_mtu(mut self, mtu: u32) -> Self { + self.mtu = Some(mtu); + self + } + + /// Sets the UUID for the connection. + #[must_use] + pub fn with_uuid(mut self, uuid: Uuid) -> Self { + self.uuid = Some(uuid); + self + } +} + +impl super::vpn::sealed::Sealed for VpnCredentials {} + +impl VpnConfig for VpnCredentials { + fn vpn_kind(&self) -> VpnKind { + self.vpn_type + } + + fn name(&self) -> &str { + &self.name + } + + fn dns(&self) -> Option<&[String]> { + self.dns.as_deref() + } + + fn mtu(&self) -> Option { + self.mtu + } + + fn uuid(&self) -> Option { + self.uuid + } +} + +/// 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() +/// .expect("all required fields set"); +/// ``` +/// +/// ## 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() +/// .expect("all required fields set"); +/// ``` +#[non_exhaustive] +#[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. + #[must_use] + pub fn wireguard(mut self) -> Self { + self.vpn_type = Some(VpnKind::WireGuard); + self + } + + /// Sets the VPN kind. + /// + /// For most use cases, prefer using [`wireguard()`](Self::wireguard) instead. + #[must_use] + pub fn vpn_kind(mut self, vpn_kind: VpnKind) -> Self { + self.vpn_type = Some(vpn_kind); + self + } + + /// Sets the connection name. + /// + /// This is the unique identifier for the VPN connection profile. + #[must_use] + 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"). + #[must_use] + 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. + #[must_use] + 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 + #[must_use] + 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. + #[must_use] + 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. + #[must_use] + 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()]); + /// ``` + #[must_use] + 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. + #[must_use] + 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. + #[must_use] + pub fn with_uuid(mut self, uuid: Uuid) -> Self { + self.uuid = Some(uuid); + self + } + + /// Builds the `VpnCredentials` from the configured values. + /// + /// # Errors + /// + /// Returns [`ConnectionError::IncompleteBuilder`](crate::ConnectionError::IncompleteBuilder) + /// if a required string field is missing, or + /// [`ConnectionError::InvalidPeers`](crate::ConnectionError::InvalidPeers) if no peers + /// were added. + /// + /// # 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() + /// .expect("all required fields set"); + /// ``` + #[must_use = "the built credentials must be passed to the VPN connect API"] + pub fn build(self) -> Result { + let vpn_type = self.vpn_type.ok_or_else(|| { + ConnectionError::IncompleteBuilder("VPN type is required (use .wireguard())".into()) + })?; + let name = self.name.ok_or_else(|| { + ConnectionError::IncompleteBuilder("connection name is required (use .name())".into()) + })?; + let gateway = self.gateway.ok_or_else(|| { + ConnectionError::IncompleteBuilder("gateway is required (use .gateway())".into()) + })?; + let private_key = self.private_key.ok_or_else(|| { + ConnectionError::IncompleteBuilder( + "private key is required (use .private_key())".into(), + ) + })?; + let address = self.address.ok_or_else(|| { + ConnectionError::IncompleteBuilder("address is required (use .address())".into()) + })?; + if self.peers.is_empty() { + return Err(ConnectionError::InvalidPeers( + "at least one peer is required (use .add_peer())".into(), + )); + } + Ok(VpnCredentials { + vpn_type, + name, + gateway, + private_key, + address, + peers: self.peers, + dns: self.dns, + mtu: self.mtu, + uuid: self.uuid, + }) + } +} + +/// 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::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). + pub public_key: String, + /// Peer endpoint in "host:port" format. + pub gateway: String, + /// IP ranges to route through this peer (e.g., ["0.0.0.0/0"]). + pub allowed_ips: Vec, + /// Optional pre-shared key for additional security. + pub preshared_key: Option, + /// Optional keepalive interval in seconds (e.g., 25). + 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. + #[must_use] + 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. + #[must_use] + pub fn with_persistent_keepalive(mut self, interval: u32) -> Self { + self.persistent_keepalive = Some(interval); + self + } +} diff --git a/nmrs/src/api/network_manager.rs b/nmrs/src/api/network_manager.rs index 6715dfcc..16a191d9 100644 --- a/nmrs/src/api/network_manager.rs +++ b/nmrs/src/api/network_manager.rs @@ -1,24 +1,36 @@ +use std::collections::HashMap; + use tokio::sync::watch; use zbus::Connection; +use zvariant::OwnedValue; use crate::Result; -use crate::api::models::{Device, Network, NetworkInfo, WifiSecurity}; +use crate::api::models::access_point::AccessPoint; +use crate::api::models::{ + AirplaneModeState, Device, Network, NetworkInfo, RadioState, SavedConnection, + SavedConnectionBrief, SettingsPatch, WifiDevice, WifiSecurity, +}; +use crate::api::wifi_scope::WifiScope; +use crate::core::airplane; use crate::core::bluetooth::connect_bluetooth; use crate::core::connection::{ - 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, + connect, connect_to_bssid, 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}; use crate::core::device::{ - is_connecting, list_bluetooth_devices, list_devices, set_wifi_enabled, wait_for_wifi_ready, - wifi_enabled, wifi_hardware_enabled, + is_connecting, list_bluetooth_devices, list_devices, wait_for_wifi_ready, +}; +use crate::core::saved_connection as saved_profiles; +use crate::core::scan::{current_network, list_access_points, list_networks, scan_networks}; +use crate::core::vpn::{ + active_vpn_connections, connect_vpn, connect_vpn_by_id, connect_vpn_by_uuid, disconnect_vpn, + disconnect_vpn_by_uuid, get_vpn_info, list_vpn_connections, }; -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::core::wifi_device::{list_wifi_devices, set_wifi_enabled_for_interface}; use crate::models::{ - BluetoothDevice, BluetoothIdentity, VpnConnection, VpnConnectionInfo, VpnCredentials, + BluetoothDevice, BluetoothIdentity, VpnConfig, VpnConfiguration, VpnConnection, + VpnConnectionInfo, }; use crate::monitoring::device as device_monitor; use crate::monitoring::info::show_details; @@ -60,14 +72,14 @@ use crate::types::constants::device_type; /// # async fn example() -> nmrs::Result<()> { /// let nm = NetworkManager::new().await?; /// -/// // Scan and list networks -/// let networks = nm.list_networks().await?; +/// // Scan and list networks (None = all Wi-Fi devices) +/// let networks = nm.list_networks(None).await?; /// for net in &networks { /// println!("{}: {}%", net.ssid, net.strength.unwrap_or(0)); /// } /// -/// // Connect to a network -/// nm.connect("MyNetwork", WifiSecurity::WpaPsk { +/// // Connect to a network on the first Wi-Fi device +/// nm.connect("MyNetwork", None, WifiSecurity::WpaPsk { /// psk: "password".into() /// }).await?; /// # Ok(()) @@ -86,8 +98,12 @@ use crate::types::constants::device_type; /// let devices = nm.list_devices().await?; /// /// // Control WiFi -/// nm.set_wifi_enabled(false).await?; // Disable WiFi -/// nm.set_wifi_enabled(true).await?; // Enable WiFi +/// nm.set_wireless_enabled(false).await?; // Disable WiFi +/// nm.set_wireless_enabled(true).await?; // Enable WiFi +/// +/// // Check airplane mode +/// let state = nm.airplane_mode_state().await?; +/// println!("Airplane mode: {}", state.is_airplane_mode()); /// # Ok(()) /// # } /// ``` @@ -213,19 +229,185 @@ impl NetworkManager { } /// Lists all visible Wi-Fi networks. - pub async fn list_networks(&self) -> Result> { - list_networks(&self.conn).await + /// + /// Networks sharing an SSID on the same device are grouped, keeping the + /// strongest AP as the representative. Each returned [`Network`] carries + /// `best_bssid`, `bssids`, and `security_features` from the underlying APs. + /// + /// Pass `interface = Some("wlan0")` to scope to a single Wi-Fi device, + /// or `None` to enumerate across every Wi-Fi device. + /// + /// **3.0 break:** added the `interface` parameter. For old behavior, + /// pass `None`. + pub async fn list_networks(&self, interface: Option<&str>) -> Result> { + list_networks(&self.conn, interface).await + } + + /// Lists every managed Wi-Fi device on the system. + /// + /// Each [`WifiDevice`] includes its interface name, MAC, current state, + /// and the SSID of any active connection. + pub async fn list_wifi_devices(&self) -> Result> { + list_wifi_devices(&self.conn).await + } + + /// Look up a single Wi-Fi device by interface name. + /// + /// Returns + /// [`WifiInterfaceNotFound`](crate::ConnectionError::WifiInterfaceNotFound) + /// if no device matches, or + /// [`NotAWifiDevice`](crate::ConnectionError::NotAWifiDevice) if the + /// interface exists but isn't a Wi-Fi device. + pub async fn wifi_device_by_interface(&self, name: &str) -> Result { + let all = list_wifi_devices(&self.conn).await?; + all.into_iter() + .find(|d| d.interface == name) + .ok_or_else(|| crate::ConnectionError::WifiInterfaceNotFound { + interface: name.to_string(), + }) + } + + /// Build a [`WifiScope`] pinned to the given interface. + /// + /// All operations on the returned scope target only that one Wi-Fi + /// device. Useful on multi-radio systems (laptops with USB dongles, + /// docks with a second wireless adapter). + /// + /// # Examples + /// + /// ```no_run + /// use nmrs::{NetworkManager, WifiSecurity}; + /// + /// # async fn example() -> nmrs::Result<()> { + /// let nm = NetworkManager::new().await?; + /// nm.wifi("wlan1").connect( + /// "Guest", + /// WifiSecurity::WpaPsk { psk: "guestpass".into() }, + /// ).await?; + /// # Ok(()) + /// # } + /// ``` + #[must_use] + pub fn wifi(&self, interface: impl Into) -> WifiScope { + WifiScope { + conn: self.conn.clone(), + interface: interface.into(), + timeout_config: self.timeout_config, + } + } + + /// Lists all visible access points, one entry per BSSID. + /// + /// Unlike [`list_networks`](Self::list_networks), this preserves + /// duplicate BSSIDs for the same SSID and includes per-AP details + /// like BSSID, exact frequency, bitrate, and device state. + /// + /// Pass `interface` to restrict to a single wireless device (e.g. + /// `Some("wlan0")`), or `None` for all devices. + /// + /// # Examples + /// + /// ```no_run + /// use nmrs::NetworkManager; + /// + /// # async fn example() -> nmrs::Result<()> { + /// let nm = NetworkManager::new().await?; + /// let mut aps = nm.list_access_points(None).await?; + /// aps.sort_by(|a, b| b.strength.cmp(&a.strength)); + /// for ap in &aps { + /// println!("{:>3}% {:<20} {} {} MHz", + /// ap.strength, ap.ssid, ap.bssid, ap.frequency_mhz); + /// } + /// # Ok(()) + /// # } + /// ``` + pub async fn list_access_points(&self, interface: Option<&str>) -> Result> { + list_access_points(&self.conn, interface).await + } + + /// Connects to a specific access point by SSID and optional BSSID. + /// + /// If `bssid` is `Some`, the connection targets that specific AP rather + /// than the strongest match for the SSID. If `None`, behaves identically + /// to [`connect`](Self::connect). + /// + /// **3.0 break:** added the `interface` parameter (3rd argument). Pass + /// `None` for the previous behavior of using the first available Wi-Fi + /// device, or `Some("wlan1")` to pin the connection to a specific + /// interface. For an ergonomic per-interface API, see + /// [`wifi`](Self::wifi). + /// + /// # Errors + /// + /// Returns [`ApBssidNotFound`](crate::ConnectionError::ApBssidNotFound) if + /// no AP matching both the SSID and BSSID is visible. + /// Returns [`InvalidBssid`](crate::ConnectionError::InvalidBssid) if the + /// BSSID format is invalid. + /// Returns + /// [`WifiInterfaceNotFound`](crate::ConnectionError::WifiInterfaceNotFound) + /// or [`NotAWifiDevice`](crate::ConnectionError::NotAWifiDevice) if the + /// supplied interface name is bad. + /// + /// # Examples + /// + /// ```no_run + /// use nmrs::{NetworkManager, WifiSecurity}; + /// + /// # async fn example() -> nmrs::Result<()> { + /// let nm = NetworkManager::new().await?; + /// nm.connect_to_bssid( + /// "HomeWiFi", + /// Some("AA:BB:CC:DD:EE:FF"), + /// None, + /// WifiSecurity::WpaPsk { psk: "password".into() }, + /// ).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn connect_to_bssid( + &self, + ssid: &str, + bssid: Option<&str>, + interface: Option<&str>, + creds: WifiSecurity, + ) -> Result<()> { + connect_to_bssid( + &self.conn, + ssid, + bssid, + creds, + interface, + Some(self.timeout_config), + ) + .await } /// Connects to a Wi-Fi network with the given credentials. /// + /// **3.0 break:** added the `interface` parameter (3rd argument). Pass + /// `None` for the previous behavior of using the first available Wi-Fi + /// device, or `Some("wlan1")` to pin the connection to a specific + /// interface. + /// /// # 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, Some(self.timeout_config)).await + pub async fn connect( + &self, + ssid: &str, + interface: Option<&str>, + creds: WifiSecurity, + ) -> Result<()> { + connect( + &self.conn, + ssid, + creds, + interface, + Some(self.timeout_config), + ) + .await } /// Connects to a wired (Ethernet) device. @@ -265,17 +447,19 @@ impl NetworkManager { connect_bluetooth(&self.conn, name, identity, Some(self.timeout_config)).await } - /// Connects to a VPN using the provided credentials. + /// Connects to a VPN using the provided configuration. /// - /// Currently supports WireGuard VPN connections. The function checks for an + /// Supports WireGuard and OpenVPN 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. + /// configuration. /// - /// # Example + /// # Examples + /// + /// ## WireGuard /// /// ```rust - /// use nmrs::{NetworkManager, VpnCredentials, VpnType, WireGuardPeer}; + /// use nmrs::{NetworkManager, WireGuardConfig, WireGuardPeer}; /// /// # async fn example() -> nmrs::Result<()> { /// let nm = NetworkManager::new().await?; @@ -286,8 +470,7 @@ impl NetworkManager { /// vec!["0.0.0.0/0".into()], /// ).with_persistent_keepalive(25); /// - /// let creds = VpnCredentials::new( - /// VpnType::WireGuard, + /// let config = WireGuardConfig::new( /// "MyVPN", /// "vpn.example.com:51820", /// "your_private_key", @@ -295,7 +478,28 @@ impl NetworkManager { /// vec![peer], /// ).with_dns(vec!["1.1.1.1".into()]); /// - /// nm.connect_vpn(creds).await?; + /// nm.connect_vpn(config).await?; + /// # Ok(()) + /// # } + /// ``` + /// + /// ## OpenVPN + /// + /// ```rust + /// use nmrs::{NetworkManager, OpenVpnConfig, OpenVpnAuthType}; + /// + /// # async fn example() -> nmrs::Result<()> { + /// let nm = NetworkManager::new().await?; + /// + /// let config = OpenVpnConfig::new("CorpVPN", "vpn.example.com", 1194, false) + /// .with_auth_type(OpenVpnAuthType::PasswordTls) + /// .with_username("user") + /// .with_password("secret") + /// .with_ca_cert("/etc/openvpn/ca.crt") + /// .with_client_cert("/etc/openvpn/client.crt") + /// .with_client_key("/etc/openvpn/client.key"); + /// + /// nm.connect_vpn(config).await?; /// # Ok(()) /// # } /// ``` @@ -304,10 +508,62 @@ impl NetworkManager { /// /// Returns an error if: /// - NetworkManager is not running or accessible - /// - The credentials are invalid or incomplete + /// - The configuration is invalid or incomplete /// - The VPN connection fails to activate - pub async fn connect_vpn(&self, creds: VpnCredentials) -> Result<()> { - connect_vpn(&self.conn, creds, Some(self.timeout_config)).await + pub async fn connect_vpn(&self, config: C) -> Result<()> + where + C: VpnConfig + Into, + { + connect_vpn(&self.conn, config.into(), Some(self.timeout_config)).await + } + + /// Imports a `.ovpn` file and activates the OpenVPN connection. + /// + /// Parses the file, persists any inline certificates, builds the + /// connection profile, and activates it through NetworkManager. + /// + /// # Arguments + /// + /// * `path` — Path to the `.ovpn` configuration file + /// * `username` — Optional username for password-based authentication + /// * `password` — Optional password for password-based authentication + /// + /// # Example + /// + /// ```no_run + /// use nmrs::NetworkManager; + /// + /// # async fn example() -> nmrs::Result<()> { + /// let nm = NetworkManager::new().await?; + /// nm.import_ovpn("corp.ovpn", Some("user"), Some("secret")).await?; + /// # Ok(()) + /// # } + /// ``` + /// + /// # Errors + /// + /// Returns an error if: + /// - The file cannot be read or parsed + /// - Inline certificate storage fails + /// - The configuration is incomplete (e.g. TLS auth without certs) + /// - The VPN connection fails to activate + pub async fn import_ovpn( + &self, + path: impl AsRef, + username: Option<&str>, + password: Option<&str>, + ) -> Result<()> { + use crate::builders::OpenVpnBuilder; + + let mut builder = OpenVpnBuilder::from_ovpn_file(path)?; + if let Some(u) = username { + builder = builder.username(u); + } + if let Some(p) = password { + builder = builder.password(p); + } + let config = builder.build()?; + self.connect_vpn(config).await } /// Disconnects from an active VPN connection by name. @@ -334,8 +590,8 @@ impl NetworkManager { /// 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. + /// including their name, type, and current state. Returns WireGuard and + /// OpenVPN connections. /// /// # Example /// @@ -356,6 +612,41 @@ impl NetworkManager { list_vpn_connections(&self.conn).await } + /// Only active VPNs (subset of `list_vpn_connections` with `active = true`). + pub async fn active_vpn_connections(&self) -> Result> { + active_vpn_connections(&self.conn).await + } + + /// Activate a saved VPN by UUID. + /// + /// # Example + /// + /// ```no_run + /// use nmrs::NetworkManager; + /// + /// # async fn example() -> nmrs::Result<()> { + /// let nm = NetworkManager::new().await?; + /// nm.connect_vpn_by_uuid("2c3f1234-abcd-5678-ef01-234567890abc").await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn connect_vpn_by_uuid(&self, uuid: &str) -> Result<()> { + connect_vpn_by_uuid(&self.conn, uuid, Some(self.timeout_config)).await + } + + /// Activate a saved VPN by connection display name. + /// + /// Fails with [`VpnIdAmbiguous`](crate::ConnectionError::VpnIdAmbiguous) + /// if multiple VPNs share the same name. + pub async fn connect_vpn_by_id(&self, id: &str) -> Result<()> { + connect_vpn_by_id(&self.conn, id, Some(self.timeout_config)).await + } + + /// Disconnect a VPN by UUID. + pub async fn disconnect_vpn_by_uuid(&self, uuid: &str) -> Result<()> { + disconnect_vpn_by_uuid(&self.conn, uuid).await + } + /// Forgets (deletes) a saved VPN connection by name. /// /// Searches through saved connections for a VPN matching the given name. @@ -414,20 +705,143 @@ impl NetworkManager { 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 + /// Returns the combined software/hardware state of the Wi-Fi radio. + /// + /// See [`RadioState`] for the distinction between `enabled` (software) + /// and `hardware_enabled` (rfkill). + pub async fn wifi_state(&self) -> Result { + airplane::wifi_state(&self.conn).await + } + + /// Returns the combined software/hardware state of the WWAN radio. + pub async fn wwan_state(&self) -> Result { + airplane::wwan_state(&self.conn).await + } + + /// Returns the combined software/hardware state of the Bluetooth radio. + /// + /// Reads power state from all BlueZ adapters and cross-references rfkill. + /// If BlueZ is not running or no adapters exist, returns + /// `RadioState { enabled: true, hardware_enabled: false }`. + pub async fn bluetooth_radio_state(&self) -> Result { + airplane::bluetooth_radio_state(&self.conn).await + } + + /// Returns the aggregated airplane-mode state across all radios. + /// + /// Fans out to Wi-Fi, WWAN, and Bluetooth concurrently and returns + /// an [`AirplaneModeState`] snapshot. + pub async fn airplane_mode_state(&self) -> Result { + airplane::airplane_mode_state(&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 + /// Enables or disables the Wi-Fi radio (software toggle). + /// + /// This replaces the deprecated [`set_wifi_enabled`](Self::set_wifi_enabled). + /// If the radio is hardware-killed, NM accepts the write but the radio + /// remains off until hardware is unkilled. + pub async fn set_wireless_enabled(&self, enabled: bool) -> Result<()> { + airplane::set_wireless_enabled(&self.conn, enabled).await } - /// Returns whether wireless hardware is currently enabled. - /// Reflects rfkill state which helps check if the radio is enabled or blocked. - pub async fn wifi_hardware_enabled(&self) -> Result { - wifi_hardware_enabled(&self.conn).await + /// Enables or disables the WWAN (mobile broadband) radio. + /// + /// Writes the `WwanEnabled` property on NetworkManager. + pub async fn set_wwan_enabled(&self, enabled: bool) -> Result<()> { + airplane::set_wwan_enabled(&self.conn, enabled).await + } + + /// Enables or disables the Bluetooth radio by toggling all BlueZ adapters. + /// + /// Returns [`BluezUnavailable`](crate::ConnectionError::BluezUnavailable) if BlueZ is not running + /// or no adapters exist. + pub async fn set_bluetooth_radio_enabled(&self, enabled: bool) -> Result<()> { + airplane::set_bluetooth_radio_enabled(&self.conn, enabled).await + } + + /// Flips all three radios in one call. + /// + /// **`enabled = true` means airplane mode is on, i.e. radios are off.** + /// + /// Does not fail fast: attempts all three toggles concurrently and + /// returns the first error at the end, if any. + pub async fn set_airplane_mode(&self, enabled: bool) -> Result<()> { + airplane::set_airplane_mode(&self.conn, enabled).await + } + + /// Current connectivity state as NM sees it (single property read). + /// + /// # Example + /// + /// ```no_run + /// use nmrs::NetworkManager; + /// + /// # async fn example() -> nmrs::Result<()> { + /// let nm = NetworkManager::new().await?; + /// let state = nm.connectivity().await?; + /// println!("{state:?}"); + /// # Ok(()) + /// # } + /// ``` + pub async fn connectivity(&self) -> Result { + crate::core::connectivity::connectivity(&self.conn).await + } + + /// Forces NM to re-check connectivity by probing the configured URI. + /// + /// Returns the new state once the check completes. + /// + /// # Errors + /// + /// Returns [`ConnectivityCheckDisabled`](crate::ConnectionError::ConnectivityCheckDisabled) + /// if NM's connectivity checks are turned off. + pub async fn check_connectivity(&self) -> Result { + crate::core::connectivity::check_connectivity(&self.conn).await + } + + /// Full connectivity report including check URI and captive-portal URL. + /// + /// # Example + /// + /// ```no_run + /// use nmrs::NetworkManager; + /// + /// # async fn example() -> nmrs::Result<()> { + /// let nm = NetworkManager::new().await?; + /// let report = nm.connectivity_report().await?; + /// println!("{:?} portal={:?}", report.state, report.captive_portal_url); + /// # Ok(()) + /// # } + /// ``` + pub async fn connectivity_report(&self) -> Result { + crate::core::connectivity::connectivity_report(&self.conn).await + } + + /// Captive-portal URL detected by NM, if state is `Portal`. + /// + /// Returns `None` if NM is not in `Portal` state or if this NM version + /// does not expose the URL. + pub async fn captive_portal_url(&self) -> Result> { + let report = crate::core::connectivity::connectivity_report(&self.conn).await?; + Ok(report.captive_portal_url) + } + + /// Disable or re-enable a single Wi-Fi interface. + /// + /// Sets `Device.Autoconnect = enabled` and, when disabling, calls + /// `Device.Disconnect()`. This is independent of the global wireless + /// killswitch ([`set_wireless_enabled`](Self::set_wireless_enabled)) and + /// safe to use on multi-radio systems. + /// + /// # Errors + /// + /// Returns + /// [`WifiInterfaceNotFound`](crate::ConnectionError::WifiInterfaceNotFound) + /// if no device with that name exists, or + /// [`NotAWifiDevice`](crate::ConnectionError::NotAWifiDevice) if the + /// interface isn't a Wi-Fi device. + pub async fn set_wifi_enabled(&self, interface: &str, enabled: bool) -> Result<()> { + set_wifi_enabled_for_interface(&self.conn, interface, enabled).await } /// Waits for a Wi-Fi device to become ready (disconnected or activated). @@ -435,9 +849,13 @@ impl NetworkManager { 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 + /// Triggers a Wi-Fi scan. + /// + /// **3.0 break:** added the `interface` parameter. Pass `None` to scan + /// every Wi-Fi device, or `Some("wlan0")` to scan one. See + /// [`wifi`](Self::wifi) for an ergonomic per-interface API. + pub async fn scan_networks(&self, interface: Option<&str>) -> Result<()> { + scan_networks(&self.conn, interface).await } /// Returns whether any network device is currently in a transitional state. @@ -471,12 +889,18 @@ impl NetworkManager { is_connected(&self.conn, ssid).await } - /// Disconnects from the current network. + /// Disconnects from the current Wi-Fi network. + /// + /// If currently connected to a Wi-Fi network, this deactivates the + /// active connection on the targeted device and waits for it to reach + /// the disconnected state. /// - /// If currently connected to a WiFi network, this will deactivate - /// the connection and wait for the device to reach disconnected state. + /// **3.0 break:** added the `interface` parameter. Pass `None` for the + /// previous behavior (first Wi-Fi device), or `Some("wlan1")` to target + /// a specific interface. /// - /// Returns `Ok(())` if disconnected successfully or if no active connection exists. + /// Returns `Ok(())` if disconnected successfully or if no active + /// connection exists. /// /// # Example /// @@ -485,12 +909,13 @@ impl NetworkManager { /// /// # async fn example() -> nmrs::Result<()> { /// let nm = NetworkManager::new().await?; - /// nm.disconnect().await?; + /// nm.disconnect(None).await?; + /// nm.disconnect(Some("wlan1")).await?; /// # Ok(()) /// # } /// ``` - pub async fn disconnect(&self) -> Result<()> { - disconnect(&self.conn, Some(self.timeout_config)).await + pub async fn disconnect(&self, interface: Option<&str>) -> Result<()> { + disconnect(&self.conn, interface, Some(self.timeout_config)).await } /// Returns the full `Network` object for the currently connected WiFi network. @@ -517,10 +942,13 @@ impl NetworkManager { current_network(&self.conn).await } - /// Lists all saved connection profiles. + /// Lists all saved connection profiles with decoded [`SavedConnection`] summaries. /// - /// Returns the names (IDs) of all saved connection profiles in NetworkManager, - /// including WiFi, Ethernet, VPN, and other connection types. + /// Secrets are not included; use a [secret agent](crate::agent) with + /// `GetSecrets` for passwords and keys. + /// + /// For a lighter call that only resolves `uuid`, `id`, and `type`, see + /// [`Self::list_saved_connections_brief`]. /// /// # Example /// @@ -529,15 +957,64 @@ impl NetworkManager { /// /// # async fn example() -> nmrs::Result<()> { /// let nm = NetworkManager::new().await?; - /// let connections = nm.list_saved_connections().await?; - /// for name in connections { - /// println!("Saved connection: {}", name); + /// for c in nm.list_saved_connections().await? { + /// println!("{} {} {}", c.id, c.connection_type, c.uuid); /// } /// # Ok(()) /// # } /// ``` - pub async fn list_saved_connections(&self) -> Result> { - list_saved_connections(&self.conn).await + pub async fn list_saved_connections(&self) -> Result> { + saved_profiles::list_saved_connections(&self.conn).await + } + + /// Lists saved profiles with only `connection.uuid`, `id`, and `type` (still one + /// `GetSettings` per profile, but skips building [`SettingsSummary`](crate::SettingsSummary)). + pub async fn list_saved_connections_brief(&self) -> Result> { + saved_profiles::list_saved_connections_brief(&self.conn).await + } + + /// Returns the human-visible names (`connection.id`) of all saved profiles. + /// + /// Convenience over `list_saved_connections().map(|v| v.into_iter().map(|c| c.id).collect())`. + pub async fn list_saved_connection_ids(&self) -> Result> { + Ok(saved_profiles::list_saved_connections_brief(&self.conn) + .await? + .into_iter() + .map(|c| c.id) + .collect()) + } + + /// Loads one saved profile by UUID with full [`SavedConnection`] decode. + /// + /// # Errors + /// + /// [`SavedConnectionNotFound`](crate::ConnectionError::SavedConnectionNotFound) if + /// the UUID does not exist. + pub async fn get_saved_connection(&self, uuid: &str) -> Result { + saved_profiles::get_saved_connection(&self.conn, uuid).await + } + + /// Raw `GetSettings` map for advanced consumers. + pub async fn get_saved_connection_raw( + &self, + uuid: &str, + ) -> Result>> { + saved_profiles::get_saved_connection_raw(&self.conn, uuid).await + } + + /// Deletes a saved profile by UUID (`Settings.Connection.Delete`). + pub async fn delete_saved_connection(&self, uuid: &str) -> Result<()> { + saved_profiles::delete_saved_connection(&self.conn, uuid).await + } + + /// Merges a [`SettingsPatch`] into an existing profile (`Update` / `UpdateUnsaved`). + pub async fn update_saved_connection(&self, uuid: &str, patch: SettingsPatch) -> Result<()> { + saved_profiles::update_saved_connection(&self.conn, uuid, &patch).await + } + + /// Calls `ReloadConnections` so NM re-reads profiles from disk. + pub async fn reload_saved_connections(&self) -> Result<()> { + saved_profiles::reload_saved_connections(&self.conn).await } /// Finds a device by its interface name (e.g., "wlan0", "eth0"). diff --git a/nmrs/src/api/wifi_scope.rs b/nmrs/src/api/wifi_scope.rs new file mode 100644 index 00000000..04f41a7a --- /dev/null +++ b/nmrs/src/api/wifi_scope.rs @@ -0,0 +1,120 @@ +//! Per-Wi-Fi-device scoped operations. +//! +//! [`WifiScope`] is a lightweight, ergonomic wrapper around +//! [`NetworkManager`](crate::NetworkManager) that pins every operation to a +//! single Wi-Fi interface. Build it with +//! [`NetworkManager::wifi`](crate::NetworkManager::wifi): +//! +//! ```no_run +//! use nmrs::{NetworkManager, WifiSecurity}; +//! +//! # async fn example() -> nmrs::Result<()> { +//! let nm = NetworkManager::new().await?; +//! let wlan1 = nm.wifi("wlan1"); +//! +//! wlan1.scan().await?; +//! let networks = wlan1.list_networks().await?; +//! wlan1.connect("Guest", WifiSecurity::Open).await?; +//! # Ok(()) +//! # } +//! ``` + +use crate::Result; +use crate::api::models::access_point::AccessPoint; +use crate::api::models::{Network, WifiSecurity}; +use crate::core::connection::{connect, connect_to_bssid, disconnect, forget_by_name_and_type}; +use crate::core::scan::{list_access_points, list_networks, scan_networks}; +use crate::core::wifi_device::set_wifi_enabled_for_interface; +use crate::types::constants::device_type; + +/// Operations scoped to a single Wi-Fi interface. +/// +/// Created via [`NetworkManager::wifi`](crate::NetworkManager::wifi). +/// Cheap to construct (`Clone` is fine). +#[derive(Debug, Clone)] +pub struct WifiScope { + pub(crate) conn: zbus::Connection, + pub(crate) interface: String, + pub(crate) timeout_config: crate::api::models::TimeoutConfig, +} + +impl WifiScope { + /// The interface name this scope is pinned to (e.g. `"wlan0"`). + #[must_use] + pub fn interface(&self) -> &str { + &self.interface + } + + /// Trigger a Wi-Fi scan on this interface only. + pub async fn scan(&self) -> Result<()> { + scan_networks(&self.conn, Some(&self.interface)).await + } + + /// List visible networks on this interface (grouped by SSID). + pub async fn list_networks(&self) -> Result> { + list_networks(&self.conn, Some(&self.interface)).await + } + + /// List individual access points on this interface (one per BSSID). + pub async fn list_access_points(&self) -> Result> { + list_access_points(&self.conn, Some(&self.interface)).await + } + + /// Connect this interface to the given SSID. + pub async fn connect(&self, ssid: &str, creds: WifiSecurity) -> Result<()> { + connect( + &self.conn, + ssid, + creds, + Some(&self.interface), + Some(self.timeout_config), + ) + .await + } + + /// Connect this interface to a specific BSSID for the given SSID. + pub async fn connect_to_bssid( + &self, + ssid: &str, + bssid: Option<&str>, + creds: WifiSecurity, + ) -> Result<()> { + connect_to_bssid( + &self.conn, + ssid, + bssid, + creds, + Some(&self.interface), + Some(self.timeout_config), + ) + .await + } + + /// Disconnect this interface from its active network, if any. + pub async fn disconnect(&self) -> Result<()> { + disconnect(&self.conn, Some(&self.interface), Some(self.timeout_config)).await + } + + /// Enable or disable autoconnect on this interface only. + /// + /// Independent of NetworkManager's global Wi-Fi killswitch + /// ([`set_wireless_enabled`](crate::NetworkManager::set_wireless_enabled)). + pub async fn set_enabled(&self, enabled: bool) -> Result<()> { + set_wifi_enabled_for_interface(&self.conn, &self.interface, enabled).await + } + + /// Forget a saved Wi-Fi connection by SSID. + /// + /// Note: NetworkManager keys profiles by SSID, not by interface, so this + /// forgets the profile globally — but is exposed here for ergonomic use + /// alongside the other per-scope operations. + pub async fn forget(&self, ssid: &str) -> Result<()> { + forget_by_name_and_type( + &self.conn, + ssid, + Some(device_type::WIFI), + Some(self.timeout_config), + ) + .await + } +} diff --git a/nmrs/src/core/airplane.rs b/nmrs/src/core/airplane.rs new file mode 100644 index 00000000..f27df3d3 --- /dev/null +++ b/nmrs/src/core/airplane.rs @@ -0,0 +1,196 @@ +//! Airplane-mode aggregation logic. +//! +//! Combines radio state from NetworkManager (Wi-Fi, WWAN), BlueZ (Bluetooth +//! adapter power), and kernel rfkill into a single [`AirplaneModeState`]. + +use log::warn; +use zbus::Connection; + +use crate::api::models::{AirplaneModeState, RadioState}; +use crate::core::rfkill::read_rfkill; +use crate::dbus::{BluezAdapterProxy, NMProxy}; +use crate::{ConnectionError, Result}; + +/// Reads Wi-Fi radio state from NetworkManager, cross-referenced with rfkill. +pub(crate) async fn wifi_state(conn: &Connection) -> Result { + let nm = NMProxy::new(conn).await?; + let enabled = nm.wireless_enabled().await?; + let nm_hw = nm.wireless_hardware_enabled().await?; + + let rfkill = read_rfkill(); + let hardware_enabled = reconcile_hardware(nm_hw, rfkill.wlan_hard_block, "wifi"); + + Ok(RadioState::new(enabled, hardware_enabled)) +} + +/// Reads WWAN radio state from NetworkManager, cross-referenced with rfkill. +pub(crate) async fn wwan_state(conn: &Connection) -> Result { + let nm = NMProxy::new(conn).await?; + let enabled = nm.wwan_enabled().await?; + let nm_hw = nm.wwan_hardware_enabled().await?; + + let rfkill = read_rfkill(); + let hardware_enabled = reconcile_hardware(nm_hw, rfkill.wwan_hard_block, "wwan"); + + Ok(RadioState::new(enabled, hardware_enabled)) +} + +/// Reads Bluetooth radio state from BlueZ adapters, cross-referenced with rfkill. +/// +/// If BlueZ is not running or no adapters exist, returns +/// `RadioState { enabled: true, hardware_enabled: false }` — "hardware killed" +/// is the honest answer when there is no Bluetooth stack. +pub(crate) async fn bluetooth_radio_state(conn: &Connection) -> Result { + let adapter_paths = match enumerate_bluetooth_adapters(conn).await { + Ok(paths) if !paths.is_empty() => paths, + Ok(_) | Err(_) => { + return Ok(RadioState::new(true, false)); + } + }; + + let mut any_powered = false; + for path in &adapter_paths { + match BluezAdapterProxy::builder(conn) + .path(path.as_str())? + .build() + .await + { + Ok(proxy) => { + if proxy.powered().await.unwrap_or(false) { + any_powered = true; + break; + } + } + Err(e) => { + warn!("failed to query BlueZ adapter {}: {}", path, e); + } + } + } + + let rfkill = read_rfkill(); + let hardware_enabled = !rfkill.bluetooth_hard_block; + + Ok(RadioState::new(any_powered, hardware_enabled)) +} + +/// Returns the combined airplane mode state for all radios. +pub(crate) async fn airplane_mode_state(conn: &Connection) -> Result { + let (wifi, wwan, bt) = futures::future::join3( + wifi_state(conn), + wwan_state(conn), + bluetooth_radio_state(conn), + ) + .await; + + Ok(AirplaneModeState::new(wifi?, wwan?, bt?)) +} + +/// Enables or disables wireless radio (software toggle). +pub(crate) async fn set_wireless_enabled(conn: &Connection, enabled: bool) -> Result<()> { + let nm = NMProxy::new(conn).await?; + Ok(nm.set_wireless_enabled(enabled).await?) +} + +/// Enables or disables WWAN radio (software toggle). +pub(crate) async fn set_wwan_enabled(conn: &Connection, enabled: bool) -> Result<()> { + let nm = NMProxy::new(conn).await?; + Ok(nm.set_wwan_enabled(enabled).await?) +} + +/// Enables or disables Bluetooth radio by toggling all BlueZ adapters. +/// +/// If BlueZ is not running, returns `BluezUnavailable`. +pub(crate) async fn set_bluetooth_radio_enabled(conn: &Connection, enabled: bool) -> Result<()> { + let adapter_paths = enumerate_bluetooth_adapters(conn).await.map_err(|e| { + ConnectionError::BluezUnavailable(format!("failed to enumerate adapters: {e}")) + })?; + + if adapter_paths.is_empty() { + return Err(ConnectionError::BluezUnavailable( + "no Bluetooth adapters found".to_string(), + )); + } + + let mut first_err: Option = None; + for path in &adapter_paths { + let result: Result<()> = async { + let proxy = BluezAdapterProxy::builder(conn) + .path(path.as_str())? + .build() + .await?; + proxy.set_powered(enabled).await?; + Ok(()) + } + .await; + + if let Err(e) = result { + warn!("failed to set Powered on {}: {}", path, e); + if first_err.is_none() { + first_err = Some(e); + } + } + } + + match first_err { + Some(e) => Err(e), + None => Ok(()), + } +} + +/// Flips all three radios in parallel. +/// +/// `enabled = true` means airplane mode **on** (radios **off**). +/// Does not fail fast — attempts all three and returns the first error. +pub(crate) async fn set_airplane_mode(conn: &Connection, enabled: bool) -> Result<()> { + let radio_on = !enabled; + + let (wifi_res, wwan_res, bt_res) = futures::future::join3( + set_wireless_enabled(conn, radio_on), + set_wwan_enabled(conn, radio_on), + set_bluetooth_radio_enabled(conn, radio_on), + ) + .await; + + // Return the first error, but don't short-circuit — all three have been attempted. + wifi_res?; + wwan_res?; + bt_res?; + Ok(()) +} + +/// Enumerates BlueZ Bluetooth adapters via the ObjectManager interface. +/// +/// Returns adapter object paths (e.g. `/org/bluez/hci0`). +async fn enumerate_bluetooth_adapters(conn: &Connection) -> Result> { + let manager = zbus::fdo::ObjectManagerProxy::builder(conn) + .destination("org.bluez")? + .path("/")? + .build() + .await + .map_err(|e| { + ConnectionError::BluezUnavailable(format!("failed to connect to BlueZ: {e}")) + })?; + + let objects = manager.get_managed_objects().await.map_err(|e| { + ConnectionError::BluezUnavailable(format!("failed to enumerate BlueZ objects: {e}")) + })?; + + let adapters: Vec = objects + .into_iter() + .filter(|(_, ifaces)| ifaces.contains_key("org.bluez.Adapter1")) + .map(|(path, _)| path.to_string()) + .collect(); + + Ok(adapters) +} + +/// Reconciles NM's hardware-enabled flag with rfkill. If they disagree, trust rfkill. +fn reconcile_hardware(nm_hardware_enabled: bool, rfkill_hard_block: bool, radio: &str) -> bool { + if nm_hardware_enabled && rfkill_hard_block { + warn!( + "{radio}: NM reports hardware enabled but rfkill reports hard block — trusting rfkill" + ); + return false; + } + nm_hardware_enabled && !rfkill_hard_block +} diff --git a/nmrs/src/core/bluetooth.rs b/nmrs/src/core/bluetooth.rs index 71d1438f..7e437938 100644 --- a/nmrs/src/core/bluetooth.rs +++ b/nmrs/src/core/bluetooth.rs @@ -43,10 +43,11 @@ use crate::{ pub(crate) async fn populate_bluez_info( conn: &Connection, bdaddr: &str, + adapter: Option<&str>, ) -> Result<(Option, Option)> { validate_bluetooth_address(bdaddr)?; - let bluez_path = bluez_device_path(bdaddr); + let bluez_path = bluez_device_path(bdaddr, adapter); match BluezDeviceExtProxy::builder(conn) .path(bluez_path)? @@ -142,8 +143,11 @@ pub(crate) async fn connect_bluetooth( // Check for saved connection let saved = get_saved_connection_path(conn, name).await?; - let specific_object = OwnedObjectPath::try_from(bluez_device_path(&settings.bdaddr)) - .map_err(|e| ConnectionError::InvalidAddress(format!("Invalid BlueZ path: {e}")))?; + let specific_object = OwnedObjectPath::try_from(bluez_device_path( + &settings.bdaddr, + settings.adapter.as_deref(), + )) + .map_err(|e| ConnectionError::InvalidAddress(format!("Invalid BlueZ path: {e}")))?; match saved { Some(saved_path) => { @@ -220,7 +224,7 @@ pub(crate) async fn disconnect_bluetooth_and_wait( .await?; debug!("Sending disconnect request to Bluetooth device"); - let _ = raw.call_method("Disconnect", &()).await; + raw.call_method("Disconnect", &()).await?; // Wait for disconnect using signal-based monitoring let timeout = timeout_config.map(|c| c.disconnect_timeout); @@ -238,13 +242,21 @@ mod tests { use crate::models::BluetoothNetworkRole; #[test] - fn test_bluez_path_format() { + fn test_bluez_path_format_default_adapter() { assert_eq!( - bluez_device_path("00:1A:7D:DA:71:13"), + bluez_device_path("00:1A:7D:DA:71:13", None), "/org/bluez/hci0/dev_00_1A_7D_DA_71_13" ); } + #[test] + fn test_bluez_path_format_specific_adapter() { + assert_eq!( + bluez_device_path("00:1A:7D:DA:71:13", Some("hci1")), + "/org/bluez/hci1/dev_00_1A_7D_DA_71_13" + ); + } + #[test] fn test_bluez_path_format_various_addresses() { let test_cases = [ @@ -255,7 +267,7 @@ mod tests { for (bdaddr, expected) in test_cases { assert_eq!( - bluez_device_path(bdaddr), + bluez_device_path(bdaddr, None), expected, "Failed for bdaddr: {bdaddr}" ); @@ -268,12 +280,26 @@ mod tests { BluetoothIdentity::new("00:1A:7D:DA:71:13".into(), BluetoothNetworkRole::PanU).unwrap(); assert_eq!(identity.bdaddr, "00:1A:7D:DA:71:13"); + assert_eq!(identity.adapter, None); assert!(matches!( identity.bt_device_type, BluetoothNetworkRole::PanU )); } + #[test] + fn test_bluetooth_identity_with_adapter() { + let identity = BluetoothIdentity::with_adapter( + "00:1A:7D:DA:71:13".into(), + BluetoothNetworkRole::PanU, + "hci1".into(), + ) + .unwrap(); + + assert_eq!(identity.bdaddr, "00:1A:7D:DA:71:13"); + assert_eq!(identity.adapter, Some("hci1".into())); + } + // 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 317e3faf..278788a2 100644 --- a/nmrs/src/core/connection.rs +++ b/nmrs/src/core/connection.rs @@ -15,7 +15,7 @@ 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}; +use crate::util::validation::{validate_bssid, validate_ssid, validate_wifi_security}; /// Decision on whether to reuse a saved connection or create a fresh one. enum SavedDecision { @@ -40,6 +40,7 @@ pub(crate) async fn connect( conn: &Connection, ssid: &str, creds: WifiSecurity, + interface: Option<&str>, timeout_config: Option, ) -> Result<()> { // Validate inputs before attempting connection @@ -47,8 +48,9 @@ pub(crate) async fn connect( validate_wifi_security(&creds)?; debug!( - "Connecting to '{}' | secured={} is_psk={} is_eap={}", + "Connecting to '{}' on {:?} | secured={} is_psk={} is_eap={}", ssid, + interface, creds.secured(), creds.is_psk(), creds.is_eap() @@ -59,8 +61,8 @@ pub(crate) async fn connect( let saved_raw = get_saved_connection_path(conn, ssid).await?; let decision = decide_saved_connection(saved_raw, &creds)?; - let wifi_device = find_wifi_device(conn, &nm).await?; - debug!("Found WiFi device: {}", wifi_device.as_str()); + let wifi_device = resolve_wifi_device(conn, &nm, interface).await?; + debug!("Resolved WiFi device: {}", wifi_device.as_str()); let wifi = NMWirelessProxy::builder(conn) .path(wifi_device.clone())? @@ -81,7 +83,7 @@ pub(crate) async fn connect( match decision { SavedDecision::UseSaved(saved) => { - ensure_disconnected(conn, &nm, &wifi_device, timeout_config).await?; + ensure_disconnected(conn, &wifi_device, timeout_config).await?; connect_via_saved( conn, &nm, @@ -445,13 +447,8 @@ pub(crate) async fn disconnect_wifi_and_wait( .await?; debug!("Sending disconnect request"); - match raw.call_method("Disconnect", &()).await { - Ok(_) => debug!("Disconnect method called successfully"), - Err(e) => warn!( - "Disconnect method call failed (device may already be disconnected): {}", - e - ), - } + raw.call_method("Disconnect", &()).await?; + debug!("Disconnect method called successfully"); // Wait for disconnect using signal-based monitoring let timeout = timeout_config.map(|c| c.disconnect_timeout); @@ -503,6 +500,45 @@ async fn find_wifi_device(conn: &Connection, nm: &NMProxy<'_>) -> Result, + interface: Option<&str>, +) -> Result { + match interface { + None => find_wifi_device(conn, nm).await, + Some(name) => { + let path = match get_device_by_interface(conn, name).await { + Ok(p) => p, + Err(ConnectionError::NotFound) => { + return Err(ConnectionError::WifiInterfaceNotFound { + interface: name.to_string(), + }); + } + Err(e) => return Err(e), + }; + let dev = NMDeviceProxy::builder(conn) + .path(path.clone())? + .build() + .await?; + if dev.device_type().await? != device_type::WIFI { + return Err(ConnectionError::NotAWifiDevice { + interface: name.to_string(), + }); + } + Ok(path) + } + } +} + /// Finds an access point by SSID. /// /// Searches through all visible access points on the wireless device @@ -532,32 +568,133 @@ async fn find_ap( Err(ConnectionError::NotFound) } -/// Ensures the Wi-Fi device is disconnected before attempting a new connection. +/// Finds an access point matching both SSID and BSSID. +async fn find_ap_by_bssid( + conn: &Connection, + wifi: &NMWirelessProxy<'_>, + target_ssid: &str, + target_bssid: &str, +) -> Result { + let access_points = wifi.access_points().await?; + + for ap_path in access_points { + let ap = NMAccessPointProxy::builder(conn) + .path(ap_path.clone())? + .build() + .await?; + + let ssid_bytes = ap.ssid().await?; + let ssid = decode_ssid_or_empty(&ssid_bytes); + + if ssid != target_ssid { + continue; + } + + let bssid = ap.hw_address().await?; + if bssid.eq_ignore_ascii_case(target_bssid) { + return Ok(ap_path); + } + } + + Err(ConnectionError::ApBssidNotFound { + ssid: target_ssid.to_string(), + bssid: target_bssid.to_string(), + }) +} + +/// Connects to a specific access point identified by SSID and optionally BSSID. /// -/// If currently connected to any network, deactivates all active connections -/// and waits for the device to reach disconnected state. -async fn ensure_disconnected( +/// If `bssid` is `Some`, the connection targets that specific AP. +/// If `None`, falls through to the existing best-match behavior. +pub(crate) async fn connect_to_bssid( conn: &Connection, - nm: &NMProxy<'_>, - wifi_device: &OwnedObjectPath, + ssid: &str, + bssid: Option<&str>, + creds: WifiSecurity, + interface: Option<&str>, timeout_config: Option, ) -> Result<()> { - if let Some(active) = Wifi::current(conn).await { - debug!("Disconnecting from {active}"); + if let Some(b) = bssid { + validate_bssid(b)?; + } + + match bssid { + None => connect(conn, ssid, creds, interface, timeout_config).await, + Some(target_bssid) => { + validate_ssid(ssid)?; + validate_wifi_security(&creds)?; + + debug!( + "Connecting to '{}' BSSID={} on {:?} | secured={} is_psk={} is_eap={}", + ssid, + target_bssid, + interface, + creds.secured(), + creds.is_psk(), + creds.is_eap() + ); + + let nm = NMProxy::new(conn).await?; + let saved_raw = get_saved_connection_path(conn, ssid).await?; + let decision = decide_saved_connection(saved_raw, &creds)?; + let wifi_device = resolve_wifi_device(conn, &nm, interface).await?; + let wifi = NMWirelessProxy::builder(conn) + .path(wifi_device.clone())? + .build() + .await?; - if let Ok(conns) = nm.active_connections().await { - for conn_path in conns { - match nm.deactivate_connection(conn_path.clone()).await { - Ok(_) => debug!("Connection deactivated during cleanup"), - Err(e) => warn!("Failed to deactivate connection during cleanup: {}", e), + match wifi.request_scan(HashMap::new()).await { + Ok(_) => debug!("Scan requested successfully"), + Err(e) => warn!("Scan request failed: {e}"), + } + futures_timer::Delay::new(timeouts::scan_wait()).await; + + let specific_object = find_ap_by_bssid(conn, &wifi, ssid, target_bssid).await?; + + match decision { + SavedDecision::UseSaved(saved) => { + ensure_disconnected(conn, &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, + timeout_config, + ) + .await?; } } - } - disconnect_wifi_and_wait(conn, wifi_device, timeout_config).await?; + info!("Successfully connected to '{ssid}' (BSSID: {target_bssid})"); + Ok(()) + } } +} - Ok(()) +/// Ensures the target Wi-Fi device is torn down before attempting a new connection. +/// +/// Only the given `wifi_device` is affected. Other interfaces (e.g. VPN, wired, +/// a second Wi-Fi radio) are not deactivated. +async fn ensure_disconnected( + conn: &Connection, + wifi_device: &OwnedObjectPath, + timeout_config: Option, +) -> Result<()> { + disconnect_wifi_and_wait(conn, wifi_device, timeout_config).await } /// Attempts to connect using a saved connection profile. @@ -691,7 +828,7 @@ async fn build_and_activate_new( debug!("Creating new connection, settings: \n{settings:#?}"); - ensure_disconnected(conn, nm, wifi_device, timeout_config).await?; + ensure_disconnected(conn, wifi_device, timeout_config).await?; let (_, active_conn) = match nm .add_and_activate_connection(settings, wifi_device.clone(), ap.clone()) @@ -799,11 +936,12 @@ pub(crate) async fn is_connected(conn: &Connection, ssid: &str) -> Result /// Returns `Ok(())` if disconnected successfully or if no active connection exists. pub(crate) async fn disconnect( conn: &Connection, + interface: Option<&str>, timeout_config: Option, ) -> Result<()> { let nm = NMProxy::new(conn).await?; - let wifi_device = match find_wifi_device(conn, &nm).await { + let wifi_device = match resolve_wifi_device(conn, &nm, interface).await { Ok(dev) => dev, Err(ConnectionError::NoWifiDevice) => { debug!("No WiFi device found"); @@ -825,6 +963,24 @@ pub(crate) async fn disconnect( if let Ok(conns) = nm.active_connections().await { for conn_path in conns { + let active = match crate::dbus::NMActiveConnectionProxy::builder(conn) + .path(conn_path.clone())? + .build() + .await + { + Ok(p) => p, + Err(e) => { + warn!("Failed to build active connection proxy: {}", e); + continue; + } + }; + let owns_device = match active.devices().await { + Ok(devs) => devs.iter().any(|d| d == &wifi_device), + Err(_) => false, + }; + if !owns_device { + continue; + } match nm.deactivate_connection(conn_path.clone()).await { Ok(_) => debug!("Connection deactivated"), Err(e) => warn!("Failed to deactivate connection: {}", e), diff --git a/nmrs/src/core/connection_settings.rs b/nmrs/src/core/connection_settings.rs index 975a3ae7..64f1c1a5 100644 --- a/nmrs/src/core/connection_settings.rs +++ b/nmrs/src/core/connection_settings.rs @@ -99,40 +99,3 @@ pub(crate) async fn delete_connection(conn: &Connection, conn_path: OwnedObjectP debug!("Deleted connection: {}", conn_path.as_str()); Ok(()) } - -/// Lists all saved connection profiles. -/// -/// Returns a vector of connection names (IDs) for all saved profiles -/// in NetworkManager. This includes WiFi, Ethernet, VPN, and other connection types. -pub(crate) async fn list_saved_connections(conn: &Connection) -> Result> { - let settings = settings_proxy(conn).await?; - - let reply = settings - .call_method("ListConnections", &()) - .await - .map_err(|e| ConnectionError::DbusOperation { - context: "failed to list saved connections".to_string(), - source: e, - })?; - - let conns: Vec = reply.body().deserialize()?; - - let mut connection_names = Vec::new(); - - for cpath in conns { - let cproxy = connection_settings_proxy(conn, cpath.clone()).await?; - - if let Ok(msg) = cproxy.call_method("GetSettings", &()).await { - let body = msg.body(); - if let Ok(all) = body.deserialize::>>() - && let Some(conn_section) = all.get("connection") - && let Some(Value::Str(id)) = conn_section.get("id") - { - connection_names.push(id.to_string()); - } - } - } - - debug!("Found {} saved connection(s)", connection_names.len()); - Ok(connection_names) -} diff --git a/nmrs/src/core/connectivity.rs b/nmrs/src/core/connectivity.rs new file mode 100644 index 00000000..e785d370 --- /dev/null +++ b/nmrs/src/core/connectivity.rs @@ -0,0 +1,125 @@ +//! Connectivity state reads and captive-portal URL discovery. + +use log::debug; +use zbus::Connection; + +use crate::Result; +use crate::api::models::{ConnectionError, ConnectivityReport, ConnectivityState}; +use crate::dbus::NMProxy; + +/// Reads `Connectivity` property. +pub(crate) async fn connectivity(conn: &Connection) -> Result { + let nm = NMProxy::new(conn).await?; + let raw = nm + .connectivity() + .await + .map_err(|e| ConnectionError::DbusOperation { + context: "read Connectivity property".into(), + source: e, + })?; + Ok(ConnectivityState::from(raw)) +} + +/// Calls `CheckConnectivity` (blocks until NM finishes its probe). +pub(crate) async fn check_connectivity(conn: &Connection) -> Result { + let nm = NMProxy::new(conn).await?; + + let enabled = nm.connectivity_check_enabled().await.unwrap_or(false); + if !enabled { + return Err(ConnectionError::ConnectivityCheckDisabled); + } + + let raw = nm + .check_connectivity() + .await + .map_err(|e| ConnectionError::DbusOperation { + context: "CheckConnectivity call".into(), + source: e, + })?; + Ok(ConnectivityState::from(raw)) +} + +/// Builds a full [`ConnectivityReport`] from property reads. +pub(crate) async fn connectivity_report(conn: &Connection) -> Result { + let nm = NMProxy::new(conn).await?; + + let raw_state = nm.connectivity().await.unwrap_or(0); + let state = ConnectivityState::from(raw_state); + let check_enabled = nm.connectivity_check_enabled().await.unwrap_or(false); + let check_uri = nm + .connectivity_check_uri() + .await + .ok() + .filter(|s| !s.is_empty()); + + let captive_portal_url = if state.is_captive() { + detect_captive_portal_url(conn, &nm).await + } else { + None + }; + + Ok(ConnectivityReport { + state, + check_enabled, + check_uri, + captive_portal_url, + }) +} + +/// Best-effort captive portal URL detection. +/// +/// Tries NM's `Ip4Config` properties on the primary connection first +/// (newer NM versions). Falls back to the configured `ConnectivityCheckUri`. +async fn detect_captive_portal_url(conn: &Connection, nm: &NMProxy<'_>) -> Option { + let primary = nm.primary_connection().await.ok()?; + if primary.as_str() == "/" { + return fallback_check_uri(nm).await; + } + + let active = crate::dbus::NMActiveConnectionProxy::builder(conn) + .path(primary) + .ok()? + .build() + .await + .ok()?; + + if let Ok(ip4_path) = active.ip4_config().await + && ip4_path.as_str() != "/" + && let Some(url) = try_ip4_captive_portal(conn, &ip4_path).await + { + return Some(url); + } + + fallback_check_uri(nm).await +} + +/// Newer NM versions expose a `CaptivePortal` or `WebPortalUrl` property on Ip4Config. +async fn try_ip4_captive_portal( + conn: &Connection, + ip4_path: &zvariant::OwnedObjectPath, +) -> Option { + let raw = crate::util::utils::nm_proxy( + conn, + ip4_path.clone(), + "org.freedesktop.NetworkManager.IP4Config", + ) + .await + .ok()?; + + for prop in ["CaptivePortal", "WebPortalUrl"] { + if let Ok(v) = raw.get_property::(prop).await + && !v.is_empty() + { + debug!("captive portal URL from IP4Config.{prop}: {v}"); + return Some(v); + } + } + None +} + +async fn fallback_check_uri(nm: &NMProxy<'_>) -> Option { + nm.connectivity_check_uri() + .await + .ok() + .filter(|s| !s.is_empty()) +} diff --git a/nmrs/src/core/device.rs b/nmrs/src/core/device.rs index e67e93a0..debb5635 100644 --- a/nmrs/src/core/device.rs +++ b/nmrs/src/core/device.rs @@ -208,7 +208,7 @@ pub(crate) async fn list_bluetooth_devices(conn: &Connection) -> Result Result<()> { Err(ConnectionError::NoWifiDevice) } -/// Enables or disables Wi-Fi globally. -/// -/// This is equivalent to the Wi-Fi toggle in system settings. -/// When disabled, all Wi-Fi connections are terminated and -/// no scanning occurs. -pub(crate) async fn set_wifi_enabled(conn: &Connection, value: bool) -> Result<()> { - let nm = NMProxy::new(conn).await?; - Ok(nm.set_wireless_enabled(value).await?) -} - -/// Returns whether Wi-Fi is currently enabled. -pub(crate) async fn wifi_enabled(conn: &Connection) -> Result { - let nm = NMProxy::new(conn).await?; - Ok(nm.wireless_enabled().await?) -} - -/// Returns whether wireless hardware is enabled. -pub(crate) async fn wifi_hardware_enabled(conn: &Connection) -> Result { - let nm = NMProxy::new(conn).await?; - Ok(nm.wireless_hardware_enabled().await?) -} - #[cfg(test)] mod tests { use super::*; diff --git a/nmrs/src/core/mod.rs b/nmrs/src/core/mod.rs index 0ed7c71e..9365e53f 100644 --- a/nmrs/src/core/mod.rs +++ b/nmrs/src/core/mod.rs @@ -3,10 +3,16 @@ //! This module contains the internal implementation details for managing //! network connections, devices, scanning, and state monitoring. +pub(crate) mod airplane; pub(crate) mod bluetooth; pub(crate) mod connection; pub(crate) mod connection_settings; +pub(crate) mod connectivity; pub(crate) mod device; +pub(crate) mod ovpn_parser; +pub(crate) mod rfkill; +pub(crate) mod saved_connection; pub(crate) mod scan; pub(crate) mod state_wait; pub(crate) mod vpn; +pub(crate) mod wifi_device; diff --git a/nmrs/src/core/ovpn_parser/error.rs b/nmrs/src/core/ovpn_parser/error.rs new file mode 100644 index 00000000..b8faed53 --- /dev/null +++ b/nmrs/src/core/ovpn_parser/error.rs @@ -0,0 +1,80 @@ +use std::fmt; + +use crate::ConnectionError; + +#[derive(Debug, Clone)] +pub enum OvpnParseError { + InvalidDirectiveSyntax { + line: usize, + }, + InvalidArgument { + key: String, + arg: String, + line: usize, + }, + MissingArgument { + key: String, + line: usize, + }, + InvalidContinuation { + line: usize, + }, + UnterminatedBlock { + block: String, + line: usize, + }, + UnexpectedBlockEnd { + block: String, + line: usize, + }, + UnexpectedEof { + line: usize, + }, + InvalidNumber { + key: String, + value: String, + line: usize, + }, +} + +impl fmt::Display for OvpnParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + OvpnParseError::InvalidDirectiveSyntax { line } => { + write!(f, "invalid directive syntax at line {line}") + } + OvpnParseError::InvalidArgument { key, arg, line } => { + write!( + f, + "invalid argument '{arg}' for directive '{key}' at line {line}" + ) + } + OvpnParseError::MissingArgument { key, line } => { + write!(f, "missing argument for directive '{key}' at line {line}") + } + OvpnParseError::InvalidContinuation { line } => { + write!(f, "invalid continuation at line {line}") + } + OvpnParseError::UnterminatedBlock { block, line } => { + write!(f, "unterminated block '{block}' starting at line {line}") + } + OvpnParseError::UnexpectedBlockEnd { block, line } => { + write!(f, "unexpected end of block '{block}' at line {line}") + } + OvpnParseError::UnexpectedEof { line } => { + write!(f, "unexpected EOF at line {line}") + } + OvpnParseError::InvalidNumber { key, value, line } => { + write!(f, "invalid value '{value}' for '{key}' at line {line}") + } + } + } +} + +impl From for ConnectionError { + fn from(e: OvpnParseError) -> Self { + ConnectionError::ParseError(e) + } +} + +impl std::error::Error for OvpnParseError {} diff --git a/nmrs/src/core/ovpn_parser/mod.rs b/nmrs/src/core/ovpn_parser/mod.rs new file mode 100644 index 00000000..395e22ff --- /dev/null +++ b/nmrs/src/core/ovpn_parser/mod.rs @@ -0,0 +1,2 @@ +pub(crate) mod error; +pub(crate) mod parser; diff --git a/nmrs/src/core/ovpn_parser/parser.rs b/nmrs/src/core/ovpn_parser/parser.rs new file mode 100644 index 00000000..2ec1d40f --- /dev/null +++ b/nmrs/src/core/ovpn_parser/parser.rs @@ -0,0 +1,1143 @@ +use std::collections::HashMap; +use std::net::Ipv4Addr; + +use crate::api::models::ConnectionError; +use crate::core::ovpn_parser::error::OvpnParseError; + +#[derive(Debug, Clone)] +pub struct OvpnFile { + // All remote entries. Each defines a possible server endpoint. + // OpenVPN tries them in order unless configured otherwise. + pub remotes: Vec, + + // device directive (e.g. "tun", "tap"). + // Controls the virtual network interface type. + pub dev: Option, + + // protocol directive (e.g. "udp", "tcp-client"). + pub proto: Option, + + // ca directive. Certificate Authority used to verify server cert. + // Supports file path or inline block. + pub ca: Option, + + // cert directive. Client certificate. + pub cert: Option, + + // key directive. Private key corresponding to cert. + pub key: Option, + + // tls-auth directive. HMAC key used for additional packet auth. + // This may include key-direction (0/1). + pub tls_auth: Option, + + // tls-crypt directive. Encrypts control channel metadata. + pub tls_crypt: Option, + + // cipher directive. Legacy data channel cipher (deprecated in newer configs). + pub cipher: Option, + + // data-ciphers directive. Preferred list of ciphers (this is colon-separated). + pub data_ciphers: Vec, + + // auth directive. HMAC digest algorithm (e.g. SHA256). + pub auth: Option, + + // compress directive. Either enabled or specifies algorithm (e.g. "lz4"). + pub compress: Option, + + // OpenVPN 2.5+ specifies a allow-compress directive for safety + // https://community.openvpn.net/Security%20Announcements/VORACLE + pub allow_compress: Option, + + // All route directives. + // Each represents a network route pushed or defined locally. + pub routes: Vec, + + // redirect-gateway flag. + // Forces all traffic through VPN if present. + pub redirect_gateway: Option, + + // Standalone flag directives with no arguments. + // Examples: client, nobind, persist-key, persist-tun. + pub flags: Vec, + + // auth-user-pass directive. Indicates the server requires + // username/password authentication. The optional file path argument + // is ignored (NM handles interactive prompts). + pub auth_user_pass: bool, + + // Catch-all for unmodeled or less common directives. + // Key = directive name, Value = list of argument lists. + // Preserves information for round-tripping / forward compatibility. + pub options: HashMap>, +} + +#[derive(Debug, Clone)] +pub struct Remote { + pub host: String, + pub port: Option, + pub proto: Option, +} + +#[derive(Debug, Clone)] +pub enum CertSource { + File(String), + Inline(String), +} + +#[derive(Debug, Clone)] +pub struct TlsAuth { + pub source: CertSource, + pub key_direction: Option, +} + +#[derive(Debug, Clone)] +pub enum Compress { + Stub, + StubV2, + Algorithm(String), +} + +#[derive(Debug, Clone)] +pub enum AllowCompress { + Yes, + No, + Asym, + Other(String), +} + +#[derive(Debug, Clone)] +pub struct Route { + pub network: Ipv4Addr, + pub netmask: Option, + pub gateway: Option, +} + +#[derive(Debug, Clone)] +pub struct RedirectGateway { + pub def1: bool, + pub bypass_dhcp: bool, + pub bypass_dns: bool, + pub local: bool, + pub ipv6: bool, +} + +enum OvpnItem { + Directive { + key: String, + args: Vec, + line: usize, + }, + Block { + key: String, + content: String, + line: usize, + }, +} + +#[derive(Default)] +struct OvpnFileBuilder { + remotes: Vec, + dev: Option, + proto: Option, + ca: Option, + cert: Option, + key: Option, + key_direction: Option, + tls_auth: Option, + tls_crypt: Option, + cipher: Option, + data_ciphers: Vec, + auth: Option, + compress: Option, + allow_compress: Option, + routes: Vec, + redirect_gateway: Option, + auth_user_pass: bool, + flags: Vec, + options: HashMap>, +} + +impl OvpnFileBuilder { + fn build(mut self) -> OvpnFile { + if let Some(ref mut ta) = self.tls_auth + && ta.key_direction.is_none() + { + ta.key_direction = self.key_direction; + } + + OvpnFile { + remotes: self.remotes, + dev: self.dev, + proto: self.proto, + ca: self.ca, + cert: self.cert, + key: self.key, + tls_auth: self.tls_auth, + tls_crypt: self.tls_crypt, + cipher: self.cipher, + data_ciphers: self.data_ciphers, + auth: self.auth, + compress: self.compress, + allow_compress: self.allow_compress, + routes: self.routes, + redirect_gateway: self.redirect_gateway, + auth_user_pass: self.auth_user_pass, + flags: self.flags, + options: self.options, + } + } +} + +fn lexer(input: &str) -> Result, OvpnParseError> { + let mut items = Vec::new(); + + let mut current_line = String::new(); + let mut continuing = false; + + let mut in_block: Option = None; + let mut block_buffer = String::new(); + let mut block_line_start = 0; + + for (idx, raw_line) in input.lines().enumerate() { + let line_number = idx + 1; + let line = raw_line; + + // We're in a block + if let Some(block_name) = &in_block { + let trimmed = line.trim(); + + if trimmed.starts_with("") { + let end_tag = trimmed[2..trimmed.len() - 1].trim().to_lowercase(); + + if end_tag == *block_name { + items.push(OvpnItem::Block { + key: block_name.clone(), + content: block_buffer.clone(), + line: block_line_start, + }); + + in_block = None; + block_buffer.clear(); + continue; + } else { + return Err(OvpnParseError::UnexpectedBlockEnd { + block: end_tag, + line: line_number, + }); + } + } + + block_buffer.push_str(line); + block_buffer.push('\n'); + + continue; + } + + // Typically, one might track line numbers where the directive + // starts, as opposed to when it ends + // e.g. + // + // remote example.com \ + // 1194 udp + // + // For the sake of reporting errors in a user friendly fashion, + // I find it okay to do the latter here. + if continuing { + current_line.push(' '); + current_line.push_str(line.trim_start()); + } else { + current_line.clear(); + current_line.push_str(line); + } + + if current_line.ends_with('\\') { + continuing = true; + current_line.pop(); + continue; + } else { + continuing = false; + } + + let line = current_line.trim(); + + // Remove comments + let mut cleaned = String::new(); + let mut prev_whitespace = true; + + for c in line.chars() { + if (c == '#' || c == ';') && prev_whitespace { + break; + } + + prev_whitespace = c.is_whitespace(); + cleaned.push(c); + } + + current_line.clear(); + let line = cleaned.trim(); + + if line.is_empty() { + continue; + } + + if line.starts_with('<') && line.ends_with('>') && !line.starts_with("') { + let key = line[2..line.len() - 1].trim().to_lowercase(); + + return Err(OvpnParseError::UnexpectedBlockEnd { + block: key, + line: line_number, + }); + } + + let mut parts = line.split_whitespace(); + let key = match parts.next() { + Some(k) => k.to_lowercase(), + None => { + return Err(OvpnParseError::InvalidDirectiveSyntax { line: line_number }); + } + }; + + let args: Vec = parts.map(|s| s.to_string()).collect(); + + items.push(OvpnItem::Directive { + key, + args, + line: line_number, + }); + } + + if continuing { + return Err(OvpnParseError::InvalidContinuation { + line: input.lines().count(), + }); + } + + if let Some(block) = in_block { + return Err(OvpnParseError::UnterminatedBlock { + block, + line: block_line_start, + }); + } + + Ok(items) +} + +pub fn parse_ovpn(content: &str) -> Result { + let mut b = OvpnFileBuilder::default(); + let items = lexer(content)?; + + for item in items { + match item { + OvpnItem::Directive { key, args, line } => { + match key.as_str() { + "remote" => { + // remote [PORT] [PROTO] + + let host = args + .first() + .ok_or(OvpnParseError::MissingArgument { + key: key.clone(), + line, + })? + .clone(); + + let port = args + .get(1) + .map(|p| { + p.parse::().map_err(|_| OvpnParseError::InvalidNumber { + key: key.clone(), + value: p.clone(), + line, + }) + }) + .transpose()?; + + let proto = args.get(2).cloned(); + + b.remotes.push(Remote { host, port, proto }); + } + "dev" => { + // dev + + if args.len() != 1 { + Err(OvpnParseError::InvalidArgument { + key: key.clone(), + arg: format!("{args:?}"), + line, + })?; + } + + let value = args + .first() + .ok_or(OvpnParseError::MissingArgument { key, line })?; + + b.dev = Some(value.clone()); + } + "proto" => { + // proto + + if args.len() != 1 { + Err(OvpnParseError::InvalidArgument { + key: key.clone(), + arg: format!("{args:?}"), + line, + })?; + } + + let value = args + .first() + .ok_or(OvpnParseError::MissingArgument { key, line })?; + + b.proto = Some(value.clone()); + } + "ca" => { + let path = args + .first() + .ok_or(OvpnParseError::MissingArgument { + key: key.clone(), + line, + })? + .clone(); + b.ca = Some(CertSource::File(path)); + } + "cert" => { + let path = args + .first() + .ok_or(OvpnParseError::MissingArgument { + key: key.clone(), + line, + })? + .clone(); + b.cert = Some(CertSource::File(path)); + } + "key" => { + let path = args + .first() + .ok_or(OvpnParseError::MissingArgument { + key: key.clone(), + line, + })? + .clone(); + b.key = Some(CertSource::File(path)); + } + "tls-crypt" => { + let path = args + .first() + .ok_or(OvpnParseError::MissingArgument { + key: key.clone(), + line, + })? + .clone(); + b.tls_crypt = Some(CertSource::File(path)); + } + "tls-auth" => { + // tls-auth [DIRECTION] + + let path = args + .first() + .ok_or(OvpnParseError::MissingArgument { + key: key.clone(), + line, + })? + .clone(); + + let kd = args + .get(1) + .map(|v| { + v.parse::().map_err(|_| OvpnParseError::InvalidNumber { + key: key.clone(), + value: v.clone(), + line, + }) + }) + .transpose()? + .filter(|&d| d <= 1); + + b.tls_auth = Some(TlsAuth { + source: CertSource::File(path), + key_direction: kd, + }); + } + "key-direction" => { + // key-direction <0/1> + + let value = args.first().ok_or(OvpnParseError::MissingArgument { + key: key.clone(), + line, + })?; + + let dir = + value + .parse::() + .map_err(|_| OvpnParseError::InvalidNumber { + key: key.clone(), + value: value.clone(), + line, + })?; + + // 0 = server, 1 = client + if dir > 1 { + Err(OvpnParseError::InvalidArgument { + key, + arg: value.clone(), + line, + })?; + } + + b.key_direction = Some(dir); + } + "cipher" => { + // cipher + // Note: This is deprecated in new configs + + let value = args + .first() + .ok_or(OvpnParseError::MissingArgument { key, line })?; + + b.cipher = Some(value.clone()); + } + "data-ciphers" => { + // data-ciphers <[cipher1]:[cipher2]...> + + let ciphers = args + .first() + .ok_or(OvpnParseError::MissingArgument { key, line })?; + + b.data_ciphers.extend(ciphers.split(':').map(String::from)); + } + "auth" => { + // auth + + let value = args + .first() + .ok_or(OvpnParseError::MissingArgument { key, line })?; + + b.auth = Some(value.clone()); + } + "compress" => { + // compress [ALGORITHM] + + b.compress = Some(match args.first().map(|s| s.as_str()) { + None | Some("stub") => Compress::Stub, + Some("stub-v2") => Compress::StubV2, + Some(alg) => Compress::Algorithm(alg.to_string()), + }); + } + "allow-compress" => { + // allow-compress asym (default) <- receive compressed data but don't send + // allow-compress [yes/no] + + let value = args + .first() + .ok_or(OvpnParseError::MissingArgument { key, line })?; + + let parsed = match value.as_str() { + "yes" => AllowCompress::Yes, + "no" => AllowCompress::No, + "asym" => AllowCompress::Asym, + other => AllowCompress::Other(other.to_string()), + }; + + b.allow_compress = Some(parsed); + } + "route" => { + // route [NETMASK] [GATEWAY] + + let network = parse_ipv4_arg(&key, args.first(), line)?; + let netmask = args + .get(1) + .map(|v| parse_ipv4_arg(&key, Some(v), line)) + .transpose()?; + let gateway = args + .get(2) + .map(|v| parse_ipv4_arg(&key, Some(v), line)) + .transpose()?; + + b.routes.push(Route { + network, + netmask, + gateway, + }); + } + "auth-user-pass" => { + // auth-user-pass [FILE] + // Optional file path is ignored — NM handles interactive prompts. + b.auth_user_pass = true; + } + "redirect-gateway" => { + let mut rg = RedirectGateway { + def1: false, + bypass_dhcp: false, + bypass_dns: false, + local: false, + ipv6: false, + }; + + for arg in args { + match arg.as_str() { + "def1" => rg.def1 = true, + "bypass-dhcp" => rg.bypass_dhcp = true, + "bypass-dns" => rg.bypass_dns = true, + "local" => rg.local = true, + "ipv6" => rg.ipv6 = true, + _ => {} + } + } + + b.redirect_gateway = Some(rg); + } + _ => { + if args.is_empty() { + b.flags.push(key); + } else { + b.options.entry(key).or_default().extend(args); + } + } + } + } + OvpnItem::Block { + key: block_key, + content, + line: _line, + } => match block_key.as_str() { + "ca" => { + b.ca = Some(CertSource::Inline(content)); + } + + "cert" => { + b.cert = Some(CertSource::Inline(content)); + } + + "key" => { + b.key = Some(CertSource::Inline(content)); + } + + "tls-auth" => { + b.tls_auth = Some(TlsAuth { + source: CertSource::Inline(content), + key_direction: None, + }); + } + + "tls-crypt" => { + b.tls_crypt = Some(CertSource::Inline(content)); + } + + _ => { + b.options.entry(block_key).or_default().push(content); + } + }, + } + } + + Ok(b.build()) +} + +fn parse_ipv4_arg( + key: &str, + value: Option<&String>, + line: usize, +) -> Result { + let v = value.ok_or(OvpnParseError::MissingArgument { + key: key.to_string(), + line, + })?; + + v.parse::() + .map_err(|_| OvpnParseError::InvalidNumber { + key: key.to_string(), + value: v.clone(), + line, + }) +} + +#[cfg(test)] +mod tests { + use std::net::Ipv4Addr; + + use super::*; + + // Macro to reduce noise on failure assertions + macro_rules! assert_parse_err { + ($input:expr, $pattern:pat $(if $guard:expr)? ) => { + match parse_ovpn($input).unwrap_err() { + ConnectionError::ParseError(e) => { + assert!(matches!(e, $pattern $(if $guard)?)); + } + _ => panic!("expected OvpnParseError"), + } + }; + } + + fn parse_ok(input: &str) -> OvpnFile { + parse_ovpn(input).unwrap() + } + + fn assert_one_remote(f: &OvpnFile, host: &str, port: Option, proto: Option<&str>) { + assert_eq!(f.remotes.len(), 1, "expected exactly one remote"); + let r = &f.remotes[0]; + assert_eq!(r.host, host); + assert_eq!(r.port, port); + assert_eq!(r.proto.as_deref(), proto); + } + + fn assert_inline_cert(source: &CertSource, expected_substr: &str) { + match source { + CertSource::Inline(s) => assert!( + s.contains(expected_substr), + "inline cert should contain {expected_substr:?}, got {s:?}" + ), + other => panic!("expected CertSource::Inline, got {other:?}"), + } + } + + #[test] + fn parse_remote_directive() { + let result = parse_ok("remote example.com 1194 udp"); + assert_one_remote(&result, "example.com", Some(1194), Some("udp")); + } + + #[test] + fn remote_missing_host_fails() { + assert_parse_err!( + "remote", + OvpnParseError::MissingArgument { key, .. } if key == "remote" + ); + } + + #[test] + fn remote_host_only_passes() { + let result = parse_ok("remote example.com"); + assert_one_remote(&result, "example.com", None, None); + } + + #[test] + fn remote_invalid_port_fails() { + assert_parse_err!( + "remote example.com bogus", + OvpnParseError::InvalidNumber { key, value, .. } + if key == "remote" && value == "bogus" + ); + } + + #[test] + fn remote_multiple_in_order_passes() { + let result = parse_ok("remote a.example 1194 udp\nremote b.example 443 tcp-client"); + assert_eq!(result.remotes.len(), 2); + assert_eq!(result.remotes[0].host, "a.example"); + assert_eq!(result.remotes[0].port, Some(1194)); + assert_eq!(result.remotes[0].proto.as_deref(), Some("udp")); + assert_eq!(result.remotes[1].host, "b.example"); + assert_eq!(result.remotes[1].port, Some(443)); + assert_eq!(result.remotes[1].proto.as_deref(), Some("tcp-client")); + } + + #[test] + fn parse_dev_directive() { + let result = parse_ok("dev tun"); + assert_eq!(result.dev.as_deref(), Some("tun")); + } + + #[test] + fn dev_arity_check_fails() { + assert_parse_err!( + "dev tun panu", + OvpnParseError::InvalidArgument { key, .. } if key == "dev" + ); + } + + #[test] + fn dev_missing_device_fails() { + assert_parse_err!( + "dev", + OvpnParseError::InvalidArgument { key, .. } if key == "dev" + ); + } + + #[test] + fn parse_proto_directive() { + let result = parse_ok("proto udp"); + assert_eq!(result.proto.as_deref(), Some("udp")); + } + + #[test] + fn proto_arity_check_fails() { + assert_parse_err!( + "proto udp tcp", + OvpnParseError::InvalidArgument { key, .. } if key == "proto" + ); + } + + #[test] + fn proto_missing_arg_fails() { + assert_parse_err!( + "proto", + OvpnParseError::InvalidArgument { key, .. } if key == "proto" + ); + } + + #[test] + fn dev_strips_comments_passes() { + let result = parse_ok(" dev tun # interface\n; ignored"); + assert_eq!(result.dev.as_deref(), Some("tun")); + } + + #[test] + fn remote_line_continuation_passes() { + let result = parse_ok("remote example.com \\\n1194 udp"); + assert_one_remote(&result, "example.com", Some(1194), Some("udp")); + } + + #[test] + fn invalid_line_continuation_fails() { + assert_parse_err!("dev tun\\", OvpnParseError::InvalidContinuation { .. }); + } + + #[test] + fn block_unterminated_fails() { + assert_parse_err!( + "\n-----BEGIN CERTIFICATE-----", + OvpnParseError::UnterminatedBlock { block, .. } if block == "ca" + ); + } + + #[test] + fn block_close_without_open_fails() { + assert_parse_err!("", OvpnParseError::UnexpectedBlockEnd { .. }); + } + + #[test] + fn block_mismatched_end_tag_fails() { + assert_parse_err!( + "\n", + OvpnParseError::UnexpectedBlockEnd { block, .. } if block == "cert" + ); + } + + #[test] + fn parse_cipher_directive() { + let result = parse_ok("cipher AES-256-GCM"); + assert_eq!(result.cipher.as_deref(), Some("AES-256-GCM")); + } + + #[test] + fn parse_data_ciphers_directive() { + let result = parse_ok("data-ciphers AES-128-GCM:CHACHA20-POLY1305"); + assert_eq!( + result.data_ciphers, + vec!["AES-128-GCM", "CHACHA20-POLY1305"] + ); + } + + #[test] + fn parse_auth_directive() { + let result = parse_ok("auth SHA256"); + assert_eq!(result.auth.as_deref(), Some("SHA256")); + } + + #[test] + fn cipher_missing_value_fails() { + assert_parse_err!( + "cipher", + OvpnParseError::MissingArgument { key, .. } if key == "cipher" + ); + } + + #[test] + fn data_ciphers_missing_value_fails() { + assert_parse_err!( + "data-ciphers", + OvpnParseError::MissingArgument { key, .. } if key == "data-ciphers" + ); + } + + #[test] + fn data_ciphers_repeat_directives_passes() { + let result = + parse_ok("data-ciphers AES-256-GCM:AES-128-GCM\ndata-ciphers CHACHA20-POLY1305"); + assert_eq!( + result.data_ciphers, + vec!["AES-256-GCM", "AES-128-GCM", "CHACHA20-POLY1305"] + ); + } + + #[test] + fn compress_directive_variants_passes() { + assert!(matches!( + parse_ok("compress").compress, + Some(Compress::Stub) + )); + assert!(matches!( + parse_ok("compress stub").compress, + Some(Compress::Stub) + )); + assert!(matches!( + parse_ok("compress stub-v2").compress, + Some(Compress::StubV2) + )); + assert!(matches!( + parse_ok("compress lz4").compress, + Some(Compress::Algorithm(s)) if s == "lz4" + )); + } + + #[test] + fn allow_compress_directive_variants_passes() { + assert!(matches!( + parse_ok("allow-compress yes").allow_compress, + Some(AllowCompress::Yes) + )); + assert!(matches!( + parse_ok("allow-compress no").allow_compress, + Some(AllowCompress::No) + )); + assert!(matches!( + parse_ok("allow-compress asym").allow_compress, + Some(AllowCompress::Asym) + )); + assert!(matches!( + parse_ok("allow-compress legacy").allow_compress, + Some(AllowCompress::Other(s)) if s == "legacy" + )); + } + + #[test] + fn allow_compress_missing_arg_fails() { + assert_parse_err!( + "allow-compress", + OvpnParseError::MissingArgument { key, .. } if key == "allow-compress" + ); + } + + #[test] + fn parse_route_directive() { + let result = parse_ok("route 10.0.0.0 255.255.255.0 192.168.1.1"); + assert_eq!(result.routes.len(), 1); + assert_eq!(result.routes[0].network, Ipv4Addr::new(10, 0, 0, 0)); + assert_eq!( + result.routes[0].netmask, + Some(Ipv4Addr::new(255, 255, 255, 0)) + ); + assert_eq!( + result.routes[0].gateway, + Some(Ipv4Addr::new(192, 168, 1, 1)) + ); + } + + #[test] + fn route_network_only_passes() { + let result = parse_ok("route 172.16.0.0"); + assert_eq!(result.routes.len(), 1); + assert_eq!(result.routes[0].network, Ipv4Addr::new(172, 16, 0, 0)); + assert_eq!(result.routes[0].netmask, None); + assert_eq!(result.routes[0].gateway, None); + } + + #[test] + fn route_missing_network_fails() { + assert_parse_err!( + "route", + OvpnParseError::MissingArgument { key, .. } if key == "route" + ); + } + + #[test] + fn route_invalid_ipv4_fails() { + assert_parse_err!( + "route not-an-ip", + OvpnParseError::InvalidNumber { key, value, .. } + if key == "route" && value == "not-an-ip" + ); + } + + #[test] + fn parse_redirect_gateway_directive() { + let result = parse_ok("redirect-gateway def1 bypass-dhcp bypass-dns local ipv6"); + let rg = result.redirect_gateway.expect("redirect-gateway"); + assert!(rg.def1 && rg.bypass_dhcp && rg.bypass_dns && rg.local && rg.ipv6); + } + + #[test] + fn redirect_gateway_unknown_flags_passes() { + let result = parse_ok("redirect-gateway def1 nosuch"); + let rg = result.redirect_gateway.expect("redirect-gateway"); + assert!(rg.def1); + assert!(!rg.bypass_dhcp); + } + + #[test] + fn flags_and_options_directives_passes() { + let result = parse_ok("client\nnobind\n tls-version-min 1.2 \n"); + assert!(result.flags.contains(&"client".to_string())); + assert!(result.flags.contains(&"nobind".to_string())); + let expected = vec!["1.2".to_string()]; + assert_eq!(result.options.get("tls-version-min"), Some(&expected)); + } + + #[test] + fn ca_inline_block_passes() { + let result = parse_ok("\nTESTCABODY\n"); + assert_inline_cert(result.ca.as_ref().expect("ca"), "TESTCABODY"); + } + + #[test] + fn cert_and_key_inline_blocks_passes() { + let result = parse_ok("\nCERTPEM\n\n\nKEYPEM\n"); + assert_inline_cert(result.cert.as_ref().expect("cert"), "CERTPEM"); + assert_inline_cert(result.key.as_ref().expect("key"), "KEYPEM"); + } + + #[test] + fn tls_auth_and_tls_crypt_inline_passes() { + let result = + parse_ok("\nAUTHKEY\n\n\nCRYPTKEY\n"); + let ta = result.tls_auth.as_ref().expect("tls-auth"); + assert_inline_cert(&ta.source, "AUTHKEY"); + assert_eq!(ta.key_direction, None); + assert_inline_cert(result.tls_crypt.as_ref().expect("tls-crypt"), "CRYPTKEY"); + } + + #[test] + fn unknown_inline_block_in_options_passes() { + let result = parse_ok("\nbar\n"); + let v = result.options.get("foo").expect("foo block"); + assert_eq!(v.len(), 1); + assert!(v[0].contains("bar")); + } + + #[test] + fn error_reports_correct_line_number() { + let input = "dev tun\nproto udp\nremote\ncipher AES-256-GCM"; + match parse_ovpn(input).unwrap_err() { + ConnectionError::ParseError(OvpnParseError::MissingArgument { key, line }) => { + assert_eq!(key, "remote"); + assert_eq!(line, 3); + } + other => panic!("expected MissingArgument, got {other:?}"), + } + } + + #[test] + fn unterminated_block_reports_start_line() { + let input = "dev tun\n\ncontent"; + match parse_ovpn(input).unwrap_err() { + ConnectionError::ParseError(OvpnParseError::UnterminatedBlock { block, line }) => { + assert_eq!(block, "ca"); + assert_eq!(line, 2); + } + other => panic!("expected UnterminatedBlock, got {other:?}"), + } + } + + #[test] + fn tls_auth_directive_with_direction_passes() { + let result = parse_ok("tls-auth /etc/openvpn/ta.key 1"); + let ta = result.tls_auth.as_ref().expect("tls-auth"); + match &ta.source { + CertSource::File(p) => assert_eq!(p, "/etc/openvpn/ta.key"), + other => panic!("expected CertSource::File, got {other:?}"), + } + assert_eq!(ta.key_direction, Some(1)); + } + + #[test] + fn tls_auth_directive_without_direction_passes() { + let result = parse_ok("tls-auth /etc/openvpn/ta.key"); + let ta = result.tls_auth.as_ref().expect("tls-auth"); + match &ta.source { + CertSource::File(p) => assert_eq!(p, "/etc/openvpn/ta.key"), + other => panic!("expected CertSource::File, got {other:?}"), + } + assert_eq!(ta.key_direction, None); + } + + #[test] + fn tls_auth_directive_missing_path_fails() { + assert_parse_err!( + "tls-auth", + OvpnParseError::MissingArgument { key, .. } if key == "tls-auth" + ); + } + + #[test] + fn key_direction_standalone_passes() { + let result = parse_ok("\nAUTHKEY\n\nkey-direction 0"); + let ta = result.tls_auth.as_ref().expect("tls-auth"); + assert_inline_cert(&ta.source, "AUTHKEY"); + assert_eq!(ta.key_direction, Some(0)); + } + + #[test] + fn key_direction_standalone_before_block_passes() { + let result = parse_ok("key-direction 1\n\nAUTHKEY\n"); + let ta = result.tls_auth.as_ref().expect("tls-auth"); + assert_inline_cert(&ta.source, "AUTHKEY"); + assert_eq!(ta.key_direction, Some(1)); + } + + #[test] + fn key_direction_does_not_override_inline_arg() { + let result = parse_ok("tls-auth /path/ta.key 1\nkey-direction 0"); + let ta = result.tls_auth.as_ref().expect("tls-auth"); + assert_eq!(ta.key_direction, Some(1)); + } + + #[test] + fn key_direction_invalid_value_fails() { + assert_parse_err!( + "key-direction 2", + OvpnParseError::InvalidArgument { key, arg, .. } + if key == "key-direction" && arg == "2" + ); + } + + #[test] + fn key_direction_non_numeric_fails() { + assert_parse_err!( + "key-direction server", + OvpnParseError::InvalidNumber { key, value, .. } + if key == "key-direction" && value == "server" + ); + } + + #[test] + fn key_direction_missing_arg_fails() { + assert_parse_err!( + "key-direction", + OvpnParseError::MissingArgument { key, .. } if key == "key-direction" + ); + } + + #[test] + fn auth_user_pass_bare_passes() { + let result = parse_ok("auth-user-pass"); + assert!(result.auth_user_pass); + } + + #[test] + fn auth_user_pass_with_file_path_passes() { + let result = parse_ok("auth-user-pass /etc/openvpn/creds.txt"); + assert!(result.auth_user_pass); + } + + #[test] + fn auth_user_pass_absent_defaults_false() { + let result = parse_ok("remote example.com 1194 udp"); + assert!(!result.auth_user_pass); + } +} diff --git a/nmrs/src/core/rfkill.rs b/nmrs/src/core/rfkill.rs new file mode 100644 index 00000000..4f51ab32 --- /dev/null +++ b/nmrs/src/core/rfkill.rs @@ -0,0 +1,59 @@ +//! Kernel rfkill state reader via sysfs. +//! +//! Reads `/sys/class/rfkill/*/type` and `/sys/class/rfkill/*/hard` to detect +//! hardware radio kill switches. This is a fallback for cases where +//! NetworkManager's `*HardwareEnabled` properties disagree with the kernel. + +use std::fs; +use std::path::Path; + +/// Snapshot of hardware (hard-block) rfkill state for each radio type. +#[derive(Debug, Clone, Copy, Default)] +pub(crate) struct RfkillSnapshot { + /// `true` if any WLAN rfkill entry reports a hard block. + pub wlan_hard_block: bool, + /// `true` if any WWAN rfkill entry reports a hard block. + pub wwan_hard_block: bool, + /// `true` if any Bluetooth rfkill entry reports a hard block. + pub bluetooth_hard_block: bool, +} + +/// Reads the current rfkill hardware-block state from sysfs. +/// +/// Returns an all-false snapshot if `/sys/class/rfkill` is unreadable +/// (common in containers and CI environments). +pub(crate) fn read_rfkill() -> RfkillSnapshot { + let rfkill_dir = Path::new("/sys/class/rfkill"); + + let entries = match fs::read_dir(rfkill_dir) { + Ok(e) => e, + Err(_) => return RfkillSnapshot::default(), + }; + + let mut snapshot = RfkillSnapshot::default(); + + for entry in entries.flatten() { + let path = entry.path(); + + let type_str = match fs::read_to_string(path.join("type")) { + Ok(s) => s.trim().to_string(), + Err(_) => continue, + }; + + let hard_blocked = match fs::read_to_string(path.join("hard")) { + Ok(s) => s.trim() == "1", + Err(_) => false, + }; + + if hard_blocked { + match type_str.as_str() { + "wlan" => snapshot.wlan_hard_block = true, + "wwan" => snapshot.wwan_hard_block = true, + "bluetooth" => snapshot.bluetooth_hard_block = true, + _ => {} + } + } + } + + snapshot +} diff --git a/nmrs/src/core/saved_connection.rs b/nmrs/src/core/saved_connection.rs new file mode 100644 index 00000000..472b2c4e --- /dev/null +++ b/nmrs/src/core/saved_connection.rs @@ -0,0 +1,949 @@ +//! Decode and manage NetworkManager saved connection settings. + +use std::collections::HashMap; + +use futures::stream::{self, StreamExt}; +use log::warn; +use zbus::Connection; +use zvariant::{OwnedObjectPath, OwnedValue, Str}; + +use crate::Result; +use crate::api::models::{ + ConnectionError, SavedConnection, SavedConnectionBrief, SettingsPatch, SettingsSummary, + VpnSecretFlags, WifiKeyMgmt, WifiSecuritySummary, +}; +use crate::dbus::{NMSettingsConnectionProxy, NMSettingsProxy}; +use crate::util::utils::decode_ssid_or_empty; + +/// Builds the `a{sa{sv}}` delta for [`SettingsPatch`] (unit-tested). +pub(crate) fn build_settings_patch_delta( + patch: &SettingsPatch, +) -> HashMap> { + let mut delta: HashMap> = HashMap::new(); + + if let Some(v) = patch.autoconnect { + delta + .entry("connection".to_string()) + .or_default() + .insert("autoconnect".to_string(), OwnedValue::from(v)); + } + if let Some(v) = patch.autoconnect_priority { + delta + .entry("connection".to_string()) + .or_default() + .insert("autoconnect-priority".to_string(), OwnedValue::from(v)); + } + if let Some(ref s) = patch.id { + delta + .entry("connection".to_string()) + .or_default() + .insert("id".to_string(), OwnedValue::from(Str::from(s.as_str()))); + } + if let Some(opt) = &patch.interface_name { + let v = match opt { + Some(name) => OwnedValue::from(Str::from(name.as_str())), + None => OwnedValue::from(Str::from("")), + }; + delta + .entry("connection".to_string()) + .or_default() + .insert("interface-name".to_string(), v); + } + if let Some(ref overlay) = patch.raw_overlay { + for (sec, entries) in overlay { + let e = delta.entry(sec.clone()).or_default(); + for (k, v) in entries { + e.insert(k.clone(), v.clone()); + } + } + } + + delta +} + +fn owned_to_str(v: &OwnedValue) -> Option { + Str::try_from(v.clone()) + .ok() + .map(|s| s.to_string()) + .or_else(|| String::try_from(v.clone()).ok()) +} + +fn owned_to_bool(v: &OwnedValue) -> Option { + bool::try_from(v.clone()).ok() +} + +fn owned_to_u32(v: &OwnedValue) -> Option { + u32::try_from(v.clone()).ok() +} + +fn owned_to_i32(v: &OwnedValue) -> Option { + i32::try_from(v.clone()).ok() +} + +fn owned_to_u64(v: &OwnedValue) -> Option { + u64::try_from(v.clone()).ok() +} + +fn owned_to_bytes(v: &OwnedValue) -> Option> { + Vec::::try_from(v.clone()).ok() +} + +fn take_str(m: &HashMap, key: &str) -> Option { + m.get(key).and_then(owned_to_str) +} + +fn take_bool(m: &HashMap, key: &str) -> Option { + m.get(key).and_then(owned_to_bool) +} + +fn take_u32(m: &HashMap, key: &str) -> Option { + m.get(key).and_then(owned_to_u32) +} + +fn take_i32(m: &HashMap, key: &str) -> Option { + m.get(key).and_then(owned_to_i32) +} + +fn take_u64(m: &HashMap, key: &str) -> Option { + m.get(key).and_then(owned_to_u64) +} + +fn take_str_vec(m: &HashMap, key: &str) -> Vec { + let Some(v) = m.get(key) else { + return Vec::new(); + }; + let Ok(arr) = zvariant::Array::try_from(v.clone()) else { + return Vec::new(); + }; + let mut out = Vec::new(); + for item in arr.iter() { + if let Ok(s) = Str::try_from(item.clone()) { + out.push(s.to_string()); + } + } + out +} + +/// Decodes a full [`SavedConnection`] from `GetSettings` output. +pub(crate) fn decode_saved( + path: OwnedObjectPath, + unsaved: bool, + filename: Option, + settings: HashMap>, +) -> Result { + let Some(conn) = settings.get("connection") else { + return Err(ConnectionError::MalformedSavedConnection( + "missing 'connection' section".into(), + )); + }; + + let uuid = take_str(conn, "uuid").ok_or_else(|| { + ConnectionError::MalformedSavedConnection("missing connection.uuid".into()) + })?; + let id = take_str(conn, "id") + .ok_or_else(|| ConnectionError::MalformedSavedConnection("missing connection.id".into()))?; + let connection_type = take_str(conn, "type").ok_or_else(|| { + ConnectionError::MalformedSavedConnection("missing connection.type".into()) + })?; + + let interface_name = take_str(conn, "interface-name").filter(|s| !s.is_empty()); + let autoconnect = take_bool(conn, "autoconnect").unwrap_or(true); + let autoconnect_priority = take_i32(conn, "autoconnect-priority").unwrap_or(0); + let timestamp_unix = take_u64(conn, "timestamp").unwrap_or(0); + let permissions = take_str_vec(conn, "permissions"); + + let summary = decode_summary(&connection_type, &settings); + + Ok(SavedConnection { + path, + uuid, + id, + connection_type, + interface_name, + autoconnect, + autoconnect_priority, + timestamp_unix, + permissions, + unsaved, + filename, + summary, + }) +} + +/// Brief row without building [`SettingsSummary`]. +pub(crate) fn decode_saved_brief( + path: OwnedObjectPath, + settings: &HashMap>, +) -> Result { + let Some(conn) = settings.get("connection") else { + return Err(ConnectionError::MalformedSavedConnection( + "missing 'connection' section".into(), + )); + }; + let uuid = take_str(conn, "uuid").ok_or_else(|| { + ConnectionError::MalformedSavedConnection("missing connection.uuid".into()) + })?; + let id = take_str(conn, "id") + .ok_or_else(|| ConnectionError::MalformedSavedConnection("missing connection.id".into()))?; + let connection_type = take_str(conn, "type").ok_or_else(|| { + ConnectionError::MalformedSavedConnection("missing connection.type".into()) + })?; + + Ok(SavedConnectionBrief { + path, + uuid, + id, + connection_type, + }) +} + +fn decode_summary( + conn_type: &str, + settings: &HashMap>, +) -> SettingsSummary { + match conn_type { + "802-11-wireless" => decode_wifi(settings), + "802-3-ethernet" => decode_ethernet(settings), + "wireguard" => decode_wireguard(settings), + "vpn" => { + if is_wireguard_vpn_service(settings) { + decode_wireguard(settings) + } else { + decode_vpn(settings) + } + } + "gsm" => decode_gsm(settings), + "cdma" => decode_cdma(settings), + "bluetooth" => decode_bluetooth(settings), + _ => SettingsSummary::Other { + sections: settings.keys().cloned().collect(), + }, + } +} + +fn is_wireguard_vpn_service(settings: &HashMap>) -> bool { + let Some(vpn) = settings.get("vpn") else { + return false; + }; + let Some(st) = take_str(vpn, "service-type") else { + return false; + }; + st.contains("wireguard") +} + +fn decode_wifi(settings: &HashMap>) -> SettingsSummary { + let w = settings.get("802-11-wireless").cloned().unwrap_or_default(); + let ssid_bytes = w.get("ssid").and_then(owned_to_bytes).unwrap_or_default(); + let ssid = decode_ssid_or_empty(&ssid_bytes).into_owned(); + let mode = take_str(&w, "mode"); + let band = take_str(&w, "band"); + let channel = take_u32(&w, "channel"); + let bssid = take_str(&w, "bssid"); + let hidden = take_bool(&w, "hidden").unwrap_or(false); + let mac_randomization = take_str(&w, "mac-address-randomization"); + + let has_sec_key = w + .get("security") + .map(|v| owned_to_str(v).is_some()) + .unwrap_or(false); + let security = if has_sec_key + || settings.contains_key("802-11-wireless-security") + || settings.contains_key("802-1x") + { + Some(decode_wifi_security(settings)) + } else { + None + }; + + SettingsSummary::Wifi { + ssid, + mode, + security, + band, + channel, + bssid, + hidden, + mac_randomization, + } +} + +fn decode_wifi_security( + settings: &HashMap>, +) -> WifiSecuritySummary { + let ws = settings + .get("802-11-wireless-security") + .cloned() + .unwrap_or_default(); + let eap = settings.get("802-1x").cloned().unwrap_or_default(); + + let key_mgmt_str = take_str(&ws, "key-mgmt").unwrap_or_default(); + let key_mgmt = match key_mgmt_str.as_str() { + "none" | "" => WifiKeyMgmt::None, + "ieee8021x" => WifiKeyMgmt::WpaEap, + "wpa-none" => WifiKeyMgmt::Wep, + "wpa-psk" | "wpa-psk-sha256" => WifiKeyMgmt::WpaPsk, + "wpa-eap" | "wpa-eap-suite-b-192" | "wpa-eap-sha256" => WifiKeyMgmt::WpaEap, + "sae" | "sae-ext" => WifiKeyMgmt::Sae, + "owe" => WifiKeyMgmt::Owe, + "owe-transition-mode" => WifiKeyMgmt::OweTransitionMode, + s if s.contains("wep") => WifiKeyMgmt::Wep, + _ if !eap.is_empty() => WifiKeyMgmt::WpaEap, + _ => WifiKeyMgmt::None, + }; + + let has_psk_field = ws.contains_key("psk"); + let psk_flags = take_u32(&ws, "psk-flags").unwrap_or(0); + let psk_agent_owned = VpnSecretFlags(psk_flags).agent_owned(); + + let eap_methods = take_str_vec(&eap, "eap"); + + WifiSecuritySummary { + key_mgmt, + has_psk_field, + psk_agent_owned, + eap_methods, + } +} + +fn decode_ethernet(settings: &HashMap>) -> SettingsSummary { + let e = settings.get("802-3-ethernet").cloned().unwrap_or_default(); + SettingsSummary::Ethernet { + mac_address: take_str(&e, "mac-address"), + auto_negotiate: take_bool(&e, "auto-negotiate"), + speed_mbps: take_u32(&e, "speed"), + mtu: take_u32(&e, "mtu"), + } +} + +fn decode_vpn(settings: &HashMap>) -> SettingsSummary { + let v = settings.get("vpn").cloned().unwrap_or_default(); + let service_type = take_str(&v, "service-type").unwrap_or_default(); + let user_name = take_str(&v, "user-name"); + let password_flags = VpnSecretFlags(take_u32(&v, "password-flags").unwrap_or(0)); + let persistent = take_bool(&v, "persistent").unwrap_or(false); + + let mut data_keys = Vec::new(); + if let Some(data_v) = v.get("data") + && let Ok(dict) = zvariant::Dict::try_from(data_v.clone()) + { + for (k, _) in dict.iter() { + if let Ok(key) = Str::try_from(k.clone()) { + data_keys.push(key.to_string()); + } + } + } + data_keys.sort(); + + SettingsSummary::Vpn { + service_type, + user_name, + password_flags, + data_keys, + persistent, + } +} + +fn decode_wireguard(settings: &HashMap>) -> SettingsSummary { + let wg = settings.get("wireguard").cloned().unwrap_or_default(); + let listen_port = take_u32(&wg, "listen-port").map(|p| p as u16); + let mtu = take_u32(&wg, "mtu"); + let fwmark = take_u32(&wg, "fwmark"); + + let mut peer_count = 0usize; + let mut first_peer_endpoint = None; + + if let Some(peers_v) = wg.get("peers") + && let Ok(arr) = zvariant::Array::try_from(peers_v.clone()) + { + peer_count = arr.len(); + if let Some(first) = arr.iter().next() + && let Ok(dict) = zvariant::Dict::try_from(first.clone()) + { + for (k, val) in dict.iter() { + if let Ok(key) = Str::try_from(k.clone()) + && key.as_str() == "endpoint" + && let Ok(ov) = OwnedValue::try_from(val.clone()) + { + first_peer_endpoint = owned_to_str(&ov); + break; + } + } + } + } + + SettingsSummary::WireGuard { + listen_port, + mtu, + fwmark, + peer_count, + first_peer_endpoint, + } +} + +fn decode_gsm(settings: &HashMap>) -> SettingsSummary { + let g = settings.get("gsm").cloned().unwrap_or_default(); + SettingsSummary::Gsm { + apn: take_str(&g, "apn"), + user_name: take_str(&g, "username"), + password_flags: take_u32(&g, "password-flags").unwrap_or(0), + pin_flags: take_u32(&g, "pin-flags").unwrap_or(0), + } +} + +fn decode_cdma(settings: &HashMap>) -> SettingsSummary { + let c = settings.get("cdma").cloned().unwrap_or_default(); + SettingsSummary::Cdma { + number: take_str(&c, "number"), + user_name: take_str(&c, "username"), + password_flags: take_u32(&c, "password-flags").unwrap_or(0), + } +} + +fn decode_bluetooth(settings: &HashMap>) -> SettingsSummary { + let b = settings.get("bluetooth").cloned().unwrap_or_default(); + let bdaddr = take_str(&b, "bdaddr").unwrap_or_default(); + let bt_type = take_str(&b, "type").unwrap_or_else(|| "panu".into()); + SettingsSummary::Bluetooth { bdaddr, bt_type } +} + +async fn fetch_one_full( + conn: &Connection, + path: OwnedObjectPath, +) -> Result> { + let proxy = match NMSettingsConnectionProxy::builder(conn) + .path(path.clone()) + .map_err(ConnectionError::Dbus)? + .build() + .await + { + Ok(p) => p, + Err(e) => { + warn!( + "saved connection {}: failed to build proxy: {}", + path.as_str(), + e + ); + return Ok(None); + } + }; + + let unsaved = proxy.unsaved().await.unwrap_or(false); + let filename = proxy.filename().await.ok().filter(|s| !s.is_empty()); + + let settings = match proxy.get_settings().await { + Ok(s) => s, + Err(e) => { + warn!( + "saved connection {}: GetSettings failed: {}", + path.as_str(), + e + ); + return Ok(None); + } + }; + + let path_str = path.as_str().to_string(); + match decode_saved(path, unsaved, filename, settings) { + Ok(c) => Ok(Some(c)), + Err(ConnectionError::MalformedSavedConnection(msg)) => { + warn!( + "skipping malformed saved connection at {}: {}", + path_str, msg + ); + Ok(None) + } + Err(e) => Err(e), + } +} + +async fn fetch_one_brief( + conn: &Connection, + path: OwnedObjectPath, +) -> Result> { + let proxy = match NMSettingsConnectionProxy::builder(conn) + .path(path.clone()) + .map_err(ConnectionError::Dbus)? + .build() + .await + { + Ok(p) => p, + Err(e) => { + warn!( + "saved connection {}: failed to build proxy: {}", + path.as_str(), + e + ); + return Ok(None); + } + }; + + let settings = match proxy.get_settings().await { + Ok(s) => s, + Err(e) => { + warn!( + "saved connection {}: GetSettings failed: {}", + path.as_str(), + e + ); + return Ok(None); + } + }; + + let path_str = path.as_str().to_string(); + match decode_saved_brief(path, &settings) { + Ok(b) => Ok(Some(b)), + Err(ConnectionError::MalformedSavedConnection(msg)) => { + warn!( + "skipping malformed saved connection at {}: {}", + path_str, msg + ); + Ok(None) + } + Err(e) => Err(e), + } +} + +/// Lists all saved profiles with full summaries (bounded concurrency). +pub(crate) async fn list_saved_connections(conn: &Connection) -> Result> { + const IN_FLIGHT: usize = 16; + + let settings = + NMSettingsProxy::new(conn) + .await + .map_err(|e| ConnectionError::DbusOperation { + context: "failed to create NM Settings proxy".into(), + source: e, + })?; + + let paths = settings + .list_connections() + .await + .map_err(|e| ConnectionError::DbusOperation { + context: "failed to list saved connections".into(), + source: e, + })?; + + let conn = conn.clone(); + let mut out: Vec = stream::iter(paths) + .map(|path| { + let conn = conn.clone(); + async move { fetch_one_full(&conn, path).await } + }) + .buffer_unordered(IN_FLIGHT) + .filter_map(|r| async move { + match r { + Ok(Some(c)) => Some(c), + Ok(None) => None, + Err(e) => { + warn!("list_saved_connections: {e}"); + None + } + } + }) + .collect() + .await; + + out.sort_by(|a, b| a.id.cmp(&b.id)); + Ok(out) +} + +/// Lists saved profiles with only `connection` identity fields. +pub(crate) async fn list_saved_connections_brief( + conn: &Connection, +) -> Result> { + const IN_FLIGHT: usize = 16; + + let settings = + NMSettingsProxy::new(conn) + .await + .map_err(|e| ConnectionError::DbusOperation { + context: "failed to create NM Settings proxy".into(), + source: e, + })?; + + let paths = settings + .list_connections() + .await + .map_err(|e| ConnectionError::DbusOperation { + context: "failed to list saved connections".into(), + source: e, + })?; + + let conn = conn.clone(); + let mut out: Vec = stream::iter(paths) + .map(|path| { + let conn = conn.clone(); + async move { fetch_one_brief(&conn, path).await } + }) + .buffer_unordered(IN_FLIGHT) + .filter_map(|r| async move { + match r { + Ok(Some(c)) => Some(c), + Ok(None) => None, + Err(e) => { + warn!("list_saved_connections_brief: {e}"); + None + } + } + }) + .collect() + .await; + + out.sort_by(|a, b| a.id.cmp(&b.id)); + Ok(out) +} + +pub(crate) async fn resolve_saved_path_by_uuid( + conn: &Connection, + uuid: &str, +) -> Result { + let settings = + NMSettingsProxy::new(conn) + .await + .map_err(|e| ConnectionError::DbusOperation { + context: "failed to create NM Settings proxy".into(), + source: e, + })?; + + settings + .get_connection_by_uuid(uuid) + .await + .map_err(|_| ConnectionError::SavedConnectionNotFound(uuid.to_string())) +} + +pub(crate) async fn get_saved_connection(conn: &Connection, uuid: &str) -> Result { + let path = resolve_saved_path_by_uuid(conn, uuid).await?; + fetch_one_full(conn, path) + .await? + .ok_or_else(|| ConnectionError::MalformedSavedConnection(uuid.to_string())) +} + +pub(crate) async fn get_saved_connection_raw( + conn: &Connection, + uuid: &str, +) -> Result>> { + let path = resolve_saved_path_by_uuid(conn, uuid).await?; + let proxy = NMSettingsConnectionProxy::builder(conn) + .path(path) + .map_err(ConnectionError::Dbus)? + .build() + .await + .map_err(|e| ConnectionError::DbusOperation { + context: "failed to build Settings.Connection proxy".into(), + source: e, + })?; + + proxy + .get_settings() + .await + .map_err(|e| ConnectionError::DbusOperation { + context: "GetSettings failed".into(), + source: e, + }) +} + +pub(crate) async fn delete_saved_connection(conn: &Connection, uuid: &str) -> Result<()> { + let path = resolve_saved_path_by_uuid(conn, uuid).await?; + let proxy = NMSettingsConnectionProxy::builder(conn) + .path(path) + .map_err(ConnectionError::Dbus)? + .build() + .await + .map_err(|e| ConnectionError::DbusOperation { + context: "failed to build Settings.Connection proxy".into(), + source: e, + })?; + + proxy + .delete() + .await + .map_err(|e| ConnectionError::DbusOperation { + context: "Delete failed".into(), + source: e, + }) +} + +pub(crate) async fn update_saved_connection( + conn: &Connection, + uuid: &str, + patch: &SettingsPatch, +) -> Result<()> { + let path = resolve_saved_path_by_uuid(conn, uuid).await?; + let proxy = NMSettingsConnectionProxy::builder(conn) + .path(path) + .map_err(ConnectionError::Dbus)? + .build() + .await + .map_err(|e| ConnectionError::DbusOperation { + context: "failed to build Settings.Connection proxy".into(), + source: e, + })?; + + let delta = build_settings_patch_delta(patch); + if delta.is_empty() { + return Ok(()); + } + + let unsaved = proxy + .unsaved() + .await + .map_err(|e| ConnectionError::DbusOperation { + context: "read unsaved property".into(), + source: e, + })?; + + if unsaved { + proxy + .update_unsaved(delta) + .await + .map_err(|e| ConnectionError::DbusOperation { + context: "UpdateUnsaved failed".into(), + source: e, + })?; + } else { + proxy + .update(delta) + .await + .map_err(|e| ConnectionError::DbusOperation { + context: "Update failed".into(), + source: e, + })?; + } + + Ok(()) +} + +pub(crate) async fn reload_saved_connections(conn: &Connection) -> Result<()> { + let settings = + NMSettingsProxy::new(conn) + .await + .map_err(|e| ConnectionError::DbusOperation { + context: "failed to create NM Settings proxy".into(), + source: e, + })?; + + let _ok = settings + .reload_connections() + .await + .map_err(|e| ConnectionError::DbusOperation { + context: "ReloadConnections failed".into(), + source: e, + })?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use zvariant::Str; + + fn conn_section(uuid: &str, id: &str, ty: &str) -> HashMap { + let mut m = HashMap::new(); + m.insert("uuid".into(), OwnedValue::from(Str::from(uuid))); + m.insert("id".into(), OwnedValue::from(Str::from(id))); + m.insert("type".into(), OwnedValue::from(Str::from(ty))); + m.insert("autoconnect".into(), OwnedValue::from(true)); + m.insert("autoconnect-priority".into(), OwnedValue::from(0i32)); + m.insert("timestamp".into(), OwnedValue::from(0u64)); + m + } + + #[test] + fn decode_malformed_missing_uuid() { + let mut settings = HashMap::new(); + let mut c = HashMap::new(); + c.insert("id".into(), OwnedValue::from(Str::from("x"))); + c.insert( + "type".into(), + OwnedValue::from(Str::from("802-11-wireless")), + ); + settings.insert("connection".into(), c); + + let r = decode_saved( + OwnedObjectPath::try_from("/o").unwrap(), + false, + None, + settings, + ); + assert!(matches!( + r, + Err(ConnectionError::MalformedSavedConnection(_)) + )); + } + + #[test] + fn decode_wifi_open() { + let mut settings = HashMap::new(); + settings.insert( + "connection".into(), + conn_section("u1", "Coffee", "802-11-wireless"), + ); + + let mut w = HashMap::new(); + w.insert( + "ssid".into(), + OwnedValue::try_from(zvariant::Array::from(vec![67u8, 111, 102, 102, 101, 101])) + .expect("ssid array"), + ); + settings.insert("802-11-wireless".into(), w); + + let c = decode_saved( + OwnedObjectPath::try_from("/o").unwrap(), + false, + Some("/etc/NetworkManager/system-connections/coffee.nmconnection".into()), + settings, + ) + .unwrap(); + + assert_eq!(c.uuid, "u1"); + assert_eq!(c.id, "Coffee"); + match c.summary { + SettingsSummary::Wifi { + ref ssid, + security: None, + .. + } => { + assert_eq!(ssid, "Coffee"); + } + _ => panic!("expected wifi summary"), + } + } + + #[test] + fn decode_wifi_psk_security() { + let mut settings = HashMap::new(); + settings.insert( + "connection".into(), + conn_section("u2", "Home", "802-11-wireless"), + ); + + let mut w = HashMap::new(); + w.insert( + "ssid".into(), + OwnedValue::try_from(zvariant::Array::from(vec![72u8, 111, 109, 101])) + .expect("ssid array"), + ); + w.insert( + "security".into(), + OwnedValue::from(Str::from("802-11-wireless-security")), + ); + settings.insert("802-11-wireless".into(), w); + + let mut sec = HashMap::new(); + sec.insert("key-mgmt".into(), OwnedValue::from(Str::from("wpa-psk"))); + sec.insert("psk-flags".into(), OwnedValue::from(1u32)); + sec.insert( + "psk".into(), + OwnedValue::from(Str::from("not-a-secret-in-test")), + ); + settings.insert("802-11-wireless-security".into(), sec); + + let c = decode_saved( + OwnedObjectPath::try_from("/o2").unwrap(), + false, + None, + settings, + ) + .unwrap(); + + match c.summary { + SettingsSummary::Wifi { + security: Some(s), .. + } => { + assert_eq!(s.key_mgmt, WifiKeyMgmt::WpaPsk); + assert!(s.has_psk_field); + assert!(s.psk_agent_owned); + } + _ => panic!("expected wifi with security"), + } + } + + #[test] + fn decode_vpn_wireguard_service() { + let mut settings = HashMap::new(); + settings.insert("connection".into(), conn_section("u3", "wg", "vpn")); + + let mut vpn = HashMap::new(); + vpn.insert( + "service-type".into(), + OwnedValue::from(Str::from("org.freedesktop.NetworkManager.wireguard")), + ); + vpn.insert("password-flags".into(), OwnedValue::from(0u32)); + settings.insert("vpn".into(), vpn); + + let mut wg = HashMap::new(); + wg.insert("listen-port".into(), OwnedValue::from(51820u32)); + settings.insert("wireguard".into(), wg); + + let c = decode_saved( + OwnedObjectPath::try_from("/o3").unwrap(), + false, + None, + settings, + ) + .unwrap(); + + match c.summary { + SettingsSummary::WireGuard { + listen_port: Some(51820), + peer_count: 0, + first_peer_endpoint: None, + .. + } => {} + ref s => panic!("expected wireguard summary, got {s:?}"), + } + } + + #[test] + fn decode_other_type() { + let mut settings = HashMap::new(); + settings.insert("connection".into(), conn_section("u4", "tun", "tun")); + + let c = decode_saved( + OwnedObjectPath::try_from("/o4").unwrap(), + false, + None, + settings, + ) + .unwrap(); + + match c.summary { + SettingsSummary::Other { sections } => { + assert!(sections.contains(&"connection".to_string())); + } + _ => panic!("expected other"), + } + } + + #[test] + fn patch_delta_autoconnect() { + let patch = SettingsPatch { + autoconnect: Some(false), + ..Default::default() + }; + let d = build_settings_patch_delta(&patch); + assert_eq!( + d.get("connection").unwrap().get("autoconnect"), + Some(&OwnedValue::from(false)) + ); + } + + #[test] + fn patch_delta_overlay_merges_section() { + let mut overlay = HashMap::new(); + let mut inner = HashMap::new(); + inner.insert("foo".into(), OwnedValue::from(Str::from("bar"))); + overlay.insert("ipv4".into(), inner); + + let patch = SettingsPatch { + raw_overlay: Some(overlay), + ..Default::default() + }; + let d = build_settings_patch_delta(&patch); + assert_eq!( + owned_to_str(d.get("ipv4").unwrap().get("foo").unwrap()).as_deref(), + Some("bar") + ); + } +} diff --git a/nmrs/src/core/scan.rs b/nmrs/src/core/scan.rs index 38fb0ebe..02e0a8ce 100644 --- a/nmrs/src/core/scan.rs +++ b/nmrs/src/core/scan.rs @@ -7,23 +7,26 @@ use std::collections::HashMap; use zbus::Connection; use crate::Result; -use crate::api::models::{ConnectionError, Network}; +use crate::api::models::access_point::{AccessPoint, ApMode, decode_security}; +use crate::api::models::{ConnectionError, DeviceState, Network}; +use crate::core::connection_settings::has_saved_connection; use crate::dbus::{NMAccessPointProxy, NMDeviceProxy, NMProxy, NMWirelessProxy}; use crate::monitoring::info::current_ssid; use crate::types::constants::{device_type, security_flags, wifi_mode}; use crate::util::utils::{ - decode_ssid_or_empty, decode_ssid_or_hidden, for_each_access_point, - get_ip_addresses_from_active_connection, + decode_ssid_or_empty, decode_ssid_or_hidden, get_ip_addresses_from_active_connection, }; -/// Triggers a Wi-Fi scan on all wireless devices. +/// Triggers a Wi-Fi scan. /// -/// Requests NetworkManager to scan for available networks. The scan -/// runs asynchronously; call `list_networks` after a delay to see results. -pub(crate) async fn scan_networks(conn: &Connection) -> Result<()> { +/// When `interface` is `None`, scans on every Wi-Fi device. +/// When `Some`, scans only the matching device. +/// The scan runs asynchronously; call [`list_networks`] after a delay. +pub(crate) async fn scan_networks(conn: &Connection, interface: Option<&str>) -> Result<()> { let nm = NMProxy::new(conn).await?; let devices = nm.get_devices().await?; + let mut scanned_any = false; for dp in devices { let d_proxy = NMDeviceProxy::builder(conn) .path(dp.clone())? @@ -45,6 +48,13 @@ pub(crate) async fn scan_networks(conn: &Connection) -> Result<()> { continue; } + if let Some(want) = interface { + let iface = d_proxy.interface().await.unwrap_or_default(); + if iface != want { + continue; + } + } + let wifi = NMWirelessProxy::builder(conn) .path(dp.clone())? .build() @@ -57,77 +67,203 @@ pub(crate) async fn scan_networks(conn: &Connection) -> Result<()> { context: format!("failed to request Wi-Fi scan on device {}", dp.as_str()), source: e, })?; + scanned_any = true; + } + + if let Some(want) = interface + && !scanned_any + { + return Err(ConnectionError::WifiInterfaceNotFound { + interface: want.to_string(), + }); } Ok(()) } -/// Lists all visible Wi-Fi networks. -/// -/// Enumerates access points from all Wi-Fi devices and returns a deduplicated -/// list of networks. Networks are keyed by (SSID, frequency) to distinguish -/// 2.4GHz and 5GHz bands of the same network. +/// Lists all visible access points, one entry per BSSID. /// -/// When multiple access points share the same SSID and frequency (e.g., mesh -/// networks), the one with the strongest signal is returned. +/// When `interface` is `Some`, only APs from that wireless device are returned. +/// When `None`, APs from all wireless devices are returned. /// -/// For the access point that matches the device's active connection, the result -/// includes the interface name and assigned IPv4/IPv6 addresses (CIDR), consistent -/// with `current_network`. -pub(crate) async fn list_networks(conn: &Connection) -> Result> { - let mut networks: HashMap<(String, u32), Network> = HashMap::new(); - - let all_networks = for_each_access_point(conn, |_dev, active_ap, ap_path, ap, on_device| { - let is_this_ap = active_ap.as_str() != "/" && active_ap == &ap_path; - Box::pin(async move { +/// The returned list is ordered per-device then NM's native order (no explicit +/// strength sort — consumers can sort as needed). +pub(crate) async fn list_access_points( + conn: &Connection, + interface: Option<&str>, +) -> Result> { + let nm = NMProxy::new(conn).await?; + let devices = nm.get_devices().await?; + + let mut results = Vec::new(); + + for dp in devices { + let dev = NMDeviceProxy::builder(conn) + .path(dp.clone())? + .build() + .await?; + + if dev.device_type().await? != device_type::WIFI { + continue; + } + + let iface = dev.interface().await.unwrap_or_default(); + + if let Some(target) = interface + && iface != target + { + continue; + } + + let raw_state = dev.state().await?; + let device_state: DeviceState = raw_state.into(); + + let wifi = NMWirelessProxy::builder(conn) + .path(dp.clone())? + .build() + .await?; + + let active_ap = wifi.active_access_point().await?; + let is_active_ap = |path: &zvariant::OwnedObjectPath| -> bool { + active_ap.as_str() != "/" && &active_ap == path + }; + + for ap_path in wifi.access_points().await? { + let ap = NMAccessPointProxy::builder(conn) + .path(ap_path.clone())? + .build() + .await?; + let ssid_bytes = ap.ssid().await?; let ssid = decode_ssid_or_hidden(&ssid_bytes); - let strength = ap.strength().await?; let bssid = ap.hw_address().await?; let flags = ap.flags().await?; let wpa = ap.wpa_flags().await?; let rsn = ap.rsn_flags().await?; - let frequency = ap.frequency().await?; - - let secured = (flags & security_flags::WEP) != 0 || wpa != 0 || rsn != 0; - let is_psk = (wpa & security_flags::PSK) != 0 || (rsn & security_flags::PSK) != 0; - let is_eap = (wpa & security_flags::EAP) != 0 || (rsn & security_flags::EAP) != 0; - let is_hotspot = ap.mode().await.unwrap_or(0) == wifi_mode::AP; - - let (device, ip4_address, ip6_address) = if is_this_ap { - on_device + let frequency_mhz = ap.frequency().await?; + let max_bitrate_kbps = ap.max_bitrate().await.unwrap_or(0); + let strength = ap.strength().await?; + let mode_raw = ap.mode().await.unwrap_or(0); + let last_seen_raw = ap.last_seen().await.unwrap_or(-1); + let last_seen_secs = if last_seen_raw < 0 { + None } else { - (String::new(), None, None) + Some(i64::from(last_seen_raw)) }; - let network = Network { - device, + results.push(AccessPoint { + path: ap_path.clone(), + device_path: dp.clone(), + interface: iface.clone(), ssid: ssid.to_string(), - bssid: Some(bssid), - strength: Some(strength), - frequency: Some(frequency), - secured, - is_psk, - is_eap, - is_hotspot, - ip4_address, - ip6_address, - }; + ssid_bytes: ssid_bytes.clone(), + bssid, + frequency_mhz, + max_bitrate_kbps, + strength, + mode: ApMode::from(mode_raw), + security: decode_security(flags, wpa, rsn), + last_seen_secs, + is_active: is_active_ap(&ap_path), + device_state: device_state.clone(), + }); + } + } + + Ok(results) +} + +/// Lists all visible Wi-Fi networks. +/// +/// Enumerates access points from all Wi-Fi devices and returns a deduplicated +/// list of networks. Networks are keyed by (SSID, device interface) and groups +/// APs by SSID, picking the strongest signal as the representative. +/// +/// Each returned [`Network`] carries the `best_bssid`, `bssids` list, and +/// `security_features` from the underlying access points. +pub(crate) async fn list_networks( + conn: &Connection, + interface: Option<&str>, +) -> Result> { + let aps = list_access_points(conn, interface).await?; + + let mut groups: HashMap<(String, String), Network> = HashMap::new(); + + for ap in &aps { + let key = (ap.interface.clone(), ap.ssid.clone()); + let sec_flags = ap.security; + let secured = !sec_flags.is_open(); + let is_psk = sec_flags.psk; + let is_eap = sec_flags.eap || sec_flags.eap_suite_b_192; + let is_hotspot = ap.mode == ApMode::Ap; + + let (ip4_address, ip6_address) = if ap.is_active { + active_ip_addresses(conn, &ap.device_path).await + } else { + (None, None) + }; + + let net = Network { + device: if ap.is_active { + ap.interface.clone() + } else { + String::new() + }, + ssid: ap.ssid.clone(), + bssid: Some(ap.bssid.clone()), + strength: Some(ap.strength), + frequency: Some(ap.frequency_mhz), + secured, + is_psk, + is_eap, + is_hotspot, + ip4_address, + ip6_address, + best_bssid: ap.bssid.clone(), + bssids: vec![ap.bssid.clone()], + is_active: ap.is_active, + known: false, + security_features: sec_flags, + }; + + groups + .entry(key) + .and_modify(|n| n.merge_ap(&net)) + .or_insert(net); + } - Ok(Some((ssid, frequency, network))) - }) - }) - .await?; - - // Deduplicate: use (SSID, frequency) as key, keep strongest signal - for (ssid, frequency, new_net) in all_networks { - networks - .entry((ssid.to_string(), frequency)) - .and_modify(|n| n.merge_ap(&new_net)) - .or_insert(new_net); + // Populate `known` by checking saved connections + for net in groups.values_mut() { + net.known = has_saved_connection(conn, &net.ssid).await.unwrap_or(false); + if net.device.is_empty() + && net.is_active + && let Some(ap) = aps.iter().find(|a| a.ssid == net.ssid && a.is_active) + { + net.device.clone_from(&ap.interface); + } } - Ok(networks.into_values().collect()) + Ok(groups.into_values().collect()) +} + +/// Helper to get IP addresses from the active connection on a device. +async fn active_ip_addresses( + conn: &Connection, + device_path: &zvariant::OwnedObjectPath, +) -> (Option, Option) { + let builder = match NMDeviceProxy::builder(conn).path(device_path.clone()) { + Ok(b) => b, + Err(_) => return (None, None), + }; + let dev = match builder.build().await { + Ok(d) => d, + Err(_) => return (None, None), + }; + + match dev.active_connection().await { + Ok(ac) if ac.as_str() != "/" => get_ip_addresses_from_active_connection(conn, &ac).await, + _ => (None, None), + } } /// Returns the full Network object for the currently connected WiFi network. @@ -203,10 +339,12 @@ pub(crate) async fn current_network(conn: &Connection) -> Result (None, None) }; + let sec_features = decode_security(flags, wpa, rsn); + return Ok(Some(Network { device: interface, ssid: ssid.to_string(), - bssid: Some(bssid), + bssid: Some(bssid.clone()), strength: Some(strength), frequency: Some(frequency), secured, @@ -215,6 +353,11 @@ pub(crate) async fn current_network(conn: &Connection) -> Result is_hotspot, ip4_address, ip6_address, + best_bssid: bssid.clone(), + bssids: vec![bssid], + is_active: true, + known: true, + security_features: sec_features, })); } diff --git a/nmrs/src/core/vpn.rs b/nmrs/src/core/vpn.rs index 18f4a604..28a1c6ab 100644 --- a/nmrs/src/core/vpn.rs +++ b/nmrs/src/core/vpn.rs @@ -1,14 +1,10 @@ //! Core VPN connection management logic. //! -//! This module contains internal implementation for managing VPN connections -//! through NetworkManager, including connecting, disconnecting, listing, and -//! deleting VPN profiles. -//! -//! Currently supports: -//! - WireGuard connections (NetworkManager connection.type == "wireguard") -//! -//! These functions are not part of the public API and should be accessed -//! through the [`NetworkManager`][crate::NetworkManager] interface. +//! Supports: +//! - WireGuard connections (`connection.type == "wireguard"`) +//! - NM plugin VPNs (`connection.type == "vpn"`) — OpenVPN, OpenConnect, +//! strongSwan, PPTP, L2TP, and any other installed plugin. +#![allow(deprecated)] use log::{debug, info, warn}; use std::collections::HashMap; @@ -17,42 +13,626 @@ use zvariant::OwnedObjectPath; use crate::Result; use crate::api::models::{ - ConnectionError, ConnectionOptions, DeviceState, TimeoutConfig, VpnConnection, - VpnConnectionInfo, VpnCredentials, VpnType, + ConnectionError, ConnectionOptions, DeviceState, OpenVpnConnectionType, TimeoutConfig, + VpnConfig, VpnConnection, VpnConnectionInfo, VpnCredentials, VpnDetails, VpnKind, + VpnSecretFlags, VpnType, }; -use crate::builders::build_wireguard_connection; +use crate::builders::{build_openvpn_connection, build_wireguard_connection}; use crate::core::state_wait::wait_for_connection_activation; use crate::dbus::{NMActiveConnectionProxy, NMProxy}; +use crate::models::VpnConfiguration; use crate::util::utils::{extract_connection_state_reason, nm_proxy, settings_proxy}; -use crate::util::validation::{validate_connection_name, validate_vpn_credentials}; +use crate::util::validation::{ + validate_connection_name, validate_openvpn_config, validate_vpn_credentials, +}; -/// Connects to a WireGuard connection. -/// -/// This function checks for an existing saved connection by name. -/// If found, it activates the saved connection. If not found, it creates -/// a new WireGuard connection using the provided credentials. -/// The function waits for the connection to reach the activated state -/// before returning. +/// Detects whether a saved connection is a VPN and what kind. +fn detect_vpn_kind( + settings: &HashMap>>, +) -> Option { + let conn = settings.get("connection")?; + let conn_type = match conn.get("type")? { + zvariant::Value::Str(s) => s.as_str(), + _ => return None, + }; + + match conn_type { + "wireguard" => Some(VpnKind::WireGuard), + "vpn" => Some(VpnKind::Plugin), + _ => None, + } +} + +/// Extracts a string from a `Dict` (vpn.data / vpn.secrets) by key. +fn dict_str(dict: &zvariant::Dict<'_, '_>, key: &str) -> Option { + dict.iter().find_map(|(k, v)| match (k, v) { + (zvariant::Value::Str(k_str), zvariant::Value::Str(v_str)) if k_str.as_str() == key => { + Some(v_str.to_string()) + } + _ => None, + }) +} + +/// Converts a full `Dict` to `HashMap`. +fn dict_to_map(dict: &zvariant::Dict<'_, '_>) -> HashMap { + dict.iter() + .filter_map(|(k, v)| match (k, v) { + (zvariant::Value::Str(k_str), zvariant::Value::Str(v_str)) => { + Some((k_str.to_string(), v_str.to_string())) + } + _ => None, + }) + .collect() +} + +/// Decodes a [`VpnType`] from raw NM settings dictionaries. /// -/// WireGuard activations do not require binding to an underlying device. -/// Use "/" so NetworkManager auto-selects. -pub(crate) async fn connect_vpn( +/// Pure function — no D-Bus calls. +pub(crate) fn vpn_type_from_settings( + kind: VpnKind, + settings: &HashMap>>, +) -> VpnType { + match kind { + VpnKind::WireGuard => decode_wireguard_type(settings), + VpnKind::Plugin => decode_plugin_type(settings), + } +} + +fn decode_wireguard_type( + settings: &HashMap>>, +) -> VpnType { + let wg = settings.get("wireguard"); + + let private_key = wg.and_then(|s| s.get("private-key")).and_then(|v| match v { + zvariant::Value::Str(s) if !s.is_empty() => Some(s.to_string()), + _ => None, + }); + + let (peer_public_key, endpoint, allowed_ips, persistent_keepalive) = + if let Some(peers_val) = wg.and_then(|s| s.get("peers")) { + decode_wg_first_peer(peers_val) + } else { + (None, None, vec![], None) + }; + + VpnType::WireGuard { + private_key, + peer_public_key, + endpoint, + allowed_ips, + persistent_keepalive, + } +} + +fn decode_wg_first_peer( + peers_val: &zvariant::Value<'_>, +) -> (Option, Option, Vec, Option) { + match peers_val { + zvariant::Value::Str(s) => { + let text = s.as_str(); + let first = text.split(',').next().unwrap_or(text).trim(); + let mut pk = None; + let mut ep = None; + let mut ips = vec![]; + let mut ka = None; + for tok in first.split_whitespace() { + if let Some(v) = tok.strip_prefix("public-key=") { + pk = Some(v.to_string()); + } else if let Some(v) = tok.strip_prefix("endpoint=") { + ep = Some(v.to_string()); + } else if let Some(v) = tok.strip_prefix("allowed-ips=") { + ips = v.split(';').map(|s| s.trim().to_string()).collect(); + } else if let Some(v) = tok.strip_prefix("persistent-keepalive=") { + ka = v.parse().ok(); + } + } + (pk, ep, ips, ka) + } + zvariant::Value::Array(arr) => { + if let Some(zvariant::Value::Dict(dict)) = arr.first() { + let pk = dict_str(dict, "public-key"); + let ep = dict_str(dict, "endpoint"); + let ips = dict_str(dict, "allowed-ips") + .map(|s| s.split(';').map(|p| p.trim().to_string()).collect()) + .unwrap_or_default(); + let ka = dict_str(dict, "persistent-keepalive").and_then(|s| s.parse().ok()); + return (pk, ep, ips, ka); + } + (None, None, vec![], None) + } + _ => (None, None, vec![], None), + } +} + +fn decode_plugin_type(settings: &HashMap>>) -> VpnType { + let vpn_sec = match settings.get("vpn") { + Some(s) => s, + None => { + return VpnType::Generic { + service_type: String::new(), + data: HashMap::new(), + secrets: HashMap::new(), + user_name: None, + password_flags: VpnSecretFlags::default(), + }; + } + }; + + let service_type = vpn_sec + .get("service-type") + .and_then(|v| match v { + zvariant::Value::Str(s) => Some(s.to_string()), + _ => None, + }) + .unwrap_or_default(); + + let user_name = vpn_sec.get("user-name").and_then(|v| match v { + zvariant::Value::Str(s) if !s.is_empty() => Some(s.to_string()), + _ => None, + }); + + let pf_raw = vpn_sec + .get("password-flags") + .and_then(|v| match v { + zvariant::Value::U32(n) => Some(*n), + _ => None, + }) + .unwrap_or(0); + let password_flags = VpnSecretFlags(pf_raw); + + let data_dict = vpn_sec.get("data"); + let secrets_dict = vpn_sec.get("secrets"); + + if service_type.ends_with(".openvpn") { + return decode_openvpn(data_dict, user_name, password_flags); + } + if service_type.ends_with(".openconnect") { + return decode_openconnect(data_dict, user_name, password_flags); + } + if service_type.ends_with(".strongswan") { + return decode_strongswan(data_dict, user_name, password_flags); + } + if service_type.ends_with(".pptp") { + return decode_pptp(data_dict, user_name, password_flags); + } + if service_type.ends_with(".l2tp") { + return decode_l2tp(data_dict, user_name, password_flags); + } + + let data = data_dict + .and_then(|v| match v { + zvariant::Value::Dict(d) => Some(dict_to_map(d)), + _ => None, + }) + .unwrap_or_default(); + let secrets = secrets_dict + .and_then(|v| match v { + zvariant::Value::Dict(d) => Some(dict_to_map(d)), + _ => None, + }) + .unwrap_or_default(); + + VpnType::Generic { + service_type, + data, + secrets, + user_name, + password_flags, + } +} + +fn data_str(data_dict: Option<&zvariant::Value<'_>>, key: &str) -> Option { + match data_dict? { + zvariant::Value::Dict(d) => dict_str(d, key), + _ => None, + } +} + +fn data_pf(data_dict: Option<&zvariant::Value<'_>>, key: &str) -> VpnSecretFlags { + VpnSecretFlags( + data_str(data_dict, key) + .and_then(|s| s.parse().ok()) + .unwrap_or(0), + ) +} + +fn decode_openvpn( + data_dict: Option<&zvariant::Value<'_>>, + user_name: Option, + _section_pf: VpnSecretFlags, +) -> VpnType { + let remote = data_str(data_dict, "remote"); + let ct = + data_str(data_dict, "connection-type").and_then(|s| OpenVpnConnectionType::from_nm_str(&s)); + let un = data_str(data_dict, "username").or(user_name); + let ca = data_str(data_dict, "ca"); + let cert = data_str(data_dict, "cert"); + let key = data_str(data_dict, "key"); + let ta = data_str(data_dict, "ta"); + let pf = data_pf(data_dict, "password-flags"); + + VpnType::OpenVpn { + remote, + connection_type: ct, + user_name: un, + ca, + cert, + key, + ta, + password_flags: pf, + } +} + +fn decode_openconnect( + data_dict: Option<&zvariant::Value<'_>>, + user_name: Option, + password_flags: VpnSecretFlags, +) -> VpnType { + VpnType::OpenConnect { + gateway: data_str(data_dict, "gateway"), + user_name: data_str(data_dict, "username").or(user_name), + protocol: data_str(data_dict, "protocol"), + password_flags, + } +} + +fn decode_strongswan( + data_dict: Option<&zvariant::Value<'_>>, + user_name: Option, + password_flags: VpnSecretFlags, +) -> VpnType { + VpnType::StrongSwan { + address: data_str(data_dict, "address"), + method: data_str(data_dict, "method"), + user_name: data_str(data_dict, "user").or(user_name), + certificate: data_str(data_dict, "certificate"), + password_flags, + } +} + +fn decode_pptp( + data_dict: Option<&zvariant::Value<'_>>, + user_name: Option, + password_flags: VpnSecretFlags, +) -> VpnType { + VpnType::Pptp { + gateway: data_str(data_dict, "gateway"), + user_name: data_str(data_dict, "user").or(user_name), + password_flags, + } +} + +fn decode_l2tp( + data_dict: Option<&zvariant::Value<'_>>, + user_name: Option, + password_flags: VpnSecretFlags, +) -> VpnType { + let ipsec = data_str(data_dict, "ipsec-enabled") + .map(|v| v == "yes" || v == "true" || v == "1") + .unwrap_or(false); + VpnType::L2tp { + gateway: data_str(data_dict, "gateway"), + user_name: data_str(data_dict, "user").or(user_name), + password_flags, + ipsec_enabled: ipsec, + } +} + +/// Extracts `connection.uuid` from settings. +fn extract_uuid( + settings: &HashMap>>, +) -> Option { + settings + .get("connection")? + .get("uuid") + .and_then(|v| match v { + zvariant::Value::Str(s) => Some(s.to_string()), + _ => None, + }) +} + +/// Extracts `connection.id` from settings. +fn extract_id(settings: &HashMap>>) -> Option { + settings.get("connection")?.get("id").and_then(|v| match v { + zvariant::Value::Str(s) => Some(s.to_string()), + _ => None, + }) +} + +/// Extracts `vpn.service-type` or returns empty string for WireGuard. +fn extract_service_type( + kind: VpnKind, + settings: &HashMap>>, +) -> String { + if kind == VpnKind::WireGuard { + return String::new(); + } + settings + .get("vpn") + .and_then(|s| s.get("service-type")) + .and_then(|v| match v { + zvariant::Value::Str(s) => Some(s.to_string()), + _ => None, + }) + .unwrap_or_default() +} + +fn extract_vpn_user_name( + settings: &HashMap>>, +) -> Option { + settings.get("vpn")?.get("user-name").and_then(|v| match v { + zvariant::Value::Str(s) if !s.is_empty() => Some(s.to_string()), + _ => None, + }) +} + +fn extract_password_flags( + settings: &HashMap>>, +) -> VpnSecretFlags { + let raw = settings + .get("vpn") + .and_then(|s| s.get("password-flags")) + .and_then(|v| match v { + zvariant::Value::U32(n) => Some(*n), + _ => None, + }) + .unwrap_or(0); + VpnSecretFlags(raw) +} + +// ── Public core functions ────────────────────────────────────────────── + +/// Lists all saved VPN connections with rich metadata. +pub(crate) async fn list_vpn_connections(conn: &Connection) -> Result> { + let nm = NMProxy::new(conn).await?; + + let settings_proxy = nm_proxy( + conn, + "/org/freedesktop/NetworkManager/Settings", + "org.freedesktop.NetworkManager.Settings", + ) + .await?; + + let list_reply = settings_proxy + .call_method("ListConnections", &()) + .await + .map_err(|e| ConnectionError::DbusOperation { + context: "failed to list saved connections".to_string(), + source: e, + })?; + + let saved_paths: Vec = list_reply.body().deserialize()?; + + let active_map = build_active_vpn_map(conn, &nm).await; + + let mut vpn_conns = Vec::new(); + + for cpath in saved_paths { + let cproxy = match nm_proxy( + conn, + cpath.clone(), + "org.freedesktop.NetworkManager.Settings.Connection", + ) + .await + { + Ok(p) => p, + Err(_) => continue, + }; + + let msg = match cproxy.call_method("GetSettings", &()).await { + Ok(m) => m, + Err(_) => continue, + }; + + let body = msg.body(); + let settings_map: HashMap> = + match body.deserialize() { + Ok(m) => m, + Err(_) => continue, + }; + + let Some(kind) = detect_vpn_kind(&settings_map) else { + continue; + }; + + let Some(uuid) = extract_uuid(&settings_map) else { + continue; + }; + let id = extract_id(&settings_map).unwrap_or_default(); + + let vpn_type = vpn_type_from_settings(kind, &settings_map); + let service_type = extract_service_type(kind, &settings_map); + let user_name = extract_vpn_user_name(&settings_map); + let password_flags = extract_password_flags(&settings_map); + + let (state, interface, active) = + active_map + .get(&uuid) + .cloned() + .unwrap_or((DeviceState::Other(0), None, false)); + + vpn_conns.push(VpnConnection { + uuid, + id: id.clone(), + name: id, + vpn_type, + state, + interface, + active, + user_name, + password_flags, + service_type, + kind, + }); + } + + Ok(vpn_conns) +} + +/// Only active VPN connections. +pub(crate) async fn active_vpn_connections(conn: &Connection) -> Result> { + let all = list_vpn_connections(conn).await?; + Ok(all.into_iter().filter(|v| v.active).collect()) +} + +/// Builds uuid → (state, interface, active) map from NM active connections. +async fn build_active_vpn_map( conn: &Connection, - creds: VpnCredentials, + nm: &NMProxy<'_>, +) -> HashMap, bool)> { + let mut map = HashMap::new(); + + let active_conns = match nm.active_connections().await { + Ok(c) => c, + Err(_) => return map, + }; + + for ac_path in active_conns { + let ac_proxy = match nm_proxy( + conn, + ac_path.clone(), + "org.freedesktop.NetworkManager.Connection.Active", + ) + .await + { + Ok(p) => p, + Err(_) => continue, + }; + + let uuid: String = match ac_proxy.get_property("Uuid").await { + Ok(u) => u, + Err(_) => continue, + }; + + let conn_type: String = match ac_proxy.get_property("Type").await { + Ok(t) => t, + Err(_) => continue, + }; + + if conn_type != "vpn" && conn_type != "wireguard" { + continue; + } + + let state = ac_proxy + .get_property::("State") + .await + .map(DeviceState::from) + .unwrap_or(DeviceState::Other(0)); + + let interface = ac_proxy + .get_property::>("Devices") + .await + .ok() + .and_then(|devs| devs.first().cloned()) + .and_then(|dev_path| { + futures::executor::block_on(async { + let dp = nm_proxy(conn, dev_path, "org.freedesktop.NetworkManager.Device") + .await + .ok()?; + dp.get_property::("Interface").await.ok() + }) + }); + + map.insert(uuid, (state, interface, true)); + } + + map +} + +/// Activate a saved VPN by UUID. +pub(crate) async fn connect_vpn_by_uuid( + conn: &Connection, + uuid: &str, timeout_config: Option, ) -> Result<()> { - // Validate VPN credentials before attempting connection - validate_vpn_credentials(&creds)?; + let nm = NMProxy::new(conn).await?; + + let settings_proxy = nm_proxy( + conn, + "/org/freedesktop/NetworkManager/Settings", + "org.freedesktop.NetworkManager.Settings", + ) + .await?; + + let reply = settings_proxy + .call_method("GetConnectionByUuid", &(uuid,)) + .await + .map_err(|_| ConnectionError::VpnNotFound(uuid.to_string()))?; + + let conn_path: OwnedObjectPath = reply.body().deserialize()?; + + let active_conn = nm + .activate_connection( + conn_path, + OwnedObjectPath::default(), + OwnedObjectPath::default(), + ) + .await?; + + let timeout = timeout_config.map(|c| c.connection_timeout); + wait_for_connection_activation(conn, &active_conn, timeout).await +} + +/// Activate a saved VPN by connection id (display name). +pub(crate) async fn connect_vpn_by_id( + conn: &Connection, + id: &str, + timeout_config: Option, +) -> Result<()> { + let all = list_vpn_connections(conn).await?; + let matches: Vec<_> = all.iter().filter(|v| v.id == id).collect(); + + match matches.len() { + 0 => Err(ConnectionError::VpnNotFound(id.to_string())), + 1 => connect_vpn_by_uuid(conn, &matches[0].uuid, timeout_config).await, + _ => Err(ConnectionError::VpnIdAmbiguous(id.to_string())), + } +} + +/// Disconnect a VPN by UUID. +pub(crate) async fn disconnect_vpn_by_uuid(conn: &Connection, uuid: &str) -> Result<()> { + let nm = NMProxy::new(conn).await?; + let active_conns = nm.active_connections().await.unwrap_or_default(); + + for ac_path in active_conns { + let ac_proxy = match nm_proxy( + conn, + ac_path.clone(), + "org.freedesktop.NetworkManager.Connection.Active", + ) + .await + { + Ok(p) => p, + Err(_) => continue, + }; + + let ac_uuid: String = match ac_proxy.get_property("Uuid").await { + Ok(u) => u, + Err(_) => continue, + }; + + if ac_uuid == uuid { + nm.deactivate_connection(ac_path).await?; + return Ok(()); + } + } - debug!("Connecting to VPN: {}", creds.name); + Ok(()) +} + +/// Connects to a VPN (WireGuard or OpenVPN) from configuration. +pub(crate) async fn connect_vpn( + conn: &Connection, + config: VpnConfiguration, + timeout_config: Option, +) -> Result<()> { + let name = config.name().to_string(); + debug!("Connecting to VPN: {}", name); let nm = NMProxy::new(conn).await?; - // Check saved connections - let saved = - crate::core::connection_settings::get_saved_connection_path(conn, &creds.name).await?; + let saved = crate::core::connection_settings::get_saved_connection_path(conn, &name).await?; - // For WireGuard activation, always use "/" as device path - NetworkManager will auto-select let vpn_device_path = OwnedObjectPath::default(); let specific_object = OwnedObjectPath::default(); @@ -68,7 +648,17 @@ pub(crate) async fn connect_vpn( autoconnect_retries: None, }; - let settings = build_wireguard_connection(&creds, &opts)?; + let settings = match config { + VpnConfiguration::WireGuard(ref wg) => { + let creds: VpnCredentials = wg.clone().into(); + validate_vpn_credentials(&creds)?; + build_wireguard_connection(&creds, &opts)? + } + VpnConfiguration::OpenVpn(ref ovpn) => { + validate_openvpn_config(ovpn)?; + build_openvpn_connection(ovpn, &opts)? + } + }; let settings_api = settings_proxy(conn).await?; @@ -96,7 +686,7 @@ pub(crate) async fn connect_vpn( match state { crate::api::models::ActiveConnectionState::Activated => { - info!("Successfully connected to VPN: {}", creds.name); + info!("Successfully connected to VPN: {}", name); Ok(()) } crate::api::models::ActiveConnectionState::Deactivated => { @@ -110,182 +700,40 @@ pub(crate) async fn connect_vpn( warn!("Connection in unexpected state: {:?}", state); Err(crate::api::models::ConnectionError::Stuck(format!( "connection in state {:?}", - state - ))) - } - } - } - Err(e) => { - warn!("Failed to build active connection proxy after delay: {}", e); - let reason = extract_connection_state_reason(conn, &active_conn).await; - Err(crate::api::models::ConnectionError::ActivationFailed( - reason, - )) - } - }, - Err(e) => { - warn!( - "Failed to create active connection proxy builder after delay: {}", - e - ); - let reason = extract_connection_state_reason(conn, &active_conn).await; - Err(crate::api::models::ConnectionError::ActivationFailed( - reason, - )) - } - } -} - -/// Disconnects from a connection by name. -/// -/// Searches through active connections for a WireGuard connection matching the given name. -/// If found, deactivates the connection. If not found, assumes already -/// disconnected and returns success. -pub(crate) async fn disconnect_vpn(conn: &Connection, name: &str) -> Result<()> { - // Validate connection name - validate_connection_name(name)?; - - debug!("Disconnecting VPN: {name}"); - - let nm = NMProxy::new(conn).await?; - let active_conns = match nm.active_connections().await { - Ok(conns) => conns, - Err(e) => { - debug!("Failed to get active connections: {}", e); - info!("Disconnected VPN: {name} (could not verify active state)"); - return Ok(()); - } - }; - - for ac_path in active_conns { - let ac_proxy = match nm_proxy( - conn, - ac_path.clone(), - "org.freedesktop.NetworkManager.Connection.Active", - ) - .await - { - Ok(p) => p, - Err(e) => { - warn!( - "Failed to create proxy for active connection {}: {}", - ac_path, e - ); - continue; - } - }; - - let conn_path: OwnedObjectPath = match ac_proxy.call_method("Connection", &()).await { - Ok(msg) => match msg.body().deserialize::() { - Ok(path) => path, - Err(e) => { - warn!( - "Failed to deserialize connection path for {}: {}", - ac_path, e - ); - continue; - } - }, - Err(e) => { - warn!("Failed to get Connection property from {}: {}", ac_path, e); - continue; - } - }; - - let cproxy = match nm_proxy( - conn, - conn_path.clone(), - "org.freedesktop.NetworkManager.Settings.Connection", - ) - .await - { - Ok(p) => p, - Err(e) => { - warn!( - "Failed to create proxy for connection settings {}: {}", - conn_path, e - ); - continue; - } - }; - - let msg = match cproxy.call_method("GetSettings", &()).await { - Ok(msg) => msg, - Err(e) => { - warn!("Failed to get settings for connection {}: {}", conn_path, e); - continue; - } - }; - - let body = msg.body(); - let settings_map: HashMap> = - match body.deserialize() { - Ok(map) => map, - Err(e) => { - warn!("Failed to deserialize settings for {}: {}", conn_path, e); - continue; - } - }; - - if let Some(conn_sec) = settings_map.get("connection") { - let id_match = conn_sec - .get("id") - .and_then(|v| match v { - zvariant::Value::Str(s) => Some(s.as_str() == name), - _ => None, - }) - .unwrap_or(false); - - let is_wireguard = conn_sec - .get("type") - .and_then(|v| match v { - zvariant::Value::Str(s) => Some(s.as_str() == "wireguard"), - _ => None, - }) - .unwrap_or(false); - - if id_match && is_wireguard { - debug!("Found active WireGuard connection, deactivating: {name}"); - match nm.deactivate_connection(ac_path.clone()).await { - Ok(_) => info!("Successfully disconnected VPN: {name}"), - Err(e) => warn!("Failed to deactivate connection {}: {}", ac_path, e), + state + ))) + } } - return Ok(()); } + Err(e) => { + warn!("Failed to build active connection proxy after delay: {}", e); + let reason = extract_connection_state_reason(conn, &active_conn).await; + Err(crate::api::models::ConnectionError::ActivationFailed( + reason, + )) + } + }, + Err(e) => { + warn!( + "Failed to create active connection proxy builder after delay: {}", + e + ); + let reason = extract_connection_state_reason(conn, &active_conn).await; + Err(crate::api::models::ConnectionError::ActivationFailed( + reason, + )) } } - - info!("Disconnected VPN: {name} (not active)"); - Ok(()) } -/// Lists all saved WireGuard connections with their current state. -/// -/// Returns connections where `connection.type == "wireguard"`. -/// For active connections, populates `state` and `interface` from the active connection. -pub(crate) async fn list_vpn_connections(conn: &Connection) -> Result> { - let nm = NMProxy::new(conn).await?; - - let settings = nm_proxy( - conn, - "/org/freedesktop/NetworkManager/Settings", - "org.freedesktop.NetworkManager.Settings", - ) - .await?; - - let list_reply = settings - .call_method("ListConnections", &()) - .await - .map_err(|e| ConnectionError::DbusOperation { - context: "failed to list saved connections".to_string(), - source: e, - })?; +/// Disconnects from a VPN connection by name (legacy — prefer `disconnect_vpn_by_uuid`). +pub(crate) async fn disconnect_vpn(conn: &Connection, name: &str) -> Result<()> { + validate_connection_name(name)?; - let saved_conns: Vec = list_reply.body().deserialize()?; + debug!("Disconnecting VPN: {name}"); - // Map active WireGuard connection id -> (state, interface) + let nm = NMProxy::new(conn).await?; let active_conns = nm.active_connections().await?; - let mut active_wg_map: HashMap)> = HashMap::new(); for ac_path in active_conns { let ac_proxy = match nm_proxy( @@ -296,213 +744,56 @@ pub(crate) async fn list_vpn_connections(conn: &Connection) -> Result p, - Err(e) => { - warn!( - "Failed to create proxy for active connection {}: {}", - ac_path, e - ); - continue; - } - }; - - let conn_path: OwnedObjectPath = match ac_proxy.call_method("Connection", &()).await { - Ok(msg) => match msg.body().deserialize::() { - Ok(p) => p, - Err(e) => { - warn!( - "Failed to deserialize connection path for {}: {}", - ac_path, e - ); - continue; - } - }, - Err(e) => { - warn!("Failed to get Connection property from {}: {}", ac_path, e); - continue; - } + Err(_) => continue, }; - let cproxy = match nm_proxy( - conn, - conn_path.clone(), - "org.freedesktop.NetworkManager.Settings.Connection", - ) - .await - { + let conn_path: OwnedObjectPath = match ac_proxy.get_property("Connection").await { Ok(p) => p, - Err(e) => { - warn!( - "Failed to create proxy for connection settings {}: {}", - conn_path, e - ); - continue; - } - }; - - let msg = match cproxy.call_method("GetSettings", &()).await { - Ok(m) => m, - Err(e) => { - warn!("Failed to get settings for connection {}: {}", conn_path, e); - continue; - } - }; - - let body = msg.body(); - let settings_map: HashMap> = - match body.deserialize() { - Ok(m) => m, - Err(e) => { - warn!("Failed to deserialize settings for {}: {}", conn_path, e); - continue; - } - }; - - let conn_sec = match settings_map.get("connection") { - Some(s) => s, - None => continue, - }; - - let id = match conn_sec.get("id") { - Some(zvariant::Value::Str(s)) => s.as_str().to_string(), - _ => continue, - }; - - let conn_type = match conn_sec.get("type") { - Some(zvariant::Value::Str(s)) => s.as_str(), - _ => continue, - }; - - if conn_type != "wireguard" { - continue; - } - - let state = if let Ok(state_val) = ac_proxy.get_property::("State").await { - DeviceState::from(state_val) - } else { - DeviceState::Other(0) - }; - - let interface = if let Ok(dev_paths) = ac_proxy - .get_property::>("Devices") - .await - { - if let Some(dev_path) = dev_paths.first() { - match nm_proxy( - conn, - dev_path.clone(), - "org.freedesktop.NetworkManager.Device", - ) - .await - { - Ok(dev_proxy) => match dev_proxy.get_property::("Interface").await { - Ok(iface) => Some(iface), - Err(e) => { - debug!( - "Failed to get interface name for VPN device {}: {}", - dev_path, e - ); - None - } - }, - Err(e) => { - debug!("Failed to create device proxy for {}: {}", dev_path, e); - None - } - } - } else { - None - } - } else { - None + Err(_) => continue, }; - active_wg_map.insert(id, (state, interface)); - } - - let mut wg_conns = Vec::new(); - - for cpath in saved_conns { let cproxy = match nm_proxy( conn, - cpath.clone(), + conn_path.clone(), "org.freedesktop.NetworkManager.Settings.Connection", ) .await { Ok(p) => p, - Err(e) => { - warn!( - "Failed to create proxy for saved connection {}: {}", - cpath, e - ); - continue; - } + Err(_) => continue, }; let msg = match cproxy.call_method("GetSettings", &()).await { - Ok(m) => m, - Err(e) => { - warn!( - "Failed to get settings for saved connection {}: {}", - cpath, e - ); - continue; - } + Ok(msg) => msg, + Err(_) => continue, }; let body = msg.body(); let settings_map: HashMap> = match body.deserialize() { - Ok(m) => m, - Err(e) => { - warn!( - "Failed to deserialize settings for saved connection {}: {}", - cpath, e - ); - continue; - } + Ok(map) => map, + Err(_) => continue, }; - let conn_sec = match settings_map.get("connection") { - Some(s) => s, - None => continue, - }; - - let id = match conn_sec.get("id") { - Some(zvariant::Value::Str(s)) => s.as_str().to_string(), - _ => continue, - }; - - let conn_type = match conn_sec.get("type") { - Some(zvariant::Value::Str(s)) => s.as_str(), - _ => continue, - }; + let id_match = extract_id(&settings_map) + .map(|id| id == name) + .unwrap_or(false); + let is_vpn = detect_vpn_kind(&settings_map).is_some(); - if conn_type != "wireguard" { - continue; + if id_match && is_vpn { + debug!("Found active VPN connection, deactivating: {name}"); + nm.deactivate_connection(ac_path.clone()).await?; + info!("Successfully disconnected VPN: {name}"); + return Ok(()); } - - let (state, interface) = active_wg_map - .get(&id) - .cloned() - .unwrap_or((DeviceState::Other(0), None)); - - wg_conns.push(VpnConnection { - name: id, - vpn_type: VpnType::WireGuard, - interface, - state, - }); } - Ok(wg_conns) + info!("Disconnected VPN: {name} (not active)"); + Ok(()) } -/// Forgets (deletes) a saved WireGuard connection by name. -/// -/// If currently connected, the connection will be disconnected first before deletion. +/// Forgets (deletes) a saved VPN connection by name. pub(crate) async fn forget_vpn(conn: &Connection, name: &str) -> Result<()> { - // Validate connection name validate_connection_name(name)?; debug!("Starting forget operation for VPN: {name}"); @@ -534,51 +825,38 @@ pub(crate) async fn forget_vpn(conn: &Connection, name: &str) -> Result<()> { .await { Ok(p) => p, - Err(e) => { - warn!("Failed to create proxy for connection {}: {}", cpath, e); - continue; - } + Err(_) => continue, }; let msg = match cproxy.call_method("GetSettings", &()).await { Ok(msg) => msg, - Err(e) => { - warn!("Failed to get settings for connection {}: {}", cpath, e); - continue; - } + Err(_) => continue, }; let body = msg.body(); let settings_map: HashMap> = body.deserialize()?; - if let Some(conn_sec) = settings_map.get("connection") { - let id_ok = conn_sec - .get("id") - .and_then(|v| match v { - zvariant::Value::Str(s) => Some(s.as_str() == name), - _ => None, - }) - .unwrap_or(false); + let id_ok = extract_id(&settings_map) + .map(|id| id == name) + .unwrap_or(false); + let vpn_kind = detect_vpn_kind(&settings_map); + + if id_ok && vpn_kind.is_some() { + debug!("Found VPN connection, deleting: {name}"); + cproxy.call_method("Delete", &()).await.map_err(|e| { + ConnectionError::DbusOperation { + context: format!("failed to delete VPN connection '{}'", name), + source: e, + } + })?; + info!("Successfully deleted VPN connection: {name}"); - let type_ok = conn_sec - .get("type") - .and_then(|v| match v { - zvariant::Value::Str(s) => Some(s.as_str() == "wireguard"), - _ => None, - }) - .unwrap_or(false); - - if id_ok && type_ok { - debug!("Found WireGuard connection, deleting: {name}"); - cproxy.call_method("Delete", &()).await.map_err(|e| { - ConnectionError::DbusOperation { - context: format!("failed to delete VPN connection '{}'", name), - source: e, - } - })?; - info!("Successfully deleted VPN connection: {name}"); - return Ok(()); + if vpn_kind == Some(VpnKind::Plugin) + && let Err(e) = crate::util::cert_store::cleanup_certs(name) + { + warn!("Failed to remove nmrs cert directory for '{}': {}", name, e); } + return Ok(()); } } @@ -586,11 +864,8 @@ pub(crate) async fn forget_vpn(conn: &Connection, name: &str) -> Result<()> { Ok(()) } -/// Gets detailed information about a WireGuard connection. -/// -/// The connection must be in the active connections list to retrieve full details. +/// Gets detailed information about an active VPN connection. pub(crate) async fn get_vpn_info(conn: &Connection, name: &str) -> Result { - // Validate connection name validate_connection_name(name)?; let nm = NMProxy::new(conn).await?; @@ -605,30 +880,12 @@ pub(crate) async fn get_vpn_info(conn: &Connection, name: &str) -> Result p, - Err(e) => { - warn!( - "Failed to create proxy for active connection {}: {}", - ac_path, e - ); - continue; - } + Err(_) => continue, }; - let conn_path: OwnedObjectPath = match ac_proxy.call_method("Connection", &()).await { - Ok(msg) => match msg.body().deserialize::() { - Ok(p) => p, - Err(e) => { - warn!( - "Failed to deserialize connection path for {}: {}", - ac_path, e - ); - continue; - } - }, - Err(e) => { - warn!("Failed to get Connection property from {}: {}", ac_path, e); - continue; - } + let conn_path: OwnedObjectPath = match ac_proxy.get_property("Connection").await { + Ok(p) => p, + Err(_) => continue, }; let cproxy = match nm_proxy( @@ -639,60 +896,37 @@ pub(crate) async fn get_vpn_info(conn: &Connection, name: &str) -> Result p, - Err(e) => { - warn!( - "Failed to create proxy for connection settings {}: {}", - conn_path, e - ); - continue; - } + Err(_) => continue, }; let msg = match cproxy.call_method("GetSettings", &()).await { Ok(m) => m, - Err(e) => { - warn!("Failed to get settings for connection {}: {}", conn_path, e); - continue; - } + Err(_) => continue, }; let body = msg.body(); let settings_map: HashMap> = match body.deserialize() { Ok(m) => m, - Err(e) => { - warn!("Failed to deserialize settings for {}: {}", conn_path, e); - continue; - } + Err(_) => continue, }; - let conn_sec = match settings_map.get("connection") { - Some(s) => s, + let id = match extract_id(&settings_map) { + Some(i) => i, None => continue, }; - let id = match conn_sec.get("id") { - Some(zvariant::Value::Str(s)) => s.as_str(), - _ => continue, - }; - - let conn_type = match conn_sec.get("type") { - Some(zvariant::Value::Str(s)) => s.as_str(), - _ => continue, + let Some(kind) = detect_vpn_kind(&settings_map) else { + continue; }; - if conn_type != "wireguard" || id != name { + if id != name { continue; } - // WireGuard type is known by connection.type - let vpn_type = VpnType::WireGuard; - - // ActiveConnection state let state_val: u32 = ac_proxy.get_property("State").await?; let state = DeviceState::from(state_val); - // Device/interface let dev_paths: Vec = ac_proxy.get_property("Devices").await?; let interface = if let Some(dev_path) = dev_paths.first() { let dev_proxy = nm_proxy( @@ -706,27 +940,26 @@ pub(crate) async fn get_vpn_info(conn: &Connection, name: &str) -> Result Some(s.as_str().to_string()), - _ => None, - }) - .and_then(|peers| { - // peers: "pubkey endpoint=host:port allowed-ips=... , pubkey2 ..." - let first = peers.split(',').next()?.trim().to_string(); - for tok in first.split_whitespace() { - if let Some(rest) = tok.strip_prefix("endpoint=") { - return Some(rest.to_string()); + let gateway = match kind { + VpnKind::WireGuard => settings_map + .get("wireguard") + .and_then(|wg_sec| wg_sec.get("peers")) + .and_then(|v| match v { + zvariant::Value::Str(s) => Some(s.as_str().to_string()), + _ => None, + }) + .and_then(|peers| { + let first = peers.split(',').next()?.trim().to_string(); + for tok in first.split_whitespace() { + if let Some(rest) = tok.strip_prefix("endpoint=") { + return Some(rest.to_string()); + } } - } - None - }); + None + }), + VpnKind::Plugin => extract_openvpn_gateway(&settings_map), + }; - // IPv4 config let ip4_path: OwnedObjectPath = ac_proxy.get_property("Ip4Config").await?; let (ip4_address, dns_servers) = if ip4_path.as_str() != "/" { let ip4_proxy = @@ -774,7 +1007,6 @@ pub(crate) async fn get_vpn_info(conn: &Connection, name: &str) -> Result Result extract_wireguard_details(&settings_map), + VpnKind::Plugin => extract_openvpn_details(&settings_map), + }; + return Ok(VpnConnectionInfo { - name: id.to_string(), - vpn_type, + name: id, + vpn_kind: kind, state, interface, gateway, ip4_address, ip6_address, dns_servers, + details, }); } Err(crate::api::models::ConnectionError::NoVpnConnection) } + +fn extract_openvpn_gateway( + settings_map: &HashMap>>, +) -> Option { + let zvariant::Value::Dict(dict) = settings_map.get("vpn")?.get("data")? else { + return None; + }; + dict_str(dict, "remote") +} + +fn extract_openvpn_data_value( + settings_map: &HashMap>>, + key: &str, +) -> Option { + let zvariant::Value::Dict(dict) = settings_map.get("vpn")?.get("data")? else { + return None; + }; + dict_str(dict, key) +} + +fn extract_openvpn_details( + settings_map: &HashMap>>, +) -> Option { + let remote_raw = extract_openvpn_data_value(settings_map, "remote")?; + + let (remote, port) = if let Some(idx) = remote_raw.rfind(':') { + let host = remote_raw[..idx].to_string(); + let port = remote_raw[idx + 1..].parse::().unwrap_or(1194); + (host, port) + } else { + (remote_raw, 1194) + }; + + let protocol = + if extract_openvpn_data_value(settings_map, "proto-tcp").as_deref() == Some("yes") { + "tcp".to_string() + } else { + "udp".to_string() + }; + + let cipher = extract_openvpn_data_value(settings_map, "cipher"); + let auth = extract_openvpn_data_value(settings_map, "auth"); + + let compression = extract_openvpn_data_value(settings_map, "compress") + .or_else(|| extract_openvpn_data_value(settings_map, "comp-lzo").map(|_| "lzo".into())); + + Some(VpnDetails::OpenVpn { + remote, + port, + protocol, + cipher, + auth, + compression, + }) +} + +fn extract_wireguard_details( + settings_map: &HashMap>>, +) -> Option { + let wg_sec = settings_map.get("wireguard")?; + + let public_key = wg_sec.get("public-key").and_then(|v| match v { + zvariant::Value::Str(s) => Some(s.to_string()), + _ => None, + }); + + let endpoint = wg_sec + .get("peers") + .and_then(|v| match v { + zvariant::Value::Str(s) => Some(s.as_str().to_string()), + _ => None, + }) + .and_then(|peers| { + let first = peers.split(',').next()?.trim().to_string(); + for tok in first.split_whitespace() { + if let Some(rest) = tok.strip_prefix("endpoint=") { + return Some(rest.to_string()); + } + } + None + }); + + Some(VpnDetails::WireGuard { + public_key, + endpoint, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn openvpn_settings_with_data( + data: HashMap, + ) -> HashMap>> { + let dict = zvariant::Dict::from(data); + let vpn_sec = HashMap::from([("data".to_string(), zvariant::Value::Dict(dict))]); + HashMap::from([("vpn".to_string(), vpn_sec)]) + } + + fn vpn_settings_with_service( + service: &str, + data: HashMap, + ) -> HashMap>> { + let dict = zvariant::Dict::from(data); + let vpn_sec = HashMap::from([ + ( + "service-type".to_string(), + zvariant::Value::Str(service.to_string().into()), + ), + ("data".to_string(), zvariant::Value::Dict(dict)), + ]); + let conn_sec = HashMap::from([("type".to_string(), zvariant::Value::Str("vpn".into()))]); + HashMap::from([ + ("vpn".to_string(), vpn_sec), + ("connection".to_string(), conn_sec), + ]) + } + + #[test] + fn detect_wireguard() { + let conn_sec = + HashMap::from([("type".to_string(), zvariant::Value::Str("wireguard".into()))]); + let settings = HashMap::from([("connection".to_string(), conn_sec)]); + assert_eq!(detect_vpn_kind(&settings), Some(VpnKind::WireGuard)); + } + + #[test] + fn detect_plugin() { + let conn_sec = HashMap::from([("type".to_string(), zvariant::Value::Str("vpn".into()))]); + let settings = HashMap::from([("connection".to_string(), conn_sec)]); + assert_eq!(detect_vpn_kind(&settings), Some(VpnKind::Plugin)); + } + + #[test] + fn detect_non_vpn() { + let conn_sec = HashMap::from([( + "type".to_string(), + zvariant::Value::Str("802-11-wireless".into()), + )]); + let settings = HashMap::from([("connection".to_string(), conn_sec)]); + assert_eq!(detect_vpn_kind(&settings), None); + } + + #[test] + fn decode_openvpn_full() { + let data = HashMap::from([ + ("remote".to_string(), "vpn.example.com:1194".to_string()), + ("connection-type".to_string(), "password-tls".to_string()), + ("username".to_string(), "alice".to_string()), + ("ca".to_string(), "/etc/openvpn/ca.crt".to_string()), + ("password-flags".to_string(), "1".to_string()), + ]); + let settings = vpn_settings_with_service("org.freedesktop.NetworkManager.openvpn", data); + let vt = vpn_type_from_settings(VpnKind::Plugin, &settings); + match vt { + VpnType::OpenVpn { + remote, + connection_type, + user_name, + ca, + password_flags, + .. + } => { + assert_eq!(remote, Some("vpn.example.com:1194".into())); + assert_eq!(connection_type, Some(OpenVpnConnectionType::PasswordTls)); + assert_eq!(user_name, Some("alice".into())); + assert_eq!(ca, Some("/etc/openvpn/ca.crt".into())); + assert!(password_flags.agent_owned()); + } + _ => panic!("expected OpenVpn"), + } + } + + #[test] + fn decode_strongswan() { + let data = HashMap::from([ + ("address".to_string(), "ipsec.corp.com".to_string()), + ("method".to_string(), "eap".to_string()), + ("user".to_string(), "bob".to_string()), + ]); + let settings = vpn_settings_with_service("org.freedesktop.NetworkManager.strongswan", data); + let vt = vpn_type_from_settings(VpnKind::Plugin, &settings); + match vt { + VpnType::StrongSwan { + address, + method, + user_name, + .. + } => { + assert_eq!(address, Some("ipsec.corp.com".into())); + assert_eq!(method, Some("eap".into())); + assert_eq!(user_name, Some("bob".into())); + } + _ => panic!("expected StrongSwan"), + } + } + + #[test] + fn decode_l2tp_with_ipsec() { + let data = HashMap::from([ + ("gateway".to_string(), "l2tp.example.com".to_string()), + ("ipsec-enabled".to_string(), "yes".to_string()), + ]); + let settings = vpn_settings_with_service("org.freedesktop.NetworkManager.l2tp", data); + let vt = vpn_type_from_settings(VpnKind::Plugin, &settings); + match vt { + VpnType::L2tp { + gateway, + ipsec_enabled, + .. + } => { + assert_eq!(gateway, Some("l2tp.example.com".into())); + assert!(ipsec_enabled); + } + _ => panic!("expected L2tp"), + } + } + + #[test] + fn decode_generic_unknown_plugin() { + let data = HashMap::from([("server".to_string(), "my.server.com".to_string())]); + let settings = + vpn_settings_with_service("org.freedesktop.NetworkManager.my-custom-vpn", data); + let vt = vpn_type_from_settings(VpnKind::Plugin, &settings); + match vt { + VpnType::Generic { + service_type, data, .. + } => { + assert_eq!(service_type, "org.freedesktop.NetworkManager.my-custom-vpn"); + assert_eq!(data.get("server").unwrap(), "my.server.com"); + } + _ => panic!("expected Generic"), + } + } + + #[test] + fn openvpn_connection_type_roundtrip() { + for (s, expected) in [ + ("tls", OpenVpnConnectionType::Tls), + ("static-key", OpenVpnConnectionType::StaticKey), + ("password", OpenVpnConnectionType::Password), + ("password-tls", OpenVpnConnectionType::PasswordTls), + ] { + assert_eq!(OpenVpnConnectionType::from_nm_str(s), Some(expected)); + } + assert_eq!(OpenVpnConnectionType::from_nm_str("bogus"), None); + } + + #[test] + fn vpn_secret_flags_roundtrip() { + let f = VpnSecretFlags(0x3); + assert!(f.agent_owned()); + assert_eq!(f.0 & 0x2, 0x2); // NOT_SAVED + } + + #[test] + fn openvpn_gateway_extracted_from_vpn_data() { + let data = HashMap::from([("remote".to_string(), "vpn.example.com:1194".to_string())]); + let settings = openvpn_settings_with_data(data); + assert_eq!( + extract_openvpn_gateway(&settings), + Some("vpn.example.com:1194".to_string()) + ); + } + + #[test] + fn openvpn_gateway_none_when_remote_key_absent() { + let data = HashMap::from([("dev".to_string(), "tun".to_string())]); + let settings = openvpn_settings_with_data(data); + assert_eq!(extract_openvpn_gateway(&settings), None); + } + + #[test] + fn openvpn_gateway_none_when_vpn_section_absent() { + let settings: HashMap>> = + HashMap::from([("connection".to_string(), HashMap::new())]); + assert_eq!(extract_openvpn_gateway(&settings), None); + } + + #[test] + fn openvpn_details_full() { + let data = HashMap::from([ + ("remote".to_string(), "vpn.example.com:1194".to_string()), + ("proto-tcp".to_string(), "yes".to_string()), + ("cipher".to_string(), "AES-256-GCM".to_string()), + ("auth".to_string(), "SHA256".to_string()), + ("compress".to_string(), "lz4-v2".to_string()), + ]); + let settings = openvpn_settings_with_data(data); + let details = extract_openvpn_details(&settings).unwrap(); + match details { + VpnDetails::OpenVpn { + remote, + port, + protocol, + cipher, + auth, + compression, + } => { + assert_eq!(remote, "vpn.example.com"); + assert_eq!(port, 1194); + assert_eq!(protocol, "tcp"); + assert_eq!(cipher, Some("AES-256-GCM".into())); + assert_eq!(auth, Some("SHA256".into())); + assert_eq!(compression, Some("lz4-v2".into())); + } + _ => panic!("expected OpenVpn variant"), + } + } + + #[test] + fn openvpn_details_minimal() { + let data = HashMap::from([("remote".to_string(), "vpn.example.com:443".to_string())]); + let settings = openvpn_settings_with_data(data); + let details = extract_openvpn_details(&settings).unwrap(); + match details { + VpnDetails::OpenVpn { + remote, + port, + protocol, + cipher, + auth, + compression, + } => { + assert_eq!(remote, "vpn.example.com"); + assert_eq!(port, 443); + assert_eq!(protocol, "udp"); + assert!(cipher.is_none()); + assert!(auth.is_none()); + assert!(compression.is_none()); + } + _ => panic!("expected OpenVpn variant"), + } + } + + fn wireguard_settings( + pairs: Vec<(&str, zvariant::Value<'static>)>, + ) -> HashMap>> { + let wg_sec: HashMap> = + pairs.into_iter().map(|(k, v)| (k.to_string(), v)).collect(); + HashMap::from([("wireguard".to_string(), wg_sec)]) + } + + #[test] + fn wireguard_details_full() { + let settings = wireguard_settings(vec![ + ( + "public-key", + zvariant::Value::Str("YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=".into()), + ), + ( + "peers", + zvariant::Value::Str("endpoint=vpn.example.com:51820 allowed-ips=0.0.0.0/0".into()), + ), + ]); + let details = extract_wireguard_details(&settings).unwrap(); + match details { + VpnDetails::WireGuard { + public_key, + endpoint, + } => { + assert_eq!( + public_key, + Some("YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=".into()) + ); + assert_eq!(endpoint, Some("vpn.example.com:51820".into())); + } + _ => panic!("expected WireGuard variant"), + } + } +} diff --git a/nmrs/src/core/wifi_device.rs b/nmrs/src/core/wifi_device.rs new file mode 100644 index 00000000..20a7eccd --- /dev/null +++ b/nmrs/src/core/wifi_device.rs @@ -0,0 +1,131 @@ +//! Per-Wi-Fi-device enumeration and control. +//! +//! NetworkManager's global `WirelessEnabled` flag toggles every Wi-Fi radio +//! at once. To disable one specific Wi-Fi device while leaving the others +//! online, we set `Device.Autoconnect = false` and then call +//! `Device.Disconnect()` — the kernel hands the radio back to NM but no +//! connection will be re-activated until autoconnect is re-enabled. + +use log::{debug, warn}; +use zbus::Connection; + +use crate::Result; +use crate::api::models::{ConnectionError, WifiDevice}; +use crate::core::connection::{disconnect_wifi_and_wait, get_device_by_interface}; +use crate::dbus::{NMAccessPointProxy, NMDeviceProxy, NMProxy, NMWirelessProxy}; +use crate::types::constants::device_type; +use crate::util::utils::decode_ssid_or_hidden; + +/// Lists every managed Wi-Fi device with current MAC, state, and active SSID. +pub(crate) async fn list_wifi_devices(conn: &Connection) -> Result> { + let nm = NMProxy::new(conn).await?; + let paths = nm.get_devices().await?; + + let mut out = Vec::new(); + for p in paths { + let dev = NMDeviceProxy::builder(conn) + .path(p.clone())? + .build() + .await?; + if dev.device_type().await? != device_type::WIFI { + continue; + } + + let interface = dev.interface().await.unwrap_or_default(); + let hw_address = dev + .hw_address() + .await + .unwrap_or_else(|_| String::from("00:00:00:00:00:00")); + let permanent_hw_address = dev.perm_hw_address().await.ok(); + let driver = dev.driver().await.ok(); + let state = dev.state().await?.into(); + let managed = dev.managed().await.unwrap_or(false); + let autoconnect = dev.autoconnect().await.unwrap_or(true); + + let wifi = NMWirelessProxy::builder(conn) + .path(p.clone())? + .build() + .await?; + let active_ap_path = wifi.active_access_point().await.ok(); + let (is_active, active_ssid) = match active_ap_path { + Some(ap_path) if ap_path.as_str() != "/" => { + match NMAccessPointProxy::builder(conn) + .path(ap_path)? + .build() + .await + { + Ok(ap) => match ap.ssid().await { + Ok(bytes) => (true, Some(decode_ssid_or_hidden(&bytes).into_owned())), + Err(_) => (true, None), + }, + Err(_) => (true, None), + } + } + _ => (false, None), + }; + + out.push(WifiDevice { + path: p, + interface, + hw_address, + permanent_hw_address, + driver, + state, + managed, + autoconnect, + is_active, + active_ssid, + }); + } + + Ok(out) +} + +/// Disable or re-enable a single Wi-Fi device. +/// +/// `enabled = false` clears `Device.Autoconnect` and disconnects the device. +/// `enabled = true` re-enables autoconnect; NM will activate any saved +/// connection on its own. +/// +/// This is independent of NetworkManager's global `WirelessEnabled` killswitch +/// (controlled via [`crate::NetworkManager::set_wireless_enabled`]). +pub(crate) async fn set_wifi_enabled_for_interface( + conn: &Connection, + interface: &str, + enabled: bool, +) -> Result<()> { + let path = match get_device_by_interface(conn, interface).await { + Ok(p) => p, + Err(ConnectionError::NotFound) => { + return Err(ConnectionError::WifiInterfaceNotFound { + interface: interface.to_string(), + }); + } + Err(e) => return Err(e), + }; + + let dev = NMDeviceProxy::builder(conn) + .path(path.clone())? + .build() + .await?; + if dev.device_type().await? != device_type::WIFI { + return Err(ConnectionError::NotAWifiDevice { + interface: interface.to_string(), + }); + } + + debug!("setting Autoconnect={} for {}", enabled, interface); + if let Err(e) = dev.set_autoconnect(enabled).await { + warn!("failed to set autoconnect on {}: {}", interface, e); + return Err(ConnectionError::DbusOperation { + context: format!("failed to set Autoconnect on {}", interface), + source: e, + }); + } + + if !enabled { + disconnect_wifi_and_wait(conn, &path, None).await?; + } + + Ok(()) +} diff --git a/nmrs/src/dbus/access_point.rs b/nmrs/src/dbus/access_point.rs index b17580b8..bb2f4849 100644 --- a/nmrs/src/dbus/access_point.rs +++ b/nmrs/src/dbus/access_point.rs @@ -43,7 +43,11 @@ pub trait NMAccessPoint { #[zbus(property)] fn max_bitrate(&self) -> Result; - /// Wi-Fi mode (1 = adhoc, 2 = infrastructure, 3 = AP). + /// Wi-Fi mode (1 = adhoc, 2 = infrastructure, 3 = AP, 4 = mesh). #[zbus(property)] fn mode(&self) -> Result; + + /// Monotonic seconds since boot when this AP was last seen, or -1 if never. + #[zbus(property)] + fn last_seen(&self) -> Result; } diff --git a/nmrs/src/dbus/bluez_adapter.rs b/nmrs/src/dbus/bluez_adapter.rs new file mode 100644 index 00000000..95edf325 --- /dev/null +++ b/nmrs/src/dbus/bluez_adapter.rs @@ -0,0 +1,18 @@ +//! BlueZ Adapter1 proxy for reading and controlling Bluetooth radio power. + +use zbus::proxy; + +/// Proxy for `org.bluez.Adapter1` on a specific adapter path (e.g. `/org/bluez/hci0`). +/// +/// Used to read and toggle the adapter's `Powered` property, which controls +/// whether the Bluetooth radio is software-enabled. +#[proxy(interface = "org.bluez.Adapter1", default_service = "org.bluez")] +pub trait BluezAdapter { + /// Whether the adapter is currently powered on (software-enabled). + #[zbus(property)] + fn powered(&self) -> zbus::Result; + + /// Enable or disable the adapter. + #[zbus(property)] + fn set_powered(&self, value: bool) -> zbus::Result<()>; +} diff --git a/nmrs/src/dbus/device.rs b/nmrs/src/dbus/device.rs index bcd421d4..da2c39b0 100644 --- a/nmrs/src/dbus/device.rs +++ b/nmrs/src/dbus/device.rs @@ -64,6 +64,17 @@ pub trait NMDevice { #[zbus(property)] fn active_connection(&self) -> Result; + /// Whether NM automatically activates known connections on this device. + #[zbus(property)] + fn autoconnect(&self) -> Result; + + /// Set the per-device autoconnect flag. + #[zbus(property)] + fn set_autoconnect(&self, value: bool) -> Result<()>; + + /// Disconnect the active connection on this device, if any. + fn disconnect(&self) -> Result<()>; + /// Signal emitted when device state changes. /// /// The method is named `device_state_changed` to avoid conflicts with the diff --git a/nmrs/src/dbus/main_nm.rs b/nmrs/src/dbus/main_nm.rs index 818877e8..f02a1ea1 100644 --- a/nmrs/src/dbus/main_nm.rs +++ b/nmrs/src/dbus/main_nm.rs @@ -62,7 +62,38 @@ pub trait NM { #[zbus(signal, name = "DeviceRemoved")] fn device_removed(&self, device: OwnedObjectPath); + /// Whether WWAN (mobile broadband) is globally enabled. + #[zbus(property)] + fn wwan_enabled(&self) -> zbus::Result; + + /// Enable or disable WWAN globally. + #[zbus(property)] + fn set_wwan_enabled(&self, value: bool) -> zbus::Result<()>; + + /// Whether WWAN hardware is enabled (rfkill state). + #[zbus(property)] + fn wwan_hardware_enabled(&self) -> zbus::Result; + /// Signal emitted when any device changes state. #[zbus(signal, name = "StateChanged")] fn state_changed(&self, state: u32); + + /// Current connectivity state (`0`–`4`). + #[zbus(property)] + fn connectivity(&self) -> zbus::Result; + + /// Whether NM is allowed to probe for connectivity. + #[zbus(property)] + fn connectivity_check_enabled(&self) -> zbus::Result; + + /// URL NM probes when checking connectivity. + #[zbus(property)] + fn connectivity_check_uri(&self) -> zbus::Result; + + /// Primary active connection path (`/` when none). + #[zbus(property)] + fn primary_connection(&self) -> zbus::Result; + + /// Forces a fresh connectivity check; blocks until done. + fn check_connectivity(&self) -> zbus::Result; } diff --git a/nmrs/src/dbus/mod.rs b/nmrs/src/dbus/mod.rs index 21374c52..6fd85a28 100644 --- a/nmrs/src/dbus/mod.rs +++ b/nmrs/src/dbus/mod.rs @@ -7,8 +7,11 @@ mod access_point; mod active_connection; pub(crate) mod agent_manager; mod bluetooth; +mod bluez_adapter; mod device; mod main_nm; +mod settings; +mod settings_connection; mod wired; mod wireless; @@ -16,7 +19,10 @@ pub(crate) use access_point::NMAccessPointProxy; pub(crate) use active_connection::NMActiveConnectionProxy; pub(crate) use agent_manager::AgentManagerProxy; pub(crate) use bluetooth::{BluezDeviceExtProxy, NMBluetoothProxy}; +pub(crate) use bluez_adapter::BluezAdapterProxy; pub(crate) use device::NMDeviceProxy; pub(crate) use main_nm::NMProxy; +pub(crate) use settings::NMSettingsProxy; +pub(crate) use settings_connection::NMSettingsConnectionProxy; pub(crate) use wired::NMWiredProxy; pub(crate) use wireless::NMWirelessProxy; diff --git a/nmrs/src/dbus/settings.rs b/nmrs/src/dbus/settings.rs new file mode 100644 index 00000000..6aaa0c2d --- /dev/null +++ b/nmrs/src/dbus/settings.rs @@ -0,0 +1,21 @@ +//! NetworkManager Settings D-Bus proxy (`org.freedesktop.NetworkManager.Settings`). + +use zbus::proxy; +use zvariant::OwnedObjectPath; + +/// Proxy for `/org/freedesktop/NetworkManager/Settings`. +#[proxy( + interface = "org.freedesktop.NetworkManager.Settings", + default_service = "org.freedesktop.NetworkManager", + default_path = "/org/freedesktop/NetworkManager/Settings" +)] +pub trait NMSettings { + /// Returns object paths of all saved connection profiles. + fn list_connections(&self) -> zbus::Result>; + + /// Resolves a connection object path by UUID string. + fn get_connection_by_uuid(&self, uuid: &str) -> zbus::Result; + + /// Reload connection profiles from disk. + fn reload_connections(&self) -> zbus::Result; +} diff --git a/nmrs/src/dbus/settings_connection.rs b/nmrs/src/dbus/settings_connection.rs new file mode 100644 index 00000000..2a77409f --- /dev/null +++ b/nmrs/src/dbus/settings_connection.rs @@ -0,0 +1,40 @@ +//! NetworkManager Settings.Connection D-Bus proxy. + +use std::collections::HashMap; +use zbus::proxy; +use zvariant::OwnedValue; + +/// Proxy for `org.freedesktop.NetworkManager.Settings.Connection` instances. +#[proxy( + interface = "org.freedesktop.NetworkManager.Settings.Connection", + default_service = "org.freedesktop.NetworkManager" +)] +pub trait NMSettingsConnection { + /// Full connection settings (`a{sa{sv}}`), excluding secrets. + fn get_settings(&self) -> zbus::Result>>; + + /// Merges partial settings into this profile. + fn update(&self, settings: HashMap>) -> zbus::Result<()>; + + /// Like [`update`](Self::update) for in-memory (unsaved) profiles. + #[zbus(name = "UpdateUnsaved")] + fn update_unsaved( + &self, + settings: HashMap>, + ) -> zbus::Result<()>; + + /// Deletes this saved connection. + fn delete(&self) -> zbus::Result<()>; + + /// `true` if the profile exists only in memory. + #[zbus(property)] + fn unsaved(&self) -> zbus::Result; + + /// On-disk path, or `""` if none. + #[zbus(property)] + fn filename(&self) -> zbus::Result; + + /// Connection flags bitmask. + #[zbus(property)] + fn flags(&self) -> zbus::Result; +} diff --git a/nmrs/src/lib.rs b/nmrs/src/lib.rs index 57c1a6bb..b2df98f0 100644 --- a/nmrs/src/lib.rs +++ b/nmrs/src/lib.rs @@ -13,14 +13,14 @@ //! # async fn example() -> nmrs::Result<()> { //! let nm = NetworkManager::new().await?; //! -//! // List visible networks -//! let networks = nm.list_networks().await?; +//! // List visible networks (None = all Wi-Fi devices) +//! let networks = nm.list_networks(None).await?; //! for net in &networks { //! println!("{} - Signal: {}%", net.ssid, net.strength.unwrap_or(0)); //! } //! -//! // Connect to a network -//! nm.connect("MyNetwork", WifiSecurity::WpaPsk { +//! // Connect to a network on the first Wi-Fi device +//! nm.connect("MyNetwork", None, WifiSecurity::WpaPsk { //! psk: "password123".into() //! }).await?; //! @@ -35,20 +35,18 @@ //! ## VPN Connection (WireGuard) //! //! ```rust -//! use nmrs::{NetworkManager, VpnCredentials, VpnType, WireGuardPeer}; +//! use nmrs::{NetworkManager, WireGuardConfig, WireGuardPeer}; //! //! # async fn example() -> nmrs::Result<()> { //! let nm = NetworkManager::new().await?; //! -//! // Configure WireGuard VPN //! 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, +//! let config = WireGuardConfig::new( //! "MyVPN", //! "vpn.example.com:51820", //! "your_private_key", @@ -56,8 +54,31 @@ //! vec![peer], //! ).with_dns(vec!["1.1.1.1".into(), "8.8.8.8".into()]); //! -//! // Connect to VPN -//! nm.connect_vpn(creds).await?; +//! nm.connect_vpn(config).await?; +//! # Ok(()) +//! # } +//! ``` +//! +//! ## VPN Connection (OpenVPN) +//! +//! ```rust +//! use nmrs::{NetworkManager, OpenVpnConfig, OpenVpnAuthType}; +//! +//! # async fn example() -> nmrs::Result<()> { +//! let nm = NetworkManager::new().await?; +//! +//! let config = OpenVpnConfig::new("CorpVPN", "vpn.example.com", 1194, false) +//! .with_auth_type(OpenVpnAuthType::PasswordTls) +//! .with_username("user") +//! .with_password("secret") +//! .with_ca_cert("/etc/openvpn/ca.crt") +//! .with_client_cert("/etc/openvpn/client.crt") +//! .with_client_key("/etc/openvpn/client.key"); +//! +//! nm.connect_vpn(config).await?; +//! +//! // Or import an .ovpn file directly: +//! nm.import_ovpn("corp.ovpn", Some("user"), Some("secret")).await?; //! //! // List VPN connections //! let vpns = nm.list_vpn_connections().await?; @@ -65,8 +86,7 @@ //! println!("{}: {:?} - {:?}", vpn.name, vpn.vpn_type, vpn.state); //! } //! -//! // Disconnect -//! nm.disconnect_vpn("MyVPN").await?; +//! nm.disconnect_vpn("CorpVPN").await?; //! # Ok(()) //! # } //! ``` @@ -88,10 +108,15 @@ //! - [`Device`] - Represents a network device (WiFi, Ethernet, etc.) //! - [`Network`] - Represents a discovered WiFi network //! - [`WifiSecurity`] - Security types (Open, WPA-PSK, WPA-EAP) -//! - [`VpnCredentials`] - VPN connection credentials -//! - [`VpnType`] - Supported VPN types (WireGuard, etc.) -//! - [`VpnConnection`] - Active VPN connection information +//! - [`VpnCredentials`] - Legacy VPN connection credentials +//! - [`VpnType`] - Protocol-specific VPN metadata (WireGuard, OpenVPN, strongSwan, etc.) +//! - [`VpnKind`] - Plugin-based vs kernel WireGuard distinction +//! - [`VpnConnection`] - VPN connection information +//! - [`VpnDetails`] - Protocol-specific VPN details (WireGuard / OpenVPN) +//! - [`WireGuardConfig`] - WireGuard connection configuration //! - [`WireGuardPeer`] - WireGuard peer configuration +//! - [`OpenVpnConfig`] - OpenVPN connection configuration +//! - [`OpenVpnAuthType`] - OpenVPN authentication types //! - [`ConnectionError`] - Comprehensive error types //! //! ## Connection Builders @@ -111,10 +136,10 @@ //! let nm = NetworkManager::new().await?; //! //! // Open network -//! nm.connect("OpenWiFi", WifiSecurity::Open).await?; +//! nm.connect("OpenWiFi", None, WifiSecurity::Open).await?; //! //! // WPA-PSK (password-protected) -//! nm.connect("HomeWiFi", WifiSecurity::WpaPsk { +//! nm.connect("HomeWiFi", None, WifiSecurity::WpaPsk { //! psk: "my_password".into() //! }).await?; //! @@ -125,7 +150,7 @@ //! .with_method(EapMethod::Peap) //! .with_phase2(Phase2::Mschapv2); //! -//! nm.connect("CorpWiFi", WifiSecurity::WpaEap { +//! nm.connect("CorpWiFi", None, WifiSecurity::WpaEap { //! opts: eap_opts //! }).await?; //! @@ -146,7 +171,7 @@ //! # async fn example() -> nmrs::Result<()> { //! let nm = NetworkManager::new().await?; //! -//! match nm.connect("MyNetwork", WifiSecurity::WpaPsk { +//! match nm.connect("MyNetwork", None, WifiSecurity::WpaPsk { //! psk: "wrong_password".into() //! }).await { //! Ok(_) => println!("Connected successfully"), @@ -187,8 +212,8 @@ //! } //! //! // Enable/disable WiFi -//! nm.set_wifi_enabled(false).await?; -//! nm.set_wifi_enabled(true).await?; +//! nm.set_wireless_enabled(false).await?; +//! nm.set_wireless_enabled(true).await?; //! # Ok(()) //! # } //! ``` @@ -321,14 +346,21 @@ pub mod models { } // Re-export commonly used types at crate root for convenience +#[allow(deprecated)] pub use api::models::{ - ActiveConnectionState, BluetoothDevice, BluetoothIdentity, BluetoothNetworkRole, - ConnectionError, ConnectionOptions, ConnectionStateReason, Device, DeviceState, DeviceType, - EapMethod, EapOptions, Network, NetworkInfo, Phase2, StateReason, TimeoutConfig, VpnConnection, - VpnConnectionInfo, VpnCredentials, VpnType, WifiSecurity, WireGuardPeer, - connection_state_reason_to_error, reason_to_error, + AccessPoint, ActiveConnectionState, AirplaneModeState, ApMode, BluetoothDevice, + BluetoothIdentity, BluetoothNetworkRole, ConnectType, ConnectionError, ConnectionOptions, + ConnectionStateReason, ConnectivityReport, ConnectivityState, Device, DeviceState, DeviceType, + EapMethod, EapOptions, Network, NetworkInfo, OpenVpnAuthType, OpenVpnCompression, + OpenVpnConfig, OpenVpnConnectionType, OpenVpnProxy, Phase2, RadioState, SavedConnection, + SavedConnectionBrief, SecurityFeatures, SettingsPatch, SettingsSummary, StateReason, + TimeoutConfig, VpnConfig, VpnConfiguration, VpnConnection, VpnConnectionInfo, VpnCredentials, + VpnDetails, VpnKind, VpnRoute, VpnSecretFlags, VpnType, WifiDevice, WifiKeyMgmt, WifiSecurity, + WifiSecuritySummary, WireGuardConfig, WireGuardPeer, connection_state_reason_to_error, + reason_to_error, }; pub use api::network_manager::NetworkManager; +pub use api::wifi_scope::WifiScope; /// A specialized `Result` type for network operations. /// diff --git a/nmrs/src/util/cert_store.rs b/nmrs/src/util/cert_store.rs new file mode 100644 index 00000000..4f07bb2a --- /dev/null +++ b/nmrs/src/util/cert_store.rs @@ -0,0 +1,222 @@ +//! Persist inline PEM material from `.ovpn` profiles to disk for NetworkManager +//! +//! # Connection-rename caveat +//! +//! The cert directory is keyed by `connection_name` at import time. If a user +//! later renames the NM connection (e.g. via `nmcli`), `forget_vpn` will look +//! for `certs//` which won't exist, and `certs//` will +//! linger on disk. A future improvement could store the cert directory name in +//! a custom `vpn.data` key so cleanup remains correct after renames. + +use std::{ + fs::{self, OpenOptions}, + io::Write, + path::{Path, PathBuf}, +}; + +use crate::{ConnectionError, util::validation::validate_connection_name}; + +/// Writes PEM bytes for one material type and returns an **absolute** path for `vpn.data`. +/// +/// `cert_type`: `"ca"`, `"cert"`, `"key"`, or `"ta"` (tls-auth static key). +/// +/// The write is atomic: data is flushed to a temporary file in the same +/// directory and then renamed into place, so readers never see a half-written +/// PEM file. +pub fn store_inline_cert( + connection_name: &str, + cert_type: &str, + pem_data: &str, +) -> Result { + let dir = connection_cert_dir(connection_name)?; + fs::create_dir_all(&dir).map_err(|e| { + ConnectionError::VpnFailed(format!( + "cert store: create directory {}: {e}", + dir.display() + )) + })?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)).map_err(|e| { + ConnectionError::VpnFailed(format!( + "cert store: chmod directory {}: {e}", + dir.display() + )) + })?; + } + + let filename = filename_for(cert_type)?; + let path = dir.join(filename); + let tmp_path = dir.join(format!(".{filename}.tmp")); + + { + let mut opts = OpenOptions::new(); + opts.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + let mut file = opts.open(&tmp_path).map_err(|e| { + ConnectionError::VpnFailed(format!( + "cert store: open {} for write: {e}", + tmp_path.display(), + )) + })?; + file.write_all(pem_data.as_bytes()).map_err(|e| { + ConnectionError::VpnFailed(format!("cert store: write {}: {e}", tmp_path.display(),)) + })?; + file.sync_all().map_err(|e| { + ConnectionError::VpnFailed(format!("cert store: sync {}: {e}", tmp_path.display())) + })?; + } + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&tmp_path, fs::Permissions::from_mode(0o600)).map_err(|e| { + let _ = fs::remove_file(&tmp_path); + ConnectionError::VpnFailed(format!("cert store: chmod {}: {e}", tmp_path.display())) + })?; + } + + fs::rename(&tmp_path, &path).map_err(|e| { + let _ = fs::remove_file(&tmp_path); + ConnectionError::VpnFailed(format!( + "cert store: rename {} -> {}: {e}", + tmp_path.display(), + path.display() + )) + })?; + + path.canonicalize().map_err(|e| { + ConnectionError::VpnFailed(format!("cert store: canonicalize {}: {e}", path.display())) + }) +} + +/// Removes all stored cert files for this connection. +/// +/// **Idempotent:** if the directory does not exist, returns `Ok(())`. +pub fn cleanup_certs(connection_name: &str) -> Result<(), ConnectionError> { + let dir = connection_cert_dir(connection_name)?; + match fs::remove_dir_all(&dir) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(ConnectionError::VpnFailed(format!( + "cert store: remove {}: {e}", + dir.display() + ))), + } +} + +/// Resolved XDG data home: `$XDG_DATA_HOME`, or `$HOME/.local/share` if unset or empty. +fn xdg_data_home() -> Result { + match std::env::var_os("XDG_DATA_HOME") { + Some(p) if !p.is_empty() => Ok(PathBuf::from(p)), + _ => { + let home = std::env::var_os("HOME").ok_or_else(|| { + ConnectionError::VpnFailed( + "cert store: HOME is not set (cannot resolve XDG data directory)".into(), + ) + })?; + Ok(Path::new(&home).join(".local/share")) + } + } +} + +/// `$XDG_DATA_HOME/nmrs/certs//` +fn connection_cert_dir(connection_name: &str) -> Result { + validate_connection_name(connection_name)?; + if connection_name.contains('/') || connection_name.contains('\\') { + return Err(ConnectionError::InvalidAddress( + "connection name must not contain path separators".into(), + )); + } + if connection_name == "." || connection_name == ".." { + return Err(ConnectionError::InvalidAddress( + "invalid connection name".into(), + )); + } + Ok(xdg_data_home()? + .join("nmrs") + .join("certs") + .join(connection_name)) +} + +fn filename_for(cert_type: &str) -> Result<&'static str, ConnectionError> { + match cert_type { + "ca" => Ok("ca.pem"), + "cert" => Ok("cert.pem"), + "key" => Ok("key.pem"), + "ta" => Ok("ta.key"), + "tls-crypt" => Ok("tls-crypt.key"), + _ => Err(ConnectionError::InvalidAddress(format!( + "unknown cert_type {cert_type:?} (expected ca, cert, key, ta, tls-crypt)" + ))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + fn with_fake_xdg(f: impl FnOnce() -> R) -> R { + let _g = ENV_LOCK.lock().unwrap(); + let base = std::env::temp_dir().join(format!("nmrs-cert-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&base).unwrap(); + // SAFETY: tests are serialized on this mutex; no other thread reads env concurrently. + unsafe { + std::env::set_var("XDG_DATA_HOME", &base); + } + let out = f(); + unsafe { + std::env::remove_var("XDG_DATA_HOME"); + } + let _ = std::fs::remove_dir_all(&base); + out + } + + #[test] + fn write_read_cleanup_cycle() { + with_fake_xdg(|| { + let pem = "-----BEGIN CERTIFICATE-----\nABC\n-----END CERTIFICATE-----\n"; + let p = store_inline_cert("MyVPN", "ca", pem).unwrap(); + let got = std::fs::read_to_string(&p).unwrap(); + assert_eq!(got, pem); + cleanup_certs("MyVPN").unwrap(); + assert!(!p.exists()); + }); + } + + #[test] + fn cleanup_nonexistent_is_ok() { + with_fake_xdg(|| { + cleanup_certs("does-not-exist").unwrap(); + }); + } + + #[test] + fn double_cleanup_ok() { + with_fake_xdg(|| { + store_inline_cert("x", "ca", "pem").unwrap(); + cleanup_certs("x").unwrap(); + cleanup_certs("x").unwrap(); + }); + } + + #[cfg(unix)] + #[test] + fn permissions_are_rw_for_owner_only() { + use std::os::unix::fs::PermissionsExt; + with_fake_xdg(|| { + let p = store_inline_cert("perm", "key", "secret").unwrap(); + let mode = std::fs::metadata(&p).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + }); + } +} diff --git a/nmrs/src/util/mod.rs b/nmrs/src/util/mod.rs index d91719c8..5750dba4 100644 --- a/nmrs/src/util/mod.rs +++ b/nmrs/src/util/mod.rs @@ -2,5 +2,6 @@ //! //! This module contains helper functions used throughout the crate. +pub(crate) mod cert_store; pub(crate) mod utils; pub(crate) mod validation; diff --git a/nmrs/src/util/utils.rs b/nmrs/src/util/utils.rs index 18bf6fe7..3c2815e6 100644 --- a/nmrs/src/util/utils.rs +++ b/nmrs/src/util/utils.rs @@ -271,11 +271,21 @@ pub(crate) async fn extract_connection_state_reason( /// Constructs a BlueZ D-Bus object path from a Bluetooth device address. /// -/// Converts a BDADDR like `"00:1A:7D:DA:71:13"` into -/// `"/org/bluez/hci0/dev_00_1A_7D_DA_71_13"`. -// TODO: Instead of hardcoding hci0, determine the actual adapter name. -pub(crate) fn bluez_device_path(bdaddr: &str) -> String { - format!("/org/bluez/hci0/dev_{}", bdaddr.replace(':', "_")) +/// Uses the given adapter name (e.g. `"hci0"`) or defaults to `"hci0"` +/// when `None` is provided. +/// +/// # Example +/// +/// ```ignore +/// bluez_device_path("00:1A:7D:DA:71:13", None) +/// // => "/org/bluez/hci0/dev_00_1A_7D_DA_71_13" +/// +/// bluez_device_path("00:1A:7D:DA:71:13", Some("hci1")) +/// // => "/org/bluez/hci1/dev_00_1A_7D_DA_71_13" +/// ``` +pub(crate) fn bluez_device_path(bdaddr: &str, adapter: Option<&str>) -> String { + let adapter = adapter.unwrap_or("hci0"); + format!("/org/bluez/{adapter}/dev_{}", bdaddr.replace(':', "_")) } /// Macro to convert Result to Option with error logging. diff --git a/nmrs/src/util/validation.rs b/nmrs/src/util/validation.rs index 77b9d014..f10244a7 100644 --- a/nmrs/src/util/validation.rs +++ b/nmrs/src/util/validation.rs @@ -3,7 +3,12 @@ //! This module provides validation functions for various inputs to ensure //! they meet NetworkManager's requirements before attempting D-Bus operations. -use crate::api::models::{ConnectionError, VpnCredentials, WifiSecurity, WireGuardPeer}; +#![allow(deprecated)] + +use crate::api::models::{ + ConnectionError, OpenVpnAuthType, OpenVpnConfig, OpenVpnProxy, VpnCredentials, WifiSecurity, + WireGuardPeer, +}; /// Maximum SSID length in bytes (802.11 standard). const MAX_SSID_BYTES: usize = 32; @@ -326,7 +331,6 @@ fn validate_cidr(cidr: &str) -> Result<(), ConnectionError> { let address = parts[0]; let prefix = parts[1]; - // Validate prefix is a number let prefix_num = prefix.parse::().map_err(|_| { ConnectionError::InvalidAddress(format!( "Invalid prefix length '{}' in CIDR '{}'", @@ -334,7 +338,6 @@ fn validate_cidr(cidr: &str) -> Result<(), ConnectionError> { )) })?; - // Determine if IPv4 or IPv6 and validate prefix range if address.contains(':') { // IPv6 if prefix_num > 128 { @@ -465,21 +468,7 @@ pub fn validate_vpn_credentials(creds: &VpnCredentials) -> Result<(), Connection } } - // Validate MTU if provided - if let Some(mtu) = creds.mtu { - if mtu < 576 { - return Err(ConnectionError::InvalidAddress(format!( - "MTU too small: {} (minimum 576)", - mtu - ))); - } - if mtu > 9000 { - return Err(ConnectionError::InvalidAddress(format!( - "MTU too large: {} (maximum 9000)", - mtu - ))); - } - } + validate_mtu(creds.mtu)?; Ok(()) } @@ -495,9 +484,7 @@ fn validate_ip_address(ip: &str) -> Result<(), ConnectionError> { )); } - // Check if IPv6 (contains colons) if ip.contains(':') { - // Basic IPv6 validation if !ip.chars().all(|c| c.is_ascii_hexdigit() || c == ':') { return Err(ConnectionError::InvalidAddress(format!( "Invalid IPv6 address '{}'", @@ -505,7 +492,6 @@ fn validate_ip_address(ip: &str) -> Result<(), ConnectionError> { ))); } } else { - // IPv4 validation let octets: Vec<&str> = ip.split('.').collect(); if octets.len() != 4 { return Err(ConnectionError::InvalidAddress(format!( @@ -532,6 +518,171 @@ fn validate_ip_address(ip: &str) -> Result<(), ConnectionError> { Ok(()) } +/// Validates an OpenVPN configuration. +/// +/// # Rules +/// - Connection name must be valid (via [`validate_connection_name`]) +/// - Remote server must not be empty +/// - Port is validated at the type level (`u16`), no extra check needed +/// - Auth-type-specific required fields: +/// - `Password`: username must be set +/// - `Tls`: CA cert, client cert, and client key must be set +/// - `PasswordTls`: username plus all TLS cert paths must be set +/// - `StaticKey`: no additional fields required +/// - Cert paths (if set) must be non-empty strings +/// - DNS servers (if provided) must be valid IP addresses +/// - MTU (if provided) must be in 576–9000 +/// - Proxy server (if provided) must not be empty +/// +/// # Errors +/// Returns appropriate `ConnectionError` if the configuration is invalid. +pub fn validate_openvpn_config(config: &OpenVpnConfig) -> Result<(), ConnectionError> { + validate_connection_name(&config.name)?; + + if config.remote.trim().is_empty() { + return Err(ConnectionError::InvalidGateway( + "OpenVPN remote server cannot be empty".to_string(), + )); + } + + if let Some(ref auth_type) = config.auth_type { + match auth_type { + OpenVpnAuthType::Password => { + if config.username.as_deref().unwrap_or("").is_empty() { + return Err(ConnectionError::InvalidAddress( + "Username is required for password authentication".to_string(), + )); + } + } + OpenVpnAuthType::Tls => { + validate_openvpn_cert_paths(config)?; + } + OpenVpnAuthType::PasswordTls => { + if config.username.as_deref().unwrap_or("").is_empty() { + return Err(ConnectionError::InvalidAddress( + "Username is required for password+TLS authentication".to_string(), + )); + } + validate_openvpn_cert_paths(config)?; + } + OpenVpnAuthType::StaticKey => {} + } + } + + validate_optional_cert_path(&config.ca_cert, "CA certificate")?; + validate_optional_cert_path(&config.client_cert, "Client certificate")?; + validate_optional_cert_path(&config.client_key, "Client key")?; + + if let Some(ref dns_servers) = config.dns { + if dns_servers.is_empty() { + return Err(ConnectionError::InvalidAddress( + "DNS server list cannot be empty if provided".to_string(), + )); + } + for dns in dns_servers { + validate_ip_address(dns)?; + } + } + + validate_mtu(config.mtu)?; + + if let Some(ref proxy) = config.proxy { + match proxy { + OpenVpnProxy::Http { server, .. } | OpenVpnProxy::Socks { server, .. } => { + if server.trim().is_empty() { + return Err(ConnectionError::InvalidAddress( + "Proxy server address cannot be empty".to_string(), + )); + } + } + } + } + + for route in &config.routes { + if route.dest.trim().is_empty() { + return Err(ConnectionError::InvalidAddress( + "OpenVPN route destination cannot be empty".to_string(), + )); + } + if route.prefix > 32 { + return Err(ConnectionError::InvalidAddress(format!( + "OpenVPN route prefix must be at most 32, got {}", + route.prefix + ))); + } + if let Some(ref nh) = route.next_hop { + validate_ip_address(nh)?; + } + } + + for (label, val) in [ + ("ping", config.ping), + ("ping-exit", config.ping_exit), + ("ping-restart", config.ping_restart), + ("reneg-sec", config.reneg_seconds), + ("connect-timeout", config.connect_timeout), + ] { + if let Some(v) = val + && v == 0 + { + return Err(ConnectionError::InvalidAddress(format!( + "{label} must be greater than 0 if set" + ))); + } + } + + Ok(()) +} + +/// Validates that TLS cert paths required for certificate authentication are present. +fn validate_openvpn_cert_paths(config: &OpenVpnConfig) -> Result<(), ConnectionError> { + if config.ca_cert.as_deref().unwrap_or("").is_empty() { + return Err(ConnectionError::InvalidAddress( + "CA certificate path is required for TLS authentication".to_string(), + )); + } + if config.client_cert.as_deref().unwrap_or("").is_empty() { + return Err(ConnectionError::InvalidAddress( + "Client certificate path is required for TLS authentication".to_string(), + )); + } + if config.client_key.as_deref().unwrap_or("").is_empty() { + return Err(ConnectionError::InvalidAddress( + "Client key path is required for TLS authentication".to_string(), + )); + } + Ok(()) +} + +/// Validates that an optional certificate path, if provided, is non-empty. +fn validate_optional_cert_path(path: &Option, label: &str) -> Result<(), ConnectionError> { + if let Some(p) = path + && p.trim().is_empty() + { + return Err(ConnectionError::InvalidAddress(format!( + "{label} path cannot be empty if provided" + ))); + } + Ok(()) +} + +/// Validates an MTU value (576–9000). +fn validate_mtu(mtu: Option) -> Result<(), ConnectionError> { + if let Some(mtu) = mtu { + if mtu < 576 { + return Err(ConnectionError::InvalidAddress(format!( + "MTU too small: {mtu} (minimum 576)" + ))); + } + if mtu > 9000 { + return Err(ConnectionError::InvalidAddress(format!( + "MTU too large: {mtu} (maximum 9000)" + ))); + } + } + Ok(()) +} + /// Validates a Bluetooth address against the EUI-48 format (using colons). /// /// # Errors @@ -565,6 +716,29 @@ pub fn validate_bluetooth_address(bdaddr: &str) -> Result<(), ConnectionError> { Ok(()) } +/// Validates a BSSID (MAC address) in `XX:XX:XX:XX:XX:XX` format. +/// +/// Both uppercase and lowercase hex digits are accepted. +/// +/// # Errors +/// +/// Returns [`ConnectionError::InvalidBssid`] if the format is invalid. +pub fn validate_bssid(bssid: &str) -> Result<(), ConnectionError> { + let parts: Vec<&str> = bssid.split(':').collect(); + + if parts.len() != 6 { + return Err(ConnectionError::InvalidBssid(bssid.to_string())); + } + + for part in parts { + if part.len() != 2 || !part.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(ConnectionError::InvalidBssid(bssid.to_string())); + } + } + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -774,4 +948,256 @@ mod tests { assert!(validate_bluetooth_address("00:1A:7D:DA:71:13:FF").is_err()); assert!(validate_bluetooth_address("").is_err()); } + + fn base_openvpn_config() -> OpenVpnConfig { + OpenVpnConfig::new("MyVPN", "vpn.example.com", 1194, false) + } + + #[test] + fn test_validate_openvpn_valid_minimal() { + assert!(validate_openvpn_config(&base_openvpn_config()).is_ok()); + } + + #[test] + fn test_validate_openvpn_empty_name() { + let config = OpenVpnConfig::new("", "vpn.example.com", 1194, false); + assert!(validate_openvpn_config(&config).is_err()); + } + + #[test] + fn test_validate_openvpn_whitespace_name() { + let config = OpenVpnConfig::new(" ", "vpn.example.com", 1194, false); + assert!(validate_openvpn_config(&config).is_err()); + } + + #[test] + fn test_validate_openvpn_empty_remote() { + let config = OpenVpnConfig::new("MyVPN", "", 1194, false); + assert!(validate_openvpn_config(&config).is_err()); + } + + #[test] + fn test_validate_openvpn_whitespace_remote() { + let config = OpenVpnConfig::new("MyVPN", " ", 1194, false); + assert!(validate_openvpn_config(&config).is_err()); + } + + #[test] + fn test_validate_openvpn_password_auth_missing_username() { + let config = base_openvpn_config().with_auth_type(OpenVpnAuthType::Password); + assert!(validate_openvpn_config(&config).is_err()); + } + + #[test] + fn test_validate_openvpn_password_auth_with_username() { + let config = base_openvpn_config() + .with_auth_type(OpenVpnAuthType::Password) + .with_username("user"); + assert!(validate_openvpn_config(&config).is_ok()); + } + + #[test] + fn test_validate_openvpn_tls_auth_missing_certs() { + let config = base_openvpn_config().with_auth_type(OpenVpnAuthType::Tls); + assert!(validate_openvpn_config(&config).is_err()); + } + + #[test] + fn test_validate_openvpn_tls_auth_partial_certs() { + let config = base_openvpn_config() + .with_auth_type(OpenVpnAuthType::Tls) + .with_ca_cert("/path/to/ca.crt"); + assert!(validate_openvpn_config(&config).is_err()); + } + + #[test] + fn test_validate_openvpn_tls_auth_with_all_certs() { + let config = base_openvpn_config() + .with_auth_type(OpenVpnAuthType::Tls) + .with_ca_cert("/path/to/ca.crt") + .with_client_cert("/path/to/client.crt") + .with_client_key("/path/to/client.key"); + assert!(validate_openvpn_config(&config).is_ok()); + } + + #[test] + fn test_validate_openvpn_password_tls_missing_username() { + let config = base_openvpn_config() + .with_auth_type(OpenVpnAuthType::PasswordTls) + .with_ca_cert("/path/to/ca.crt") + .with_client_cert("/path/to/client.crt") + .with_client_key("/path/to/client.key"); + assert!(validate_openvpn_config(&config).is_err()); + } + + #[test] + fn test_validate_openvpn_password_tls_missing_certs() { + let config = base_openvpn_config() + .with_auth_type(OpenVpnAuthType::PasswordTls) + .with_username("user"); + assert!(validate_openvpn_config(&config).is_err()); + } + + #[test] + fn test_validate_openvpn_password_tls_complete() { + let config = base_openvpn_config() + .with_auth_type(OpenVpnAuthType::PasswordTls) + .with_username("user") + .with_ca_cert("/path/to/ca.crt") + .with_client_cert("/path/to/client.crt") + .with_client_key("/path/to/client.key"); + assert!(validate_openvpn_config(&config).is_ok()); + } + + #[test] + fn test_validate_openvpn_static_key_minimal() { + let config = base_openvpn_config().with_auth_type(OpenVpnAuthType::StaticKey); + assert!(validate_openvpn_config(&config).is_ok()); + } + + #[test] + fn test_validate_openvpn_empty_cert_path_provided() { + let config = base_openvpn_config().with_ca_cert(""); + assert!(validate_openvpn_config(&config).is_err()); + } + + #[test] + fn test_validate_openvpn_whitespace_cert_path() { + let config = base_openvpn_config().with_client_cert(" "); + assert!(validate_openvpn_config(&config).is_err()); + } + + #[test] + fn test_validate_openvpn_valid_dns() { + let config = base_openvpn_config().with_dns(vec!["1.1.1.1".into(), "8.8.8.8".into()]); + assert!(validate_openvpn_config(&config).is_ok()); + } + + #[test] + fn test_validate_openvpn_empty_dns_list() { + let config = base_openvpn_config().with_dns(vec![]); + assert!(validate_openvpn_config(&config).is_err()); + } + + #[test] + fn test_validate_openvpn_invalid_dns() { + let config = base_openvpn_config().with_dns(vec!["not-an-ip".into()]); + assert!(validate_openvpn_config(&config).is_err()); + } + + #[test] + fn test_validate_openvpn_mtu_too_small() { + let config = base_openvpn_config().with_mtu(100); + assert!(validate_openvpn_config(&config).is_err()); + } + + #[test] + fn test_validate_openvpn_mtu_too_large() { + let config = base_openvpn_config().with_mtu(10000); + assert!(validate_openvpn_config(&config).is_err()); + } + + #[test] + fn test_validate_openvpn_mtu_valid() { + let config = base_openvpn_config().with_mtu(1500); + assert!(validate_openvpn_config(&config).is_ok()); + } + + #[test] + fn test_validate_openvpn_mtu_boundary_min() { + let config = base_openvpn_config().with_mtu(576); + assert!(validate_openvpn_config(&config).is_ok()); + } + + #[test] + fn test_validate_openvpn_mtu_boundary_max() { + let config = base_openvpn_config().with_mtu(9000); + assert!(validate_openvpn_config(&config).is_ok()); + } + + #[test] + fn test_validate_openvpn_empty_proxy_server() { + let config = base_openvpn_config().with_proxy(OpenVpnProxy::Http { + server: "".into(), + port: 8080, + username: None, + password: None, + retry: false, + }); + assert!(validate_openvpn_config(&config).is_err()); + } + + #[test] + fn test_validate_openvpn_valid_http_proxy() { + let config = base_openvpn_config().with_proxy(OpenVpnProxy::Http { + server: "proxy.example.com".into(), + port: 8080, + username: None, + password: None, + retry: false, + }); + assert!(validate_openvpn_config(&config).is_ok()); + } + + #[test] + fn test_validate_openvpn_empty_socks_proxy_server() { + let config = base_openvpn_config().with_proxy(OpenVpnProxy::Socks { + server: " ".into(), + port: 1080, + retry: false, + }); + assert!(validate_openvpn_config(&config).is_err()); + } + + #[test] + fn test_validate_openvpn_valid_socks_proxy() { + let config = base_openvpn_config().with_proxy(OpenVpnProxy::Socks { + server: "socks.example.com".into(), + port: 1080, + retry: false, + }); + assert!(validate_openvpn_config(&config).is_ok()); + } + + #[test] + fn test_validate_openvpn_no_auth_type_is_valid() { + let config = base_openvpn_config(); + assert!(config.auth_type.is_none()); + assert!(validate_openvpn_config(&config).is_ok()); + } + + #[test] + fn test_validate_bssid_valid_uppercase() { + assert!(validate_bssid("AA:BB:CC:DD:EE:FF").is_ok()); + } + + #[test] + fn test_validate_bssid_valid_lowercase() { + assert!(validate_bssid("aa:bb:cc:dd:ee:ff").is_ok()); + } + + #[test] + fn test_validate_bssid_valid_mixed() { + assert!(validate_bssid("aA:Bb:cC:Dd:eE:fF").is_ok()); + } + + #[test] + fn test_validate_bssid_too_short() { + assert!(validate_bssid("AA:BB:CC:DD:EE").is_err()); + } + + #[test] + fn test_validate_bssid_empty() { + assert!(validate_bssid("").is_err()); + } + + #[test] + fn test_validate_bssid_unicode() { + assert!(validate_bssid("AA:BB:CC:DD:EE:ÀÀ").is_err()); + } + + #[test] + fn test_validate_bssid_invalid_segment() { + assert!(validate_bssid("GG:BB:CC:DD:EE:FF").is_err()); + } } diff --git a/nmrs/tests/integration_test.rs b/nmrs/tests/integration_test.rs index d2ddb992..c0c731fa 100644 --- a/nmrs/tests/integration_test.rs +++ b/nmrs/tests/integration_test.rs @@ -1,6 +1,6 @@ use nmrs::{ - ConnectionError, DeviceState, DeviceType, NetworkManager, StateReason, VpnCredentials, VpnType, - WifiSecurity, WireGuardPeer, reason_to_error, + ConnectionError, DeviceState, DeviceType, NetworkManager, OpenVpnAuthType, StateReason, + VpnKind, WifiSecurity, WireGuardConfig, WireGuardPeer, reason_to_error, }; use std::time::Duration; use tokio::time::sleep; @@ -98,18 +98,20 @@ async fn test_wifi_enabled_get_set() { require_wifi!(&nm); let initial_state = nm - .wifi_enabled() + .wifi_state() .await - .expect("Failed to get WiFi enabled state"); + .expect("Failed to get WiFi enabled state") + .enabled; - match nm.set_wifi_enabled(!initial_state).await { + match nm.set_wireless_enabled(!initial_state).await { Ok(_) => { sleep(Duration::from_millis(500)).await; let new_state = nm - .wifi_enabled() + .wifi_state() .await - .expect("Failed to get WiFi enabled state after toggle"); + .expect("Failed to get WiFi enabled state after toggle") + .enabled; if new_state == initial_state { eprintln!( @@ -125,16 +127,17 @@ async fn test_wifi_enabled_get_set() { } } - nm.set_wifi_enabled(initial_state) + nm.set_wireless_enabled(initial_state) .await .expect("Failed to restore WiFi enabled state"); sleep(Duration::from_millis(500)).await; let restored_state = nm - .wifi_enabled() + .wifi_state() .await - .expect("Failed to get WiFi enabled state after restore"); + .expect("Failed to get WiFi enabled state after restore") + .enabled; assert_eq!( restored_state, initial_state, "WiFi state should be restored to original" @@ -152,10 +155,11 @@ async fn test_wifi_hardware_enabled() { require_wifi!(&nm); // Read-only property — just verify the call succeeds - let _ = nm - .wifi_hardware_enabled() + let state = nm + .wifi_state() .await - .expect("Failed to get WiFi hardware enabled state"); + .expect("Failed to get WiFi radio state"); + let _ = state.hardware_enabled; } /// Test waiting for WiFi to be ready @@ -169,7 +173,7 @@ async fn test_wait_for_wifi_ready() { require_wifi!(&nm); // Ensure WiFi is enabled - nm.set_wifi_enabled(true) + nm.set_wireless_enabled(true) .await .expect("Failed to enable WiFi"); @@ -200,7 +204,7 @@ async fn test_scan_networks() { require_wifi!(&nm); // Ensure WiFi is enabled - nm.set_wifi_enabled(true) + nm.set_wireless_enabled(true) .await .expect("Failed to enable WiFi"); @@ -208,7 +212,7 @@ async fn test_scan_networks() { let _ = nm.wait_for_wifi_ready().await; // Request a scan - let result = nm.scan_networks().await; + let result = nm.scan_networks(None).await; // Scan should either succeed or fail gracefully match result { @@ -233,7 +237,7 @@ async fn test_list_networks() { require_wifi!(&nm); // Ensure WiFi is enabled - nm.set_wifi_enabled(true) + nm.set_wireless_enabled(true) .await .expect("Failed to enable WiFi"); @@ -241,11 +245,14 @@ async fn test_list_networks() { let _ = nm.wait_for_wifi_ready().await; // Request a scan first - let _ = nm.scan_networks().await; + let _ = nm.scan_networks(None).await; sleep(Duration::from_secs(2)).await; // List networks - let networks = nm.list_networks().await.expect("Failed to list networks"); + let networks = nm + .list_networks(None) + .await + .expect("Failed to list networks"); // Verify network structure for network in &networks { @@ -271,7 +278,7 @@ async fn test_current_ssid() { require_wifi!(&nm); // Ensure WiFi is enabled - nm.set_wifi_enabled(true) + nm.set_wireless_enabled(true) .await .expect("Failed to enable WiFi"); @@ -298,7 +305,7 @@ async fn test_current_connection_info() { require_wifi!(&nm); // Ensure WiFi is enabled - nm.set_wifi_enabled(true) + nm.set_wireless_enabled(true) .await .expect("Failed to enable WiFi"); @@ -325,7 +332,7 @@ async fn test_show_details() { require_wifi!(&nm); // Ensure WiFi is enabled - nm.set_wifi_enabled(true) + nm.set_wireless_enabled(true) .await .expect("Failed to enable WiFi"); @@ -333,11 +340,14 @@ async fn test_show_details() { let _ = nm.wait_for_wifi_ready().await; // Request a scan first - let _ = nm.scan_networks().await; + let _ = nm.scan_networks(None).await; sleep(Duration::from_secs(2)).await; // List networks - let networks = nm.list_networks().await.expect("Failed to list networks"); + let networks = nm + .list_networks(None) + .await + .expect("Failed to list networks"); // Try to show details for the first network (if any) if let Some(network) = networks.first() { @@ -428,7 +438,7 @@ async fn test_connect_open_network() { require_wifi!(&nm); // Ensure WiFi is enabled - nm.set_wifi_enabled(true) + nm.set_wireless_enabled(true) .await .expect("Failed to enable WiFi"); @@ -436,11 +446,14 @@ async fn test_connect_open_network() { let _ = nm.wait_for_wifi_ready().await; // Request a scan first - let _ = nm.scan_networks().await; + let _ = nm.scan_networks(None).await; sleep(Duration::from_secs(2)).await; // List networks to find an open network - let networks = nm.list_networks().await.expect("Failed to list networks"); + let networks = nm + .list_networks(None) + .await + .expect("Failed to list networks"); // Find an open network (if any) let open_network = networks.iter().find(|n| !n.secured); @@ -455,7 +468,7 @@ async fn test_connect_open_network() { } // Try to connect to the open network - let result = nm.connect(test_ssid, WifiSecurity::Open).await; + let result = nm.connect(test_ssid, None, WifiSecurity::Open).await; match result { Ok(_) => { @@ -488,7 +501,7 @@ async fn test_connect_psk_network_with_empty_password() { require_wifi!(&nm); // Ensure WiFi is enabled - nm.set_wifi_enabled(true) + nm.set_wireless_enabled(true) .await .expect("Failed to enable WiFi"); @@ -496,11 +509,14 @@ async fn test_connect_psk_network_with_empty_password() { let _ = nm.wait_for_wifi_ready().await; // Request a scan first - let _ = nm.scan_networks().await; + let _ = nm.scan_networks(None).await; sleep(Duration::from_secs(2)).await; // List networks to find a PSK network - let networks = nm.list_networks().await.expect("Failed to list networks"); + let networks = nm + .list_networks(None) + .await + .expect("Failed to list networks"); // Find a PSK network (if any) let psk_network = networks.iter().find(|n| n.is_psk); @@ -523,7 +539,7 @@ async fn test_connect_psk_network_with_empty_password() { if has_saved { // Try to connect with empty password (should use saved credentials) let result = nm - .connect(test_ssid, WifiSecurity::WpaPsk { psk: String::new() }) + .connect(test_ssid, None, WifiSecurity::WpaPsk { psk: String::new() }) .await; match result { @@ -640,7 +656,7 @@ async fn test_network_properties() { require_wifi!(&nm); // Ensure WiFi is enabled - nm.set_wifi_enabled(true) + nm.set_wireless_enabled(true) .await .expect("Failed to enable WiFi"); @@ -648,11 +664,14 @@ async fn test_network_properties() { let _ = nm.wait_for_wifi_ready().await; // Request a scan first - let _ = nm.scan_networks().await; + let _ = nm.scan_networks(None).await; sleep(Duration::from_secs(2)).await; // List networks - let networks = nm.list_networks().await.expect("Failed to list networks"); + let networks = nm + .list_networks(None) + .await + .expect("Failed to list networks"); // Verify network properties for network in &networks { @@ -692,7 +711,7 @@ async fn test_multiple_scan_requests() { require_wifi!(&nm); // Ensure WiFi is enabled - nm.set_wifi_enabled(true) + nm.set_wireless_enabled(true) .await .expect("Failed to enable WiFi"); @@ -703,7 +722,7 @@ async fn test_multiple_scan_requests() { for i in 0..3 { nm.wait_for_wifi_ready().await.expect("WiFi not ready"); - let result = nm.scan_networks().await; + let result = nm.scan_networks(None).await; match result { Ok(_) => eprintln!("Scan {} succeeded", i + 1), Err(e) => eprintln!("Scan {} failed: {}", i + 1, e), @@ -716,7 +735,10 @@ async fn test_multiple_scan_requests() { } // List networks after multiple scans - let networks = nm.list_networks().await.expect("Failed to list networks"); + let networks = nm + .list_networks(None) + .await + .expect("Failed to list networks"); eprintln!("Found {} networks after multiple scans", networks.len()); } @@ -731,17 +753,17 @@ async fn test_concurrent_operations() { require_wifi!(&nm); // Ensure WiFi is enabled - nm.set_wifi_enabled(true) + nm.set_wireless_enabled(true) .await .expect("Failed to enable WiFi"); // Run multiple operations concurrently - let (devices_result, wifi_enabled_result, networks_result) = - tokio::join!(nm.list_devices(), nm.wifi_enabled(), nm.list_networks()); + let (devices_result, wifi_state_result, networks_result) = + tokio::join!(nm.list_devices(), nm.wifi_state(), nm.list_networks(None)); // All should succeed assert!(devices_result.is_ok(), "list_devices should succeed"); - assert!(wifi_enabled_result.is_ok(), "wifi_enabled should succeed"); + assert!(wifi_state_result.is_ok(), "wifi_state should succeed"); // networks_result may fail if WiFi is not ready, which is acceptable let _ = networks_result; } @@ -869,8 +891,8 @@ async fn test_connect_wired() { } } -/// Helper to create test VPN credentials -fn create_test_vpn_creds(name: &str) -> VpnCredentials { +/// Helper to create test VPN configuration +fn create_test_vpn_creds(name: &str) -> WireGuardConfig { let peer = WireGuardPeer::new( "HIgo9xNzJMWLKAShlKl6/bUT1VI9Q0SDBXGtLXkPFXc=", "test.example.com:51820", @@ -878,8 +900,7 @@ fn create_test_vpn_creds(name: &str) -> VpnCredentials { ) .with_persistent_keepalive(25); - VpnCredentials::new( - VpnType::WireGuard, + WireGuardConfig::new( name, "test.example.com:51820", "YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=", @@ -1025,7 +1046,7 @@ async fn test_get_nonexistent_vpn_info() { #[tokio::test] async fn test_vpn_type() { // Verify VPN types are properly defined - let wg = VpnType::WireGuard; + let wg = VpnKind::WireGuard; assert_eq!(format!("{:?}", wg), "WireGuard"); } @@ -1047,13 +1068,12 @@ async fn test_wireguard_peer_structure() { assert_eq!(peer.persistent_keepalive, Some(25)); } -/// Test VPN credentials structure +/// Test VPN configuration structure #[tokio::test] async fn test_vpn_credentials_structure() { let creds = create_test_vpn_creds("test_credentials"); assert_eq!(creds.name, "test_credentials"); - assert_eq!(creds.vpn_type, VpnType::WireGuard); assert_eq!(creds.peers.len(), 1); assert_eq!(creds.address, "10.100.0.2/24"); assert!(creds.dns.is_some()); @@ -1248,3 +1268,92 @@ fn test_bluetooth_network_role_from_u32() { BluetoothNetworkRole::PanU )); } + +// --- OpenVPN import tests --- + +/// Test that OpenVpnBuilder::from_ovpn_str produces correct settings for a +/// full TLS config, and that build_openvpn_connection serializes them. +#[test] +fn test_ovpn_import_tls_roundtrip() { + use nmrs::ConnectionOptions; + use nmrs::builders::{OpenVpnBuilder, build_openvpn_connection}; + + let ovpn = "\ +remote vpn.example.com 1194 udp +ca /etc/openvpn/ca.crt +cert /etc/openvpn/client.crt +key /etc/openvpn/client.key +cipher AES-256-GCM +auth SHA256 +tls-auth /etc/openvpn/ta.key 1 +"; + let config = OpenVpnBuilder::from_ovpn_str(ovpn, "roundtrip-test") + .unwrap() + .build() + .unwrap(); + + assert_eq!(config.remote, "vpn.example.com"); + assert_eq!(config.port, 1194); + assert_eq!(config.auth_type, Some(OpenVpnAuthType::Tls)); + assert_eq!(config.cipher, Some("AES-256-GCM".into())); + assert_eq!(config.auth, Some("SHA256".into())); + assert_eq!(config.tls_auth_key, Some("/etc/openvpn/ta.key".into())); + assert_eq!(config.tls_auth_direction, Some(1)); + + let opts = ConnectionOptions::new(false); + let settings = build_openvpn_connection(&config, &opts).unwrap(); + assert!(settings.contains_key("connection")); + assert!(settings.contains_key("vpn")); +} + +/// Test that from_ovpn_str infers password+TLS auth when both +/// auth-user-pass and cert/key are present. +#[test] +fn test_ovpn_import_password_tls() { + use nmrs::builders::OpenVpnBuilder; + + let ovpn = "\ +remote vpn.example.com 443 tcp +auth-user-pass +ca /etc/openvpn/ca.crt +cert /etc/openvpn/client.crt +key /etc/openvpn/client.key +"; + let config = OpenVpnBuilder::from_ovpn_str(ovpn, "pw-tls-test") + .unwrap() + .username("user") + .build() + .unwrap(); + + assert_eq!(config.auth_type, Some(OpenVpnAuthType::PasswordTls)); + assert!(config.tcp); + assert_eq!(config.port, 443); +} + +/// Test that the caller can override parsed settings before build. +#[test] +fn test_ovpn_import_override() { + use nmrs::builders::OpenVpnBuilder; + + let ovpn = "\ +remote vpn.example.com 1194 +ca /etc/openvpn/ca.crt +cert /etc/openvpn/client.crt +key /etc/openvpn/client.key +"; + let config = OpenVpnBuilder::from_ovpn_str(ovpn, "override-test") + .unwrap() + .port(443) + .tcp(true) + .dns(vec!["1.1.1.1".into()]) + .mtu(1400) + .remote_cert_tls("server") + .build() + .unwrap(); + + assert_eq!(config.port, 443); + assert!(config.tcp); + assert_eq!(config.dns, Some(vec!["1.1.1.1".into()])); + assert_eq!(config.mtu, Some(1400)); + assert_eq!(config.remote_cert_tls, Some("server".into())); +} diff --git a/nmrs/tests/validation_test.rs b/nmrs/tests/validation_test.rs index 43d525dc..7269ab5e 100644 --- a/nmrs/tests/validation_test.rs +++ b/nmrs/tests/validation_test.rs @@ -3,7 +3,7 @@ //! These tests verify that invalid inputs are rejected before attempting //! D-Bus operations, providing clear error messages to users. -use nmrs::{ConnectionError, EapOptions, VpnCredentials, VpnType, WifiSecurity, WireGuardPeer}; +use nmrs::{ConnectionError, EapOptions, WifiSecurity, WireGuardConfig, WireGuardPeer}; use zvariant::OwnedObjectPath; #[test] @@ -121,8 +121,7 @@ fn test_invalid_vpn_empty_name() { ) .with_persistent_keepalive(25); - let creds = VpnCredentials::new( - VpnType::WireGuard, + let creds = WireGuardConfig::new( "", // Empty name should be rejected "vpn.example.com:51820", "YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=", @@ -144,8 +143,7 @@ fn test_invalid_vpn_gateway_no_port() { ) .with_persistent_keepalive(25); - let creds = VpnCredentials::new( - VpnType::WireGuard, + let creds = WireGuardConfig::new( "TestVPN", "vpn.example.com", // Missing port "YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=", @@ -160,8 +158,7 @@ fn test_invalid_vpn_gateway_no_port() { #[test] fn test_invalid_vpn_no_peers() { - let creds = VpnCredentials::new( - VpnType::WireGuard, + let creds = WireGuardConfig::new( "TestVPN", "vpn.example.com:51820", "YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=", @@ -183,8 +180,7 @@ fn test_invalid_vpn_bad_cidr() { ) .with_persistent_keepalive(25); - let creds = VpnCredentials::new( - VpnType::WireGuard, + let creds = WireGuardConfig::new( "TestVPN", "vpn.example.com:51820", "YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=", @@ -206,8 +202,7 @@ fn test_invalid_vpn_mtu_too_small() { ) .with_persistent_keepalive(25); - let creds = VpnCredentials::new( - VpnType::WireGuard, + let creds = WireGuardConfig::new( "TestVPN", "vpn.example.com:51820", "YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=", @@ -230,8 +225,7 @@ fn test_valid_vpn_credentials() { ) .with_persistent_keepalive(25); - let creds = VpnCredentials::new( - VpnType::WireGuard, + let creds = WireGuardConfig::new( "TestVPN", "vpn.example.com:51820", "YBk6X3pP8KjKz7+HFWzVHNqL3qTZq8hX9VxFQJ4zVmM=", diff --git a/package.nix b/package.nix index c25bf929..669368a0 100644 --- a/package.nix +++ b/package.nix @@ -19,7 +19,7 @@ rustPlatform.buildRustPackage { src = ./.; - cargoHash = "sha256-UvtbiWFTXtccdfTbSWN35WPs/hepCeUcrn1gX2YSOoI="; + cargoHash = "sha256-GtL1R9PpEqsyTubMvy2RPkYHQBRZHuF7f0BZKAUyNrY="; nativeBuildInputs = [ pkg-config