Skip to content
Merged
1 change: 1 addition & 0 deletions nmrs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<SavedConnection>` (full decode + summaries). Use `list_saved_connection_ids()` for the previous `Vec<String>` behavior (connection `id` names only).
Expand Down
4 changes: 4 additions & 0 deletions nmrs/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
1 change: 1 addition & 0 deletions nmrs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
20 changes: 20 additions & 0 deletions nmrs/examples/connectivity.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
129 changes: 129 additions & 0 deletions nmrs/src/api/models/connectivity.rs
Original file line number Diff line number Diff line change
@@ -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<u32> 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<ConnectivityState> 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<String>,
/// 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<String>,
}

#[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());
}
}
4 changes: 4 additions & 0 deletions nmrs/src/api/models/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions nmrs/src/api/models/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ pub(crate) mod access_point;
mod bluetooth;
mod config;
mod connection_state;
mod connectivity;
mod device;
mod error;
mod openvpn;
Expand All @@ -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::*;
Expand Down
57 changes: 57 additions & 0 deletions nmrs/src/api/network_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::ConnectivityState> {
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::ConnectivityState> {
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::ConnectivityReport> {
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<Option<String>> {
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
Expand Down
125 changes: 125 additions & 0 deletions nmrs/src/core/connectivity.rs
Original file line number Diff line number Diff line change
@@ -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<ConnectivityState> {
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<ConnectivityState> {
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<ConnectivityReport> {
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<String> {
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<String> {
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::<String>(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<String> {
nm.connectivity_check_uri()
.await
.ok()
.filter(|s| !s.is_empty())
}
1 change: 1 addition & 0 deletions nmrs/src/core/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading