diff --git a/nmrs/CHANGELOG.md b/nmrs/CHANGELOG.md index cf701800..ddbf7b9d 100644 --- a/nmrs/CHANGELOG.md +++ b/nmrs/CHANGELOG.md @@ -11,6 +11,15 @@ All notable changes to the `nmrs` crate will be documented in this file. `ConnectionError::InvalidInput` instead of panicking. ([#478](https://github.com/freedesktop-rs/nmrs/issues/478)) +### Fixed + +- `get_vpn_info()` no longer surfaces a raw D-Bus error (e.g. "a VPN interface + does not exist") when a VPN is disconnected externally while it is being read. + The active connection and its device can vanish mid-read; missing-object + reads are now treated as the VPN no longer being active (`NoVpnConnection`), + and the interface/IP details degrade gracefully. + ([#523](https://github.com/freedesktop-rs/nmrs/issues/523)) + ## [3.4.2] - 2026-07-27 ### Changed diff --git a/nmrs/src/core/active_connection.rs b/nmrs/src/core/active_connection.rs index 45735144..ca5a042b 100644 --- a/nmrs/src/core/active_connection.rs +++ b/nmrs/src/core/active_connection.rs @@ -91,7 +91,7 @@ async fn failed_active_path_vanished( Ok(active_path_is_absent(active_path, ¤t_paths)) } -fn is_missing_dbus_object_error(error: &ConnectionError) -> bool { +pub(crate) fn is_missing_dbus_object_error(error: &ConnectionError) -> bool { let dbus_error = match error { ConnectionError::Dbus(error) => error, ConnectionError::DbusOperation { source, .. } => source, diff --git a/nmrs/src/core/vpn.rs b/nmrs/src/core/vpn.rs index 5bf9f127..0c8f1080 100644 --- a/nmrs/src/core/vpn.rs +++ b/nmrs/src/core/vpn.rs @@ -18,6 +18,7 @@ use crate::api::models::{ VpnSecretFlags, VpnType, }; use crate::builders::{build_openvpn_connection, build_wireguard_connection}; +use crate::core::active_connection::is_missing_dbus_object_error; use crate::core::state_wait::wait_for_connection_activation; use crate::dbus::{NMActiveConnectionProxy, NMProxy}; use crate::models::VpnConfiguration; @@ -906,6 +907,25 @@ pub(crate) async fn forget_vpn(conn: &Connection, name: &str) -> Result<()> { Ok(()) } +/// Classifies a D-Bus read that can race with VPN teardown. +/// +/// Returns `Ok(Some(value))` on success, `Ok(None)` when the underlying object +/// vanished (a missing-object D-Bus error, expected when a VPN is disconnected +/// externally while we read it), and `Err(_)` for genuinely unexpected errors. +fn vanished_or_err(result: std::result::Result) -> Result> { + match result { + Ok(value) => Ok(Some(value)), + Err(error) => { + let error = ConnectionError::from(error); + if is_missing_dbus_object_error(&error) { + Ok(None) + } else { + Err(error) + } + } + } +} + /// Gets detailed information about an active VPN connection. pub(crate) async fn get_vpn_info(conn: &Connection, name: &str) -> Result { validate_connection_name(name)?; @@ -966,20 +986,36 @@ pub(crate) async fn get_vpn_info(conn: &Connection, name: &str) -> Result(ac_proxy.get_property("State").await)? else { + continue; + }; let state = DeviceState::from(state_val); - 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( + let Some(dev_paths) = + vanished_or_err::>(ac_proxy.get_property("Devices").await)? + else { + continue; + }; + // The VPN device (e.g. `tun0`) may already be gone while the active + // connection lingers in the deactivating state; degrade the interface + // name to `None` rather than failing the whole call. + let interface = match dev_paths.first() { + Some(dev_path) => match nm_proxy( conn, dev_path.clone(), "org.freedesktop.NetworkManager.Device", ) - .await?; - Some(dev_proxy.get_property::("Interface").await?) - } else { - None + .await + { + Ok(dev_proxy) => dev_proxy.get_property::("Interface").await.ok(), + Err(_) => None, + }, + None => None, }; let gateway = match kind { @@ -990,11 +1026,19 @@ pub(crate) async fn get_vpn_info(conn: &Connection, name: &str) -> Result extract_openvpn_gateway(&settings_map), }; - let ip4_path: OwnedObjectPath = ac_proxy.get_property("Ip4Config").await?; - let (ip4_address, dns_servers) = if ip4_path.as_str() != "/" { - let ip4_proxy = - nm_proxy(conn, ip4_path, "org.freedesktop.NetworkManager.IP4Config").await?; - + let Some(ip4_path) = + vanished_or_err::(ac_proxy.get_property("Ip4Config").await)? + else { + continue; + }; + let ip4_proxy = if ip4_path.as_str() != "/" { + nm_proxy(conn, ip4_path, "org.freedesktop.NetworkManager.IP4Config") + .await + .ok() + } else { + None + }; + let (ip4_address, dns_servers) = if let Some(ip4_proxy) = ip4_proxy { let ip4_address = if let Ok(addr_array) = ip4_proxy .get_property::>>("AddressData") .await @@ -1037,11 +1081,19 @@ pub(crate) async fn get_vpn_info(conn: &Connection, name: &str) -> Result(ac_proxy.get_property("Ip6Config").await)? + else { + continue; + }; + let ip6_proxy = if ip6_path.as_str() != "/" { + nm_proxy(conn, ip6_path, "org.freedesktop.NetworkManager.IP6Config") + .await + .ok() + } else { + None + }; + let ip6_address = if let Some(ip6_proxy) = ip6_proxy { if let Ok(addr_array) = ip6_proxy .get_property::>>("AddressData") .await @@ -1239,6 +1291,32 @@ mod tests { assert_eq!(detect_vpn_kind(&settings), None); } + #[test] + fn vanished_or_err_passes_through_success() { + assert!(matches!(vanished_or_err::(Ok(7)), Ok(Some(7)))); + } + + #[test] + fn vanished_or_err_treats_missing_object_as_vanished() { + for error in [ + zbus::fdo::Error::UnknownMethod("gone".into()), + zbus::fdo::Error::UnknownObject("gone".into()), + zbus::fdo::Error::UnknownInterface("gone".into()), + zbus::fdo::Error::UnknownProperty("gone".into()), + ] { + let result = vanished_or_err::(Err(zbus::Error::FDO(Box::new(error)))); + assert!(matches!(result, Ok(None)), "missing-object error"); + } + } + + #[test] + fn vanished_or_err_propagates_unexpected_errors() { + let result = vanished_or_err::(Err(zbus::Error::FDO(Box::new( + zbus::fdo::Error::AccessDenied("denied".into()), + )))); + assert!(matches!(result, Err(ConnectionError::Dbus(_)))); + } + #[test] fn decode_openvpn_full() { let data = HashMap::from([