diff --git a/nmrs/CHANGELOG.md b/nmrs/CHANGELOG.md index a8fb1ccd..b6b651a8 100644 --- a/nmrs/CHANGELOG.md +++ b/nmrs/CHANGELOG.md @@ -12,6 +12,7 @@ All notable changes to the `nmrs` crate will be documented in this file. - 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 - `WifiInterfaceNotFound` and `NotAWifiDevice` error variants - 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` +- Connectivity state surface: `ConnectivityState`, `ConnectivityReport`, `connectivity()`, `check_connectivity()`, `connectivity_report()`, `captive_portal_url()`; `ConnectivityCheckDisabled` error variant ### Changed -`list_saved_connections()` now returns `Vec` (full decode + summaries). Use `list_saved_connection_ids()` for the previous `Vec` behavior (connection `id` names only). diff --git a/nmrs/Cargo.toml b/nmrs/Cargo.toml index 4641a65b..6b6c3293 100644 --- a/nmrs/Cargo.toml +++ b/nmrs/Cargo.toml @@ -63,3 +63,7 @@ path = "examples/multi_wifi.rs" [[example]] name = "saved_list" path = "examples/saved_list.rs" + +[[example]] +name = "connectivity" +path = "examples/connectivity.rs" diff --git a/nmrs/README.md b/nmrs/README.md index 65574532..7c9c97dd 100644 --- a/nmrs/README.md +++ b/nmrs/README.md @@ -21,6 +21,7 @@ Rust bindings for NetworkManager via D-Bus. - **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 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/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/error.rs b/nmrs/src/api/models/error.rs index c5471b0b..b1ec4855 100644 --- a/nmrs/src/api/models/error.rs +++ b/nmrs/src/api/models/error.rs @@ -122,6 +122,10 @@ pub enum ConnectionError { #[error("saved connection malformed: {0}")] MalformedSavedConnection(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, diff --git a/nmrs/src/api/models/mod.rs b/nmrs/src/api/models/mod.rs index ab9bd30a..f5ca15b7 100644 --- a/nmrs/src/api/models/mod.rs +++ b/nmrs/src/api/models/mod.rs @@ -2,6 +2,7 @@ pub(crate) mod access_point; mod bluetooth; mod config; mod connection_state; +mod connectivity; mod device; mod error; mod openvpn; @@ -20,6 +21,7 @@ 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::*; diff --git a/nmrs/src/api/network_manager.rs b/nmrs/src/api/network_manager.rs index 768c5f2f..4f0ab329 100644 --- a/nmrs/src/api/network_manager.rs +++ b/nmrs/src/api/network_manager.rs @@ -731,6 +731,63 @@ impl NetworkManager { 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 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/mod.rs b/nmrs/src/core/mod.rs index b6461fd8..9365e53f 100644 --- a/nmrs/src/core/mod.rs +++ b/nmrs/src/core/mod.rs @@ -7,6 +7,7 @@ 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; diff --git a/nmrs/src/dbus/main_nm.rs b/nmrs/src/dbus/main_nm.rs index eebe7d67..f02a1ea1 100644 --- a/nmrs/src/dbus/main_nm.rs +++ b/nmrs/src/dbus/main_nm.rs @@ -77,4 +77,23 @@ pub trait NM { /// 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/lib.rs b/nmrs/src/lib.rs index 0bd35ccb..47a2dbab 100644 --- a/nmrs/src/lib.rs +++ b/nmrs/src/lib.rs @@ -349,13 +349,13 @@ pub mod models { pub use api::models::{ AccessPoint, ActiveConnectionState, AirplaneModeState, ApMode, BluetoothDevice, BluetoothIdentity, BluetoothNetworkRole, ConnectType, ConnectionError, ConnectionOptions, - ConnectionStateReason, Device, DeviceState, DeviceType, EapMethod, EapOptions, Network, - NetworkInfo, OpenVpnAuthType, OpenVpnCompression, OpenVpnConfig, OpenVpnProxy, Phase2, - RadioState, SavedConnection, SavedConnectionBrief, SecurityFeatures, SettingsPatch, - SettingsSummary, StateReason, TimeoutConfig, VpnConfig, VpnConfiguration, VpnConnection, - VpnConnectionInfo, VpnCredentials, VpnDetails, VpnRoute, VpnSecretFlags, VpnType, WifiDevice, - WifiKeyMgmt, WifiSecurity, WifiSecuritySummary, WireGuardConfig, WireGuardPeer, - connection_state_reason_to_error, reason_to_error, + ConnectionStateReason, ConnectivityReport, ConnectivityState, Device, DeviceState, DeviceType, + EapMethod, EapOptions, Network, NetworkInfo, OpenVpnAuthType, OpenVpnCompression, + OpenVpnConfig, OpenVpnProxy, Phase2, RadioState, SavedConnection, SavedConnectionBrief, + SecurityFeatures, SettingsPatch, SettingsSummary, StateReason, TimeoutConfig, VpnConfig, + VpnConfiguration, VpnConnection, VpnConnectionInfo, VpnCredentials, VpnDetails, 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;