diff --git a/docs/src/api/network-manager.md b/docs/src/api/network-manager.md index 642db8fa..43d59f4e 100644 --- a/docs/src/api/network-manager.md +++ b/docs/src/api/network-manager.md @@ -153,6 +153,13 @@ devices that NetworkManager reports as `veth`. | `connectivity_report()` | `Result` | Full report with captive portal URL | | `captive_portal_url()` | `Result>` | Captive portal URL if in Portal state | +## Global DNS + +| Method | Returns | Description | +|--------|---------|-------------| +| `global_dns_configuration()` | `Result` | Read the manager-wide DNS override | +| `set_global_dns_configuration(&config)` | `Result<()>` | Write it; empty config clears the override | + ## Bluetooth Methods | Method | Returns | Description | diff --git a/nmrs/CHANGELOG.md b/nmrs/CHANGELOG.md index 7bb85f0f..0f3e7103 100644 --- a/nmrs/CHANGELOG.md +++ b/nmrs/CHANGELOG.md @@ -4,11 +4,17 @@ 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)) +- `NetworkManager::global_dns_configuration()` / + `set_global_dns_configuration()` read and write the manager + `GlobalDnsConfiguration` property. An empty value clears the + override. ([#540](https://github.com/freedesktop-rs/nmrs/issues/540)) + ### Fixed diff --git a/nmrs/src/api/models/dns.rs b/nmrs/src/api/models/dns.rs new file mode 100644 index 00000000..3a96481d --- /dev/null +++ b/nmrs/src/api/models/dns.rs @@ -0,0 +1,315 @@ +//! Global DNS override exposed as NetworkManager.GlobalDnsConfiguration. + +use std::collections::HashMap; +use zvariant::{OwnedValue, Value}; + +use super::error::ConnectionError; + +/// One domain entry under `domains`. +#[non_exhaustive] +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct GlobalDnsDomain { + /// Nameservers for this domain (plain IPs or `dns+udp` / `dns+tls` URIs). + pub servers: Vec, + + /// Domain-specific resolver options. + pub options: Vec, +} + +impl GlobalDnsDomain { + /// Empty domain entry. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Sets this domain's nameservers. + #[must_use] + pub fn with_servers(mut self, servers: impl Into>) -> Self { + self.servers = servers.into(); + self + } + + /// Sets this domain's resolver options. + #[must_use] + pub fn with_options(mut self, options: impl Into>) -> Self { + self.options = options.into(); + self + } +} + +/// Typed form of NetworkManager's `GlobalDnsConfiguration` property. +/// +/// An empty value (no searches, options, or domain servers) serializes to an +/// empty dict and clears the global override. +#[non_exhaustive] +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct GlobalDnsConfiguration { + /// Global search domains, applied when the override is active. + pub searches: Vec, + + /// Global resolver options (for example `timeout:2`, `rotate`). + pub options: Vec, + + /// Per-domain configuration. The `"*"` key is the default domain and is + /// required on any non-empty override. + pub domains: HashMap, +} + +impl GlobalDnsConfiguration { + /// Empty configuration; writing it clears the global override. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Default-domain nameservers only (`domains["*"].servers`). + #[must_use] + pub fn from_servers(servers: impl Into>) -> Self { + Self::new().with_default_servers(servers) + } + + /// Sets the global search domains. + #[must_use] + pub fn with_searches(mut self, searches: impl Into>) -> Self { + self.searches = searches.into(); + self + } + + /// Sets the global resolver options. + #[must_use] + pub fn with_options(mut self, options: impl Into>) -> Self { + self.options = options.into(); + self + } + + /// Inserts or replaces one domain entry. + #[must_use] + pub fn with_domain(mut self, name: impl Into, domain: GlobalDnsDomain) -> Self { + self.domains.insert(name.into(), domain); + self + } + + /// Sets nameservers for the default `"*"` domain. + #[must_use] + pub fn with_default_servers(self, servers: impl Into>) -> Self { + self.with_domain("*", GlobalDnsDomain::new().with_servers(servers)) + } + + /// `true` when writing this value should send an empty dict. + #[must_use] + pub fn is_empty(&self) -> bool { + self.searches.is_empty() + && self.options.is_empty() + && self + .domains + .values() + .all(|domain| domain.servers.is_empty() && domain.options.is_empty()) + } + + /// Validates a value before writing it to NetworkManager. + /// + /// Empty configs are valid and clear the override. A non-empty config must + /// include the default `"*"` domain, and that domain must list at least one + /// nameserver. + /// + /// # Errors + /// + /// Returns [`ConnectionError::InvalidInput`] when the default domain or its + /// servers list is missing. + pub fn validate(&self) -> Result<(), ConnectionError> { + if self.is_empty() { + return Ok(()); + } + + let Some(default_domain) = self.domains.get("*") else { + return Err(ConnectionError::InvalidInput { + field: "domains".into(), + reason: "missing default domain \"*\"".into(), + }); + }; + + if default_domain.servers.is_empty() { + return Err(ConnectionError::InvalidInput { + field: "domains.*.servers".into(), + reason: "default domain \"*\" must include at least one nameserver".into(), + }); + } + + Ok(()) + } + + /// Nameservers configured on the default `"*"` domain. + #[must_use] + pub fn default_servers(&self) -> &[String] { + self.domains + .get("*") + .map(|domain| domain.servers.as_slice()) + .unwrap_or(&[]) + } + + pub(crate) fn to_dbus(&self) -> HashMap<&'static str, Value<'static>> { + if self.is_empty() { + return HashMap::new(); + } + + let mut map = HashMap::new(); + if !self.searches.is_empty() { + map.insert("searches", Value::from(self.searches.clone())); + } + if !self.options.is_empty() { + map.insert("options", Value::from(self.options.clone())); + } + if !self.domains.is_empty() { + let mut domains = HashMap::new(); + for (name, domain) in &self.domains { + domains.insert(name.clone(), domain_to_dbus(domain)); + } + map.insert("domains", Value::from(domains)); + } + map + } + + pub(crate) fn from_dbus(map: &HashMap) -> Self { + let searches = take_strings(map, "searches"); + let options = take_strings(map, "options"); + let mut domains = HashMap::new(); + + if let Some(value) = map.get("domains") + && let Ok(raw_domains) = HashMap::::try_from(value.clone()) + { + for (name, raw_domain) in raw_domains { + let inner = HashMap::::try_from(raw_domain).unwrap_or_default(); + domains.insert( + name, + GlobalDnsDomain { + servers: take_strings(&inner, "servers"), + options: take_strings(&inner, "options"), + }, + ); + } + } + + Self { + searches, + options, + domains, + } + } +} + +fn domain_to_dbus(domain: &GlobalDnsDomain) -> Value<'static> { + let mut inner: HashMap<&str, Value<'static>> = HashMap::new(); + if !domain.servers.is_empty() { + inner.insert("servers", Value::from(domain.servers.clone())); + } + if !domain.options.is_empty() { + inner.insert("options", Value::from(domain.options.clone())); + } + Value::from(inner) +} + +fn take_strings(map: &HashMap, key: &str) -> Vec { + map.get(key) + .and_then(|value| Vec::::try_from(value.clone()).ok()) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use zvariant::Str; + + #[test] + fn empty_encodes_as_empty_dict() { + let encoded = GlobalDnsConfiguration::default().to_dbus(); + assert!(encoded.is_empty()); + } + + #[test] + fn empty_dict_decodes_as_empty_config() { + let decoded = GlobalDnsConfiguration::from_dbus(&HashMap::new()); + assert!(decoded.is_empty()); + assert!(decoded.default_servers().is_empty()); + } + + #[test] + fn default_servers_round_trip() { + let original = + GlobalDnsConfiguration::from_servers(vec!["1.1.1.1".into(), "8.8.8.8".into()]); + + let encoded = original.to_dbus(); + assert!(!encoded.contains_key("searches")); + assert!(encoded.contains_key("domains")); + + let owned: HashMap = encoded + .into_iter() + .map(|(k, v)| (k.to_string(), OwnedValue::try_from(v).expect("owned value"))) + .collect(); + let decoded = GlobalDnsConfiguration::from_dbus(&owned); + + assert_eq!(decoded.default_servers(), &["1.1.1.1", "8.8.8.8"]); + assert!(decoded.searches.is_empty()); + assert!(decoded.options.is_empty()); + } + + #[test] + fn searches_options_and_split_domain_round_trip() { + let original = GlobalDnsConfiguration::new() + .with_searches(vec!["example.test".into()]) + .with_options(vec!["timeout:2".into()]) + .with_default_servers(vec!["9.9.9.9".into()]) + .with_domain( + "corp.example", + GlobalDnsDomain::new().with_servers(vec!["10.0.0.1".into()]), + ); + + let owned: HashMap = original + .to_dbus() + .into_iter() + .map(|(k, v)| (k.to_string(), OwnedValue::try_from(v).expect("owned value"))) + .collect(); + let decoded = GlobalDnsConfiguration::from_dbus(&owned); + + assert_eq!(decoded, original); + } + + #[test] + fn missing_keys_decode_as_empty() { + let decoded = GlobalDnsConfiguration::from_dbus(&HashMap::from([( + "unrelated".into(), + OwnedValue::from(Str::from("x")), + )])); + assert!(decoded.is_empty()); + } + + #[test] + fn validate_accepts_empty_and_default_servers() { + GlobalDnsConfiguration::default().validate().unwrap(); + GlobalDnsConfiguration::from_servers(vec!["1.1.1.1".into()]) + .validate() + .unwrap(); + } + + #[test] + fn validate_rejects_missing_default_domain() { + let config = GlobalDnsConfiguration::new().with_searches(vec!["example.test".into()]); + assert!(matches!( + config.validate().unwrap_err(), + ConnectionError::InvalidInput { field, reason } + if field == "domains" && reason.contains("missing default domain") + )); + } + + #[test] + fn validate_rejects_default_domain_without_servers() { + let config = GlobalDnsConfiguration::new() + .with_searches(vec!["example.test".into()]) + .with_domain("*", GlobalDnsDomain::new()); + assert!(matches!( + config.validate().unwrap_err(), + ConnectionError::InvalidInput { field, reason } + if field == "domains.*.servers" && reason.contains("nameserver") + )); + } +} diff --git a/nmrs/src/api/models/mod.rs b/nmrs/src/api/models/mod.rs index a29dd252..8d5cb269 100644 --- a/nmrs/src/api/models/mod.rs +++ b/nmrs/src/api/models/mod.rs @@ -5,6 +5,7 @@ mod config; mod connection_state; mod connectivity; mod device; +mod dns; mod error; mod monitor; mod network_event; @@ -18,6 +19,7 @@ mod vpn; mod wifi; mod wireguard; +pub use dns::*; use std::fmt; pub(crate) struct Redacted; diff --git a/nmrs/src/api/network_manager.rs b/nmrs/src/api/network_manager.rs index 0d3f192a..ba528fd1 100644 --- a/nmrs/src/api/network_manager.rs +++ b/nmrs/src/api/network_manager.rs @@ -11,9 +11,9 @@ use crate::api::models::snapshot::{ saved_wifi_profiles as filter_saved_wifi_profiles, }; use crate::api::models::{ - ActiveConnection, AirplaneModeState, ConnectionError, Device, MonitorHandle, Network, - NetworkInfo, NetworkSnapshot, RadioState, SavedConnection, SavedConnectionBrief, SettingsPatch, - WifiDevice, WifiSecurity, WiredDevice, + ActiveConnection, AirplaneModeState, ConnectionError, Device, GlobalDnsConfiguration, + MonitorHandle, Network, NetworkInfo, NetworkSnapshot, RadioState, SavedConnection, + SavedConnectionBrief, SettingsPatch, WifiDevice, WifiSecurity, WiredDevice, }; use crate::api::wifi_scope::WifiScope; use crate::core::active_connection as active_connections; @@ -1198,6 +1198,53 @@ impl NetworkManager { Ok(report.captive_portal_url) } + /// Reads NetworkManager's global DNS override. + /// + /// An empty value means no override: per-connection DNS is used. + pub async fn global_dns_configuration(&self) -> Result { + crate::core::dns::global_dns_configuration(&self.conn).await + } + + /// Writes the global DNS override. + /// + /// Pass [`GlobalDnsConfiguration::default()`] (or any empty value) to clear + /// the override. A non-empty value must include the `"*"` default domain + /// with at least one nameserver. + /// + /// # Errors + /// + /// Returns [`ConnectionError::InvalidInput`] if a non-empty config is missing + /// the `"*"` domain or that domain has no servers. + /// + /// NetworkManager itself refuses the write when a `[global-dns]` section is + /// already set in `NetworkManager.conf` (file config wins over D-Bus). The + /// caller also needs the `settings.modify.global-dns` polkit action; + /// otherwise the D-Bus set fails with an authorization error. + /// + /// # Example + /// + /// ```no_run + /// use nmrs::{GlobalDnsConfiguration, NetworkManager}; + /// + /// # async fn example() -> nmrs::Result<()> { + /// let nm = NetworkManager::new().await?; + /// nm.set_global_dns_configuration( + /// &GlobalDnsConfiguration::from_servers(vec![ + /// "1.1.1.1".into(), + /// "8.8.8.8".into(), + /// ]), + /// ) + /// .await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn set_global_dns_configuration( + &self, + config: &GlobalDnsConfiguration, + ) -> Result<()> { + crate::core::dns::set_global_dns_configuration(&self.conn, config).await + } + /// Disable or re-enable a single Wi-Fi interface. /// /// Sets `Device.Autoconnect = enabled` and, when disabling, calls diff --git a/nmrs/src/core/dns.rs b/nmrs/src/core/dns.rs new file mode 100644 index 00000000..0354c3bf --- /dev/null +++ b/nmrs/src/core/dns.rs @@ -0,0 +1,34 @@ +//! Global DNS configuration property reads and writes. + +use zbus::Connection; + +use crate::Result; +use crate::api::models::{ConnectionError, GlobalDnsConfiguration}; +use crate::dbus::NMProxy; + +pub(crate) async fn global_dns_configuration(conn: &Connection) -> Result { + let nm = NMProxy::new(conn).await?; + let raw = + nm.global_dns_configuration() + .await + .map_err(|source| ConnectionError::DbusOperation { + context: "read GlobalDnsConfiguration property".into(), + source, + })?; + Ok(GlobalDnsConfiguration::from_dbus(&raw)) +} + +pub(crate) async fn set_global_dns_configuration( + conn: &Connection, + config: &GlobalDnsConfiguration, +) -> Result<()> { + config.validate()?; + + let nm = NMProxy::new(conn).await?; + nm.set_global_dns_configuration(config.to_dbus()) + .await + .map_err(|source| ConnectionError::DbusOperation { + context: "set GlobalDnsConfiguration property".into(), + source, + }) +} diff --git a/nmrs/src/core/mod.rs b/nmrs/src/core/mod.rs index 31bc02ba..c1a2bb40 100644 --- a/nmrs/src/core/mod.rs +++ b/nmrs/src/core/mod.rs @@ -11,6 +11,7 @@ pub(crate) mod connection_settings; pub(crate) mod connectivity; pub(crate) mod custom_connection; pub(crate) mod device; +pub(crate) mod dns; pub(crate) mod ovpn_parser; pub(crate) mod rfkill; pub(crate) mod saved_connection; diff --git a/nmrs/src/dbus/main_nm.rs b/nmrs/src/dbus/main_nm.rs index f02a1ea1..fe849951 100644 --- a/nmrs/src/dbus/main_nm.rs +++ b/nmrs/src/dbus/main_nm.rs @@ -96,4 +96,17 @@ pub trait NM { /// Forces a fresh connectivity check; blocks until done. fn check_connectivity(&self) -> zbus::Result; + + /// Global DNS override (`a{sv}`). Empty dict clears it. + #[zbus(property)] + fn global_dns_configuration( + &self, + ) -> zbus::Result>; + + /// Write the global DNS override. + #[zbus(property)] + fn set_global_dns_configuration( + &self, + value: std::collections::HashMap<&str, zvariant::Value<'_>>, + ) -> zbus::Result<()>; } diff --git a/nmrs/src/lib.rs b/nmrs/src/lib.rs index b4b14bed..9b757b74 100644 --- a/nmrs/src/lib.rs +++ b/nmrs/src/lib.rs @@ -442,14 +442,15 @@ pub use api::models::{ AppletNetworkSummary, BluetoothDevice, BluetoothIdentity, BluetoothNetworkRole, ConnectByUuidConfig, ConnectType, ConnectionError, ConnectionOptions, ConnectionStateReason, ConnectivityReport, ConnectivityState, Device, DeviceState, DeviceType, EapMethod, EapOptions, - MonitorHandle, Network, NetworkEvent, NetworkEventStream, NetworkInfo, NetworkSnapshot, - OpenVpnAuthType, OpenVpnCompression, OpenVpnConfig, OpenVpnConnectionType, OpenVpnProxy, - Phase2, RadioState, SavedConnection, SavedConnectionBrief, SavedVpnSummary, SecurityFeatures, - SettingsChange, SettingsEventStream, SettingsPatch, SettingsSummary, StateReason, - TimeoutConfig, VlanConfig, VpnConfig, VpnConfiguration, VpnConnection, VpnConnectionInfo, - VpnCredentials, VpnDetails, VpnKind, VpnRoute, VpnSecretFlags, VpnType, WifiDevice, - WifiKeyMgmt, WifiNetworkGroup, WifiSecurity, WifiSecuritySummary, WireGuardConfig, - WireGuardPeer, WiredDevice, connection_state_reason_to_error, reason_to_error, + GlobalDnsConfiguration, GlobalDnsDomain, MonitorHandle, Network, NetworkEvent, + NetworkEventStream, NetworkInfo, NetworkSnapshot, OpenVpnAuthType, OpenVpnCompression, + OpenVpnConfig, OpenVpnConnectionType, OpenVpnProxy, Phase2, RadioState, SavedConnection, + SavedConnectionBrief, SavedVpnSummary, SecurityFeatures, SettingsChange, SettingsEventStream, + SettingsPatch, SettingsSummary, StateReason, TimeoutConfig, VlanConfig, VpnConfig, + VpnConfiguration, VpnConnection, VpnConnectionInfo, VpnCredentials, VpnDetails, VpnKind, + VpnRoute, VpnSecretFlags, VpnType, WifiDevice, WifiKeyMgmt, WifiNetworkGroup, WifiSecurity, + WifiSecuritySummary, WireGuardConfig, WireGuardPeer, WiredDevice, + connection_state_reason_to_error, reason_to_error, }; pub use api::network_manager::NetworkManager; pub use api::wifi_scope::WifiScope; diff --git a/nmrs/tests/integration_test.rs b/nmrs/tests/integration_test.rs index 8fcdfeab..5284030f 100644 --- a/nmrs/tests/integration_test.rs +++ b/nmrs/tests/integration_test.rs @@ -11,9 +11,9 @@ use nmrs::builders::WireGuardBuilder; use nmrs::raw::zvariant::{OwnedObjectPath, OwnedValue, Value}; use nmrs::{ ActiveConnection, ActiveConnectionState, ConnectByUuidConfig, ConnectType, ConnectionError, - DeviceState, MonitorHandle, NetworkEvent, NetworkEventStream, NetworkManager, SettingsChange, - SettingsEventStream, SettingsPatch, SettingsSummary, TimeoutConfig, WifiKeyMgmt, WifiScope, - WifiSecurity, WireGuardPeer, + DeviceState, GlobalDnsConfiguration, MonitorHandle, NetworkEvent, NetworkEventStream, + NetworkManager, SettingsChange, SettingsEventStream, SettingsPatch, SettingsSummary, + TimeoutConfig, WifiKeyMgmt, WifiScope, WifiSecurity, WireGuardPeer, }; use serial_test::serial; use tokio::time::{sleep, timeout}; @@ -343,6 +343,67 @@ async fn active_connections(nm: &NetworkManager) -> Vec { #[tokio::test] #[serial] #[ignore = "requires NMRS_REQUIRE_NETWORKMANAGER=1 and an isolated NetworkManager"] +async fn networkmanager_global_dns_configuration_round_trip() { + let nm = network_manager().await; + + let original = bounded( + "read GlobalDnsConfiguration", + DBUS_TIMEOUT, + nm.global_dns_configuration(), + ) + .await + .expect("failed to read GlobalDnsConfiguration"); + + let desired = GlobalDnsConfiguration::from_servers(vec!["1.1.1.1".into(), "8.8.8.8".into()]) + .with_searches(vec!["example.test".into()]); + + bounded( + "write GlobalDnsConfiguration", + DBUS_TIMEOUT, + nm.set_global_dns_configuration(&desired), + ) + .await + .expect("failed to write GlobalDnsConfiguration"); + + let read_back = bounded( + "read GlobalDnsConfiguration after write", + DBUS_TIMEOUT, + nm.global_dns_configuration(), + ) + .await + .expect("failed to read GlobalDnsConfiguration after write"); + + assert_eq!(read_back.default_servers(), desired.default_servers()); + assert_eq!(read_back.searches, desired.searches); + + bounded( + "clear GlobalDnsConfiguration", + DBUS_TIMEOUT, + nm.set_global_dns_configuration(&GlobalDnsConfiguration::default()), + ) + .await + .expect("failed to clear GlobalDnsConfiguration"); + + let cleared = bounded( + "read GlobalDnsConfiguration after clear", + DBUS_TIMEOUT, + nm.global_dns_configuration(), + ) + .await + .expect("failed to read GlobalDnsConfiguration after clear"); + assert!(cleared.is_empty()); + + bounded( + "restore GlobalDnsConfiguration", + DBUS_TIMEOUT, + nm.set_global_dns_configuration(&original), + ) + .await + .expect("failed to restore GlobalDnsConfiguration"); +} +#[tokio::test] +#[serial] +#[ignore = "requires NMRS_REQUIRE_NETWORKMANAGER=1 and an isolated NetworkManager"] async fn networkmanager_profile_crud_and_settings_events() { let nm = network_manager().await; let mut events = bounded(