From e79883224ffe3fce43678f707e0ecc44a7eb95e2 Mon Sep 17 00:00:00 2001 From: Dave Kempe Date: Wed, 22 Apr 2026 16:32:37 +1000 Subject: [PATCH] RDP: default to NTLM + persist Connections tree state ### RDP NTLM default New [rdp] config section with default_auth_pkg. The resolver in session.rs walks entry value -> config default -> hardcoded "ntlm". Kerberos/Negotiate are still selectable per-entry or via the config override, but the default is NTLM because Kerberos needs a KDC reachable via DNS (often over TCP) and its failure mode is a silent RDP hang that looks exactly like an unrelated network issue. Existing entries and Guacamole-imported entries that stored auth_pkg = None now resolve to NTLM automatically. Admins who do run Kerberos-integrated hosts can set default_auth_pkg = "kerberos" or "negotiate" in config.toml to restore the old behaviour. UI: the entry modal's NLA dropdown now says "Server default (NTLM)" instead of "Default (negotiate)" so the behaviour matches the label. Added an explicit "Negotiate (Kerberos first, NTLM fallback)" option for completeness. 5 unit tests cover the resolver matrix (entry wins, empty entry falls through, no entry falls through, empty config default falls through, server default wins when entry is None). ### Connections tree persistence Folder expansion state and the selected folder are now persisted to localStorage, so reopening the page / logging back in no longer collapses the whole tree or snaps you back to the alphabetical first folder. - `rustguac_connections_expanded`: {scope|path: true} map, saved on every toggleFolder() and on the auto-expand-on-subfolder- create path. - `rustguac_connections_selected`: {scope, path}, saved on every selectedFolder assignment (click, new folder, new subfolder, delete-to-null, move entry). On page load, loadFolders() now chains: fetch top-level folders -> restoreExpandedTree() walks saved keys shallowest-first so deeper paths can resolve via findFolder() after their ancestors populate subfolderCache -> try restoring saved selection -> fall back to the current auto-select-first behaviour only if nothing restored. Stale keys (deleted folders, ACL-revoked folders) are dropped opportunistically during the restore walk. Per-browser, not per-user; try/catch wraps every storage call so private-mode / quota errors degrade silently to the pre-persistence behaviour. --- config.example.toml | 18 ++++++ src/config.rs | 16 ++++++ src/session.rs | 79 ++++++++++++++++++++++++- static/connections.html | 124 +++++++++++++++++++++++++++++++++++++--- 4 files changed, 228 insertions(+), 9 deletions(-) diff --git a/config.example.toml b/config.example.toml index b6b5496..8f38324 100644 --- a/config.example.toml +++ b/config.example.toml @@ -260,3 +260,21 @@ web_allowed_networks = ["127.0.0.0/8", "::1/128"] # luks_device = "/opt/rustguac/drives.luks" # LUKS container file # luks_name = "rustguac-drives" # device-mapper name # luks_key_path = "rustguac/luks-key" # Vault KV path for encryption key + +# ────────────────────────────────────────────────────────────────── +# RDP defaults +# ────────────────────────────────────────────────────────────────── +# +# Applied to every RDP session unless the address book entry (or the +# ad-hoc connect request) overrides the same field. +# +# default_auth_pkg: NLA/CredSSP authentication package. Rustguac +# defaults to "ntlm" because Kerberos requires a KDC reachable via +# DNS (usually over TCP) and its failure mode is a silent hang. +# Set to "kerberos" if you actually run AD-integrated hosts with +# Kerberos working. "negotiate" means Kerberos-first with NTLM +# fallback and is also prone to the silent-hang failure mode. +# Leave commented to accept the "ntlm" default. + +# [rdp] +# default_auth_pkg = "ntlm" diff --git a/src/config.rs b/src/config.rs index d08cbef..2b9310f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -375,6 +375,21 @@ pub struct Config { pub theme: Option, pub recording: Option, pub vdi: Option, + pub rdp: Option, +} + +/// RDP-wide defaults applied when an address book entry (or ad-hoc +/// connect request) leaves a field unset. +/// +/// `default_auth_pkg` picks the NLA/CredSSP authentication package +/// FreeRDP uses. Rustguac defaults to `"ntlm"` because Kerberos +/// requires a working KDC reachable via DNS, which most deployments +/// don't have, and the failure mode is a silent hang. Override here +/// with `"kerberos"` or `"negotiate"` if your environment actually +/// supports it. +#[derive(Debug, Deserialize, Serialize, Clone, Default)] +pub struct RdpConfig { + pub default_auth_pkg: Option, } /// Fully-resolved theme palette with all 26 color fields. @@ -971,6 +986,7 @@ impl Default for Config { theme: None, recording: None, vdi: None, + rdp: None, } } } diff --git a/src/session.rs b/src/session.rs index c17edd5..323c185 100644 --- a/src/session.rs +++ b/src/session.rs @@ -255,6 +255,83 @@ fn generate_share_token() -> String { hex::encode(bytes) } +/// Resolve the RDP NLA authentication package for this session. +/// +/// Precedence: per-entry (or per-request) value if non-empty, else the +/// server-wide `[rdp] default_auth_pkg`, else `"ntlm"`. We default to +/// NTLM because Kerberos requires a KDC reachable via DNS (often over +/// TCP) and its failure mode is a silent hang that looks like a stuck +/// RDP connection. Admins who actually run Kerberos-integrated hosts +/// can set `default_auth_pkg = "kerberos"` or `"negotiate"` in +/// `config.toml`. +fn resolve_rdp_auth_pkg(entry_value: Option<&str>, config: &Config) -> Option { + if let Some(v) = entry_value { + let trimmed = v.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + if let Some(ref rdp) = config.rdp { + if let Some(ref pkg) = rdp.default_auth_pkg { + let trimmed = pkg.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + } + Some("ntlm".to_string()) +} + +#[cfg(test)] +mod auth_pkg_tests { + use super::*; + + fn cfg(default_auth_pkg: Option<&str>) -> Config { + let mut c = Config::default(); + c.rdp = Some(crate::config::RdpConfig { + default_auth_pkg: default_auth_pkg.map(|s| s.to_string()), + }); + c + } + + #[test] + fn entry_value_wins_over_server_default() { + let c = cfg(Some("ntlm")); + assert_eq!( + resolve_rdp_auth_pkg(Some("kerberos"), &c), + Some("kerberos".into()) + ); + } + + #[test] + fn empty_entry_value_falls_through_to_server_default() { + let c = cfg(Some("kerberos")); + assert_eq!(resolve_rdp_auth_pkg(Some(""), &c), Some("kerberos".into())); + assert_eq!( + resolve_rdp_auth_pkg(Some(" "), &c), + Some("kerberos".into()) + ); + } + + #[test] + fn no_entry_no_config_defaults_to_ntlm() { + let c = Config::default(); + assert_eq!(resolve_rdp_auth_pkg(None, &c), Some("ntlm".into())); + } + + #[test] + fn empty_config_default_falls_through_to_ntlm() { + let c = cfg(Some("")); + assert_eq!(resolve_rdp_auth_pkg(None, &c), Some("ntlm".into())); + } + + #[test] + fn server_default_applies_when_entry_none() { + let c = cfg(Some("negotiate")); + assert_eq!(resolve_rdp_auth_pkg(None, &c), Some("negotiate".into())); + } +} + /// Check that a host resolves to an IP within the allowed CIDR networks. fn check_allowed_network(host: &str, port: u16, allowed: &[String]) -> Result<(), SessionError> { let networks: Vec = allowed @@ -639,7 +716,7 @@ impl SessionManager { drive_name: drive_cfg.drive_name.clone(), disable_download: !drive_cfg.allow_download, disable_upload: !drive_cfg.allow_upload, - auth_pkg: req.auth_pkg.clone(), + auth_pkg: resolve_rdp_auth_pkg(req.auth_pkg.as_deref(), &self.config), kdc_url: req.kdc_url.clone(), kerberos_cache: req.kerberos_cache.clone(), remote_app: req.remote_app.clone(), diff --git a/static/connections.html b/static/connections.html index 92c8b39..15cfb5c 100644 --- a/static/connections.html +++ b/static/connections.html @@ -496,12 +496,13 @@ -
Force a specific NLA authentication package. Kerberos requires domain-joined machines.
+
Leave blank to use the server default (NTLM unless overridden in config.toml [rdp] default_auth_pkg). Kerberos and Negotiate need a domain-joined host with a KDC reachable via DNS; when that isn't set up the RDP connection hangs silently, so NTLM is the safe default.
@@ -855,9 +856,87 @@ var folderEntryCounts = {}; // "scope|path" -> count var folderEntries = {}; // "scope|path" -> entries array var subfolderCache = {}; // "scope|path" -> [FolderInfo] (lazy-loaded children) - var expandedPaths = {}; // "scope|path" -> true (tree expansion state) + var expandedPaths = {}; // "scope|path" -> true (tree expansion state; persisted) + var EXPANDED_STORAGE_KEY = 'rustguac_connections_expanded'; + var SELECTED_STORAGE_KEY = 'rustguac_connections_selected'; function folderKey(scope, path) { return scope + '|' + path; } function folderPath(f) { return f.path || f.name; } + + // Persist tree expansion across page loads so users don't have to + // reopen the same folders every time. Per-browser (localStorage), + // not per-user. Stale keys for deleted folders are harmless: the + // restore walk drops anything findFolder() can't resolve. + function saveExpandedPaths() { + try { localStorage.setItem(EXPANDED_STORAGE_KEY, JSON.stringify(expandedPaths)); } catch (e) {} + } + function loadExpandedPaths() { + try { + var raw = localStorage.getItem(EXPANDED_STORAGE_KEY); + if (raw) { + var parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object') expandedPaths = parsed; + } + } catch (e) { expandedPaths = {}; } + } + loadExpandedPaths(); + + // Persist the selected folder so reopening the page re-opens the + // same folder, not the alphabetical first. + function saveSelectedFolder() { + try { + if (selectedFolder) { + localStorage.setItem(SELECTED_STORAGE_KEY, JSON.stringify({ + scope: selectedFolder.scope, + path: folderPath(selectedFolder) + })); + } else { + localStorage.removeItem(SELECTED_STORAGE_KEY); + } + } catch (e) {} + } + function loadSelectedFolder() { + try { + var raw = localStorage.getItem(SELECTED_STORAGE_KEY); + if (!raw) return null; + var parsed = JSON.parse(raw); + if (parsed && parsed.scope && parsed.path) return parsed; + } catch (e) {} + return null; + } + + // Restore the expanded tree depth-first: shallow paths must load + // before their descendants so findFolder() can resolve deeper keys + // (deeper keys live in subfolderCache which is populated by + // loadSubfolders of their parent). Drops any stale keys along the + // way. Calls the callback when the walk is done. + function restoreExpandedTree(cb) { + var keys = Object.keys(expandedPaths); + if (keys.length === 0) { if (cb) cb(); return; } + keys.sort(function (a, b) { + var depth = function (k) { return (k.split('|')[1] || '').split('/').length; }; + return depth(a) - depth(b); + }); + var queue = keys.slice(); + function processNext() { + if (queue.length === 0) { if (cb) cb(); return; } + var key = queue.shift(); + var sep = key.indexOf('|'); + if (sep < 0) { processNext(); return; } + var scope = key.slice(0, sep); + var path = key.slice(sep + 1); + var f = findFolder(scope, path); + if (!f) { + // Folder no longer exists (deleted, renamed, ACL change); + // drop the stale key so it doesn't drift forever. + delete expandedPaths[key]; + processNext(); + return; + } + if (subfolderCache[key]) { processNext(); return; } + loadSubfolders(scope, path, processNext); + } + processNext(); + } var currentEntries = []; // entries for the currently selected folder var loginScriptsCache = null; // cached list from /api/login-scripts var knownGroupsCache = null; // cached list from /api/auth/known-groups @@ -1402,12 +1481,33 @@ document.getElementById('empty-state').style.display = 'none'; document.getElementById('main-content').style.display = ''; renderFolders(); - // Auto-select first folder if none selected - if (!selectedFolder && folders.length > 0) { - selectedFolder = folders[0]; + // Restore: expanded tree first (populates subfolderCache so + // deep selections can resolve via findFolder), then the + // selected folder, then fall back to folders[0] if nothing + // else picked up. + restoreExpandedTree(function() { renderFolders(); - loadEntries(folders[0].scope, folderPath(folders[0])); - } + if (!selectedFolder) { + var saved = loadSelectedFolder(); + if (saved) { + var f = findFolder(saved.scope, saved.path); + if (f) { + selectedFolder = f; + renderFolders(); + loadEntries(f.scope, folderPath(f)); + maybeAutoOpenSingleton(); + return; + } + } + // Nothing restored; pick the first folder. + selectedFolder = folders[0]; + saveSelectedFolder(); + renderFolders(); + loadEntries(folders[0].scope, folderPath(folders[0])); + } + maybeAutoOpenSingleton(); + }); + return; } maybeAutoOpenSingleton(); }) @@ -1492,6 +1592,7 @@ row.addEventListener('click', function() { selectedFolder = f; + saveSelectedFolder(); renderFolders(); loadEntries(f.scope, path); }); @@ -1532,9 +1633,11 @@ var key = folderKey(f.scope, path); if (expandedPaths[key]) { delete expandedPaths[key]; + saveExpandedPaths(); renderFolders(); } else { expandedPaths[key] = true; + saveExpandedPaths(); renderFolders(); if (!subfolderCache[key]) { loadSubfolders(f.scope, path, function() { renderFolders(); }); @@ -2047,10 +2150,12 @@ var parentPath = subfolderParent.path; var parentKey = folderKey(parentScope, parentPath); expandedPaths[parentKey] = true; + saveExpandedPaths(); delete subfolderCache[parentKey]; var parent = findFolder(parentScope, parentPath); if (parent) parent.has_children = true; selectedFolder = { name: name, scope: postScope, description: desc, path: fullPath, has_children: false }; + saveSelectedFolder(); subfolderParent = null; loadSubfolders(parentScope, parentPath, function() { renderFolders(); @@ -2058,6 +2163,7 @@ }); } else { selectedFolder = { name: name, scope: scope, description: desc, path: name, has_children: false }; + saveSelectedFolder(); loadFolders(); } }) @@ -2091,6 +2197,7 @@ showInfo(msg); } selectedFolder = null; + saveSelectedFolder(); subfolderCache = {}; // drop all cached subfolder listings; they may reference the deleted path document.getElementById('entries-header').style.display = 'none'; document.getElementById('entries-content').innerHTML = '

Select a folder to view entries.

'; @@ -2969,6 +3076,7 @@ }).then(function() { // Switch to the target folder selectedFolder = findFolder(targetScope, targetFolder) || selectedFolder; + saveSelectedFolder(); }); } })