vnc: network presets, like UltraVNC Viewer's quick options

A VNC connection can choose how the server encodes its screen for
rustguac: Auto (default, unchanged), LAN (Ultra/ZRLE/Hextile first, low
compression, top JPEG quality), WAN (Tight/ZRLE, compression and JPEG
quality 6) or Slow link (Tight, compression 9, JPEG quality 3, 16-bit
colour unless the connection sets a colour depth). They set guacd's
encodings, compress-level and quality-level; Auto sends none of them.
An unknown preset is refused rather than ignored. Web sessions are
unchanged.

Docs: the VNC section of the overview now covers screen size, the
presets, and which VNC authentication methods work (VNC password,
VeNCrypt, macOS, UltraVNC MS-Logon II) and which don't (RSA-AES,
UltraVNC encryption plugins, MS-Logon III).
This commit is contained in:
Dave Kempe
2026-10-20 15:37:43 +11:00
parent 634fa521c0
commit 87fffebe27
9 changed files with 165 additions and 1 deletions
+2
View File
@@ -219,6 +219,7 @@ The `jump_hosts` array defines an ordered chain of SSH bastion hops. Each hop co
| `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. |
| `vnc_network` | string | VNC | Network preset for the link between the VNC server and rustguac: `auto` (default), `lan`, `wan` or `slow`. See [VNC network presets](overview.md#vnc-network-presets). |
| `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) |
@@ -649,6 +650,7 @@ Create a connection entry. The body includes a `name` field plus all entry field
| `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). |
| `vnc_network` | string | VNC | Network preset: `auto` (default), `lan`, `wan` or `slow`. |
| `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) |
+17
View File
@@ -103,6 +103,23 @@ Connects guacd to a target VNC server. Supports password-based authentication, p
Supports optional [multi-hop SSH tunnel chains](#ssh-tunnel--jump-hosts) to reach VNC targets through bastion hosts.
**Screen size.** By default the remote screen keeps its own size and is scaled to fit the browser window. Tick **Resize the remote desktop to fit the window** only for servers with their own virtual screen (TigerVNC, Xvnc). A physical console (UltraVNC, RealVNC, x11vnc) can't resize, and asking it to stalls the session.
#### VNC network presets
**Network** on a VNC connection chooses how the server encodes the screen for rustguac, like UltraVNC Viewer's quick options:
| Preset | Encodings (in order) | Compression | JPEG quality | Colour |
|---|---|---|---|---|
| Auto (default) | negotiated | default | default | as set |
| LAN | Ultra, ZRLE, Hextile, CopyRect, Raw | 1 | 9 | as set |
| WAN | Tight, ZRLE, CopyRect | 6 | 6 | as set |
| Slow link | Tight, CopyRect | 9 | 3 | 16-bit unless set |
This is the link between the VNC server and rustguac. The picture rustguac sends on to the browser is compressed separately, so a preset helps most when the VNC server is far from rustguac.
**Authentication.** These work: VNC password, VeNCrypt (including username and password over TLS, e.g. TigerVNC with PAM), macOS Screen Sharing, and UltraVNC's Windows login (MS-Logon II, "New MS-Logon"), which allows Active Directory accounts. These don't: RealVNC's or UltraVNC's RSA-AES encryption (security types 5, 6, 129, 130), UltraVNC's encryption plugins, and MS-Logon III. A server offering only those fails with "Unknown authentication scheme". MS-Logon II protects the password weakly and doesn't encrypt the session, so use it on a trusted network or through a jump host.
### SPICE and Proxmox VE
SPICE sessions connect guacd's SPICE plugin to a SPICE server, such as a libvirt/QEMU VM console, with optional TLS, CA verification, certificate-subject pinning and a SPICE proxy. Proxmox VE console sessions open a Proxmox VE VM's console: at connect time rustguac fetches a one-time SPICE ticket from the Proxmox API, so only the API token is stored.
+4
View File
@@ -4015,6 +4015,7 @@ pub fn session_request_from_entry(
autofill: entry.autofill,
allowed_domains: entry.allowed_domains,
vnc_resize: entry.vnc_resize,
vnc_network: entry.vnc_network,
disable_copy: entry.disable_copy,
disable_paste: entry.disable_paste,
enable_gfx: entry.enable_gfx,
@@ -7380,6 +7381,7 @@ pub async fn quick_connect(
autofill: None,
allowed_domains: None,
vnc_resize: None,
vnc_network: None,
disable_copy: None,
disable_paste: None,
enable_gfx: None,
@@ -7655,6 +7657,7 @@ mod tests {
autofill: ab_entry.autofill,
allowed_domains: ab_entry.allowed_domains,
vnc_resize: ab_entry.vnc_resize,
vnc_network: ab_entry.vnc_network.clone(),
disable_copy: ab_entry.disable_copy,
disable_paste: ab_entry.disable_paste,
enable_gfx: ab_entry.enable_gfx,
@@ -7750,6 +7753,7 @@ mod tests {
autofill: ab_entry.autofill,
allowed_domains: ab_entry.allowed_domains,
vnc_resize: ab_entry.vnc_resize,
vnc_network: ab_entry.vnc_network.clone(),
disable_copy: ab_entry.disable_copy,
disable_paste: ab_entry.disable_paste,
enable_gfx: ab_entry.enable_gfx,
+58
View File
@@ -113,10 +113,36 @@ pub struct VncParams {
/// 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,
/// How the VNC server should encode the screen for guacd: encodings in
/// order of preference, and Tight's zlib compression and JPEG quality
/// (0-9). `None` leaves libvncclient's defaults.
pub encodings: Option<String>,
pub compress_level: Option<u8>,
pub quality_level: Option<u8>,
/// Wake-on-LAN parameters.
pub wol: WolParams,
}
/// A VNC "network" preset, like UltraVNC Viewer's quick options: how to
/// trade picture quality for bandwidth between the VNC server and guacd.
/// Returns (encodings, compress level, quality level, colour depth to use
/// when the connection doesn't set one). `None` for `auto` or an unknown
/// name, which leave every setting at its default.
pub fn vnc_network_preset(preset: &str) -> Option<(&'static str, u8, u8, Option<u8>)> {
match preset {
// Fast, lossless-looking: UltraVNC's own LZO encoding first.
"lan" => Some(("ultra zrle hextile copyrect raw", 1, 9, None)),
// Tight with JPEG for photos and video, moderate compression.
"wan" => Some(("tight zrle copyrect", 6, 6, None)),
// As little bandwidth as possible.
"slow" => Some(("tight copyrect", 9, 3, Some(16))),
_ => None,
}
}
/// The preset names `vnc_network_preset` knows, `auto` included.
pub const VNC_NETWORK_PRESETS: &[&str] = &["auto", "lan", "wan", "slow"];
/// SPICE connection parameters to pass to guacd.
///
/// Credentials (`password`/`username`) are sent as connect args: guacd's SPICE
@@ -185,6 +211,9 @@ fn vnc_arg_value(name: &str, p: &VncParams) -> String {
"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(),
"encodings" => p.encodings.clone().unwrap_or_default(),
"compress-level" => p.compress_level.map(|v| v.to_string()).unwrap_or_default(),
"quality-level" => p.quality_level.map(|v| v.to_string()).unwrap_or_default(),
_ => wol_arg_value(name, &p.wol).unwrap_or_else(|| {
tracing::debug!("Unknown guacd VNC parameter '{}', sending empty", name);
String::new()
@@ -759,10 +788,39 @@ mod tests {
disable_copy: false,
disable_paste: false,
display_resize,
encodings: None,
compress_level: None,
quality_level: None,
wol: WolParams::default(),
}
}
#[test]
fn vnc_network_presets_set_encodings_compression_and_quality() {
// Auto (no preset): every one empty, so guacd keeps libvncclient's
// own defaults.
let p = vnc(false);
for arg in ["encodings", "compress-level", "quality-level"] {
assert_eq!(vnc_arg_value(arg, &p), "", "{arg}");
}
let (enc, compress, quality, depth) = vnc_network_preset("slow").unwrap();
let mut p = vnc(false);
p.encodings = Some(enc.into());
p.compress_level = Some(compress);
p.quality_level = Some(quality);
assert_eq!(vnc_arg_value("encodings", &p), "tight copyrect");
assert_eq!(vnc_arg_value("compress-level", &p), "9");
assert_eq!(vnc_arg_value("quality-level", &p), "3");
assert_eq!(depth, Some(16));
// Every listed preset other than auto resolves; anything else doesn't.
for name in VNC_NETWORK_PRESETS.iter().filter(|n| **n != "auto") {
let (enc, c, q, _) = vnc_network_preset(name).unwrap();
assert!(!enc.is_empty() && c <= 9 && q <= 9, "{name}");
}
assert!(vnc_network_preset("auto").is_none());
assert!(vnc_network_preset("modem").is_none());
}
#[test]
fn vnc_display_resize_is_off_unless_asked() {
// A physical console can't resize, and libvncclient then stops asking
+1
View File
@@ -156,6 +156,7 @@ pub async fn cmd_import_guacamole(
autofill: None,
allowed_domains: None,
vnc_resize: None,
vnc_network: None,
disable_copy: None,
disable_paste: None,
banner: None,
+43 -1
View File
@@ -109,6 +109,11 @@ pub struct CreateSessionRequest {
/// can, and one that can't leaves the session frozen.
#[serde(default)]
pub vnc_resize: Option<bool>,
/// VNC: a network preset (`auto`, `lan`, `wan`, `slow`) choosing the
/// encodings, compression and JPEG quality between the server and guacd,
/// like UltraVNC Viewer's quick options. Unset or `auto`: the defaults.
#[serde(default)]
pub vnc_network: Option<String>,
pub disable_copy: Option<bool>,
/// Disable clipboard paste (client → server).
pub disable_paste: Option<bool>,
@@ -1348,12 +1353,13 @@ impl SessionManager {
"Creating new VNC session"
);
let preset = vnc_network(req.vnc_network.as_deref())?;
let params = guacd::ConnectionParams::Vnc(guacd::VncParams {
hostname: connect_host,
port,
username: req.username.clone().filter(|u| !u.is_empty()),
password: req.password.clone(),
color_depth: req.color_depth,
color_depth: req.color_depth.or(preset.and_then(|p| p.3)),
width,
height,
dpi,
@@ -1361,6 +1367,9 @@ impl SessionManager {
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),
encodings: preset.map(|p| p.0.to_string()),
compress_level: preset.map(|p| p.1),
quality_level: preset.map(|p| p.2),
wol: wol.clone(),
});
(
@@ -1673,6 +1682,9 @@ impl SessionManager {
disable_paste: req.disable_paste.unwrap_or(false),
// Our own Xvnc can always be resized to the browser.
display_resize: true,
encodings: None,
compress_level: None,
quality_level: None,
// Local Xvnc — WoL not applicable.
wol: guacd::WolParams::default(),
});
@@ -2825,6 +2837,21 @@ fn drive_cleanup_settings(drive: &Option<DriveConfig>) -> (bool, u64) {
}
}
/// The settings for a VNC network preset, or `None` for `auto` / unset. An
/// unknown name is refused rather than silently treated as `auto`.
type VncPreset = (&'static str, u8, u8, Option<u8>);
fn vnc_network(preset: Option<&str>) -> Result<Option<VncPreset>, SessionError> {
match preset.map(str::trim).filter(|p| !p.is_empty()) {
None | Some("auto") => Ok(None),
Some(p) => guacd::vnc_network_preset(p).map(Some).ok_or_else(|| {
SessionError::ValidationError(format!(
"vnc_network must be one of {}",
guacd::VNC_NETWORK_PRESETS.join(", ")
))
}),
}
}
/// A SPICE CA certificate on its way to guacd. guacd's SPICE plugin hands a
/// PEM to spice-gtk to verify the server against, but if the PEM holds no
/// certificate spice-gtk quietly falls back to the system trust store, so a
@@ -3174,6 +3201,21 @@ mod tests {
assert_eq!(req.vnc_resize, Some(true));
}
#[test]
fn vnc_network_preset_names_are_checked() {
assert!(vnc_network(None).unwrap().is_none());
assert!(vnc_network(Some("")).unwrap().is_none());
assert!(vnc_network(Some("auto")).unwrap().is_none());
assert_eq!(
vnc_network(Some("lan")).unwrap().unwrap().0,
"ultra zrle hextile copyrect raw"
);
assert!(
vnc_network(Some("modem")).is_err(),
"an unknown preset is refused, not ignored"
);
}
#[test]
fn vdi_username_must_be_a_plain_name() {
// It becomes a directory under home_base that is bind-mounted into the
+7
View File
@@ -354,6 +354,9 @@ pub struct AddressBookEntry {
/// only servers with a virtual screen (Xvnc, TigerVNC) can resize.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vnc_resize: Option<bool>,
/// VNC network preset: `auto`, `lan`, `wan` or `slow`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vnc_network: Option<String>,
/// Disable clipboard copy (server → client). Prevents copying from the remote session.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disable_copy: Option<bool>,
@@ -598,6 +601,9 @@ pub struct EntryInfo {
/// VNC: resize the server's screen to the browser window.
#[serde(skip_serializing_if = "Option::is_none")]
pub vnc_resize: Option<bool>,
/// VNC network preset.
#[serde(skip_serializing_if = "Option::is_none")]
pub vnc_network: Option<String>,
/// Disable clipboard copy (server → client).
#[serde(skip_serializing_if = "Option::is_none")]
pub disable_copy: Option<bool>,
@@ -780,6 +786,7 @@ impl From<(&str, &AddressBookEntry)> for EntryInfo {
autofill: e.autofill.clone(),
allowed_domains: e.allowed_domains.clone(),
vnc_resize: e.vnc_resize,
vnc_network: e.vnc_network.clone(),
disable_copy: e.disable_copy,
disable_paste: e.disable_paste,
banner: e.banner.clone(),
+13
View File
@@ -967,6 +967,15 @@
Resize the remote desktop to fit the window
</label>
<div class="field-hint">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.</div>
<label style="margin-top:0.6em">Network
<select id="em-vnc-network">
<option value="">Auto (default)</option>
<option value="lan">LAN: fast, best picture</option>
<option value="wan">WAN: compressed, JPEG for photos and video</option>
<option value="slow">Slow link: least bandwidth, 16-bit colour</option>
</select>
</label>
<div class="field-hint">How the VNC server encodes the screen for rustguac, like UltraVNC Viewer's quick options. Auto lets the server and client agree; pick a preset only if a link is slow or the picture is poor. A colour depth set above takes precedence.</div>
<div class="entry-only">
<label style="margin-top:0.8em">
<input type="checkbox" id="em-vnc-prompt-creds" style="display:inline;width:auto;margin-right:0.4em"> Prompt for credentials at connect time
@@ -4414,6 +4423,7 @@
document.getElementById('em-vnc-password').value = '';
document.getElementById('em-vnc-color-depth').value = '';
document.getElementById('em-vnc-resize').checked = false;
document.getElementById('em-vnc-network').value = '';
document.getElementById('em-vnc-prompt-creds').checked = false;
document.getElementById('em-spice-hostname').value = '';
document.getElementById('em-spice-port').value = '5900';
@@ -4576,6 +4586,7 @@
document.getElementById('em-vnc-username').value = entryData.username || '';
document.getElementById('em-vnc-color-depth').value = entryData.color_depth ? String(entryData.color_depth) : '';
document.getElementById('em-vnc-resize').checked = !!entryData.vnc_resize;
document.getElementById('em-vnc-network').value = entryData.vnc_network || '';
document.getElementById('em-vnc-prompt-creds').checked = !!entryData.prompt_credentials;
} else if (type === 'spice') {
document.getElementById('em-spice-hostname').value = entryData.hostname || '';
@@ -4802,6 +4813,8 @@
var cd = document.getElementById('em-vnc-color-depth').value;
if (cd) entry.color_depth = parseInt(cd);
if (document.getElementById('em-vnc-resize').checked) entry.vnc_resize = true;
var net = document.getElementById('em-vnc-network').value;
if (net) entry.vnc_network = net;
if (document.getElementById('em-vnc-prompt-creds').checked) entry.prompt_credentials = true;
} else if (type === 'spice') {
var h = document.getElementById('em-spice-hostname').value.trim();
@@ -269,3 +269,23 @@ test('VNC asks the server to resize only when the box is ticked', async ({ page
else expect(sent.vnc_resize).toBeUndefined();
}
});
// The VNC network preset is sent only when one is picked.
test('a VNC network preset is sent when chosen, and nothing for Auto', async ({ page }) => {
const api = await installApi(page, { role: 'poweruser' });
await page.goto('/connections.html');
await settle(page);
for (const preset of ['', 'slow']) {
await openEditor(page);
await page.locator('#em-type').selectOption('vnc');
await expect(page.locator('#em-vnc-network')).toHaveValue('');
if (preset) await page.locator('#em-vnc-network').selectOption(preset);
await page.locator('#em-vnc-hostname').fill('192.0.2.21');
const before = adhocCalls(api).length;
await Promise.all([page.waitForEvent('popup'), page.locator('#em-connect').click()]);
await expect.poll(() => adhocCalls(api).length).toBe(before + 1);
const sent = adhocCalls(api)[before].body.entry;
if (preset) expect(sent.vnc_network).toBe(preset);
else expect(sent.vnc_network).toBeUndefined();
}
});