feat(spice): Proxmox VE console connection type (node auto-detect, split token, SSH tunneling)

Split SPICE into two connection types: "spice" (direct libvirt/QEMU) and
"proxmox" (PVE console brokered via the spiceproxy API). Both produce a guacd
SPICE connection.

- Deliver the SPICE ticket/password as a connect arg instead of a post-connect
  argv stream, so it is set before guacd authenticates. Fixes an auth race that
  produced intermittent "SPICE authentication failed".
- TLS-only SPICE sends an empty plain port so guacd connects via tls-port
  rather than plaintext against a TLS endpoint.
- Proxmox node is optional: resolve it from the VM id via /cluster/resources
  (as the PVE web UI does).
- Split the PVE API token into a visible Token ID (shown in the User column)
  and a masked secret; join them as "id=secret" for the API.
- Surface the PVE response body on non-2xx (safe: only a 2xx spiceproxy
  response carries a ticket), turning opaque 500s into actionable messages.
- SSH tunneling for Proxmox: tunnel both the PVE API call and the spiceproxy
  connection through the jump-host chain in-branch. Also rewrite tls_port
  (not port) for direct-SPICE TLS over a tunnel.
- Store proxmox fields on address book entries; populate Host/User columns;
  orange Proxmox badge. Runtime dep: libspice-client-glib-2.0-8.
This commit is contained in:
Dave Kempe
2026-07-24 17:42:09 +10:00
parent 8c70a23780
commit 00a8f42572
8 changed files with 540 additions and 180 deletions
+4 -2
View File
@@ -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.
+29 -15
View File
@@ -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 {
+24 -42
View File
@@ -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<String>,
/// Optional SPICE username, delivered via argv.
/// Optional SPICE username, sent as the `username` connect arg.
pub username: Option<String>,
pub tls: bool,
pub tls_port: Option<u16>,
@@ -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
+12
View File
@@ -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()),
+95 -13
View File
@@ -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::<serde_json::Value>(&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, PveError> {
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<String, PveError> {
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": ...,
+246 -102
View File
@@ -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<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>,
/// 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<String>,
/// Proxmox node name hosting the VM (e.g. "pve").
pub proxmox_node: Option<String>,
/// Proxmox VM id (QEMU) whose console to open.
pub proxmox_vmid: Option<u32>,
/// Proxmox API token id ("user@realm!tokenname") — the non-secret half.
pub proxmox_token_id: Option<String>,
/// 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<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>,
/// self-signed cluster cert). Also controls SPICE-proxy cert verification.
pub proxmox_verify_tls: Option<bool>,
}
/// 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<IpNetwork> = 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<tunnel::JumpHost> = 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<tunnel::SshTunnel> = 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<tunnel::JumpHost> = 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).
+45
View File
@@ -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<String>,
/// 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<String>,
/// Proxmox node name hosting the VM.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub proxmox_node: Option<String>,
/// Proxmox VM id (QEMU) whose console to open.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub proxmox_vmid: Option<u32>,
/// 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<String>,
/// 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<String>,
/// 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<bool>,
}
impl AddressBookEntry {
@@ -398,6 +420,20 @@ pub struct EntryInfo {
pub spice_cert_subject: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub spice_proxy: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub proxmox_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub proxmox_node: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub proxmox_vmid: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub proxmox_verify_tls: Option<bool>,
/// Proxmox token id (non-secret; shown in the User column).
#[serde(skip_serializing_if = "Option::is_none")]
pub proxmox_token_id: Option<String>,
/// 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()),
}
}
}
+85 -6
View File
@@ -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 @@
<option value="rdp">RDP</option>
<option value="vnc">VNC</option>
<option value="spice">SPICE</option>
<option value="proxmox">Proxmox VE console</option>
<option value="web">Web</option>
<option value="vdi">VDI (Docker)</option>
</select>
@@ -577,7 +580,37 @@
<label style="margin-top:0.8em">
<input type="checkbox" id="em-spice-prompt-creds" style="display:inline;width:auto;margin-right:0.4em"> Prompt for credentials at connect time
</label>
<div class="field-hint">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.</div>
<div class="field-hint">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).</div>
</div>
<div id="em-proxmox-fields" style="display:none">
<label>PVE API URL
<input type="text" id="em-proxmox-url" placeholder="https://pve.example.com:8006">
</label>
<label>Node <span style="color:var(--text-muted);font-size:0.85em">(optional — auto-detected from VM ID)</span>
<input type="text" id="em-proxmox-node" placeholder="(auto-detect)">
</label>
<label>VM ID
<input type="number" id="em-proxmox-vmid" placeholder="100">
</label>
<label>Token ID
<input type="text" id="em-proxmox-token-id" placeholder="user@realm!tokenname">
</label>
<label>Token secret
<div class="pw-wrap"><input type="password" id="em-proxmox-token-secret" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"><button type="button" class="pw-toggle" onclick="togglePw(this)" title="Show/hide">&#9679;</button></div>
</label>
<label>Color depth
<select id="em-proxmox-color-depth">
<option value="">Default (24-bit)</option>
<option value="8">8-bit</option>
<option value="16">16-bit</option>
<option value="24">24-bit</option>
<option value="32">32-bit</option>
</select>
</label>
<label style="margin-top:0.8em">
<input type="checkbox" id="em-proxmox-verify-tls" style="display:inline;width:auto;margin-right:0.4em"> Verify the PVE cluster TLS certificate
</label>
<div class="field-hint">Opens a Proxmox VE VM console via SPICE. rustguac calls the PVE <code>spiceproxy</code> API at connect to fetch a one-time ticket, so nothing sensitive is stored beyond the API token. Leave <strong>Node</strong> blank to auto-detect which cluster node hosts the VM. The token needs <code>VM.Console</code> (and <code>VM.Audit</code> for node auto-detect) on the target VM. PVE ships a self-signed cluster certificate, so leave TLS verification off unless the cluster CA is trusted.</div>
</div>
<div id="em-web-fields" style="display:none">
<label>URL
@@ -1806,12 +1839,14 @@
var label = e.display_name || e.name;
var host = e.session_type === 'web' ? (e.url || '')
: e.session_type === 'vdi' ? (e.container_image || '')
: e.session_type === 'proxmox' ? ((e.proxmox_url || '') + (e.proxmox_vmid ? ' · VM ' + e.proxmox_vmid : ''))
: (e.hostname || '');
var haystack = [
e.name,
e.display_name || '',
host,
e.username || '',
e.proxmox_token_id || '',
e.url || '',
e.domain || '',
e.session_type,
@@ -1909,9 +1944,11 @@
var typeLabel = e.session_type.toUpperCase();
if (e.auth_pkg) typeLabel += ' <span style="font-size:0.75em;color:#888">(' + escapeHtml(e.auth_pkg) + ')</span>';
var typeCls = 'type-' + e.session_type;
var needsPrompt = (e.prompt_credentials || !e.has_credentials) && e.session_type !== 'web';
var needsPrompt = (e.prompt_credentials || !e.has_credentials) && e.session_type !== 'web' && e.session_type !== 'proxmox';
var connectLabel = needsPrompt ? 'Login...' : 'Connect';
var userCol = e.username ? highlight(e.username, tokens) : '';
var userCol = e.session_type === 'proxmox'
? highlight(e.proxmox_token_id || '', tokens)
: (e.username ? highlight(e.username, tokens) : '');
if (needsPrompt && userCol) userCol += ' <span style="color:#888;font-size:0.8em" title="Credentials prompted at connect">[prompt]</span>';
var scopeIcon = r.scope === 'shared' ? '⊕' : '▣';
html += '<tr>';
@@ -2058,6 +2095,7 @@
var label = e.display_name || e.name;
var hostBase = e.session_type === 'web' ? (e.url || '')
: e.session_type === 'vdi' ? (e.container_image || '')
: e.session_type === 'proxmox' ? ((e.proxmox_url || '') + (e.proxmox_vmid ? ' · VM ' + e.proxmox_vmid : ''))
: (e.hostname || '');
if (e.port && e.session_type !== 'web' && e.session_type !== 'vdi') hostBase += ':' + e.port;
var hostSuffix = '';
@@ -2073,8 +2111,10 @@
if (e.auth_pkg) typeLabel += ' <span style="font-size:0.75em;color:#888">(' + escapeHtml(e.auth_pkg) + ')</span>';
html += '<td><span class="type-badge ' + typeCls + '">' + typeLabel + '</span></td>';
html += '<td>' + escapeHtml(hostBase) + hostSuffix + '</td>';
var userCol = e.username ? escapeHtml(e.username) : '';
var needsPrompt = (e.prompt_credentials || !e.has_credentials) && e.session_type !== 'web';
var userCol = e.session_type === 'proxmox'
? escapeHtml(e.proxmox_token_id || '')
: (e.username ? escapeHtml(e.username) : '');
var needsPrompt = (e.prompt_credentials || !e.has_credentials) && e.session_type !== 'web' && e.session_type !== 'proxmox';
if (needsPrompt) userCol += ' <span style="color:#888;font-size:0.8em" title="Credentials will be prompted at connect time">[prompt]</span>';
html += '<td>' + userCol + '</td>';
var connectLabel = needsPrompt ? 'Login...' : 'Connect';
@@ -2246,7 +2286,7 @@
// Prompt for credentials if prompt_credentials is set or no stored creds
// (RDP and SSH entries without passwords/keys)
if (entry && (entry.prompt_credentials || !entry.has_credentials) && entry.session_type !== 'web' && entry.session_type !== 'vdi') {
if (entry && (entry.prompt_credentials || !entry.has_credentials) && entry.session_type !== 'web' && entry.session_type !== 'vdi' && entry.session_type !== 'proxmox') {
showCredPrompt(entry, scope, folder, btn);
} else {
doConnect(scope, folder, name, btn);
@@ -2540,6 +2580,7 @@
document.getElementById('em-rdp-fields').style.display = 'none';
document.getElementById('em-vnc-fields').style.display = 'none';
document.getElementById('em-spice-fields').style.display = 'none';
document.getElementById('em-proxmox-fields').style.display = 'none';
document.getElementById('em-web-fields').style.display = 'none';
document.getElementById('em-vdi-fields').style.display = 'none';
var val = entryTypeSelect.value;
@@ -2547,6 +2588,7 @@
else if (val === 'rdp') document.getElementById('em-rdp-fields').style.display = '';
else if (val === 'vnc') document.getElementById('em-vnc-fields').style.display = '';
else if (val === 'spice') document.getElementById('em-spice-fields').style.display = '';
else if (val === 'proxmox') document.getElementById('em-proxmox-fields').style.display = '';
else if (val === 'web') {
document.getElementById('em-web-fields').style.display = '';
loadLoginScripts(function() { populateLoginScriptDropdown(''); });
@@ -2913,6 +2955,9 @@
} else if (type === 'spice') {
targetHost = document.getElementById('em-spice-hostname').value || '???';
targetPort = document.getElementById('em-spice-port').value || '5900';
} else if (type === 'proxmox') {
targetHost = document.getElementById('em-proxmox-node').value || '???';
targetPort = document.getElementById('em-proxmox-vmid').value || '???';
} else if (type === 'web') {
try {
var u = new URL(document.getElementById('em-url').value);
@@ -2990,6 +3035,14 @@
document.getElementById('em-spice-cert-subject').value = '';
document.getElementById('em-spice-proxy').value = '';
document.getElementById('em-spice-prompt-creds').checked = false;
document.getElementById('em-proxmox-url').value = '';
document.getElementById('em-proxmox-node').value = '';
document.getElementById('em-proxmox-vmid').value = '';
document.getElementById('em-proxmox-token-id').value = '';
document.getElementById('em-proxmox-token-secret').value = '';
document.getElementById('em-proxmox-token-secret').placeholder = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx';
document.getElementById('em-proxmox-color-depth').value = '';
document.getElementById('em-proxmox-verify-tls').checked = false;
document.getElementById('em-url').value = '';
document.getElementById('em-banner').value = '';
document.getElementById('em-web-username').value = '';
@@ -3128,6 +3181,17 @@
document.getElementById('em-spice-cert-subject').value = entryData.spice_cert_subject || '';
document.getElementById('em-spice-proxy').value = entryData.spice_proxy || '';
document.getElementById('em-spice-prompt-creds').checked = !!entryData.prompt_credentials;
} else if (type === 'proxmox') {
document.getElementById('em-proxmox-url').value = entryData.proxmox_url || '';
document.getElementById('em-proxmox-node').value = entryData.proxmox_node || '';
document.getElementById('em-proxmox-vmid').value = entryData.proxmox_vmid || '';
document.getElementById('em-proxmox-color-depth').value = entryData.color_depth ? String(entryData.color_depth) : '';
document.getElementById('em-proxmox-verify-tls').checked = !!entryData.proxmox_verify_tls;
document.getElementById('em-proxmox-token-id').value = entryData.proxmox_token_id || '';
// Secret is never returned; show a hint if one is stored.
var secEl = document.getElementById('em-proxmox-token-secret');
secEl.value = '';
secEl.placeholder = entryData.has_proxmox_token_secret ? '(leave blank to keep existing)' : 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx';
} else if (type === 'web') {
document.getElementById('em-url').value = entryData.url || '';
document.getElementById('em-banner').value = entryData.banner || '';
@@ -3335,6 +3399,21 @@
var px = document.getElementById('em-spice-proxy').value.trim();
if (px) entry.spice_proxy = px;
if (document.getElementById('em-spice-prompt-creds').checked) entry.prompt_credentials = true;
} else if (type === 'proxmox') {
var purl = document.getElementById('em-proxmox-url').value.trim();
if (purl) entry.proxmox_url = purl;
var pnode = document.getElementById('em-proxmox-node').value.trim();
if (pnode) entry.proxmox_node = pnode;
var pvmid = parseInt(document.getElementById('em-proxmox-vmid').value);
if (pvmid) entry.proxmox_vmid = pvmid;
var ptid = document.getElementById('em-proxmox-token-id').value.trim();
if (ptid) entry.proxmox_token_id = ptid;
// Only send the secret if the user entered one (blank = keep existing).
var psec = document.getElementById('em-proxmox-token-secret').value.trim();
if (psec) entry.proxmox_token_secret = psec;
var pcd = document.getElementById('em-proxmox-color-depth').value;
if (pcd) entry.color_depth = parseInt(pcd);
entry.proxmox_verify_tls = document.getElementById('em-proxmox-verify-tls').checked;
} else if (type === 'web') {
var url = document.getElementById('em-url').value.trim();
if (url) entry.url = url;