mirror of
https://github.com/sol1/rustguac.git
synced 2026-09-10 01:26:06 +00:00
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:
Vendored
+4
-2
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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).
|
||||
|
||||
@@ -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
@@ -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">●</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;
|
||||
|
||||
Reference in New Issue
Block a user