From 6b1550f8f66af2ace5ab8f28bee3db64607ccf6e Mon Sep 17 00:00:00 2001 From: ryux1 Date: Mon, 7 Sep 2026 01:07:09 +0200 Subject: [PATCH 1/2] feat(#541): expose device control properties --- docs/src/api/models.md | 1 + docs/src/api/network-manager.md | 2 ++ nmrs/CHANGELOG.md | 6 ++++ nmrs/src/api/models/device.rs | 2 ++ nmrs/src/api/models/tests.rs | 1 + nmrs/src/api/network_manager.rs | 59 ++++++++++++++++++++++++++++++++- nmrs/src/core/device.rs | 57 +++++++++++++++++++++++++++++++ nmrs/src/dbus/device.rs | 4 +++ nmrs/tests/integration_test.rs | 59 +++++++++++++++++++++++++++++++++ 9 files changed, 190 insertions(+), 1 deletion(-) diff --git a/docs/src/api/models.md b/docs/src/api/models.md index 0ab470f6..4822b413 100644 --- a/docs/src/api/models.md +++ b/docs/src/api/models.md @@ -16,6 +16,7 @@ pub struct Device { pub device_type: DeviceType, pub state: DeviceState, pub managed: Option, + pub autoconnect: Option, pub driver: Option, pub ip4_address: Option, pub ip6_address: Option, diff --git a/docs/src/api/network-manager.md b/docs/src/api/network-manager.md index 707a9ee6..642db8fa 100644 --- a/docs/src/api/network-manager.md +++ b/docs/src/api/network-manager.md @@ -170,6 +170,8 @@ devices that NetworkManager reports as `veth`. | `list_wired_devices()` | `Result>` | List Ethernet devices | | `list_wired_device_details()` | `Result>` | List Ethernet devices with link speed, active connection id, and IPs | | `get_device_by_interface(name)` | `Result` | Find device by interface name | +| `set_device_autoconnect(name, bool)` | `Result<()>` | Allow or prevent automatic connection activation on one device | +| `set_device_managed(name, bool)` | `Result<()>` | Temporarily make NetworkManager manage or ignore one device | | `is_connecting()` | `Result` | Check if any device is connecting | | `list_active_connections()` | `Result>` | List typed active wired, Wi-Fi, VPN, and other connections | | `snapshot()` | `Result` | Read point-in-time applet state after a `NetworkEvent` | diff --git a/nmrs/CHANGELOG.md b/nmrs/CHANGELOG.md index ad2c1d61..1822cfb3 100644 --- a/nmrs/CHANGELOG.md +++ b/nmrs/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to the `nmrs` crate will be documented in this file. ## [Unreleased] +### Added + +- Device snapshots now expose per-device autoconnect state, and + `NetworkManager::set_device_autoconnect()` / `set_device_managed()` provide + high-level control of both writable properties. ([#541](https://github.com/freedesktop-rs/nmrs/issues/541)) + ## [3.5.2] - 2026-09-05 ### Fixed - `#[deprecated(since = ...)]` on `connect_vpn_by_uuid()` and `disconnect_vpn_by_uuid()` said `3.6.0`; corrected to `3.5.1`, the release that actually deprecated them.([#544](https://github.com/freedesktop-rs/nmrs/pull/544)) diff --git a/nmrs/src/api/models/device.rs b/nmrs/src/api/models/device.rs index 12e51778..b24720d1 100644 --- a/nmrs/src/api/models/device.rs +++ b/nmrs/src/api/models/device.rs @@ -53,6 +53,8 @@ pub struct Device { pub state: DeviceState, /// Whether NetworkManager manages this device pub managed: Option, + /// Whether NetworkManager may automatically activate a connection on this device. + pub autoconnect: Option, /// Kernel driver name pub driver: Option, /// Assigned IPv4 address with CIDR notation (only present when connected) diff --git a/nmrs/src/api/models/tests.rs b/nmrs/src/api/models/tests.rs index f542600d..fc17d64a 100644 --- a/nmrs/src/api/models/tests.rs +++ b/nmrs/src/api/models/tests.rs @@ -629,6 +629,7 @@ fn device_with_type(device_type: DeviceType) -> Device { device_type, state: DeviceState::Activated, managed: Some(true), + autoconnect: Some(true), driver: Some("test".into()), ip4_address: None, ip6_address: None, diff --git a/nmrs/src/api/network_manager.rs b/nmrs/src/api/network_manager.rs index f847a719..0d3f192a 100644 --- a/nmrs/src/api/network_manager.rs +++ b/nmrs/src/api/network_manager.rs @@ -32,7 +32,7 @@ use crate::core::custom_connection::{ }; use crate::core::device::{ is_connecting, list_bluetooth_devices, list_devices, list_wired_device_details, - wait_for_wifi_ready, + set_device_autoconnect, set_device_managed, 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}; @@ -368,6 +368,63 @@ impl NetworkManager { list_devices(&self.conn).await } + /// Allows or prevents automatic connection activation on one device. + /// + /// This changes NetworkManager's per-device `Autoconnect` property. Setting + /// it to `false` does not disconnect a connection that is already active; + /// it only prevents future automatic activation until the property is set + /// to `true` or a connection is activated manually. + /// + /// # Errors + /// + /// Returns [`InterfaceNotFound`](crate::ConnectionError::InterfaceNotFound) + /// if no device has the supplied interface name. D-Bus and authorization + /// failures are returned as [`ConnectionError`](crate::ConnectionError). + /// + /// # Example + /// + /// ```no_run + /// use nmrs::NetworkManager; + /// + /// # async fn example() -> nmrs::Result<()> { + /// let nm = NetworkManager::new().await?; + /// nm.set_device_autoconnect("eth0", false).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn set_device_autoconnect(&self, interface: &str, autoconnect: bool) -> Result<()> { + set_device_autoconnect(&self.conn, interface, autoconnect).await + } + + /// Sets whether NetworkManager manages one device. + /// + /// Setting this to `false` makes NetworkManager stop managing the device + /// and may tear down its active connection. The setting is temporary and + /// is lost when NetworkManager restarts. This uses the writable `Managed` + /// property so it also works with NetworkManager releases older than 1.58; + /// the newer `SetManaged` D-Bus method is required only for persistence. + /// + /// # Errors + /// + /// Returns [`InterfaceNotFound`](crate::ConnectionError::InterfaceNotFound) + /// if no device has the supplied interface name. D-Bus and authorization + /// failures are returned as [`ConnectionError`](crate::ConnectionError). + /// + /// # Example + /// + /// ```no_run + /// use nmrs::NetworkManager; + /// + /// # async fn example() -> nmrs::Result<()> { + /// let nm = NetworkManager::new().await?; + /// nm.set_device_managed("eth0", false).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn set_device_managed(&self, interface: &str, managed: bool) -> Result<()> { + set_device_managed(&self.conn, interface, managed).await + } + /// List all bluetooth devices. pub async fn list_bluetooth_devices(&self) -> Result> { list_bluetooth_devices(&self.conn).await diff --git a/nmrs/src/core/device.rs b/nmrs/src/core/device.rs index c5fdc62f..19250e4a 100644 --- a/nmrs/src/core/device.rs +++ b/nmrs/src/core/device.rs @@ -93,6 +93,16 @@ pub(crate) async fn list_devices(conn: &Connection) -> Result> { None } }; + let autoconnect = match d_proxy.autoconnect().await { + Ok(value) => Some(value), + Err(e) => { + trace!( + "Failed to get 'autoconnect' property for device {}: {}", + interface, e + ); + None + } + }; let driver = match d_proxy.driver().await { Ok(d) => Some(d), Err(e) => { @@ -165,6 +175,7 @@ pub(crate) async fn list_devices(conn: &Connection) -> Result> { device_type, state, managed, + autoconnect, driver, ip4_address, ip6_address, @@ -175,6 +186,52 @@ pub(crate) async fn list_devices(conn: &Connection) -> Result> { Ok(devices) } +/// Sets whether NetworkManager may automatically activate connections on a device. +pub(crate) async fn set_device_autoconnect( + conn: &Connection, + interface: &str, + autoconnect: bool, +) -> Result<()> { + let path = get_device_by_interface(conn, interface) + .await + .map_err(|error| match error { + ConnectionError::NotFound => ConnectionError::InterfaceNotFound(interface.to_string()), + other => other, + })?; + let device = NMDeviceProxy::builder(conn).path(path)?.build().await?; + + device + .set_autoconnect(autoconnect) + .await + .map_err(|source| ConnectionError::DbusOperation { + context: format!("failed to set Autoconnect on {interface}"), + source, + }) +} + +/// Sets whether NetworkManager manages a device. +pub(crate) async fn set_device_managed( + conn: &Connection, + interface: &str, + managed: bool, +) -> Result<()> { + let path = get_device_by_interface(conn, interface) + .await + .map_err(|error| match error { + ConnectionError::NotFound => ConnectionError::InterfaceNotFound(interface.to_string()), + other => other, + })?; + let device = NMDeviceProxy::builder(conn).path(path)?.build().await?; + + device + .set_managed(managed) + .await + .map_err(|source| ConnectionError::DbusOperation { + context: format!("failed to set Managed on {interface}"), + source, + }) +} + /// Lists wired Ethernet devices with Ethernet-specific details. pub(crate) async fn list_wired_device_details(conn: &Connection) -> Result> { let proxy = NMProxy::new(conn).await?; diff --git a/nmrs/src/dbus/device.rs b/nmrs/src/dbus/device.rs index da2c39b0..14c7b504 100644 --- a/nmrs/src/dbus/device.rs +++ b/nmrs/src/dbus/device.rs @@ -42,6 +42,10 @@ pub trait NMDevice { #[zbus(property)] fn managed(&self) -> Result; + /// Set whether NetworkManager manages this device until the daemon restarts. + #[zbus(property)] + fn set_managed(&self, value: bool) -> Result<()>; + /// The kernel driver in use. #[zbus(property)] fn driver(&self) -> Result; diff --git a/nmrs/tests/integration_test.rs b/nmrs/tests/integration_test.rs index e17f3e3a..d3a740e9 100644 --- a/nmrs/tests/integration_test.rs +++ b/nmrs/tests/integration_test.rs @@ -969,8 +969,67 @@ async fn wired_connection_lifecycle() { panic!("managed veth interface {interface:?} was missing: {devices:?}") }); assert_eq!(device.managed, Some(true)); + let initial_autoconnect = device + .autoconnect + .expect("managed veth did not expose its autoconnect state"); assert!(!device.path.is_empty()); + bounded( + "disable autoconnect on the managed veth client", + DBUS_TIMEOUT, + nm.set_device_autoconnect(&interface, false), + ) + .await + .expect("failed to disable device autoconnect"); + let devices = bounded("refresh devices", DBUS_TIMEOUT, nm.list_wired_devices()) + .await + .expect("failed to refresh wired devices"); + let device = devices + .iter() + .find(|device| device.interface == interface) + .expect("veth disappeared after disabling autoconnect"); + assert_eq!(device.autoconnect, Some(false)); + + bounded( + "restore autoconnect on the managed veth client", + DBUS_TIMEOUT, + nm.set_device_autoconnect(&interface, initial_autoconnect), + ) + .await + .expect("failed to restore device autoconnect"); + + bounded( + "make the veth client unmanaged", + DBUS_TIMEOUT, + nm.set_device_managed(&interface, false), + ) + .await + .expect("failed to make device unmanaged"); + let devices = bounded("refresh unmanaged device", DBUS_TIMEOUT, nm.list_devices()) + .await + .expect("failed to refresh devices after changing managed state"); + let device = devices + .iter() + .find(|device| device.interface == interface) + .expect("veth disappeared after changing managed state"); + assert_eq!(device.managed, Some(false)); + + bounded( + "restore the managed veth client", + DBUS_TIMEOUT, + nm.set_device_managed(&interface, true), + ) + .await + .expect("failed to restore managed state"); + let devices = bounded("refresh restored device", DBUS_TIMEOUT, nm.list_devices()) + .await + .expect("failed to refresh devices after restoring managed state"); + let device = devices + .iter() + .find(|device| device.interface == interface) + .expect("veth disappeared after restoring managed state"); + assert_eq!(device.managed, Some(true)); + let details = bounded( "list detailed wired devices", DBUS_TIMEOUT, From 75e5349b06cc9cf4339b0d51677d50d660778c99 Mon Sep 17 00:00:00 2001 From: ryux1 Date: Mon, 7 Sep 2026 04:37:02 +0200 Subject: [PATCH 2/2] test: cover active device control semantics --- nmrs/tests/integration_test.rs | 141 ++++++++++++++++++++------------- 1 file changed, 88 insertions(+), 53 deletions(-) diff --git a/nmrs/tests/integration_test.rs b/nmrs/tests/integration_test.rs index d3a740e9..b3b8d28c 100644 --- a/nmrs/tests/integration_test.rs +++ b/nmrs/tests/integration_test.rs @@ -195,6 +195,16 @@ async fn disconnect_device(nm: &NetworkManager, interface: &str) -> nmrs::Result async fn cleanup_wired_profile(nm: &NetworkManager, interface: &str) -> Vec { let mut failures = Vec::new(); + match timeout(DBUS_TIMEOUT, nm.set_device_managed(interface, true)).await { + Ok(Ok(())) => {} + Ok(Err(error)) => failures.push(format!("restore managed state for {interface}: {error}")), + Err(_) => failures.push(format!("restore managed state for {interface}: timed out")), + } + match timeout(DBUS_TIMEOUT, nm.set_device_autoconnect(interface, true)).await { + Ok(Ok(())) => {} + Ok(Err(error)) => failures.push(format!("restore autoconnect for {interface}: {error}")), + Err(_) => failures.push(format!("restore autoconnect for {interface}: timed out")), + } match timeout(DBUS_TIMEOUT, disconnect_device(nm, interface)).await { Ok(Ok(())) => {} Ok(Err(error)) => failures.push(format!("disconnect {interface}: {error}")), @@ -967,68 +977,28 @@ async fn wired_connection_lifecycle() { .find(|device| device.interface == interface) .unwrap_or_else(|| { panic!("managed veth interface {interface:?} was missing: {devices:?}") - }); + }); assert_eq!(device.managed, Some(true)); - let initial_autoconnect = device - .autoconnect - .expect("managed veth did not expose its autoconnect state"); + assert_eq!(device.autoconnect, Some(true)); assert!(!device.path.is_empty()); - bounded( - "disable autoconnect on the managed veth client", - DBUS_TIMEOUT, - nm.set_device_autoconnect(&interface, false), - ) - .await - .expect("failed to disable device autoconnect"); - let devices = bounded("refresh devices", DBUS_TIMEOUT, nm.list_wired_devices()) - .await - .expect("failed to refresh wired devices"); - let device = devices - .iter() - .find(|device| device.interface == interface) - .expect("veth disappeared after disabling autoconnect"); - assert_eq!(device.autoconnect, Some(false)); - - bounded( - "restore autoconnect on the managed veth client", - DBUS_TIMEOUT, - nm.set_device_autoconnect(&interface, initial_autoconnect), - ) - .await - .expect("failed to restore device autoconnect"); - - bounded( - "make the veth client unmanaged", + let missing_interface = "nmrs-missing-interface"; + let error = bounded( + "reject autoconnect changes for a missing interface", DBUS_TIMEOUT, - nm.set_device_managed(&interface, false), + nm.set_device_autoconnect(missing_interface, false), ) .await - .expect("failed to make device unmanaged"); - let devices = bounded("refresh unmanaged device", DBUS_TIMEOUT, nm.list_devices()) - .await - .expect("failed to refresh devices after changing managed state"); - let device = devices - .iter() - .find(|device| device.interface == interface) - .expect("veth disappeared after changing managed state"); - assert_eq!(device.managed, Some(false)); - - bounded( - "restore the managed veth client", + .expect_err("missing interface unexpectedly accepted an autoconnect change"); + assert!(matches!(error, ConnectionError::InterfaceNotFound(name) if name == missing_interface)); + let error = bounded( + "reject managed-state changes for a missing interface", DBUS_TIMEOUT, - nm.set_device_managed(&interface, true), + nm.set_device_managed(missing_interface, false), ) .await - .expect("failed to restore managed state"); - let devices = bounded("refresh restored device", DBUS_TIMEOUT, nm.list_devices()) - .await - .expect("failed to refresh devices after restoring managed state"); - let device = devices - .iter() - .find(|device| device.interface == interface) - .expect("veth disappeared after restoring managed state"); - assert_eq!(device.managed, Some(true)); + .expect_err("missing interface unexpectedly accepted a managed-state change"); + assert!(matches!(error, ConnectionError::InterfaceNotFound(name) if name == missing_interface)); let details = bounded( "list detailed wired devices", @@ -1109,6 +1079,71 @@ async fn wired_connection_lifecycle() { .is_some_and(|address| address.starts_with("192.168.251.")) ); + bounded( + "disable autoconnect on the active veth client", + DBUS_TIMEOUT, + nm.set_device_autoconnect(&interface, false), + ) + .await + .expect("failed to disable device autoconnect"); + let devices = bounded("refresh devices", DBUS_TIMEOUT, nm.list_wired_devices()) + .await + .expect("failed to refresh wired devices"); + let device = devices + .iter() + .find(|device| device.interface == interface) + .expect("veth disappeared after disabling autoconnect"); + assert_eq!(device.autoconnect, Some(false)); + assert!(active_connections(&nm).await.iter().any( + |connection| matches!(connection, ActiveConnection::Wired(wired) if wired.uuid == saved_uuid) + )); + + bounded( + "make the active veth client unmanaged", + DBUS_TIMEOUT, + nm.set_device_managed(&interface, false), + ) + .await + .expect("failed to make device unmanaged"); + timeout(EVENT_TIMEOUT, async { + loop { + let devices = bounded("refresh unmanaged device", DBUS_TIMEOUT, nm.list_devices()) + .await + .expect("failed to refresh devices after changing managed state"); + let device = devices + .iter() + .find(|device| device.interface == interface) + .expect("veth disappeared after changing managed state"); + let connection_is_active = active_connections(&nm).await.iter().any( + |connection| matches!(connection, ActiveConnection::Wired(wired) if wired.uuid == saved_uuid), + ); + if device.managed == Some(false) + && device.state == DeviceState::Unmanaged + && !connection_is_active + { + break; + } + sleep(Duration::from_millis(25)).await; + } + }) + .await + .expect("managed=false did not deactivate the veth connection"); + + bounded( + "restore the managed veth client", + DBUS_TIMEOUT, + nm.set_device_managed(&interface, true), + ) + .await + .expect("failed to restore managed state"); + bounded( + "restore autoconnect on the managed veth client", + DBUS_TIMEOUT, + nm.set_device_autoconnect(&interface, true), + ) + .await + .expect("failed to restore device autoconnect"); + bounded( "disconnect the managed veth client", DBUS_TIMEOUT,