diff --git a/debian/control b/debian/control index 6efb758..8508754 100644 --- a/debian/control +++ b/debian/control @@ -26,6 +26,7 @@ Depends: ${shlibs:Depends}, ${misc:Depends}, libfreerdp3-3, libfreerdp-client3-3, libwinpr3-3, + libspice-client-glib-2.0-8, ca-certificates, tigervnc-standalone-server, chromium, @@ -34,5 +35,6 @@ Depends: ${shlibs:Depends}, ${misc:Depends}, Recommends: haproxy Description: Lightweight Rust replacement for Apache Guacamole webapp rustguac proxies the Guacamole protocol over WebSockets between web - browsers and guacd. Supports SSH, VNC, RDP, and web browser sessions - (headless Chromium on Xvnc). This package bundles both rustguac and guacd. + browsers and guacd. Supports SSH, VNC, RDP, SPICE, and web browser + sessions (headless Chromium on Xvnc). This package bundles both rustguac + and guacd. diff --git a/src/api.rs b/src/api.rs index e108877..0ae2301 100644 --- a/src/api.rs +++ b/src/api.rs @@ -78,6 +78,15 @@ pub async fn create_session( req.port.unwrap_or(5900) ) } + crate::session::SessionType::Proxmox => { + format!( + "{}/{}", + req.proxmox_node.as_deref().unwrap_or("?"), + req.proxmox_vmid + .map(|v| v.to_string()) + .unwrap_or_else(|| "?".into()) + ) + } crate::session::SessionType::Web => req.url.as_deref().unwrap_or("?").to_string(), crate::session::SessionType::Vdi => { req.container_image.as_deref().unwrap_or("?").to_string() @@ -2420,6 +2429,7 @@ pub async fn ab_connect_entry( "rdp" => SessionType::Rdp, "vnc" => SessionType::Vnc, "spice" => SessionType::Spice, + "proxmox" => SessionType::Proxmox, "web" => SessionType::Web, "vdi" => SessionType::Vdi, other => { @@ -2497,11 +2507,12 @@ 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, + proxmox_url: ab_entry.proxmox_url, + proxmox_node: ab_entry.proxmox_node, + proxmox_vmid: ab_entry.proxmox_vmid, + proxmox_token_id: ab_entry.proxmox_token_id, + proxmox_token_secret: ab_entry.proxmox_token_secret, + proxmox_verify_tls: ab_entry.proxmox_verify_tls, }; let proxies = trusted.map(|Extension(t)| t.0).unwrap_or_default(); @@ -2982,6 +2993,7 @@ pub async fn ab_update_entry( password: data.password.or(existing.password), private_key: data.private_key.or(existing.private_key), container_password: data.container_password.or(existing.container_password), + proxmox_token_secret: data.proxmox_token_secret.or(existing.proxmox_token_secret), jump_hosts: merged_jump_hosts, // Clear legacy flat fields — they've been migrated jump_password: None, @@ -4167,11 +4179,12 @@ 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, + proxmox_url: None, + proxmox_node: None, + proxmox_vmid: None, + proxmox_token_id: None, + proxmox_token_secret: None, + proxmox_verify_tls: None, }; tracing::info!( @@ -4283,11 +4296,12 @@ 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, + proxmox_url: None, + proxmox_node: None, + proxmox_vmid: None, + proxmox_token_id: None, + proxmox_token_secret: None, + proxmox_verify_tls: None, }; match manager.create_session(create_req, admin_name).await { diff --git a/src/guacd.rs b/src/guacd.rs index c275be7..2dd7398 100644 --- a/src/guacd.rs +++ b/src/guacd.rs @@ -75,17 +75,17 @@ pub struct VncParams { /// SPICE connection parameters to pass to guacd. /// -/// SPICE reads credentials from an `argv` stream (guacd's `GUAC_SPICE_ARGV_*`), -/// not the connect args, so `password`/`username` are streamed separately after -/// `connect` (see `send_argv`). The TLS + proxy + cert-subject fields lay the -/// groundwork for brokered Proxmox VE consoles (which connect via a SPICE proxy -/// with a one-time ticket and cluster-CA TLS). +/// Credentials (`password`/`username`) are sent as connect args: guacd's SPICE +/// client authenticates during `connect` without awaiting an `argv` stream, so +/// argv delivery would race the auth. The TLS + proxy + cert-subject fields +/// support brokered Proxmox VE consoles (which connect via a SPICE proxy with a +/// one-time ticket and cluster-CA TLS). pub struct SpiceParams { pub hostname: String, pub port: u16, - /// SPICE ticket / password, delivered via argv (not connect args). + /// SPICE ticket / password, sent as the `password` connect arg. pub password: Option, - /// Optional SPICE username, delivered via argv. + /// Optional SPICE username, sent as the `username` connect arg. pub username: Option, pub tls: bool, pub tls_port: Option, @@ -360,7 +360,23 @@ pub async fn connect_and_handshake( // `size` instruction) and no `password` arg (delivered via argv below). ConnectionParams::Spice(p) => match name.as_str() { "hostname" => p.hostname.clone(), - "port" => p.port.to_string(), + // TLS SPICE (e.g. Proxmox) is TLS-only: send an empty plain + // port so guacd/spice-gtk connects via tls-port with TLS rather + // than plaintext against a TLS endpoint. + "port" => { + if p.tls { + String::new() + } else { + p.port.to_string() + } + } + // Credentials go in the connect args (not a post-connect argv + // stream): guacd's SPICE client sets settings->password on the + // session and authenticates during connect without awaiting + // argv, so argv delivery races the auth. The connect arg is set + // before the server connection is opened. + "username" => p.username.clone().unwrap_or_default(), + "password" => p.password.clone().unwrap_or_default(), "tls" => if p.tls { "true" } else { "false" }.into(), "tls-port" => p.tls_port.map(|x| x.to_string()).unwrap_or_default(), "ca-cert" => p.ca_cert.clone().unwrap_or_default(), @@ -398,19 +414,6 @@ pub async fn connect_and_handshake( tracing::debug!("Sent handshake instructions"); - // SPICE reads credentials from an argv stream, not the connect args. Send - // them immediately after connect so guacd's SPICE client thread has them - // set before it configures the session and connects to the server (guacd's - // SPICE handler does not guac_argv_await, so earliest delivery is safest). - if let ConnectionParams::Spice(p) = params { - if let Some(u) = p.username.as_deref().filter(|s| !s.is_empty()) { - send_argv(&mut stream, "username", u).await?; - } - if let Some(pw) = p.password.as_deref().filter(|s| !s.is_empty()) { - send_argv(&mut stream, "password", pw).await?; - } - } - // Read the ready instruction — confirms connection is established let ready = read_instruction(&mut stream).await?; if ready.opcode != "ready" { @@ -431,27 +434,6 @@ pub async fn connect_and_handshake( Ok((stream, connection_id)) } -/// Send an `argv` stream carrying a single named argument value. guacd's SPICE -/// client reads credentials (`username`/`password`) from argv rather than the -/// connect args, so these are streamed separately after `connect`. The value is -/// sent as a single base64 blob on a dedicated stream index. -async fn send_argv(stream: &mut GuacdStream, name: &str, value: &str) -> Result<(), GuacdError> { - use base64::Engine; - // Stream index 1: index 0 is reserved for the browser-side internal ping pipe. - let idx = "1"; - let open = Instruction::new("argv", vec![idx.into(), "text/plain".into(), name.into()]); - let data = base64::engine::general_purpose::STANDARD.encode(value.as_bytes()); - let blob = Instruction::new("blob", vec![idx.into(), data]); - let end = Instruction::new("end", vec![idx.into()]); - for inst in [open, blob, end] { - stream - .write_all(inst.encode().as_bytes()) - .await - .map_err(|e| GuacdError::Io(e.to_string()))?; - } - Ok(()) -} - /// Join an existing guacd connection by its connection_id. /// /// Opens a new TCP connection to guacd and sends `select` with the connection_id diff --git a/src/import.rs b/src/import.rs index 12950a7..e373118 100644 --- a/src/import.rs +++ b/src/import.rs @@ -131,6 +131,12 @@ pub async fn cmd_import_guacamole( spice_ca_cert: None, spice_cert_subject: None, spice_proxy: None, + proxmox_url: None, + proxmox_node: None, + proxmox_vmid: None, + proxmox_token_id: None, + proxmox_token_secret: None, + proxmox_verify_tls: None, }; // Place the entry into a subfolder matching its parent group path. @@ -730,6 +736,12 @@ mod tests { spice_ca_cert: None, spice_cert_subject: None, spice_proxy: None, + proxmox_url: None, + proxmox_node: None, + proxmox_vmid: None, + proxmox_token_id: None, + proxmox_token_secret: None, + proxmox_verify_tls: None, }; let mut entries = vec![ (("".into(), "web".into()), entry()), diff --git a/src/pve.rs b/src/pve.rs index 1a79360..e7f5913 100644 --- a/src/pve.rs +++ b/src/pve.rs @@ -52,9 +52,10 @@ pub struct PveBroker { 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 API returned a non-success status. Carries the status and the + /// response body message. Safe to include: only a *successful* (2xx) + /// spiceproxy response carries a ticket; error bodies do not. + Api(u16, String), /// The response could not be parsed / was missing an expected field. Parse(String), } @@ -63,14 +64,101 @@ 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::Api(code, msg) if msg.is_empty() => { + write!(f, "PVE spiceproxy returned HTTP {code}") + } + PveError::Api(code, msg) => write!(f, "PVE spiceproxy returned HTTP {code}: {msg}"), PveError::Parse(m) => write!(f, "PVE spiceproxy response parse error: {m}"), } } } impl std::error::Error for PveError {} +/// Render an error together with its `source()` chain. reqwest's top-level +/// message is often opaque ("builder error"); the cause carries the detail. +/// The chain contains the URL/kind at most, never the auth header. +fn err_chain(e: &dyn std::error::Error) -> String { + let mut out = e.to_string(); + let mut src = e.source(); + while let Some(s) = src { + out.push_str(": "); + out.push_str(&s.to_string()); + src = s.source(); + } + out +} + +/// Build a `PveError::Api` from a non-success response, surfacing PVE's +/// human-readable reason. Safe: only a *successful* (2xx) spiceproxy response +/// carries a ticket — error bodies do not. PVE puts the reason in +/// `message`/`errors`; fall back to the raw body, truncated. +async fn api_error(code: u16, resp: reqwest::Response) -> PveError { + let body = resp.text().await.unwrap_or_default(); + let msg = serde_json::from_str::(&body) + .ok() + .and_then(|v| { + v.get("message") + .and_then(|m| m.as_str()) + .map(str::to_string) + .or_else(|| v.get("errors").map(|e| e.to_string())) + }) + .unwrap_or_else(|| body.chars().take(200).collect()); + PveError::Api(code, msg.trim().to_string()) +} + impl PveBroker { + /// Build an HTTP client for PVE API calls. PVE ships a self-signed cluster + /// cert by default, so certificate verification follows `verify_tls`. + fn http_client(&self) -> Result { + 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(err_chain(&e))) + } + + /// Resolve which cluster node hosts a VM, via `/cluster/resources`. Lets a + /// caller omit the node — the PVE web UI resolves vmid→node the same way, + /// so the node-scoped console API can be reached with only the VM id. + pub async fn resolve_node(&self, vmid: u32) -> Result { + let url = format!( + "{}/api2/json/cluster/resources?type=vm", + self.base_url.trim_end_matches('/') + ); + let resp = self + .http_client()? + .get(&url) + .header("Authorization", format!("PVEAPIToken={}", self.api_token)) + .send() + .await + .map_err(|e| PveError::Transport(err_chain(&e)))?; + let status = resp.status(); + if !status.is_success() { + return Err(api_error(status.as_u16(), resp).await); + } + 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 items = wrap + .get("data") + .and_then(|d| d.as_array()) + .ok_or_else(|| PveError::Parse("cluster/resources missing 'data' array".into()))?; + for item in items { + if item.get("vmid").and_then(|v| v.as_u64()) == Some(vmid as u64) { + if let Some(node) = item.get("node").and_then(|n| n.as_str()) { + return Ok(node.to_string()); + } + } + } + Err(PveError::Parse(format!( + "VM {vmid} not found in the cluster (check the VM id, or that the token can see it)" + ))) + } + /// 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 @@ -88,12 +176,7 @@ impl PveBroker { 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 client = self.http_client()?; let mut req = client .post(&url) @@ -105,11 +188,10 @@ impl PveBroker { let resp = req .send() .await - .map_err(|e| PveError::Transport(e.to_string()))?; + .map_err(|e| PveError::Transport(err_chain(&e)))?; 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())); + return Err(api_error(status.as_u16(), resp).await); } // Response shape: {"data": { "host": ..., "proxy": ..., "tls-port": ..., diff --git a/src/session.rs b/src/session.rs index 6905034..2fb689a 100644 --- a/src/session.rs +++ b/src/session.rs @@ -18,7 +18,8 @@ use tokio_util::sync::CancellationToken; use url::Url; use uuid::Uuid; -/// Session type: SSH terminal, web browser, RDP, VNC, or VDI container. +/// Session type: SSH terminal, web browser, RDP, VNC, VDI container, direct +/// SPICE, or Proxmox VE console (SPICE brokered via the PVE spiceproxy API). #[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)] #[serde(rename_all = "lowercase")] pub enum SessionType { @@ -29,6 +30,7 @@ pub enum SessionType { Vnc, Vdi, Spice, + Proxmox, } /// Parameters for creating a new session. @@ -156,19 +158,24 @@ 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, + /// Proxmox VE console (SessionType::Proxmox): PVE API base URL, a full URL + /// including scheme and port (e.g. "https://pve.example.com:8006"). rustguac + /// fetches a just-in-time SPICE ticket + config from the PVE spiceproxy API + /// at connect. + pub proxmox_url: Option, + /// Proxmox node name hosting the VM (e.g. "pve"). + pub proxmox_node: Option, + /// Proxmox VM id (QEMU) whose console to open. + pub proxmox_vmid: Option, + /// Proxmox API token id ("user@realm!tokenname") — the non-secret half. + pub proxmox_token_id: Option, + /// Proxmox API token secret (the UUID half). Joined with the id as + /// "id=secret" for the API. Kept separate so the id can be shown while the + /// secret stays masked. + pub proxmox_token_secret: Option, /// Verify the PVE API server's TLS certificate (default false; PVE ships a - /// self-signed cluster cert). - pub spice_pve_verify_tls: Option, + /// self-signed cluster cert). Also controls SPICE-proxy cert verification. + pub proxmox_verify_tls: Option, } /// Session status in the lifecycle. @@ -400,6 +407,25 @@ mod auth_pkg_tests { } } +/// Parse a host and port from a full URL ("https://host:8006") or a bare +/// authority ("host:3128" / "host"), falling back to `default_port` when the +/// input carries no explicit port. Used to tunnel Proxmox's PVE API and SPICE +/// proxy endpoints through a jump-host chain. +fn parse_host_port(input: &str, default_port: u16) -> Result<(String, u16), SessionError> { + let parsed = if input.contains("://") { + Url::parse(input) + } else { + Url::parse(&format!("tcp://{input}")) + } + .map_err(|e| SessionError::ValidationError(format!("invalid host/URL '{input}': {e}")))?; + let host = parsed + .host_str() + .ok_or_else(|| SessionError::ValidationError(format!("no host in '{input}'")))? + .to_string(); + let port = parsed.port().unwrap_or(default_port); + Ok((host, port)) +} + /// Check that a host resolves to an IP within the allowed CIDR networks. fn check_allowed_network(host: &str, port: u16, allowed: &[String]) -> Result<(), SessionError> { let networks: Vec = allowed @@ -649,6 +675,32 @@ impl SessionManager { ); } + // Resolve jump hosts (SSH tunnel chain) up-front: the Proxmox branch + // needs them to tunnel its PVE API + SPICE-proxy connections in-branch, + // and the generic tunnel setup after the match uses them for the other + // session types. + let jump_hops: Vec = if let Some(hops) = req.jump_hosts { + hops + } else if let Some(ref jh) = req.jump_host { + if !jh.is_empty() { + vec![tunnel::JumpHost { + hostname: jh.clone(), + port: req.jump_port.unwrap_or(22), + username: req.jump_username.clone().unwrap_or_default(), + password: req.jump_password.clone(), + private_key: req.jump_private_key.clone(), + host_key: None, + }] + } else { + Vec::new() + } + } else { + Vec::new() + }; + // Tunnels the Proxmox branch establishes in-branch (PVE API + SPICE + // proxy hops); merged into the session's tunnel list after the match. + let mut proxmox_tunnels: Vec = Vec::new(); + let ( mut conn_params, hostname, @@ -907,11 +959,16 @@ impl SessionManager { SessionType::Spice => { let username = req.username.clone().unwrap_or_default(); - // 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), + // Direct SPICE connection to a SPICE server (e.g. libvirt/QEMU). + let hostname = req.hostname.clone().ok_or_else(|| { + SessionError::ValidationError("hostname is required for SPICE sessions".into()) + })?; + let port = req.port.unwrap_or(5900); + check_allowed_network(&hostname, port, &self.config.vnc_allowed_networks)?; + + let spice = guacd::SpiceParams { + hostname: hostname.clone(), + port, password: req.password.clone(), username: req.username.clone(), tls: req.spice_tls.unwrap_or(false), @@ -928,70 +985,158 @@ impl SessionManager { disable_paste: req.disable_paste.unwrap_or(false), enable_audio: false, }; + tracing::info!( + session_id = %session_id, + hostname = %hostname, + width, height, dpi, + "Creating new SPICE session" + ); - 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 + let params = guacd::ConnectionParams::Spice(Box::new(spice)); + ( + params, hostname, username, None, None, None, None, None, None, + ) + } + SessionType::Proxmox => { + let username = req.username.clone().unwrap_or_default(); + + // Proxmox VE console: SPICE brokered through the PVE spiceproxy + // API. Tickets are one-time and short-lived, so fetch a + // just-in-time SPICE config at connect rather than storing it. + let pve_url = req.proxmox_url.clone().ok_or_else(|| { + SessionError::ValidationError("Proxmox sessions require proxmox_url".into()) + })?; + let vmid = req.proxmox_vmid.unwrap_or(0); + if vmid == 0 { + return Err(SessionError::ValidationError( + "Proxmox sessions require proxmox_vmid".into(), + )); + } + let verify_tls = req.proxmox_verify_tls.unwrap_or(false); + + // Join the token id and secret into PVE's "id=secret" form. If + // the secret is empty, treat the id as already-joined (lenient: + // allows pasting a full "id=secret" into the id field). + let token_id = req.proxmox_token_id.clone().unwrap_or_default(); + let secret = req.proxmox_token_secret.clone().unwrap_or_default(); + let api_token = if secret.is_empty() { + token_id } 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 + format!("{token_id}={secret}") }; + // If jump hosts are configured, tunnel the PVE API endpoint so + // the broker call reaches it through the bastion. The tunnelled + // endpoint is 127.0.0.1, which no PVE cert matches, so cert + // verification is disabled for this hop (the SSH tunnel secures + // the transport). The SPICE server cert is still verified below. + let (broker_base, broker_verify) = if !jump_hops.is_empty() { + let (api_host, api_port) = parse_host_port(&pve_url, 8006)?; + let (mut tuns, api_local) = + tunnel::start_chain(&jump_hops, &api_host, api_port) + .await + .map_err(|e| { + SessionError::ValidationError(format!( + "Proxmox API tunnel failed: {e}" + )) + })?; + proxmox_tunnels.append(&mut tuns); + tracing::info!( + session_id = %session_id, + api_local = %api_local, + hops = jump_hops.len(), + "Tunnelled Proxmox PVE API through jump host(s)" + ); + (format!("https://{api_local}"), false) + } else { + (pve_url, verify_tls) + }; + + let broker = crate::pve::PveBroker { + base_url: broker_base, + api_token, + verify_tls: broker_verify, + }; + + // The node is optional: if not given, resolve which node hosts + // the VM via /cluster/resources (as the PVE web UI does), so the + // node-scoped console API can be reached with only the VM id. + let node = match req.proxmox_node.clone().filter(|n| !n.trim().is_empty()) { + Some(n) => n, + None => broker.resolve_node(vmid).await.map_err(|e| { + SessionError::ValidationError(format!("Proxmox node lookup failed: {e}")) + })?, + }; + + let mut 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" + ); + + // Tunnel the SPICE proxy too, and point guacd at the local + // forward. The proxy hop is plain HTTP; the SPICE-over-TLS link + // is tunnelled transparently inside the proxy CONNECT, so the + // SPICE server cert still verifies. + if !jump_hops.is_empty() { + let (proxy_host, proxy_port) = parse_host_port(&cfg.proxy, 3128)?; + let (mut tuns, proxy_local) = + tunnel::start_chain(&jump_hops, &proxy_host, proxy_port) + .await + .map_err(|e| { + SessionError::ValidationError(format!( + "Proxmox SPICE proxy tunnel failed: {e}" + )) + })?; + proxmox_tunnels.append(&mut tuns); + tracing::info!( + session_id = %session_id, + proxy_local = %proxy_local, + "Tunnelled Proxmox SPICE proxy through jump host(s)" + ); + cfg.proxy = format!("http://{proxy_local}"); + } + + // Proxmox SPICE is TLS-only: no plaintext port (guacd sends an + // empty "port" arg whenever tls is set). The one-time ticket is + // the SPICE password. PVE ships a self-signed cluster cert; when + // verification is requested, verify against the returned cluster + // CA + host subject, otherwise skip verification entirely. + let spice = guacd::SpiceParams { + hostname: cfg.host.clone(), + port: 0, + password: Some(cfg.ticket), + username: req.username.clone(), + tls: true, + tls_port: Some(cfg.tls_port), + ca_cert: if verify_tls { Some(cfg.ca_cert) } else { None }, + cert_subject: if verify_tls { + Some(cfg.host_subject) + } else { + None + }, + ignore_cert: !verify_tls, + proxy: Some(cfg.proxy), + color_depth: req.color_depth, + width, + height, + dpi, + disable_copy: req.disable_copy.unwrap_or(false), + disable_paste: req.disable_paste.unwrap_or(false), + enable_audio: false, + }; + + // `cfg.host` is an opaque PVE routing token, used as the display + // hostname for the session. + let hostname = cfg.host; let params = guacd::ConnectionParams::Spice(Box::new(spice)); ( params, hostname, username, None, None, None, None, None, None, @@ -1237,32 +1382,15 @@ impl SessionManager { } }; - // Resolve jump hosts: prefer jump_hosts array, fall back to legacy flat fields - let jump_hops: Vec = if let Some(hops) = req.jump_hosts { - hops - } else if let Some(ref jh) = req.jump_host { - if !jh.is_empty() { - vec![tunnel::JumpHost { - hostname: jh.clone(), - port: req.jump_port.unwrap_or(22), - username: req.jump_username.clone().unwrap_or_default(), - password: req.jump_password.clone(), - private_key: req.jump_private_key.clone(), - host_key: None, - }] - } else { - Vec::new() - } - } else { - Vec::new() - }; - // Set up SSH tunnel chain if jump hosts are configured. // For SSH/RDP/VNC: overrides hostname/port in conn_params so guacd // connects to the local tunnel listener instead of the real target. // For Web: tunnels to the URL's host:port and rewrites the browser URL. + // Proxmox is excluded: it tunnels its own PVE API + SPICE-proxy hops + // in-branch (the routing token / proxy field don't fit this rewrite). let is_web = url.is_some() && browser_session.is_none(); - let ssh_tunnels = if !jump_hops.is_empty() { + let is_proxmox = matches!(req.session_type, SessionType::Proxmox); + let ssh_tunnels = if !jump_hops.is_empty() && !is_proxmox { let (target_host, target_port) = if is_web { // Web session: tunnel to the URL's host:port let parsed = Url::parse(url.as_ref().unwrap()) @@ -1275,7 +1403,14 @@ impl SessionManager { guacd::ConnectionParams::Ssh(p) => (p.hostname.clone(), p.port), guacd::ConnectionParams::Rdp(p) => (p.hostname.clone(), p.port), guacd::ConnectionParams::Vnc(p) => (p.hostname.clone(), p.port), - guacd::ConnectionParams::Spice(p) => (p.hostname.clone(), p.port), + // TLS SPICE connects on tls_port, so tunnel that port. + guacd::ConnectionParams::Spice(p) => { + if p.tls { + (p.hostname.clone(), p.tls_port.unwrap_or(p.port)) + } else { + (p.hostname.clone(), p.port) + } + } } }; @@ -1300,7 +1435,14 @@ impl SessionManager { } guacd::ConnectionParams::Spice(p) => { p.hostname = final_addr.ip().to_string(); - p.port = final_addr.port(); + // Rewrite the port guacd actually dials. Cert-subject + // verification still holds (the server presents the same + // cert regardless of the tunnel). + if p.tls { + p.tls_port = Some(final_addr.port()); + } else { + p.port = final_addr.port(); + } } } } @@ -1384,7 +1526,9 @@ impl SessionManager { browser_session = Some(browser); } - let ssh_tunnels = ssh_tunnels.map(|(t, _)| t).unwrap_or_default(); + let mut ssh_tunnels = ssh_tunnels.map(|(t, _)| t).unwrap_or_default(); + // Fold in any tunnels the Proxmox branch established in-branch. + ssh_tunnels.append(&mut proxmox_tunnels); // For ephemeral keypair sessions, defer the guacd connection until // the user dismisses the banner (i.e. when the WebSocket connects). diff --git a/src/vault.rs b/src/vault.rs index 97bddcf..de91e40 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -235,6 +235,28 @@ pub struct AddressBookEntry { /// SPICE: proxy URL, e.g. a Proxmox SPICE proxy "http://host:3128". #[serde(default, skip_serializing_if = "Option::is_none")] pub spice_proxy: Option, + /// Proxmox VE console: PVE API base URL, full URL incl. scheme + port + /// (e.g. "https://pve.example.com:8006"). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub proxmox_url: Option, + /// Proxmox node name hosting the VM. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub proxmox_node: Option, + /// Proxmox VM id (QEMU) whose console to open. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub proxmox_vmid: Option, + /// Proxmox API token id ("user@realm!tokenname") — non-secret, shown in the + /// UI (User column). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub proxmox_token_id: Option, + /// Proxmox API token secret (UUID). Credential — never returned to the + /// browser (see EntryInfo::has_proxmox_token_secret). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub proxmox_token_secret: Option, + /// Verify the PVE API + SPICE-proxy TLS certificate (default false; PVE + /// ships a self-signed cluster cert). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub proxmox_verify_tls: Option, } impl AddressBookEntry { @@ -398,6 +420,20 @@ pub struct EntryInfo { pub spice_cert_subject: Option, #[serde(skip_serializing_if = "Option::is_none")] pub spice_proxy: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub proxmox_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub proxmox_node: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub proxmox_vmid: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub proxmox_verify_tls: Option, + /// Proxmox token id (non-secret; shown in the User column). + #[serde(skip_serializing_if = "Option::is_none")] + pub proxmox_token_id: Option, + /// Whether a Proxmox token secret is stored (the secret itself is never + /// returned; the UI shows "leave blank to keep"). + pub has_proxmox_token_secret: bool, } impl From<(&str, &AddressBookEntry)> for EntryInfo { @@ -470,6 +506,15 @@ impl From<(&str, &AddressBookEntry)> for EntryInfo { spice_ca_cert: e.spice_ca_cert.clone(), spice_cert_subject: e.spice_cert_subject.clone(), spice_proxy: e.spice_proxy.clone(), + proxmox_url: e.proxmox_url.clone(), + proxmox_node: e.proxmox_node.clone(), + proxmox_vmid: e.proxmox_vmid, + proxmox_verify_tls: e.proxmox_verify_tls, + proxmox_token_id: e.proxmox_token_id.clone(), + has_proxmox_token_secret: e + .proxmox_token_secret + .as_ref() + .is_some_and(|t| !t.is_empty()), } } } diff --git a/static/connections.html b/static/connections.html index 0e9a942..ece8cb2 100644 --- a/static/connections.html +++ b/static/connections.html @@ -140,6 +140,8 @@ display: flex; gap: var(--s-1); align-items: center; } .session-card-info .card-meta .type-badge { font-size: 10px; min-width: 36px; } + /* Proxmox VE brand orange badge */ + .type-badge.type-proxmox { background: #e57000; color: #000; padding: 1px 6px; border-radius: 3px; font-weight: 600; } .session-card.dormant { opacity: 0.75; } .session-card.dormant .session-card-thumb { filter: brightness(0.7); } .card-actions { @@ -442,6 +444,7 @@ + @@ -577,7 +580,37 @@ -
SPICE displays (e.g. Proxmox VE / QEMU consoles). The TLS, CA, certificate-subject and proxy fields support connecting through a SPICE proxy such as Proxmox's.
+
Direct SPICE displays (e.g. libvirt/QEMU consoles). The TLS, CA, certificate-subject and proxy fields support connecting through a SPICE proxy. For Proxmox VE VMs, use the "Proxmox VE console" type instead (it brokers the one-time ticket automatically).
+ +