diff --git a/Cargo.lock b/Cargo.lock index 3a7ccc81..6efb6cfa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -963,10 +963,11 @@ dependencies = [ [[package]] name = "nmrs" -version = "2.3.0" +version = "2.4.0" dependencies = [ "async-trait", "base64", + "bitflags", "futures", "futures-timer", "log", diff --git a/Cargo.toml b/Cargo.toml index b0115e3e..a5e4521c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,5 +41,6 @@ fs2 = "0.4.3" anyhow = "1.0.102" clap = { version = "4.6.1", features = ["derive"] } async-trait = "0.1.89" +bitflags = "2" tokio = { version = "1.52.1", features = ["rt-multi-thread", "macros", "sync", "time"] } tokio-util = { version = "0.7.18" } diff --git a/nmrs/CHANGELOG.md b/nmrs/CHANGELOG.md index b2d85f24..630e0169 100644 --- a/nmrs/CHANGELOG.md +++ b/nmrs/CHANGELOG.md @@ -3,11 +3,12 @@ All notable changes to the `nmrs` crate will be documented in this file. ## [Unreleased] -### Changed -- Introduce `VpnConfig` trait and refactor `connect_vpn` signature ([#303](https://github.com/cachebag/nmrs/pull/303)) - ### Added +- `nmrs::agent` module: NetworkManager secret agent for credential prompting over D-Bus (`SecretAgent`, `SecretAgentBuilder`, `SecretAgentHandle`, `SecretRequest`, `SecretResponder`, `SecretSetting`, `SecretAgentFlags`, `SecretAgentCapabilities`, `CancelReason`, `SecretStoreEvent`) - `VpnConfig` trait and `WireGuardConfig`; `NetworkManager::connect_vpn` accepts `VpnConfig` implementors; `VpnCredentials` deprecated with compatibility bridges ([#303](https://github.com/cachebag/nmrs/pull/303)) + +### Changed +- Introduce `VpnConfig` trait and refactor `connect_vpn` signature ([#303](https://github.com/cachebag/nmrs/pull/303)) - OpenVPN connection settings model expansion ([#309](https://github.com/cachebag/nmrs/pull/309)) - Multi-VPN plumbing: `detect_vpn_type()`, `VpnType::OpenVpn`, and shared detection across connect, disconnect, and list VPN flows ([#311](https://github.com/cachebag/nmrs/pull/311)) - `.ovpn` profile lexer and parser for translating OpenVPN configs toward NetworkManager ([#314](https://github.com/cachebag/nmrs/pull/314)) diff --git a/nmrs/Cargo.toml b/nmrs/Cargo.toml index 38df9a5a..aca499d4 100644 --- a/nmrs/Cargo.toml +++ b/nmrs/Cargo.toml @@ -24,6 +24,7 @@ futures-timer.workspace = true base64.workspace = true tokio.workspace = true async-trait.workspace = true +bitflags.workspace = true [package.metadata.docs.rs] all-features = true @@ -42,3 +43,7 @@ path = "examples/wifi_scan.rs" [[example]] name = "vpn_connect" path = "examples/vpn_connect.rs" + +[[example]] +name = "secret_agent" +path = "examples/secret_agent.rs" diff --git a/nmrs/README.md b/nmrs/README.md index af12ff04..9e4da015 100644 --- a/nmrs/README.md +++ b/nmrs/README.md @@ -18,6 +18,7 @@ Rust bindings for NetworkManager via D-Bus. - **Network Discovery**: Scan and list available access points with signal strength - **Profile Management**: Create, query, and delete saved connection profiles - **Real-Time Monitoring**: Signal-based network and device state change notifications +- **Secret Agent**: Respond to NetworkManager credential prompts via an async stream API - **Typed Errors**: Structured error types with specific failure reasons - **Fully Async**: Built on `zbus` with async/await throughout @@ -157,6 +158,36 @@ async fn main() -> nmrs::Result<()> { } ``` +### Secret Agent + +Register as a NetworkManager secret agent to handle credential prompts +(Wi-Fi passwords, VPN tokens, 802.1X credentials): + +```rust +use futures::StreamExt; +use nmrs::agent::{SecretAgent, SecretAgentFlags, SecretSetting}; + +#[tokio::main] +async fn main() -> nmrs::Result<()> { + let (handle, mut requests) = SecretAgent::builder() + .with_identifier("com.example.my_app") + .register() + .await?; + + while let Some(req) = requests.next().await { + if let SecretSetting::WifiPsk { ref ssid } = req.setting { + println!("Password needed for {ssid}"); + req.responder.wifi_psk("my-password").await?; + } else { + req.responder.cancel().await?; + } + } + + handle.unregister().await?; + Ok(()) +} +``` + ### Real-Time Monitoring ```rust diff --git a/nmrs/examples/secret_agent.rs b/nmrs/examples/secret_agent.rs new file mode 100644 index 00000000..b7eea700 --- /dev/null +++ b/nmrs/examples/secret_agent.rs @@ -0,0 +1,66 @@ +/// Registers a NetworkManager secret agent, prints incoming requests, +/// and responds to Wi-Fi PSK prompts by reading a password from stdin. +/// +/// Run with: +/// +/// ```sh +/// cargo run --example secret_agent +/// ``` +/// +/// Then trigger a password prompt (e.g. forget a saved Wi-Fi password and +/// reconnect). The agent will print the request and ask for input. +use std::io::{self, BufRead, Write}; + +use futures::StreamExt; +use nmrs::agent::{SecretAgent, SecretAgentFlags, SecretSetting}; + +#[tokio::main] +async fn main() -> nmrs::Result<()> { + let (handle, mut requests) = SecretAgent::builder() + .with_identifier("com.system76.nmrs.example.secret_agent") + .register() + .await?; + + println!("Secret agent registered. Waiting for requests…"); + println!("(The agent will exit after processing one request)\n"); + + if let Some(req) = requests.next().await { + println!("── Secret request ──"); + println!(" UUID: {}", req.connection_uuid); + println!(" Name: {}", req.connection_id); + println!(" Type: {}", req.connection_type); + println!(" Setting: {:?}", req.setting); + println!(" Hints: {:?}", req.hints); + println!(" Flags: {:?}", req.flags); + + if !req.flags.contains(SecretAgentFlags::ALLOW_INTERACTION) { + println!(" → interaction not allowed, cancelling"); + req.responder.cancel().await?; + } else { + match req.setting { + SecretSetting::WifiPsk { ref ssid } => { + print!(" Enter password for \"{ssid}\": "); + io::stdout().flush().expect("flush stdout"); + let mut line = String::new(); + io::stdin().lock().read_line(&mut line).expect("read stdin"); + let psk = line.trim(); + if psk.is_empty() { + println!(" → empty input, cancelling"); + req.responder.cancel().await?; + } else { + req.responder.wifi_psk(psk).await?; + println!(" → sent PSK"); + } + } + _ => { + println!(" → unsupported setting type, cancelling"); + req.responder.cancel().await?; + } + } + } + } + + handle.unregister().await?; + println!("Agent unregistered."); + Ok(()) +} diff --git a/nmrs/src/agent/builder.rs b/nmrs/src/agent/builder.rs new file mode 100644 index 00000000..1c054b84 --- /dev/null +++ b/nmrs/src/agent/builder.rs @@ -0,0 +1,322 @@ +//! Secret agent builder, handle, and lifecycle management. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use futures::channel::mpsc; +use log::debug; +use zbus::Connection; + +use crate::ConnectionError; +use crate::dbus::AgentManagerProxy; + +use super::iface::SecretAgentInterface; +use super::request::{CancelReason, SecretAgentCapabilities, SecretRequest, SecretStoreEvent}; + +const DEFAULT_IDENTIFIER: &str = "com.system76.CosmicApplets.nmrs.secret_agent"; +const DEFAULT_OBJECT_PATH: &str = "/com/system76/CosmicApplets/nmrs/SecretAgent"; +const DEFAULT_QUEUE_DEPTH: usize = 32; + +/// Entry point for creating a NetworkManager secret agent. +/// +/// A secret agent receives credential requests from NetworkManager over D-Bus +/// whenever a connection needs secrets the system does not already have (Wi-Fi +/// password forgotten, VPN token expired, etc.). +/// +/// Use [`SecretAgent::builder()`] to configure and register the agent. +/// +/// # Example +/// +/// ```no_run +/// use futures::StreamExt; +/// use nmrs::agent::{SecretAgent, SecretSetting}; +/// +/// # async fn example() -> nmrs::Result<()> { +/// let (handle, mut requests) = SecretAgent::builder().register().await?; +/// +/// while let Some(req) = requests.next().await { +/// if let SecretSetting::WifiPsk { ref ssid } = req.setting { +/// println!("password requested for {ssid}"); +/// req.responder.wifi_psk("my-password").await?; +/// } +/// } +/// # Ok(()) +/// # } +/// ``` +pub struct SecretAgent; + +impl SecretAgent { + /// Returns a builder for configuring and registering a secret agent. + #[must_use] + pub fn builder() -> SecretAgentBuilder { + SecretAgentBuilder::default() + } +} + +/// Builder for configuring and registering a [`SecretAgent`]. +/// +/// Use the `with_*` methods to override defaults, then call +/// [`register()`](Self::register) to connect to the system bus and start +/// serving. +/// +/// # Defaults +/// +/// | Setting | Default | +/// |---------|---------| +/// | identifier | `com.system76.CosmicApplets.nmrs.secret_agent` | +/// | capabilities | [`SecretAgentCapabilities::VPN_HINTS`] | +/// | object path | `/com/system76/CosmicApplets/nmrs/SecretAgent` | +/// | queue depth | 32 | +#[derive(Debug)] +pub struct SecretAgentBuilder { + identifier: String, + capabilities: SecretAgentCapabilities, + object_path: String, + queue_depth: usize, +} + +impl Default for SecretAgentBuilder { + fn default() -> Self { + Self { + identifier: DEFAULT_IDENTIFIER.into(), + capabilities: SecretAgentCapabilities::VPN_HINTS, + object_path: DEFAULT_OBJECT_PATH.into(), + queue_depth: DEFAULT_QUEUE_DEPTH, + } + } +} + +impl SecretAgentBuilder { + /// Sets the D-Bus well-known name the agent will own. + /// + /// This must be unique on the system bus. If another process already owns + /// the name, registration will fail with + /// [`ConnectionError::AgentAlreadyRegistered`]. + #[must_use] + pub fn with_identifier(mut self, identifier: impl Into) -> Self { + self.identifier = identifier.into(); + self + } + + /// Sets the capabilities advertised to NetworkManager. + /// + /// Defaults to [`SecretAgentCapabilities::VPN_HINTS`]. + #[must_use] + pub fn with_capabilities(mut self, capabilities: SecretAgentCapabilities) -> Self { + self.capabilities = capabilities; + self + } + + /// Sets the D-Bus object path where the agent interface is served. + #[must_use] + pub fn with_object_path(mut self, path: impl Into) -> Self { + self.object_path = path.into(); + self + } + + /// Sets the maximum number of `GetSecrets` requests to buffer before + /// back-pressure kicks in. Defaults to 32. + #[must_use] + pub fn with_queue_depth(mut self, depth: usize) -> Self { + self.queue_depth = depth; + self + } + + /// Connects to the system bus, registers the agent with NetworkManager, + /// and returns a handle and a stream of incoming secret requests. + /// + /// The returned [`mpsc::Receiver`](futures::channel::mpsc::Receiver) + /// implements [`Stream`](futures::Stream) and yields + /// [`SecretRequest`] items as they arrive from NetworkManager. + /// + /// # Errors + /// + /// - [`ConnectionError::AgentAlreadyRegistered`] if another process + /// already owns the requested bus name. + /// - [`ConnectionError::AgentRegistration`] if the bus name could not + /// be acquired or NetworkManager rejected the registration. + /// - [`ConnectionError::Dbus`] for other D-Bus failures. + pub async fn register( + self, + ) -> crate::Result<(SecretAgentHandle, mpsc::Receiver)> { + let (request_tx, request_rx) = mpsc::channel(self.queue_depth); + let (cancel_tx, cancel_rx) = mpsc::unbounded(); + let (store_tx, store_rx) = mpsc::unbounded(); + + let iface = SecretAgentInterface { + request_tx, + cancel_tx, + store_tx, + pending: Arc::new(Mutex::new(HashMap::new())), + }; + + let conn = Connection::system() + .await + .map_err(|e| ConnectionError::DbusOperation { + context: "connecting to system bus for secret agent".into(), + source: e, + })?; + + conn.object_server() + .at(&*self.object_path, iface) + .await + .map_err(|e| ConnectionError::DbusOperation { + context: format!("serving SecretAgent interface at {}", self.object_path), + source: e, + })?; + + conn.request_name(&*self.identifier).await.map_err(|e| { + ConnectionError::AgentRegistration { + context: format!("bus name '{}': {e}", self.identifier), + } + })?; + + debug!( + "Acquired bus name '{}', serving at '{}'", + self.identifier, self.object_path + ); + + let agent_proxy = + AgentManagerProxy::new(&conn) + .await + .map_err(|e| ConnectionError::DbusOperation { + context: "creating AgentManager proxy".into(), + source: e, + })?; + + agent_proxy + .register_with_capabilities(&self.identifier, self.capabilities.bits()) + .await + .map_err(|e| ConnectionError::DbusOperation { + context: "registering secret agent with NetworkManager".into(), + source: e, + })?; + + debug!( + "Registered secret agent '{}' with capabilities {:?}", + self.identifier, self.capabilities + ); + + let handle = SecretAgentHandle { + conn, + identifier: self.identifier, + capabilities: self.capabilities, + object_path: self.object_path, + cancel_rx, + store_rx, + }; + + Ok((handle, request_rx)) + } +} + +/// Handle to a running secret agent. +/// +/// Provides methods to re-register after a NetworkManager restart, access +/// the cancellation and store-event streams, and shut the agent down. +pub struct SecretAgentHandle { + conn: Connection, + identifier: String, + capabilities: SecretAgentCapabilities, + object_path: String, + cancel_rx: mpsc::UnboundedReceiver, + store_rx: mpsc::UnboundedReceiver, +} + +impl std::fmt::Debug for SecretAgentHandle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SecretAgentHandle") + .field("identifier", &self.identifier) + .field("object_path", &self.object_path) + .finish_non_exhaustive() + } +} + +impl SecretAgentHandle { + /// Re-registers the agent with NetworkManager. + /// + /// Call this after detecting that NetworkManager restarted (e.g. its + /// D-Bus name owner changed). The call is idempotent while the bus + /// connection is healthy. + /// + /// # Errors + /// + /// Returns an error if the D-Bus call to `RegisterWithCapabilities` fails. + pub async fn reregister(&self) -> crate::Result<()> { + let proxy = AgentManagerProxy::new(&self.conn).await.map_err(|e| { + ConnectionError::DbusOperation { + context: "creating AgentManager proxy for re-registration".into(), + source: e, + } + })?; + proxy + .register_with_capabilities(&self.identifier, self.capabilities.bits()) + .await + .map_err(|e| ConnectionError::DbusOperation { + context: "re-registering secret agent with NetworkManager".into(), + source: e, + })?; + debug!("Re-registered secret agent '{}'", self.identifier); + Ok(()) + } + + /// Unregisters the agent from NetworkManager and releases the bus name. + /// + /// After this call, the request stream returned by + /// [`SecretAgentBuilder::register`] will complete. + /// + /// # Errors + /// + /// Returns an error if the D-Bus `Unregister` call fails. + pub async fn unregister(self) -> crate::Result<()> { + let proxy = AgentManagerProxy::new(&self.conn).await.map_err(|e| { + ConnectionError::DbusOperation { + context: "creating AgentManager proxy for unregistration".into(), + source: e, + } + })?; + proxy + .unregister() + .await + .map_err(|e| ConnectionError::DbusOperation { + context: "unregistering secret agent".into(), + source: e, + })?; + self.conn + .release_name(&*self.identifier) + .await + .map_err(|e| ConnectionError::DbusOperation { + context: format!("releasing bus name '{}'", self.identifier), + source: e, + })?; + debug!("Unregistered secret agent '{}'", self.identifier); + Ok(()) + } + + /// Returns a mutable reference to the cancellation stream. + /// + /// Yields [`CancelReason`] items when NetworkManager calls + /// `CancelGetSecrets` for an in-flight request. By the time the + /// consumer receives this event, the agent has already replied to + /// NetworkManager. + /// + /// Use with [`StreamExt::next()`](futures::StreamExt::next): + /// + /// ```ignore + /// while let Some(reason) = handle.cancellations().next().await { + /// println!("cancelled: {}", reason.setting_name); + /// } + /// ``` + pub fn cancellations(&mut self) -> &mut mpsc::UnboundedReceiver { + &mut self.cancel_rx + } + + /// Returns a mutable reference to the save/delete event stream. + /// + /// Yields [`SecretStoreEvent`] items when NetworkManager sends + /// `SaveSecrets` or `DeleteSecrets`. These are informational — the agent + /// always acknowledges them immediately. + pub fn store_events(&mut self) -> &mut mpsc::UnboundedReceiver { + &mut self.store_rx + } +} diff --git a/nmrs/src/agent/iface.rs b/nmrs/src/agent/iface.rs new file mode 100644 index 00000000..3ac3c3de --- /dev/null +++ b/nmrs/src/agent/iface.rs @@ -0,0 +1,191 @@ +//! D-Bus object-server implementation for `org.freedesktop.NetworkManager.SecretAgent`. +//! +//! This is the interface that NetworkManager calls *into* when it needs +//! credentials for a connection. Each method translates the raw D-Bus call +//! into the channel-based API exposed by [`super::agent`]. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use futures::SinkExt; +use futures::channel::{mpsc, oneshot}; +use futures::future::{self, Either}; +use log::{debug, warn}; +use zvariant::{ObjectPath, OwnedObjectPath}; + +use crate::types::constants::timeouts; + +use super::request::{ + CancelReason, ConnectionDict, SecretAgentFlags, SecretReply, SecretRequest, SecretResponder, + SecretStoreEvent, extract_setting_string, parse_secret_setting, +}; + +/// Custom D-Bus error type for the SecretAgent interface. +/// +/// NM expects these specific error names when the agent refuses to provide +/// secrets. +#[derive(Debug, zbus::DBusError)] +#[zbus(prefix = "org.freedesktop.NetworkManager.SecretAgent")] +pub(crate) enum SecretAgentDBusError { + #[zbus(error)] + ZBus(zbus::Error), + UserCanceled(String), + NoSecrets(String), +} + +type PendingMap = Arc>>>; + +/// The object served at the agent's D-Bus path. Not part of the public API — +/// consumers interact through [`SecretRequest`] / [`SecretResponder`]. +pub(crate) struct SecretAgentInterface { + pub(crate) request_tx: mpsc::Sender, + pub(crate) cancel_tx: mpsc::UnboundedSender, + pub(crate) store_tx: mpsc::UnboundedSender, + pub(crate) pending: PendingMap, +} + +#[zbus::interface(name = "org.freedesktop.NetworkManager.SecretAgent")] +impl SecretAgentInterface { + /// Called by NetworkManager when a connection needs secrets. + /// + /// The method blocks (from NM's perspective) until the consumer replies + /// via [`SecretResponder`], the request is cancelled, or the timeout + /// expires. + async fn get_secrets( + &self, + connection: ConnectionDict, + connection_path: ObjectPath<'_>, + setting_name: &str, + hints: Vec, + flags: u32, + ) -> Result { + let path_owned: OwnedObjectPath = connection_path.into(); + let key = (path_owned.to_string(), setting_name.to_owned()); + + debug!( + "GetSecrets: path={} setting={} flags={:#x}", + path_owned, setting_name, flags + ); + + let (reply_tx, reply_rx) = oneshot::channel::(); + let (cancel_tx, cancel_rx) = oneshot::channel::<()>(); + + // Track this pending request so CancelGetSecrets can find it. + self.pending + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(key.clone(), cancel_tx); + + let setting = parse_secret_setting(&connection, setting_name); + let request = SecretRequest { + connection_uuid: extract_setting_string(&connection, "connection", "uuid") + .unwrap_or_default(), + connection_id: extract_setting_string(&connection, "connection", "id") + .unwrap_or_default(), + connection_type: extract_setting_string(&connection, "connection", "type") + .unwrap_or_default(), + connection_path: path_owned, + setting, + hints, + flags: SecretAgentFlags::from_bits_truncate(flags), + responder: SecretResponder::new(reply_tx, setting_name.to_owned()), + }; + + // Send to the consumer stream. If the channel is full or closed, + // reply NoSecrets immediately. + if self.request_tx.clone().send(request).await.is_err() { + self.pending + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(&key); + return Err(SecretAgentDBusError::NoSecrets( + "agent request channel closed".into(), + )); + } + + let timeout = futures_timer::Delay::new(timeouts::secret_agent_response_timeout()); + + // Wait for: consumer response, NM cancellation, or timeout. + let result = future::select(reply_rx, future::select(cancel_rx, timeout)).await; + + self.pending + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(&key); + + match result { + Either::Left((Ok(SecretReply::Secrets(map)), _)) => Ok(map), + Either::Left((Ok(SecretReply::UserCanceled), _)) => { + Err(SecretAgentDBusError::UserCanceled("user canceled".into())) + } + Either::Left((Ok(SecretReply::NoSecrets) | Err(_), _)) => Err( + SecretAgentDBusError::NoSecrets("no secrets available".into()), + ), + Either::Right((Either::Left(_cancel), _)) => { + debug!("GetSecrets cancelled by NetworkManager for {}", key.1); + Err(SecretAgentDBusError::UserCanceled( + "canceled by NetworkManager".into(), + )) + } + Either::Right((Either::Right(_timeout), _)) => { + warn!("GetSecrets timed out for setting {}", key.1); + Err(SecretAgentDBusError::NoSecrets( + "timeout waiting for consumer response".into(), + )) + } + } + } + + /// Called by NetworkManager when a pending `GetSecrets` should be aborted. + async fn cancel_get_secrets( + &self, + connection_path: ObjectPath<'_>, + setting_name: &str, + ) -> Result<(), SecretAgentDBusError> { + let key = (connection_path.to_string(), setting_name.to_owned()); + + debug!("CancelGetSecrets: path={} setting={}", key.0, key.1); + + if let Some(cancel_tx) = self + .pending + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(&key) + { + let _ = cancel_tx.send(()); + } + + let _ = self.cancel_tx.unbounded_send(CancelReason { + connection_path: connection_path.into(), + setting_name: setting_name.to_owned(), + }); + + Ok(()) + } + + /// Acknowledges a `SaveSecrets` call and forwards it to the store stream. + async fn save_secrets( + &self, + _connection: ConnectionDict, + connection_path: ObjectPath<'_>, + ) -> Result<(), SecretAgentDBusError> { + debug!("SaveSecrets: path={}", connection_path); + let _ = self.store_tx.unbounded_send(SecretStoreEvent::Save { + connection_path: connection_path.into(), + }); + Ok(()) + } + + /// Acknowledges a `DeleteSecrets` call and forwards it to the store stream. + async fn delete_secrets( + &self, + _connection: ConnectionDict, + connection_path: ObjectPath<'_>, + ) -> Result<(), SecretAgentDBusError> { + debug!("DeleteSecrets: path={}", connection_path); + let _ = self.store_tx.unbounded_send(SecretStoreEvent::Delete { + connection_path: connection_path.into(), + }); + Ok(()) + } +} diff --git a/nmrs/src/agent/mod.rs b/nmrs/src/agent/mod.rs new file mode 100644 index 00000000..5fde8d07 --- /dev/null +++ b/nmrs/src/agent/mod.rs @@ -0,0 +1,98 @@ +//! NetworkManager secret agent for credential prompting over D-Bus. +//! +//! When NetworkManager needs credentials it does not already have — a Wi-Fi +//! password was forgotten, a VPN token expired, an 802.1X password is required +//! — it calls every registered **secret agent** via D-Bus. This module lets +//! `nmrs` consumers register such an agent and respond to those requests +//! without touching raw D-Bus. +//! +//! # Three-stream model +//! +//! [`SecretAgentBuilder::register()`](crate::agent::SecretAgentBuilder::register) +//! returns a handle and three logical streams: +//! +//! 1. **Request stream** — the primary +//! [`mpsc::Receiver`](futures::channel::mpsc::Receiver) +//! returned alongside the handle. Each item is a credential prompt from +//! NetworkManager. Respond through the attached +//! [`SecretResponder`](crate::agent::SecretResponder). +//! +//! 2. **Cancellation stream** — accessed via +//! [`SecretAgentHandle::cancellations()`](crate::agent::SecretAgentHandle::cancellations). Yields +//! [`CancelReason`](crate::agent::CancelReason) items when +//! NetworkManager aborts a pending request. The agent replies to +//! NetworkManager automatically; this stream exists so the consumer can +//! tear down any UI it may have shown. +//! +//! 3. **Store event stream** — accessed via +//! [`SecretAgentHandle::store_events()`](crate::agent::SecretAgentHandle::store_events). Yields +//! [`SecretStoreEvent`](crate::agent::SecretStoreEvent) items when +//! NetworkManager asks the agent to save or delete persisted secrets. +//! Since `nmrs` delegates persistence to the consumer, these events are +//! optional and the agent always acknowledges them. +//! +//! # Lifecycle +//! +//! ```text +//! SecretAgent::builder() +//! .with_identifier("com.example.MyApp") +//! .register().await? +//! │ +//! ├── (SecretAgentHandle, request stream) +//! │ +//! │ ┌──────── consumer loop ────────┐ +//! │ │ while let Some(req) = rx … { │ +//! │ │ req.responder.wifi_psk(…) │ +//! │ │ } │ +//! │ └───────────────────────────────┘ +//! │ +//! └── handle.unregister().await? +//! ``` +//! +//! If NetworkManager restarts while the agent is running, call +//! [`SecretAgentHandle::reregister()`](crate::agent::SecretAgentHandle::reregister) +//! to re-register. +//! +//! # Example +//! +//! ```no_run +//! use futures::StreamExt; +//! use nmrs::agent::{SecretAgent, SecretAgentFlags, SecretSetting}; +//! +//! # async fn run() -> nmrs::Result<()> { +//! let (handle, mut requests) = SecretAgent::builder() +//! .with_identifier("com.example.demo") +//! .register() +//! .await?; +//! +//! while let Some(req) = requests.next().await { +//! if !req.flags.contains(SecretAgentFlags::ALLOW_INTERACTION) { +//! req.responder.no_secrets().await?; +//! continue; +//! } +//! match req.setting { +//! SecretSetting::WifiPsk { ref ssid } => { +//! println!("Password needed for {ssid}"); +//! req.responder.wifi_psk("secret").await?; +//! } +//! _ => req.responder.cancel().await?, +//! } +//! } +//! +//! handle.unregister().await?; +//! # Ok(()) +//! # } +//! ``` + +mod builder; +pub(crate) mod iface; +mod request; + +pub use builder::{SecretAgent, SecretAgentBuilder, SecretAgentHandle}; +pub use request::{ + CancelReason, SecretAgentCapabilities, SecretAgentFlags, SecretRequest, SecretResponder, + SecretSetting, SecretStoreEvent, +}; + +/// Type alias so agent consumers only need one error type. +pub type AgentError = crate::ConnectionError; diff --git a/nmrs/src/agent/request.rs b/nmrs/src/agent/request.rs new file mode 100644 index 00000000..8f1719b3 --- /dev/null +++ b/nmrs/src/agent/request.rs @@ -0,0 +1,432 @@ +//! Secret agent request and response types. + +use std::collections::HashMap; + +use log::warn; +use zvariant::{OwnedObjectPath, OwnedValue, Str}; + +use crate::ConnectionError; + +bitflags::bitflags! { + /// Flags passed by NetworkManager with a `GetSecrets` request. + /// + /// These correspond to `NMSecretAgentGetSecretsFlags` in the NetworkManager + /// D-Bus API. + /// + /// Reference: + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub struct SecretAgentFlags: u32 { + /// The agent may interact with the user (e.g. show a dialog). + const ALLOW_INTERACTION = 0x1; + /// The agent should discard cached secrets and prompt again. + const REQUEST_NEW = 0x2; + /// The request was triggered by an explicit user action, not auto-connect. + const USER_REQUESTED = 0x4; + /// WPS push-button mode is active on the access point. + const WPS_PBC_ACTIVE = 0x8; + } +} + +bitflags::bitflags! { + /// Capabilities advertised when registering the agent with NetworkManager. + /// + /// These correspond to `NMSecretAgentCapabilities` in the NetworkManager + /// D-Bus API. + /// + /// Reference: + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub struct SecretAgentCapabilities: u32 { + /// The agent supports VPN secret hints, allowing NetworkManager to + /// send a list of required secret keys instead of the full setting. + const VPN_HINTS = 0x1; + } +} + +/// Identifies which connection setting needs secrets. +/// +/// NetworkManager sends the setting name as part of a `GetSecrets` request. +/// This enum parses common setting names and extracts relevant context from +/// the connection dictionary. +#[non_exhaustive] +#[derive(Debug, Clone)] +pub enum SecretSetting { + /// 802.11 wireless security — typically a WPA/WPA2 PSK. + WifiPsk { + /// The SSID of the network requesting credentials. + ssid: String, + }, + /// 802.1X EAP authentication. + WifiEap { + /// The identity (username) if already configured. + identity: Option, + /// The EAP method if already configured (e.g. `"peap"`, `"ttls"`). + method: Option, + }, + /// VPN secrets (password, OTP, etc.). + Vpn { + /// The D-Bus service name of the VPN plugin + /// (e.g. `"org.freedesktop.NetworkManager.openvpn"`). + service_type: String, + /// The VPN username if already configured. + user_name: Option, + }, + /// GSM/mobile broadband secrets. + Gsm, + /// CDMA mobile broadband secrets. + Cdma, + /// PPPoE secrets. + Pppoe, + /// A setting name not recognized by this library. + Other(String), +} + +/// A request from NetworkManager for connection secrets. +/// +/// When NetworkManager needs credentials it does not have (e.g. a Wi-Fi +/// password was forgotten, a VPN token expired), it calls the registered +/// secret agent's `GetSecrets` method. This struct is the parsed, high-level +/// representation of that call. +/// +/// Respond using the [`responder`](Self::responder) field. If the responder is +/// dropped without a response method being called, the agent auto-replies with +/// `NoSecrets` and logs a warning. +/// +/// # Example +/// +/// ```no_run +/// use futures::StreamExt; +/// use nmrs::agent::{SecretAgent, SecretAgentFlags, SecretSetting}; +/// +/// # async fn example() -> nmrs::Result<()> { +/// let (handle, mut requests) = SecretAgent::builder().register().await?; +/// +/// while let Some(req) = requests.next().await { +/// println!("secrets requested for {}", req.connection_id); +/// if let SecretSetting::WifiPsk { ref ssid } = req.setting { +/// req.responder.wifi_psk("hunter2").await?; +/// } +/// } +/// # Ok(()) +/// # } +/// ``` +#[non_exhaustive] +pub struct SecretRequest { + /// UUID of the connection needing secrets. + pub connection_uuid: String, + /// Human-readable name of the connection (e.g. `"MyWiFi"`). + pub connection_id: String, + /// Connection type string (e.g. `"802-11-wireless"`, `"vpn"`). + pub connection_type: String, + /// D-Bus object path of the connection settings object. + pub connection_path: OwnedObjectPath, + /// Which setting section needs secrets. + pub setting: SecretSetting, + /// Optional hints from NetworkManager about which secrets are needed. + pub hints: Vec, + /// Flags describing the context of the request. + pub flags: SecretAgentFlags, + /// The responder used to reply with secrets or cancel. + pub responder: SecretResponder, +} + +impl std::fmt::Debug for SecretRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SecretRequest") + .field("connection_uuid", &self.connection_uuid) + .field("connection_id", &self.connection_id) + .field("connection_type", &self.connection_type) + .field("connection_path", &self.connection_path) + .field("setting", &self.setting) + .field("hints", &self.hints) + .field("flags", &self.flags) + .finish_non_exhaustive() + } +} + +/// Sends secrets (or a refusal) back to NetworkManager. +/// +/// Each `SecretResponder` must be consumed exactly once by calling one of its +/// response methods. If dropped without being consumed, it auto-replies with +/// `NoSecrets` and logs a warning. +/// +/// The response methods consume `self` to enforce single-use semantics. +pub struct SecretResponder { + reply_tx: Option>, + setting_name: String, +} + +impl std::fmt::Debug for SecretResponder { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SecretResponder") + .field("setting_name", &self.setting_name) + .field("consumed", &self.reply_tx.is_none()) + .finish() + } +} + +/// A cancellation notification from NetworkManager. +/// +/// Emitted when NetworkManager calls `CancelGetSecrets` for an in-flight +/// request. By the time this is received, the agent has already replied to +/// NetworkManager on the consumer's behalf. +#[non_exhaustive] +#[derive(Debug, Clone)] +pub struct CancelReason { + /// D-Bus object path of the cancelled connection. + pub connection_path: OwnedObjectPath, + /// The setting section that was being requested. + pub setting_name: String, +} + +/// A save or delete event from NetworkManager. +/// +/// NetworkManager sends `SaveSecrets` and `DeleteSecrets` so agents can +/// persist or remove secrets from a keyring. Since `nmrs` delegates +/// persistence to the consumer, these are exposed as optional events. +#[non_exhaustive] +#[derive(Debug, Clone)] +pub enum SecretStoreEvent { + /// NetworkManager asked the agent to persist secrets for a connection. + Save { + /// D-Bus object path of the connection. + connection_path: OwnedObjectPath, + }, + /// NetworkManager asked the agent to delete stored secrets. + Delete { + /// D-Bus object path of the connection. + connection_path: OwnedObjectPath, + }, +} + +pub(crate) type ConnectionDict = HashMap>; + +pub(crate) enum SecretReply { + Secrets(ConnectionDict), + UserCanceled, + NoSecrets, +} + +impl SecretResponder { + pub(crate) fn new( + reply_tx: futures::channel::oneshot::Sender, + setting_name: String, + ) -> Self { + Self { + reply_tx: Some(reply_tx), + setting_name, + } + } + + /// Respond with a Wi-Fi PSK (pre-shared key / password). + /// + /// This is the most common response for WPA/WPA2-Personal networks. + /// + /// # Errors + /// + /// Returns an error if the reply channel is already closed (e.g. the + /// request was cancelled by NetworkManager). + pub async fn wifi_psk(mut self, psk: impl Into) -> crate::Result<()> { + let mut inner = HashMap::new(); + inner.insert("psk".to_owned(), OwnedValue::from(Str::from(psk.into()))); + let mut outer = HashMap::new(); + outer.insert("802-11-wireless-security".to_owned(), inner); + self.send_reply(SecretReply::Secrets(outer)) + } + + /// Respond with 802.1X EAP credentials. + /// + /// # Errors + /// + /// Returns an error if the reply channel is already closed. + pub async fn wifi_eap( + mut self, + identity: Option, + password: String, + ) -> crate::Result<()> { + let mut inner = HashMap::new(); + inner.insert("password".to_owned(), OwnedValue::from(Str::from(password))); + if let Some(id) = identity { + inner.insert("identity".to_owned(), OwnedValue::from(Str::from(id))); + } + let mut outer = HashMap::new(); + outer.insert("802-1x".to_owned(), inner); + self.send_reply(SecretReply::Secrets(outer)) + } + + /// Respond with VPN secrets. + /// + /// The keys depend on the VPN plugin (e.g. `"password"` for OpenVPN, + /// `"Xauth password"` for vpnc). Consult the VPN plugin's documentation + /// for the expected keys. + /// + /// # Errors + /// + /// Returns an error if the reply channel is already closed. + pub async fn vpn_secrets(mut self, secrets: HashMap) -> crate::Result<()> { + let mut inner = HashMap::new(); + inner.insert("secrets".to_owned(), OwnedValue::from(secrets)); + let mut outer = HashMap::new(); + outer.insert("vpn".to_owned(), inner); + self.send_reply(SecretReply::Secrets(outer)) + } + + /// Respond with a raw setting sub-dictionary. + /// + /// This is an escape hatch for setting types not covered by the + /// convenience methods. The `setting_name` must match the setting + /// NetworkManager requested (e.g. `"802-11-wireless-security"`). + /// + /// # Errors + /// + /// Returns an error if the reply channel is already closed. + pub async fn raw( + mut self, + setting_name: impl Into, + data: HashMap, + ) -> crate::Result<()> { + let mut outer = HashMap::new(); + outer.insert(setting_name.into(), data); + self.send_reply(SecretReply::Secrets(outer)) + } + + /// Tell NetworkManager the user canceled the secret request. + /// + /// This raises `org.freedesktop.NetworkManager.SecretAgent.UserCanceled` + /// on the D-Bus side, which typically aborts the connection attempt. + /// + /// # Errors + /// + /// Returns an error if the reply channel is already closed. + pub async fn cancel(mut self) -> crate::Result<()> { + self.send_reply(SecretReply::UserCanceled) + } + + /// Tell NetworkManager no secrets are available. + /// + /// Unlike [`cancel`](Self::cancel), this signals that the agent simply + /// doesn't have the requested secrets. NetworkManager will not retry + /// after receiving this. + /// + /// # Errors + /// + /// Returns an error if the reply channel is already closed. + pub async fn no_secrets(mut self) -> crate::Result<()> { + self.send_reply(SecretReply::NoSecrets) + } + + fn send_reply(&mut self, reply: SecretReply) -> crate::Result<()> { + let tx = self + .reply_tx + .take() + .ok_or(ConnectionError::AgentNotRegistered)?; + let _ = tx.send(reply); + Ok(()) + } +} + +impl Drop for SecretResponder { + fn drop(&mut self) { + if let Some(tx) = self.reply_tx.take() { + warn!("SecretResponder dropped without responding; auto-replying NoSecrets"); + let _ = tx.send(SecretReply::NoSecrets); + } + } +} + +/// Extracts a string value from a nested connection settings dictionary. +pub(crate) fn extract_setting_string( + connection: &ConnectionDict, + section: &str, + key: &str, +) -> Option { + let section_dict = connection.get(section)?; + let value = section_dict.get(key)?; + <&str>::try_from(value).ok().map(String::from) +} + +/// Extracts the SSID from the wireless setting. The SSID is stored as a byte +/// array (`ay`) in NetworkManager's connection dict. +pub(crate) fn extract_ssid(connection: &ConnectionDict) -> Option { + let wireless = connection.get("802-11-wireless")?; + let ssid_value = wireless.get("ssid")?; + // SSID is stored as `ay` (byte array) by NetworkManager + if let Ok(bytes) = >::try_from(ssid_value.clone()) { + return Some(String::from_utf8_lossy(&bytes).into_owned()); + } + <&str>::try_from(ssid_value).ok().map(String::from) +} + +/// Parses the raw `GetSecrets` arguments into a [`SecretSetting`]. +pub(crate) fn parse_secret_setting( + connection: &ConnectionDict, + setting_name: &str, +) -> SecretSetting { + match setting_name { + "802-11-wireless-security" => SecretSetting::WifiPsk { + ssid: extract_ssid(connection).unwrap_or_default(), + }, + "802-1x" => SecretSetting::WifiEap { + identity: extract_setting_string(connection, "802-1x", "identity"), + method: extract_setting_string(connection, "802-1x", "eap"), + }, + "vpn" => SecretSetting::Vpn { + service_type: extract_setting_string(connection, "vpn", "service-type") + .unwrap_or_default(), + user_name: extract_setting_string(connection, "vpn", "user-name"), + }, + "gsm" => SecretSetting::Gsm, + "cdma" => SecretSetting::Cdma, + "pppoe" => SecretSetting::Pppoe, + other => SecretSetting::Other(other.to_owned()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn flags_from_bits() { + let flags = SecretAgentFlags::from_bits_truncate(0x5); + assert!(flags.contains(SecretAgentFlags::ALLOW_INTERACTION)); + assert!(flags.contains(SecretAgentFlags::USER_REQUESTED)); + assert!(!flags.contains(SecretAgentFlags::REQUEST_NEW)); + } + + #[test] + fn capabilities_bits_round_trip() { + let caps = SecretAgentCapabilities::VPN_HINTS; + assert_eq!(caps.bits(), 0x1); + } + + #[test] + fn parse_wifi_psk_setting() { + let connection = HashMap::new(); + let setting = parse_secret_setting(&connection, "802-11-wireless-security"); + assert!(matches!(setting, SecretSetting::WifiPsk { .. })); + } + + #[test] + fn parse_vpn_setting() { + let connection = HashMap::new(); + let setting = parse_secret_setting(&connection, "vpn"); + assert!(matches!(setting, SecretSetting::Vpn { .. })); + } + + #[test] + fn parse_unknown_setting() { + let connection = HashMap::new(); + let setting = parse_secret_setting(&connection, "some-custom-thing"); + assert!(matches!(setting, SecretSetting::Other(s) if s == "some-custom-thing")); + } + + #[test] + fn responder_drop_sends_no_secrets() { + let (tx, mut rx) = futures::channel::oneshot::channel(); + let responder = SecretResponder::new(tx, "test".into()); + drop(responder); + let reply = rx.try_recv().expect("should have received a reply"); + assert!(reply.is_some(), "drop should have sent a reply"); + assert!(matches!(reply.unwrap(), SecretReply::NoSecrets)); + } +} diff --git a/nmrs/src/api/models/error.rs b/nmrs/src/api/models/error.rs index 3dcded13..2b9bc8fc 100644 --- a/nmrs/src/api/models/error.rs +++ b/nmrs/src/api/models/error.rs @@ -167,4 +167,19 @@ pub enum ConnectionError { #[source] source: zbus::Error, }, + + /// Secret agent registration with NetworkManager failed. + #[error("secret agent registration failed: {context}")] + AgentRegistration { + /// What went wrong during registration. + context: String, + }, + + /// Operation requires a registered secret agent but none is active. + #[error("secret agent not registered")] + AgentNotRegistered, + + /// A secret agent is already registered under this identifier. + #[error("secret agent already registered under this identifier")] + AgentAlreadyRegistered, } diff --git a/nmrs/src/dbus/agent_manager.rs b/nmrs/src/dbus/agent_manager.rs new file mode 100644 index 00000000..eb2ec8cf --- /dev/null +++ b/nmrs/src/dbus/agent_manager.rs @@ -0,0 +1,28 @@ +//! D-Bus proxy for the NetworkManager AgentManager interface. + +use zbus::proxy; + +/// Proxy for the NetworkManager AgentManager interface. +/// +/// Used to register and unregister secret agents with NetworkManager. +/// +/// Reference: +#[proxy( + interface = "org.freedesktop.NetworkManager.AgentManager", + default_service = "org.freedesktop.NetworkManager", + default_path = "/org/freedesktop/NetworkManager/AgentManager" +)] +pub trait AgentManager { + /// Registers this secret agent with the given capabilities. + /// + /// The `identifier` is a reverse-DNS string identifying this agent + /// (e.g. `"com.system76.CosmicApplets.nmrs.secret_agent"`). + /// + /// `capabilities` is a bitmask of `NMSecretAgentCapabilities`: + /// - `0x0` = none + /// - `0x1` = `VPN_HINTS` (agent can filter VPN secret hints) + fn register_with_capabilities(&self, identifier: &str, capabilities: u32) -> zbus::Result<()>; + + /// Unregisters the secret agent from NetworkManager. + fn unregister(&self) -> zbus::Result<()>; +} diff --git a/nmrs/src/dbus/mod.rs b/nmrs/src/dbus/mod.rs index 4627bd4c..21374c52 100644 --- a/nmrs/src/dbus/mod.rs +++ b/nmrs/src/dbus/mod.rs @@ -5,6 +5,7 @@ mod access_point; mod active_connection; +pub(crate) mod agent_manager; mod bluetooth; mod device; mod main_nm; @@ -13,6 +14,7 @@ mod wireless; pub(crate) use access_point::NMAccessPointProxy; pub(crate) use active_connection::NMActiveConnectionProxy; +pub(crate) use agent_manager::AgentManagerProxy; pub(crate) use bluetooth::{BluezDeviceExtProxy, NMBluetoothProxy}; pub(crate) use device::NMDeviceProxy; pub(crate) use main_nm::NMProxy; diff --git a/nmrs/src/lib.rs b/nmrs/src/lib.rs index c71f78fa..57c1a6bb 100644 --- a/nmrs/src/lib.rs +++ b/nmrs/src/lib.rs @@ -253,6 +253,12 @@ mod monitoring; mod types; mod util; +/// NetworkManager secret agent for credential prompting over D-Bus. +/// +/// See the [module documentation](agent) for the three-stream model, +/// lifecycle, and a full example. +pub mod agent; + // ============================================================================ // Public API // ============================================================================ diff --git a/nmrs/src/types/constants.rs b/nmrs/src/types/constants.rs index 63dbb344..630454ee 100644 --- a/nmrs/src/types/constants.rs +++ b/nmrs/src/types/constants.rs @@ -77,6 +77,16 @@ pub mod timeouts { pub fn stabilization_delay() -> Duration { Duration::from_millis(STABILIZATION_DELAY_MS) } + + /// Maximum time the agent waits for a consumer to respond to a `GetSecrets` + /// request before auto-replying `NoSecrets`. Matches NetworkManager's own + /// 120-second `GetSecrets` timeout with some margin. + const SECRET_AGENT_RESPONSE_TIMEOUT_SECS: u64 = 120; + + /// Returns the secret agent response timeout duration. + pub fn secret_agent_response_timeout() -> Duration { + Duration::from_secs(SECRET_AGENT_RESPONSE_TIMEOUT_SECS) + } } /// Signal strength thresholds for bar display diff --git a/package.nix b/package.nix index c46fc541..4f730851 100644 --- a/package.nix +++ b/package.nix @@ -19,7 +19,7 @@ rustPlatform.buildRustPackage { src = ./.; - cargoHash = "sha256-HGWLM5LRSC5F4ygMh4koTeCAPGka4inuRdTJAwPpxts="; + cargoHash = "sha256-DUdAavkzHPBv3U+cIX+qypzL3bo6J0LVPMWcsJ8WuVQ="; nativeBuildInputs = [ pkg-config