mirror of
https://github.com/sol1/rustguac.git
synced 2026-09-10 01:26:06 +00:00
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:
+15
@@ -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 {
|
||||
|
||||
@@ -8,6 +8,7 @@ mod guacd;
|
||||
mod import;
|
||||
mod oidc;
|
||||
mod protocol;
|
||||
mod pve;
|
||||
mod recording;
|
||||
mod session;
|
||||
mod tunnel;
|
||||
|
||||
+190
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user