From 70e8bf4b85b4495b4017b01d1ff3b591237a750f Mon Sep 17 00:00:00 2001 From: Dave Kempe Date: Tue, 20 Oct 2026 15:19:21 +1100 Subject: [PATCH] vnc: don't ask the server to resize unless the entry says it can guacd asks a VNC server to resize its screen to the browser window by default. A physical console (UltraVNC, RealVNC, x11vnc) can't, and libvncclient then stops requesting updates while it waits for a resize that never comes ("Skipping Update - resize in progress"), so the session all but freezes. Seen with UltraVNC on a two-monitor console. VNC entries and ad-hoc VNC now send disable-display-resize unless the new vnc_resize option is on ("Resize the remote desktop to fit the window" in the editor), and guacd scales the screen instead. Web sessions, on our own Xvnc, still resize. The VNC argument mapping moves into vnc_arg_value() so it can be tested. --- docs/api.md | 2 + src/api.rs | 4 ++ src/guacd.rs | 82 ++++++++++++++++------ src/import.rs | 1 + src/session.rs | 24 +++++++ src/vault.rs | 8 +++ static/connections.html | 8 +++ tests/e2e/specs/connections-adhoc.spec.mjs | 24 +++++++ 8 files changed, 131 insertions(+), 22 deletions(-) diff --git a/docs/api.md b/docs/api.md index 545dee0..bf9b976 100644 --- a/docs/api.md +++ b/docs/api.md @@ -218,6 +218,7 @@ The `jump_hosts` array defines an ordered chain of SSH bastion hops. Each hop co | `kdc_url` | string | RDP | Kerberos KDC or KDC Proxy URL | | `kerberos_cache` | string | RDP | Path to Kerberos credential cache (advanced) | | `color_depth` | integer | RDP | Color depth in bits (8, 16, 24, 32) | +| `vnc_resize` | boolean | VNC | Ask the VNC server to resize its screen to the browser window (default `false`: the screen is scaled to fit). Only for servers with their own virtual screen (TigerVNC, Xvnc); a physical console such as UltraVNC, RealVNC or x11vnc can't resize, and the session stalls if asked. | | `enable_drive` | boolean | RDP, SSH | Enable file transfer / drive redirection | | `disable_copy` | boolean | All | Disable clipboard copy (server → client) | | `disable_paste` | boolean | All | Disable clipboard paste (client → server) | @@ -647,6 +648,7 @@ Create a connection entry. The body includes a `name` field plus all entry field | `auth_pkg` | string | RDP | NLA auth package | | `kdc_url` | string | RDP | Kerberos KDC URL | | `color_depth` | integer | RDP | Color depth | +| `vnc_resize` | boolean | VNC | Resize the server's screen to the browser window (default `false`). See [`POST /api/sessions`](#post-apisessions). | | `enable_drive` | boolean | RDP, SSH | Enable file transfer | | `disable_copy` | boolean | All | Disable clipboard copy (server → client) | | `disable_paste` | boolean | All | Disable clipboard paste (client → server) | diff --git a/src/api.rs b/src/api.rs index 7c0f6da..c57c337 100644 --- a/src/api.rs +++ b/src/api.rs @@ -4014,6 +4014,7 @@ pub fn session_request_from_entry( login_script: entry.login_script, autofill: entry.autofill, allowed_domains: entry.allowed_domains, + vnc_resize: entry.vnc_resize, disable_copy: entry.disable_copy, disable_paste: entry.disable_paste, enable_gfx: entry.enable_gfx, @@ -7378,6 +7379,7 @@ pub async fn quick_connect( login_script: None, autofill: None, allowed_domains: None, + vnc_resize: None, disable_copy: None, disable_paste: None, enable_gfx: None, @@ -7652,6 +7654,7 @@ mod tests { login_script: ab_entry.login_script, autofill: ab_entry.autofill, allowed_domains: ab_entry.allowed_domains, + vnc_resize: ab_entry.vnc_resize, disable_copy: ab_entry.disable_copy, disable_paste: ab_entry.disable_paste, enable_gfx: ab_entry.enable_gfx, @@ -7746,6 +7749,7 @@ mod tests { login_script: ab_entry.login_script, autofill: ab_entry.autofill, allowed_domains: ab_entry.allowed_domains, + vnc_resize: ab_entry.vnc_resize, disable_copy: ab_entry.disable_copy, disable_paste: ab_entry.disable_paste, enable_gfx: ab_entry.enable_gfx, diff --git a/src/guacd.rs b/src/guacd.rs index cb93c7d..9763bbb 100644 --- a/src/guacd.rs +++ b/src/guacd.rs @@ -107,6 +107,12 @@ pub struct VncParams { pub dpi: u32, pub disable_copy: bool, pub disable_paste: bool, + /// Ask the VNC server to change its screen to the browser's size. Only + /// servers with their own virtual screen can (Xvnc, TigerVNC); a physical + /// console (UltraVNC, RealVNC, x11vnc) can't, and libvncclient then stops + /// requesting updates while it waits for a resize that never comes, so + /// the session all but freezes. Off, guacd scales the screen instead. + pub display_resize: bool, /// Wake-on-LAN parameters. pub wol: WolParams, } @@ -159,6 +165,33 @@ impl SpiceParams { } } +/// The value guacd gets for one VNC argument. +fn vnc_arg_value(name: &str, p: &VncParams) -> String { + match name { + "hostname" => p.hostname.clone(), + "port" => p.port.to_string(), + "width" => p.width.to_string(), + "height" => p.height.to_string(), + "dpi" => p.dpi.to_string(), + "username" => p.username.clone().unwrap_or_default(), + "password" => p.password.clone().unwrap_or_default(), + "color-depth" => p.color_depth.map_or("24".into(), |d| d.to_string()), + "cursor" => "local".into(), + "read-only" => "false".into(), + "swap-red-blue" => "false".into(), + "dest-host" => String::new(), + "dest-port" => String::new(), + "enable-audio" => "false".into(), + "disable-copy" => if p.disable_copy { "true" } else { "false" }.into(), + "disable-paste" => if p.disable_paste { "true" } else { "false" }.into(), + "disable-display-resize" => if p.display_resize { "false" } else { "true" }.into(), + _ => wol_arg_value(name, &p.wol).unwrap_or_else(|| { + tracing::debug!("Unknown guacd VNC parameter '{}', sending empty", name); + String::new() + }), + } +} + /// Resolve a guacd SPICE connect arg name to its value. /// /// SPICE has no width/height/dpi connect args: it sizes via the `size` @@ -383,28 +416,7 @@ pub async fn connect_and_handshake( String::new() }), }, - ConnectionParams::Vnc(p) => match name.as_str() { - "hostname" => p.hostname.clone(), - "port" => p.port.to_string(), - "width" => p.width.to_string(), - "height" => p.height.to_string(), - "dpi" => p.dpi.to_string(), - "username" => p.username.clone().unwrap_or_default(), - "password" => p.password.clone().unwrap_or_default(), - "color-depth" => p.color_depth.map_or("24".into(), |d| d.to_string()), - "cursor" => "local".into(), - "read-only" => "false".into(), - "swap-red-blue" => "false".into(), - "dest-host" => String::new(), - "dest-port" => String::new(), - "enable-audio" => "false".into(), - "disable-copy" => if p.disable_copy { "true" } else { "false" }.into(), - "disable-paste" => if p.disable_paste { "true" } else { "false" }.into(), - _ => wol_arg_value(name, &p.wol).unwrap_or_else(|| { - tracing::debug!("Unknown guacd VNC parameter '{}', sending empty", name); - String::new() - }), - }, + ConnectionParams::Vnc(p) => vnc_arg_value(name.as_str(), p), ConnectionParams::Rdp(p) => match name.as_str() { "hostname" => p.hostname.clone(), "port" => p.port.to_string(), @@ -734,6 +746,32 @@ mod tests { v.iter().map(|s| s.to_string()).collect() } + fn vnc(display_resize: bool) -> VncParams { + VncParams { + hostname: "192.0.2.10".into(), + port: 5900, + username: None, + password: None, + color_depth: None, + width: 2560, + height: 1266, + dpi: 96, + disable_copy: false, + disable_paste: false, + display_resize, + wol: WolParams::default(), + } + } + + #[test] + fn vnc_display_resize_is_off_unless_asked() { + // A physical console can't resize, and libvncclient then stops asking + // for updates while it waits: guacd must be told not to try. + assert_eq!(vnc_arg_value("disable-display-resize", &vnc(false)), "true"); + assert_eq!(vnc_arg_value("disable-display-resize", &vnc(true)), "false"); + assert_eq!(vnc_arg_value("hostname", &vnc(false)), "192.0.2.10"); + } + #[test] fn join_args_read_write_by_default() { let got = join_arg_values(&names(&["VERSION_1_5_0", "hostname", "read-only"]), false); diff --git a/src/import.rs b/src/import.rs index 2d302e3..7c200fe 100644 --- a/src/import.rs +++ b/src/import.rs @@ -155,6 +155,7 @@ pub async fn cmd_import_guacamole( login_script: None, autofill: None, allowed_domains: None, + vnc_resize: None, disable_copy: None, disable_paste: None, banner: None, diff --git a/src/session.rs b/src/session.rs index 57c7284..873d086 100644 --- a/src/session.rs +++ b/src/session.rs @@ -104,6 +104,11 @@ pub struct CreateSessionRequest { /// Allowed domains for web sessions. When set, Chromium can only reach these domains. pub allowed_domains: Option>, /// Disable clipboard copy (server → client). + /// VNC: ask the server to resize its screen to the browser window. + /// Off by default: only servers with a virtual screen (Xvnc, TigerVNC) + /// can, and one that can't leaves the session frozen. + #[serde(default)] + pub vnc_resize: Option, pub disable_copy: Option, /// Disable clipboard paste (client → server). pub disable_paste: Option, @@ -1354,6 +1359,8 @@ impl SessionManager { dpi, disable_copy: req.disable_copy.unwrap_or(false), disable_paste: req.disable_paste.unwrap_or(false), + // Off unless the entry says the server can resize. + display_resize: req.vnc_resize.unwrap_or(false), wol: wol.clone(), }); ( @@ -1664,6 +1671,8 @@ impl SessionManager { dpi, disable_copy: req.disable_copy.unwrap_or(false), disable_paste: req.disable_paste.unwrap_or(false), + // Our own Xvnc can always be resized to the browser. + display_resize: true, // Local Xvnc — WoL not applicable. wol: guacd::WolParams::default(), }); @@ -3150,6 +3159,21 @@ mod tests { } } + #[test] + fn vnc_entries_do_not_resize_the_server_unless_set() { + // The request field defaults to "don't resize", so an entry or ad-hoc + // VNC connection that says nothing leaves the server's screen alone. + let req: CreateSessionRequest = + serde_json::from_value(serde_json::json!({"session_type": "vnc", "hostname": "h"})) + .unwrap(); + assert!(!req.vnc_resize.unwrap_or(false)); + let req: CreateSessionRequest = serde_json::from_value( + serde_json::json!({"session_type": "vnc", "hostname": "h", "vnc_resize": true}), + ) + .unwrap(); + assert_eq!(req.vnc_resize, Some(true)); + } + #[test] fn vdi_username_must_be_a_plain_name() { // It becomes a directory under home_base that is bind-mounted into the diff --git a/src/vault.rs b/src/vault.rs index 58fc0a0..0e779b6 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -350,6 +350,10 @@ pub struct AddressBookEntry { /// these domains (plus localhost). Uses --host-rules to block all others. #[serde(default, skip_serializing_if = "Option::is_none")] pub allowed_domains: Option>, + /// VNC: resize the server's screen to the browser window. Off unless set: + /// only servers with a virtual screen (Xvnc, TigerVNC) can resize. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vnc_resize: Option, /// Disable clipboard copy (server → client). Prevents copying from the remote session. #[serde(default, skip_serializing_if = "Option::is_none")] pub disable_copy: Option, @@ -591,6 +595,9 @@ pub struct EntryInfo { /// Allowed domains for web sessions. #[serde(skip_serializing_if = "Option::is_none")] pub allowed_domains: Option>, + /// VNC: resize the server's screen to the browser window. + #[serde(skip_serializing_if = "Option::is_none")] + pub vnc_resize: Option, /// Disable clipboard copy (server → client). #[serde(skip_serializing_if = "Option::is_none")] pub disable_copy: Option, @@ -772,6 +779,7 @@ impl From<(&str, &AddressBookEntry)> for EntryInfo { login_script: e.login_script.clone(), autofill: e.autofill.clone(), allowed_domains: e.allowed_domains.clone(), + vnc_resize: e.vnc_resize, disable_copy: e.disable_copy, disable_paste: e.disable_paste, banner: e.banner.clone(), diff --git a/static/connections.html b/static/connections.html index 9d2ad87..5fc7845 100644 --- a/static/connections.html +++ b/static/connections.html @@ -962,6 +962,11 @@ + +
Only for VNC servers with their own virtual screen, such as TigerVNC or Xvnc. Leave off for a physical console (UltraVNC, RealVNC, x11vnc): they can't resize, and the session stalls if asked to. When off, the screen is scaled to fit instead.