From 8c70a237806b331ea308913831ed694d86d5fa09 Mon Sep 17 00:00:00 2001 From: Dave Kempe Date: Fri, 24 Jul 2026 12:59:30 +1000 Subject: [PATCH] feat(spice): Proxmox VE SPICE broker (backend, API-level) Add a just-in-time Proxmox broker for SPICE consoles. PVE issues one-time, ~30s SPICE tickets via its API, so they cannot be stored; the broker fetches the config at connect time: - src/pve.rs: minimal PVE API client. POSTs to /api2/json/nodes/{node}/qemu/{vmid}/spiceproxy with an API-token header, parses host / proxy / tls-port / password(ticket) / ca / host-subject, and unescapes the CA PEM newlines. Never logs the token or ticket, and never puts the response body (which carries the ticket) in an error. - session.rs: CreateSessionRequest spice_pve_* fields (host/node/vmid/token/ verify_tls); when spice_pve_host is set, the SPICE create_session branch calls the broker and maps the result onto SpiceParams (hostname=host, plus proxy, tls, tls-port, ca-cert, cert-subject, and the argv ticket). API-testable now (POST /api/sessions with session_type:spice + spice_pve_*). Address-book entry storage + a Proxmox UI are the next increment. --- src/api.rs | 15 ++++ src/main.rs | 1 + src/pve.rs | 190 +++++++++++++++++++++++++++++++++++++++++++++++++ src/session.rs | 102 +++++++++++++++++++++----- 4 files changed, 290 insertions(+), 18 deletions(-) create mode 100644 src/pve.rs diff --git a/src/api.rs b/src/api.rs index 417ef12..e108877 100644 --- a/src/api.rs +++ b/src/api.rs @@ -2497,6 +2497,11 @@ pub async fn ab_connect_entry( spice_ca_cert: ab_entry.spice_ca_cert, spice_cert_subject: ab_entry.spice_cert_subject, spice_proxy: ab_entry.spice_proxy, + spice_pve_host: None, + spice_pve_node: None, + spice_pve_vmid: None, + spice_pve_token: None, + spice_pve_verify_tls: None, }; let proxies = trusted.map(|Extension(t)| t.0).unwrap_or_default(); @@ -4162,6 +4167,11 @@ pub async fn quick_connect( spice_ca_cert: ab_entry.spice_ca_cert, spice_cert_subject: ab_entry.spice_cert_subject, spice_proxy: ab_entry.spice_proxy, + spice_pve_host: None, + spice_pve_node: None, + spice_pve_vmid: None, + spice_pve_token: None, + spice_pve_verify_tls: None, }; tracing::info!( @@ -4273,6 +4283,11 @@ pub async fn quick_connect( spice_ca_cert: None, spice_cert_subject: None, spice_proxy: None, + spice_pve_host: None, + spice_pve_node: None, + spice_pve_vmid: None, + spice_pve_token: None, + spice_pve_verify_tls: None, }; match manager.create_session(create_req, admin_name).await { diff --git a/src/main.rs b/src/main.rs index 7010af9..c009468 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ mod guacd; mod import; mod oidc; mod protocol; +mod pve; mod recording; mod session; mod tunnel; diff --git a/src/pve.rs b/src/pve.rs new file mode 100644 index 0000000..1a79360 --- /dev/null +++ b/src/pve.rs @@ -0,0 +1,190 @@ +//! Proxmox VE API client for brokering SPICE console access. +//! +//! Proxmox issues short-lived (~30s), single-use SPICE tickets via its API, so +//! a console cannot use a stored password. To open a VM console we call the +//! `spiceproxy` endpoint just-in-time and feed the returned host / proxy / +//! tls-port / ca / host-subject / ticket into a SPICE session (see the SPICE +//! branch of `session::SessionManager::create_session`). +//! +//! Endpoint: `POST /api2/json/nodes/{node}/qemu/{vmid}/spiceproxy` (optional +//! form param `proxy=`). Auth: an API token header, +//! `Authorization: PVEAPIToken=USER@REALM!TOKENID=SECRET`. +//! +//! Security: the API token and the returned ticket are credentials. This +//! module never logs them, and it never includes the response body (which +//! carries the ticket) in error messages. + +use std::collections::HashMap; +use std::time::Duration; + +/// A just-in-time SPICE connection config from the PVE `spiceproxy` endpoint. +#[derive(Debug)] +pub struct PveSpiceConfig { + /// Opaque proxy-routing token PVE returns as `host` (e.g. + /// `pvespiceproxy:…:vmid:node::…`). Passed to guacd as the SPICE hostname; + /// the SPICE proxy uses it to route to the real VM. + pub host: String, + /// The actual connect endpoint, e.g. `http://pve.example.com:3128`. + pub proxy: String, + /// TLS port on the proxy (typically 61000+). + pub tls_port: u16, + /// Single-use SPICE ticket, valid ~30s. Delivered to guacd via argv. + pub ticket: String, + /// Cluster CA certificate (PEM, with real newlines). + pub ca_cert: String, + /// Expected TLS certificate subject of the host. + pub host_subject: String, +} + +/// A configured Proxmox VE API target (host + API token). +pub struct PveBroker { + /// Base URL of the PVE API, e.g. `https://pve.example.com:8006`. + pub base_url: String, + /// API token, formatted `USER@REALM!TOKENID=SECRET`. + pub api_token: String, + /// Verify the PVE API server's TLS certificate. Proxmox ships a + /// self-signed cluster cert by default, so this is often disabled unless + /// the cluster CA is trusted on the rustguac host. + pub verify_tls: bool, +} + +#[derive(Debug)] +pub enum PveError { + /// Transport-level failure (connect, TLS, timeout). Never contains creds. + Transport(String), + /// The API returned a non-success status. Carries the status only, never + /// the body (which contains the ticket). + Api(u16), + /// The response could not be parsed / was missing an expected field. + Parse(String), +} + +impl std::fmt::Display for PveError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PveError::Transport(m) => write!(f, "PVE API transport error: {m}"), + PveError::Api(code) => write!(f, "PVE spiceproxy returned HTTP {code}"), + PveError::Parse(m) => write!(f, "PVE spiceproxy response parse error: {m}"), + } + } +} +impl std::error::Error for PveError {} + +impl PveBroker { + /// Fetch a just-in-time SPICE config for a VM console. `proxy` optionally + /// overrides the SPICE proxy node (defaults to the node handling the + /// request). This performs a live API call and should be invoked at + /// connect time, as the returned ticket expires within ~30s. + pub async fn fetch_spice_config( + &self, + node: &str, + vmid: u32, + proxy: Option<&str>, + ) -> Result { + let url = format!( + "{}/api2/json/nodes/{}/qemu/{}/spiceproxy", + self.base_url.trim_end_matches('/'), + node, + vmid + ); + + let client = reqwest::Client::builder() + .danger_accept_invalid_certs(!self.verify_tls) + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(15)) + .build() + .map_err(|e| PveError::Transport(e.to_string()))?; + + let mut req = client + .post(&url) + .header("Authorization", format!("PVEAPIToken={}", self.api_token)); + if let Some(p) = proxy { + req = req.form(&[("proxy", p)]); + } + + let resp = req + .send() + .await + .map_err(|e| PveError::Transport(e.to_string()))?; + let status = resp.status(); + if !status.is_success() { + // Deliberately do NOT include the body: it may carry a ticket. + return Err(PveError::Api(status.as_u16())); + } + + // Response shape: {"data": { "host": ..., "proxy": ..., "tls-port": ..., + // "password": , "ca": ..., "host-subject": ..., ... }} + let body = resp + .text() + .await + .map_err(|e| PveError::Transport(e.to_string()))?; + let wrap: serde_json::Value = + serde_json::from_str(&body).map_err(|e| PveError::Parse(e.to_string()))?; + let data: HashMap = + serde_json::from_value(wrap.get("data").cloned().unwrap_or_default()) + .map_err(|e| PveError::Parse(e.to_string()))?; + + let field = |k: &str| -> Option { + data.get(k).and_then(|v| v.as_str()).map(str::to_string) + }; + let require = |k: &str| field(k).ok_or_else(|| PveError::Parse(format!("missing '{k}'"))); + + // tls-port may be a JSON string or number depending on PVE version. + let tls_port = data + .get("tls-port") + .and_then(|v| { + v.as_u64() + .map(|n| n as u16) + .or_else(|| v.as_str().and_then(|s| s.trim().parse().ok())) + }) + .ok_or_else(|| PveError::Parse("missing or invalid 'tls-port'".into()))?; + + Ok(PveSpiceConfig { + host: require("host")?, + proxy: require("proxy")?, + tls_port, + ticket: require("password")?, + // PVE escapes newlines in the CA PEM as literal "\n"; guacd needs + // real newlines. + ca_cert: field("ca").unwrap_or_default().replace("\\n", "\n"), + host_subject: field("host-subject").unwrap_or_default(), + }) + } +} + +#[cfg(test)] +mod tests { + #[test] + fn parses_spiceproxy_style_fields_and_unescapes_ca() { + // Mirror of a real spiceproxy `data` payload (ticket/CA are dummies). + let body = r#"{"data":{ + "type":"spice", + "host":"pvespiceproxy:687ea156:10016:pve::abc", + "proxy":"http://pve.example.com:3128", + "tls-port":61002, + "password":"one-time-ticket", + "host-subject":"OU=PVE Cluster Node,CN=pve.example.com", + "ca":"-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n" + }}"#; + let wrap: serde_json::Value = serde_json::from_str(body).unwrap(); + let data: std::collections::HashMap = + serde_json::from_value(wrap.get("data").cloned().unwrap()).unwrap(); + // Exercise the same extraction the client uses. + assert!(data + .get("host") + .unwrap() + .as_str() + .unwrap() + .contains("pvespiceproxy")); + let tls_port = data.get("tls-port").unwrap().as_u64().unwrap() as u16; + assert_eq!(tls_port, 61002); + let ca = data + .get("ca") + .unwrap() + .as_str() + .unwrap() + .replace("\\n", "\n"); + assert!(ca.contains("-----BEGIN CERTIFICATE-----\n")); + assert!(!ca.contains("\\n")); + } +} diff --git a/src/session.rs b/src/session.rs index 2441b1e..6905034 100644 --- a/src/session.rs +++ b/src/session.rs @@ -156,6 +156,19 @@ pub struct CreateSessionRequest { pub spice_cert_subject: Option, /// SPICE: proxy URL, e.g. a Proxmox SPICE proxy "http://host:3128". pub spice_proxy: Option, + /// SPICE Proxmox broker: PVE API base URL (e.g. "https://pve:8006"). When + /// set, rustguac fetches a just-in-time SPICE ticket + config from the PVE + /// spiceproxy API at connect and overrides the direct-connect SPICE fields. + pub spice_pve_host: Option, + /// Proxmox node name for the broker call. + pub spice_pve_node: Option, + /// Proxmox VM id (QEMU) for the broker call. + pub spice_pve_vmid: Option, + /// Proxmox API token, formatted "user@realm!tokenid=secret". + pub spice_pve_token: Option, + /// Verify the PVE API server's TLS certificate (default false; PVE ships a + /// self-signed cluster cert). + pub spice_pve_verify_tls: Option, } /// Session status in the lifecycle. @@ -892,25 +905,13 @@ impl SessionManager { ) } SessionType::Spice => { - let hostname = req.hostname.ok_or_else(|| { - SessionError::ValidationError("hostname is required for SPICE sessions".into()) - })?; - let port = req.port.unwrap_or(5900); let username = req.username.clone().unwrap_or_default(); - check_allowed_network(&hostname, port, &self.config.vnc_allowed_networks)?; - - tracing::info!( - session_id = %session_id, - hostname = %hostname, - width, height, dpi, - "Creating new SPICE session" - ); - - let params = guacd::ConnectionParams::Spice(Box::new(guacd::SpiceParams { - hostname: hostname.clone(), - port, - // Credentials are streamed to guacd via argv, not connect args. + // Base SPICE params from the request (direct-connect fields). + // Credentials are streamed to guacd via argv, not connect args. + let mut spice = guacd::SpiceParams { + hostname: String::new(), + port: req.port.unwrap_or(5900), password: req.password.clone(), username: req.username.clone(), tls: req.spice_tls.unwrap_or(false), @@ -926,7 +927,72 @@ impl SessionManager { disable_copy: req.disable_copy.unwrap_or(false), disable_paste: req.disable_paste.unwrap_or(false), enable_audio: false, - })); + }; + + let hostname = if let Some(pve_url) = req.spice_pve_host.clone() { + // Proxmox VE broker: PVE tickets are one-time and short-lived, + // so fetch a just-in-time SPICE config at connect. Overrides + // the direct-connect fields. + let node = req.spice_pve_node.clone().unwrap_or_default(); + let vmid = req.spice_pve_vmid.unwrap_or(0); + if node.is_empty() || vmid == 0 { + return Err(SessionError::ValidationError( + "Proxmox SPICE requires spice_pve_node and spice_pve_vmid".into(), + )); + } + let broker = crate::pve::PveBroker { + base_url: pve_url, + api_token: req.spice_pve_token.clone().unwrap_or_default(), + verify_tls: req.spice_pve_verify_tls.unwrap_or(false), + }; + let cfg = broker + .fetch_spice_config(&node, vmid, None) + .await + .map_err(|e| { + SessionError::ValidationError(format!( + "Proxmox SPICE broker failed: {e}" + )) + })?; + tracing::info!( + session_id = %session_id, + node = %node, + vmid, + proxy = %cfg.proxy, + "Creating Proxmox VE SPICE console session" + ); + spice.hostname = cfg.host.clone(); + spice.port = cfg.tls_port; + spice.tls = true; + spice.tls_port = Some(cfg.tls_port); + spice.ca_cert = Some(cfg.ca_cert); + spice.cert_subject = Some(cfg.host_subject); + spice.proxy = Some(cfg.proxy); + // The one-time ticket is the SPICE password (sent via argv). + spice.password = Some(cfg.ticket); + cfg.host + } else { + // Direct SPICE connection. + let hostname = req.hostname.clone().ok_or_else(|| { + SessionError::ValidationError( + "hostname is required for SPICE sessions".into(), + ) + })?; + check_allowed_network( + &hostname, + spice.port, + &self.config.vnc_allowed_networks, + )?; + spice.hostname = hostname.clone(); + tracing::info!( + session_id = %session_id, + hostname = %hostname, + width, height, dpi, + "Creating new SPICE session" + ); + hostname + }; + + let params = guacd::ConnectionParams::Spice(Box::new(spice)); ( params, hostname, username, None, None, None, None, None, None, )