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.
This commit is contained in:
Dave Kempe
2026-07-24 12:59:30 +10:00
parent 4a2021bd2f
commit 8c70a23780
4 changed files with 290 additions and 18 deletions
+15
View File
@@ -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 {
+1
View File
@@ -8,6 +8,7 @@ mod guacd;
mod import;
mod oidc;
mod protocol;
mod pve;
mod recording;
mod session;
mod tunnel;
+190
View File
@@ -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=<node>`). 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<PveSpiceConfig, PveError> {
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": <ticket>, "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<String, serde_json::Value> =
serde_json::from_value(wrap.get("data").cloned().unwrap_or_default())
.map_err(|e| PveError::Parse(e.to_string()))?;
let field = |k: &str| -> Option<String> {
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<String, serde_json::Value> =
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"));
}
}
+84 -18
View File
@@ -156,6 +156,19 @@ pub struct CreateSessionRequest {
pub spice_cert_subject: Option<String>,
/// SPICE: proxy URL, e.g. a Proxmox SPICE proxy "http://host:3128".
pub spice_proxy: Option<String>,
/// 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<String>,
/// Proxmox node name for the broker call.
pub spice_pve_node: Option<String>,
/// Proxmox VM id (QEMU) for the broker call.
pub spice_pve_vmid: Option<u32>,
/// Proxmox API token, formatted "user@realm!tokenid=secret".
pub spice_pve_token: Option<String>,
/// Verify the PVE API server's TLS certificate (default false; PVE ships a
/// self-signed cluster cert).
pub spice_pve_verify_tls: Option<bool>,
}
/// 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,
)