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(); }); } })