diff --git a/docs/api.md b/docs/api.md index b278de79..3fee0caa 100644 --- a/docs/api.md +++ b/docs/api.md @@ -595,7 +595,54 @@ For `spice` and `proxmox` entries, the `spice_*` and `proxmox_*` fields listed u ### `PUT /api/addressbook/folders/:scope/:folder/entries/:entry` (admin) -Update a connection entry. Uses read-modify-write: reads existing entry from Vault, merges incoming fields on top. Credentials (`password`, `private_key`) that are omitted from the request are preserved from the existing entry. Jump host credentials are merged per-hop by index. +Update a connection entry, or create it if it does not exist. Uses read-modify-write: reads existing entry from Vault, merges incoming fields on top. Credentials (`password`, `private_key`) that are omitted from the request are preserved from the existing entry. Jump host credentials are merged per-hop by index. + +**`?move=true`** makes this folder the entry's only home in the scope: after the write, same-named entries in every other folder of the scope (subfolders included) are deleted, each with its own `delete_entry` audit record. For upserts keyed on a stable name that can change folder, such as a NetBox webhook writing to the device's current tenant folder: when the tenant changes, the entry follows instead of being doubled. The response lists what was removed: + +```json +{ "ok": true, "removed_from": ["mrgelato"] } +``` + +### `GET /api/addressbook/folders/:scope/:folder/defaults` (admin) + +The folder's default credentials, secrets stripped. These are lent to entries in the folder (and in subfolders without their own, per session type) that have no password or private key of their own, so an entry created without secrets by a webhook or an import is connectable the moment it exists. An entry's own credentials always win, and an entry set to `prompt_credentials` still prompts. + +```json +{ + "defaults": { + "ssh": { "username": "root", "domain": null, "has_password": false, "has_private_key": true }, + "web": { "username": null, "domain": null, "has_password": true, "has_private_key": false } + } +} +``` + +Entries covered by folder defaults list with `has_credentials: true` and `inherited_credentials: true`. + +### `PUT /api/addressbook/folders/:scope/:folder/defaults` (admin) + +Set default credentials per session type. Types not mentioned keep what is stored; `null` removes a type. Within a type an omitted field keeps the stored value and `""` clears it, the same rule entry PUTs use for secrets. + +```json +{ + "ssh": { "username": "root", "private_key": "-----BEGIN OPENSSH PRIVATE KEY-----\n..." }, + "web": { "password": "router-password" }, + "rdp": null +} +``` + +Returns the same summary as `GET`. A type left with nothing in it is dropped; a folder left with no types loses its defaults entirely. Audited as `update_folder_defaults`. + +### `DELETE /api/addressbook/folders/:scope/:folder/defaults` (admin) + +Remove the folder's default credentials. Returns `204`. + +### `GET /api/addressbook/version` + +A counter rustguac bumps after every successful change made through its API to the address book (entries, folders, folder config and defaults, moves, copies, bulk operations) or to the caller's credential variables. Requires **operator**. It costs no Vault read, so clients can poll it often and reload only when it moves; the Connections page does so every 3 seconds. `changed_at` is unix milliseconds, `0` before the first change since start. The counter is per process: with several rustguac instances on one Vault, each sees only its own changes, so the page also refreshes unconditionally every two minutes. + +```json +{ "version": 17, "changed_at": 1760155200000 } +``` ### `DELETE /api/addressbook/folders/:scope/:folder/entries/:entry` (admin) diff --git a/docs/netbox.md b/docs/netbox.md index 6c8408bd..4e3b4969 100644 --- a/docs/netbox.md +++ b/docs/netbox.md @@ -140,8 +140,8 @@ Use Event Rule **conditions** to sync only the devices you want. You can filter 2. **Create the Webhook**: - Name: `rustguac-sync-create` - - URL: `https://console.example.com/api/addressbook/folders/shared/netbox-sync/entries` - - HTTP method: POST + - URL: `https://console.example.com/api/addressbook/folders/shared/netbox-sync/entries/{{ data.name | lower }}?move=true` + - HTTP method: PUT - HTTP content type: `application/json` - Additional headers: ``` @@ -161,6 +161,21 @@ Use Event Rule **conditions** to sync only the devices you want. You can filter **Important:** The entry field is `type`, not `session_type` (it matches the Vault storage format). The hostname uses `.split('/')[0]` to strip the CIDR prefix from NetBox IP addresses (e.g. `10.0.0.1/24` → `10.0.0.1`). Avoid `regex_replace` and `cut` filters — they are not available in NetBox's Jinja2 environment. + `PUT` creates the entry on first sight and updates it afterwards, keeping any password or key already stored in Vault. `?move=true` matters when the folder is computed from the device, for example one folder per tenant (`.../folders/shared/{{ data.tenant.slug }}/entries/{{ data.name | lower }}?move=true`): when a device changes tenant, the entry follows it and the copy in the old folder is removed, instead of both existing. See [`PUT .../entries/:entry`](api.md#put-apiaddressbookfoldersscopefolderentriesentry-admin). + +### Credentials for synced entries + +NetBox never carries secrets, so a webhook creates entries without a password or key. Rather than a second pass that fills them in later, give the folder **default credentials**: a per-session-type username, password or private key that entries without their own borrow at connect time. Set them once on the folder (Connections, edit folder, Default credentials; or [`PUT .../defaults`](api.md#put-apiaddressbookfoldersscopefolderdefaults-admin)): + +```bash +curl -X PUT https://console.example.com/api/addressbook/folders/shared/netbox-sync/defaults \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"ssh": {"username": "root", "private_key": "'"$(sed ':a;N;$!ba;s/\n/\\n/g' fleet-key)"'"}}' +``` + +A synced entry then shows **Connect**, not **Login...**, from the moment the webhook fires. An entry that is given credentials of its own keeps them; an entry with `"prompt_credentials": true` still prompts. The Connections page picks all of this up on its own within a few seconds (it polls [`GET /api/addressbook/version`](api.md#get-apiaddressbookversion)), so nobody has to reload. + ### Create webhook: device deleted 1. **Create an Event Rule**: diff --git a/docs/reverse-proxies.md b/docs/reverse-proxies.md index 5fc65aae..1b9d58c6 100644 --- a/docs/reverse-proxies.md +++ b/docs/reverse-proxies.md @@ -218,3 +218,36 @@ trusted_proxies = ["127.0.0.1/32"] Without this setting, all requests from behind the proxy appear to come from the proxy's own IP. + +## Content-Security-Policy + +rustguac sends its own `Content-Security-Policy` on every response. Leave +it alone: the Guacamole client needs `img-src 'self' data: blob:` because +browsers without WebCodecs `ImageDecoder` (Safari, Firefox) draw every +image tile from a `data:` URI. If your proxy sets a CSP of its own, keep +that directive, or SSH/RDP sessions render black in those browsers while +Chrome looks fine. + +Releases up to v1.10.5 shipped a policy with no `img-src`, which is +exactly that failure (#249). If you can't upgrade, have the proxy replace +the header. The whole policy has to be repeated because the header is +replaced, not merged: + +HAProxy (backend section): + +``` +http-response set-header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' wss: ws:; img-src 'self' data: blob:" +``` + +nginx: + +```nginx +proxy_hide_header Content-Security-Policy; +add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' wss: ws:; img-src 'self' data: blob:" always; +``` + +Caddy: + +``` +header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' wss: ws:; img-src 'self' data: blob:" +``` diff --git a/src/api.rs b/src/api.rs index cfe236da..18bc926f 100644 --- a/src/api.rs +++ b/src/api.rs @@ -2193,6 +2193,51 @@ impl VaultBackends { .await } + pub async fn get_folder_defaults( + &self, + scope: &str, + folder: &str, + ) -> Result { + self.scoped(scope) + .await? + .get_folder_defaults(scope, folder) + .await + } + + pub async fn put_folder_defaults( + &self, + scope: &str, + folder: &str, + defaults: &crate::vault::FolderDefaults, + ) -> Result<(), VaultError> { + self.scoped(scope) + .await? + .put_folder_defaults(scope, folder, defaults) + .await + } + + pub async fn delete_folder_defaults( + &self, + scope: &str, + folder: &str, + ) -> Result<(), VaultError> { + self.scoped(scope) + .await? + .delete_folder_defaults(scope, folder) + .await + } + + pub async fn effective_folder_defaults( + &self, + scope: &str, + folder: &str, + ) -> Result, VaultError> { + self.scoped(scope) + .await? + .effective_folder_defaults(scope, folder) + .await + } + pub async fn delete_folder( &self, scope: &str, @@ -2519,6 +2564,347 @@ pub async fn ab_list_subfolders( /// GET /api/addressbook — Batch endpoint returning all visible folders with entries. /// Replaces the N+1 pattern of listing folders then entries per folder. +// ── Address book change counter ── + +/// Monotonic change counter for the address book, bumped by any successful +/// mutating request under `/api/addressbook` (other than `.../connect`) or +/// `/api/me/credentials`; see `ab_version_bump`. The Connections page polls +/// `GET /api/addressbook/version` every few seconds and reloads only when it +/// moves, so a NetBox webhook or an admin's edit reaches every open page +/// within seconds without a Vault read per poll. It is per process: a second +/// instance sharing the Vault does not see this one's bumps, which the page +/// covers with a slow unconditional refresh. +#[derive(Clone, Default)] +pub struct AddressBookVersion { + inner: Arc, +} + +#[derive(Default)] +struct AddressBookVersionInner { + version: std::sync::atomic::AtomicU64, + changed_at_ms: std::sync::atomic::AtomicU64, +} + +impl AddressBookVersion { + pub fn bump(&self) { + use std::sync::atomic::Ordering; + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + self.inner.version.fetch_add(1, Ordering::Relaxed); + self.inner.changed_at_ms.store(now_ms, Ordering::Relaxed); + } + + /// (version, changed_at in unix milliseconds; 0 before the first bump) + pub fn snapshot(&self) -> (u64, u64) { + use std::sync::atomic::Ordering; + ( + self.inner.version.load(Ordering::Relaxed), + self.inner.changed_at_ms.load(Ordering::Relaxed), + ) + } +} + +/// Whether a request path and method change the address book as the +/// Connections page shows it. Extracted for test. +pub fn ab_request_mutates(method: &axum::http::Method, path: &str) -> bool { + use axum::http::Method; + if matches!(*method, Method::GET | Method::HEAD | Method::OPTIONS) { + return false; + } + if path == "/api/me/credentials" { + return true; + } + // Connecting creates a session, not a change; everything else under the + // prefix (entries, folders, config, defaults, move/copy, bulk) does. + path.starts_with("/api/addressbook") && !path.ends_with("/connect") +} + +/// Middleware: bump the address book version after any successful mutating +/// request that touches it. Sits inside the auth layer, so only requests +/// that were allowed to change anything get here. +pub async fn ab_version_bump( + version: Option>, + request: axum::extract::Request, + next: axum::middleware::Next, +) -> axum::response::Response { + let mutates = ab_request_mutates(request.method(), request.uri().path()); + let response = next.run(request).await; + if mutates && response.status().is_success() { + if let Some(Extension(v)) = version { + v.bump(); + } + } + response +} + +/// GET /api/addressbook/version — the change counter. Requires operator. +pub async fn ab_version( + identity: Option>, + version: Option>, +) -> impl IntoResponse { + if !matches!(identity, Some(Extension(ref id)) if id.has_role("operator")) { + return ( + StatusCode::FORBIDDEN, + Json(json!({"error": "operator role required"})), + ) + .into_response(); + } + let (version, changed_at) = version.map(|v| v.snapshot()).unwrap_or((0, 0)); + Json(json!({ "version": version, "changed_at": changed_at })).into_response() +} + +// ── Folder default credentials ── + +/// Which default credentials a folder lends, without the secrets. +fn folder_defaults_summary(d: &crate::vault::FolderDefaults) -> serde_json::Value { + let mut out = serde_json::Map::new(); + for (session_type, c) in d { + out.insert( + session_type.clone(), + json!({ + "username": c.username, + "domain": c.domain, + "has_password": c.password.as_deref().is_some_and(|p| !p.is_empty()), + "has_private_key": c.private_key.as_deref().is_some_and(|k| !k.is_empty()), + }), + ); + } + serde_json::Value::Object(out) +} + +/// GET /api/addressbook/folders/:scope/:folder/defaults — admin only. The +/// folder's own `.defaults` (not what it inherits), secrets stripped. +pub async fn ab_get_folder_defaults( + identity: Option>, + Extension(vault): Extension, + Path((scope, folder)): Path<(String, String)>, +) -> impl IntoResponse { + if !matches!(identity, Some(Extension(ref id)) if id.has_role("admin")) { + return ( + StatusCode::FORBIDDEN, + Json(json!({"error": "admin role required"})), + ) + .into_response(); + } + match vault.get_folder_defaults(&scope, &folder).await { + Ok(d) => Json(json!({ "defaults": folder_defaults_summary(&d) })).into_response(), + Err(VaultError::NotFound) => Json(json!({ "defaults": {} })).into_response(), + Err(e) => ( + StatusCode::BAD_GATEWAY, + Json(json!({"error": e.to_string()})), + ) + .into_response(), + } +} + +/// One session type's part of a `PUT .../defaults` body. An omitted field +/// keeps what is stored, an empty string clears it; the same rule as entry +/// PUTs use for their secrets. +#[derive(Deserialize, Default)] +pub struct DefaultCredentialsPatch { + #[serde(default)] + pub username: Option, + #[serde(default)] + pub password: Option, + #[serde(default)] + pub private_key: Option, + #[serde(default)] + pub domain: Option, +} + +/// PUT /api/addressbook/folders/:scope/:folder/defaults — admin only. +/// +/// Body: `{ "": { username?, password?, private_key?, domain? } | null, ... }`. +/// Types not mentioned keep what is stored; `null` removes a type. Within a +/// type an omitted secret keeps the stored one and `""` clears it. A type +/// left with nothing in it is dropped; a folder left with no types loses its +/// `.defaults` key. +pub async fn ab_put_folder_defaults( + ConnectInfo(addr): ConnectInfo, + headers: axum::http::HeaderMap, + identity: Option>, + trusted: Option>, + Extension(database): Extension, + Extension(vault): Extension, + Path((scope, folder)): Path<(String, String)>, + Json(patch): Json>>, +) -> impl IntoResponse { + let admin_email = match identity.as_ref() { + Some(Extension(id)) if id.has_role("admin") => id.display_name().to_string(), + _ => { + return ( + StatusCode::FORBIDDEN, + Json(json!({"error": "admin role required"})), + ) + .into_response() + } + }; + + for session_type in patch.keys() { + let ok = !session_type.is_empty() + && session_type.len() <= 32 + && session_type + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-'); + if !ok { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": format!("invalid session type key {:?}", session_type)})), + ) + .into_response(); + } + } + + let mut current = match vault.get_folder_defaults(&scope, &folder).await { + Ok(d) => d, + Err(VaultError::NotFound) => crate::vault::FolderDefaults::new(), + Err(e) => { + return ( + StatusCode::BAD_GATEWAY, + Json(json!({"error": e.to_string()})), + ) + .into_response() + } + }; + + fn non_empty(v: String) -> Option { + if v.is_empty() { + None + } else { + Some(v) + } + } + + for (session_type, part) in patch { + match part { + None => { + current.remove(&session_type); + } + Some(p) => { + let c = current.entry(session_type).or_default(); + if let Some(u) = p.username { + c.username = non_empty(u); + } + if let Some(d) = p.domain { + c.domain = non_empty(d); + } + if let Some(pw) = p.password { + c.password = non_empty(pw); + } + if let Some(k) = p.private_key { + c.private_key = non_empty(k); + } + } + } + } + current.retain(|_, c| c.username.is_some() || c.domain.is_some() || c.has_secret()); + + match vault.put_folder_defaults(&scope, &folder, ¤t).await { + Ok(()) => { + let ip = audit_client_ip(&headers, &addr, trusted.as_ref()); + let details = json!({ "types": current.keys().collect::>() }).to_string(); + log_ab_event( + &database, + &admin_email, + "update_folder_defaults", + &scope, + &folder, + None, + &ip, + Some(&details), + ) + .await; + Json(json!({ "ok": true, "defaults": folder_defaults_summary(¤t) })) + .into_response() + } + Err(e) => ( + StatusCode::BAD_GATEWAY, + Json(json!({"error": e.to_string()})), + ) + .into_response(), + } +} + +/// DELETE /api/addressbook/folders/:scope/:folder/defaults — admin only. +pub async fn ab_delete_folder_defaults( + ConnectInfo(addr): ConnectInfo, + headers: axum::http::HeaderMap, + identity: Option>, + trusted: Option>, + Extension(database): Extension, + Extension(vault): Extension, + Path((scope, folder)): Path<(String, String)>, +) -> impl IntoResponse { + let admin_email = match identity.as_ref() { + Some(Extension(id)) if id.has_role("admin") => id.display_name().to_string(), + _ => { + return ( + StatusCode::FORBIDDEN, + Json(json!({"error": "admin role required"})), + ) + .into_response() + } + }; + match vault.delete_folder_defaults(&scope, &folder).await { + Ok(()) => { + let ip = audit_client_ip(&headers, &addr, trusted.as_ref()); + log_ab_event( + &database, + &admin_email, + "delete_folder_defaults", + &scope, + &folder, + None, + &ip, + None, + ) + .await; + StatusCode::NO_CONTENT.into_response() + } + Err(e) => ( + StatusCode::BAD_GATEWAY, + Json(json!({"error": e.to_string()})), + ) + .into_response(), + } +} + +/// Every folder path in `scope`, top level and below. Used by `PUT +/// ...?move=true` to find same-named entries elsewhere. +async fn all_folder_paths(vault: &VaultState, scope: &str) -> Vec { + let mut queue: Vec = match vault.list_all_folders().await { + Ok((f, _)) => f + .into_iter() + .filter(|f| f.scope == scope) + .map(|f| f.path.unwrap_or(f.name)) + .collect(), + Err(_) => return Vec::new(), + }; + let mut out = Vec::new(); + while let Some(path) = queue.pop() { + if let Ok(subs) = vault.list_subfolders(scope, &path).await { + for s in subs { + queue.push(s.path.unwrap_or_else(|| format!("{}/{}", path, s.name))); + } + } + out.push(path); + } + out +} + +/// Query string of `PUT .../entries/:entry`. +#[derive(Deserialize, Default)] +pub struct EntryPutQuery { + /// `?move=true`: after the write, delete same-named entries from every + /// other folder in the scope. For upserts keyed on a stable name, like + /// a NetBox webhook that writes to the device's current tenant folder: + /// when the tenant changes, the entry follows instead of being doubled. + #[serde(rename = "move", default)] + pub move_: bool, +} + pub async fn ab_list_all( identity: Option>, Extension(vault): Extension, @@ -2565,6 +2951,7 @@ pub async fn ab_list_all( "path": folder.path, "has_children": folder.has_children, "description": folder.description, + "display_name": folder.display_name, "path_only": true, "entries": [], })); @@ -2583,10 +2970,17 @@ pub async fn ab_list_all( .list_entries(&folder.scope, &folder.name) .await .unwrap_or_default(); + let defaults = vault + .effective_folder_defaults(&folder.scope, &folder.name) + .await + .ok() + .flatten(); let mut entries = Vec::new(); for name in &entry_names { if let Ok(entry) = vault.get_entry(&folder.scope, &folder.name, name).await { - entries.push(crate::vault::EntryInfo::from((name.as_str(), &entry))); + let mut info = crate::vault::EntryInfo::from((name.as_str(), &entry)); + info.apply_inherited(&entry, defaults.as_ref()); + entries.push(info); } } @@ -2596,6 +2990,7 @@ pub async fn ab_list_all( "path": folder.path, "has_children": folder.has_children, "description": folder.description, + "display_name": folder.display_name, "allowed_groups": config.as_ref().map(|c| &c.allowed_groups), // allowed_users is deliberately not listed: operators see this, // and a user who can open a folder should not learn who else can. @@ -2674,12 +3069,28 @@ pub async fn ab_search_index( Ok(n) => n, Err(_) => continue, }; + let defaults = vault + .effective_folder_defaults(&scope, &path) + .await + .ok() + .flatten(); + // The folder's human title (e.g. the NetBox tenant name), so the + // search results can show it over the folder key like the folder view. + let folder_display = vault + .get_folder_config(&scope, &path) + .await + .ok() + .map(|c| c.display_name) + .filter(|s| !s.is_empty()); for name in &names { if let Ok(entry) = vault.get_entry(&scope, &path, name).await { + let mut info = crate::vault::EntryInfo::from((name.as_str(), &entry)); + info.apply_inherited(&entry, defaults.as_ref()); emitted.push(json!({ "scope": scope, "folder_path": path, - "entry": crate::vault::EntryInfo::from((name.as_str(), &entry)), + "folder_display_name": folder_display, + "entry": info, })); } } @@ -2722,10 +3133,17 @@ pub async fn ab_list_entries( }; // Fetch each entry and strip credentials + let defaults = vault + .effective_folder_defaults(&scope, &folder) + .await + .ok() + .flatten(); let mut entries = Vec::new(); for name in &entry_names { if let Ok(entry) = vault.get_entry(&scope, &folder, name).await { - entries.push(crate::vault::EntryInfo::from((name.as_str(), &entry))); + let mut info = crate::vault::EntryInfo::from((name.as_str(), &entry)); + info.apply_inherited(&entry, defaults.as_ref()); + entries.push(info); } } @@ -2738,6 +3156,13 @@ pub struct ConnectRequest { pub width: Option, #[serde(default)] pub height: Option, + /// The screen's size in CSS pixels. An entry that opens fullscreen on + /// connect is created at this size instead of the window's, so nothing + /// has to be resized once the fullscreen transition lands. + #[serde(default)] + pub screen_width: Option, + #[serde(default)] + pub screen_height: Option, #[serde(default)] pub dpi: Option, #[serde(default)] @@ -2831,6 +3256,51 @@ pub struct ProbeHostKeyRequest { /// POST /api/addressbook/folders/:scope/:folder/entries/:entry/connect — Create session from entry. #[allow(clippy::too_many_arguments)] +/// The size a new session is created at. +/// +/// A bitmap (RDP/VNC) entry that opens fullscreen on connect is created at +/// the screen's size, which is the size it will have once fullscreen lands. +/// The client holds the connect cover up until fullscreen is entered, so the +/// desktop is painted once at that final size and revealed in fullscreen with +/// no resize and no second repaint (#257). The windowed, scaled-to-fit frame +/// that would otherwise show a letterbox gap is never seen. Everything else +/// keeps the window's size: a terminal reflows to fullscreen instantly. +pub fn initial_session_size( + session_type: &str, + fullscreen_on_connect: bool, + window: (Option, Option), + screen: (Option, Option), +) -> (Option, Option) { + let bitmap = matches!(session_type, "rdp" | "vnc"); + let (w, h) = match (bitmap && fullscreen_on_connect, screen) { + (true, (Some(w), Some(h))) if w > 0 && h > 0 => (Some(w), Some(h)), + _ => window, + }; + // RDP's display channel rounds every requested size down to a multiple + // of 16 (patches/007, which prevents the H.264 green band), so the + // client's first post-connect size send always lands on that grid. + // Create the session there already, fullscreen or windowed, so the two + // match: guacd coalesces them and there is no resize, the H.264 decoder + // is not rebuilt and the picture never flashes on connect (#257). VNC + // has no such rounding, so it keeps the exact size. + if session_type == "rdp" { + (w.map(round_down_mod16), h.map(round_down_mod16)) + } else { + (w, h) + } +} + +/// Round a dimension down to a multiple of 16, with a 16px floor so a tiny +/// screen never rounds to zero. Mirrors guacd's RDP display-channel rounding. +fn round_down_mod16(v: u32) -> u32 { + let r = v - (v % 16); + if r < 16 { + 16 + } else { + r + } +} + pub async fn ab_connect_entry( State(manager): State, ConnectInfo(addr): ConnectInfo, @@ -2875,6 +3345,32 @@ pub async fn ab_connect_entry( } }; + // An entry without credentials of its own borrows the folder's defaults + // for its type (nearest folder on the path wins), unless it is set to + // prompt. A NetBox-created entry is then usable the moment it exists. + let ab_entry = if !crate::vault::entry_has_own_credentials(&ab_entry) + && !ab_entry.prompt_credentials.unwrap_or(false) + { + let mut e = ab_entry; + match vault.effective_folder_defaults(&scope, &folder).await { + Ok(Some(d)) => { + if crate::vault::apply_folder_defaults(&mut e, &d) { + tracing::debug!( + scope, + folder, + entry, + "entry using folder default credentials" + ); + } + } + Ok(None) => {} + Err(err) => tracing::warn!("Failed to read folder defaults from Vault: {}", err), + } + e + } else { + ab_entry + }; + // Resolve credential variable references ($domain_user, $domain_password, etc.) let ab_entry = if !crate::vault::entry_credential_variables(&ab_entry).is_empty() { let user_email = match &id { @@ -2931,6 +3427,14 @@ pub async fn ab_connect_entry( // Build CreateSessionRequest from the Vault entry + connect request display params. // ConnectRequest credentials override address book values (for prompted credentials). let ab_entry_key = format!("{}/{}/{}", scope, folder, entry); + let reuse_entry_key = ab_entry_key.clone(); + let (initial_width, initial_height) = initial_session_size( + &ab_entry.session_type, + ab_entry.fullscreen_on_connect.unwrap_or(false), + (req.width, req.height), + (req.screen_width, req.screen_height), + ); + let create_req = CreateSessionRequest { session_type, hostname: ab_entry.hostname, @@ -2954,8 +3458,8 @@ pub async fn ab_connect_entry( jump_username: None, jump_password: None, jump_private_key: None, - width: req.width, - height: req.height, + width: initial_width, + height: initial_height, dpi: req.dpi, banner: req.banner.or(ab_entry.banner), enable_drive: ab_entry.enable_drive, @@ -3025,6 +3529,21 @@ pub async fn ab_connect_entry( "Address book connect requested" ); + // Reuse a Pending session this user already opened for this entry rather + // than spawning a duplicate (e.g. a repeat Connect click). Scoped by the + // entry key stored on the session. + if let Some(existing) = manager + .find_reusable_session(&admin_name, &reuse_entry_key) + .await + { + tracing::info!( + user = %admin_name, + session_id = %existing.session_id, + "Reusing existing address book session" + ); + return (StatusCode::CREATED, Json(json!(existing))).into_response(); + } + match manager.create_session(create_req, admin_name.clone()).await { Ok(info) => { tracing::info!( @@ -3057,6 +3576,8 @@ pub struct CreateFolderRequest { pub allowed_users: Vec, #[serde(default)] pub description: String, + #[serde(default)] + pub display_name: String, /// "shared" or "instance" (default: "shared") #[serde(default = "default_scope")] pub scope: String, @@ -3177,6 +3698,7 @@ pub async fn ab_create_folder( allowed_groups: req.allowed_groups, allowed_users, description: req.description, + display_name: req.display_name.trim().to_string(), inherit_from_parent: req.inherit_from_parent, }; @@ -3223,6 +3745,8 @@ pub struct UpdateFolderRequest { #[serde(default)] pub description: String, #[serde(default)] + pub display_name: String, + #[serde(default)] pub inherit_from_parent: bool, } @@ -3267,6 +3791,7 @@ pub async fn ab_update_folder( allowed_groups: req.allowed_groups, allowed_users, description: req.description, + display_name: req.display_name.trim().to_string(), inherit_from_parent: req.inherit_from_parent, }; @@ -3325,6 +3850,7 @@ pub async fn ab_get_folder_config( "allowed_groups": cfg.allowed_groups, "allowed_users": cfg.allowed_users, "description": cfg.description, + "display_name": cfg.display_name, "inherit_from_parent": cfg.inherit_from_parent, })) .into_response(), @@ -3470,6 +3996,7 @@ pub async fn ab_update_entry( Extension(database): Extension, Extension(vault): Extension, Path((scope, folder, entry)): Path<(String, String, String)>, + Query(query): Query, Json(data): Json, ) -> impl IntoResponse { let admin_email = match identity.as_ref() { @@ -3482,6 +4009,7 @@ pub async fn ab_update_entry( .into_response() } }; + let move_elsewhere = query.move_; // Read existing entry to preserve credentials the frontend never sends back let merged = match vault.get_entry(&scope, &folder, &entry).await { @@ -3550,7 +4078,35 @@ pub async fn ab_update_entry( Some(&details), ) .await; - Json(json!({"ok": true})).into_response() + + // ?move=true: this folder is now the entry's only home in the scope. + let mut removed_from = Vec::new(); + if move_elsewhere { + for other in all_folder_paths(&vault, &scope).await { + if other == folder { + continue; + } + if vault.get_entry(&scope, &other, &entry).await.is_err() { + continue; + } + if vault.delete_entry(&scope, &other, &entry).await.is_ok() { + let details = json!({ "moved_to": folder }).to_string(); + log_ab_event( + &database, + &admin_email, + "delete_entry", + &scope, + &other, + Some(&entry), + &ip, + Some(&details), + ) + .await; + removed_from.push(other); + } + } + } + Json(json!({"ok": true, "removed_from": removed_from})).into_response() } Err(e) => ( StatusCode::BAD_GATEWAY, @@ -5778,6 +6334,86 @@ fn html_escape(s: &str) -> String { #[cfg(test)] mod tests { use super::*; + + #[test] + fn fullscreen_sizing_applies_to_bitmap_sessions_only() { + let win = (Some(1710), Some(954)); + let scr = (Some(1710), Some(1112)); + // RDP opening fullscreen: created at the screen size rounded down to + // a multiple of 16, matching guacd's display-channel rounding so there + // is no post-connect resize (#257). VNC is not mod-16 rounded. + assert_eq!( + initial_session_size("rdp", true, win, scr), + (Some(1696), Some(1104)) + ); + assert_eq!(initial_session_size("vnc", true, win, scr), scr); + // A screen that is already mod-16 is created at its exact size. + assert_eq!( + initial_session_size("rdp", true, win, (Some(1920), Some(1088))), + (Some(1920), Some(1088)) + ); + // a terminal reflows instantly: window size, fullscreen or not + assert_eq!(initial_session_size("ssh", true, win, scr), win); + assert_eq!(initial_session_size("telnet", true, win, scr), win); + assert_eq!(initial_session_size("web", true, win, scr), win); + // not fullscreen, or no screen size offered: window size — still on + // the mod-16 grid for RDP, since the client's first size send is + // rounded to it by guacd either way (no resize on connect). + let win16 = (Some(1696), Some(944)); + assert_eq!(initial_session_size("rdp", false, win, scr), win16); + assert_eq!(initial_session_size("rdp", true, win, (None, None)), win16); + assert_eq!( + initial_session_size("rdp", true, win, (Some(0), Some(0))), + win16 + ); + assert_eq!(initial_session_size("vnc", false, win, scr), win); + assert_eq!( + initial_session_size("rdp", false, (None, None), scr), + (None, None) + ); + } + + #[test] + fn address_book_mutation_detection() { + use axum::http::Method; + assert!(ab_request_mutates( + &Method::PUT, + "/api/addressbook/folders/shared/x/entries/y" + )); + assert!(ab_request_mutates( + &Method::POST, + "/api/addressbook/folders" + )); + assert!(ab_request_mutates( + &Method::DELETE, + "/api/addressbook/folders/shared/x/defaults" + )); + assert!(ab_request_mutates(&Method::POST, "/api/addressbook/bulk")); + assert!(ab_request_mutates(&Method::PUT, "/api/me/credentials")); + // reads and connects are not changes + assert!(!ab_request_mutates(&Method::GET, "/api/addressbook")); + assert!(!ab_request_mutates( + &Method::GET, + "/api/addressbook/version" + )); + assert!(!ab_request_mutates( + &Method::POST, + "/api/addressbook/folders/shared/x/entries/y/connect" + )); + assert!(!ab_request_mutates(&Method::POST, "/api/sessions")); + } + + #[test] + fn address_book_version_counts_and_timestamps() { + let v = AddressBookVersion::default(); + assert_eq!(v.snapshot(), (0, 0)); + v.bump(); + let (n, t) = v.snapshot(); + assert_eq!(n, 1); + assert!(t > 1_600_000_000_000); + v.bump(); + assert_eq!(v.snapshot().0, 2); + } use std::path::Path; #[test] diff --git a/src/import.rs b/src/import.rs index 47cfc35b..08d22fcc 100644 --- a/src/import.rs +++ b/src/import.rs @@ -309,6 +309,7 @@ pub async fn cmd_import_guacamole( allowed_groups: allowed_groups.to_vec(), allowed_users: Vec::new(), description: "Imported from Guacamole".to_string(), + display_name: String::new(), inherit_from_parent: false, }; if let Err(e) = client.put_folder_config(scope, folder, &root_config).await { @@ -339,6 +340,7 @@ pub async fn cmd_import_guacamole( allowed_groups: vec![], allowed_users: Vec::new(), description: format!("Imported from Guacamole: {}", sub), + display_name: String::new(), inherit_from_parent: true, }; if let Err(e) = client.put_folder_config(scope, &full, &cfg).await { diff --git a/src/main.rs b/src/main.rs index 4bd2991b..2e92780d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -653,6 +653,24 @@ fn cmd_map_group(database: &Db, group: &str, role: &str, force: bool) { #[derive(Clone)] struct TlsEnabled(bool); +/// Content-Security-Policy sent with every response. +/// +/// `img-src` must allow `data:` and `blob:`. The Guacamole client draws each +/// `img` stream by loading a `data:image/...;base64,...` URI into an `Image` +/// (the `DataURIReader` path in `Display.drawStream`) on browsers without +/// WebCodecs `ImageDecoder`, which is Safari and Firefox. Without `img-src` +/// the `default-src 'self'` fallback refuses those loads, the client silently +/// skips the draw, and SSH/RDP sessions render black with only rect fills +/// showing (#249). Chromium decodes straight from the stream bytes and never +/// fetches a URL, so it hid the problem. The remote mouse cursor +/// (`cursor: url(data:...)`) and blob: image URLs need the same allowance. +const CONTENT_SECURITY_POLICY: &str = "default-src 'self'; \ + script-src 'self' 'unsafe-inline'; \ + style-src 'self' 'unsafe-inline'; \ + connect-src 'self' wss: ws:; \ + img-src 'self' data: blob:; \ + frame-ancestors 'self'"; + async fn security_headers( tls: Extension, request: Request, @@ -661,7 +679,11 @@ async fn security_headers( let mut response = next.run(request).await; let headers = response.headers_mut(); headers.insert("X-Content-Type-Options", "nosniff".parse().unwrap()); - headers.insert("X-Frame-Options", "DENY".parse().unwrap()); + // SAMEORIGIN (not DENY) so the Connections page can host a session in a + // full-viewport same-origin iframe for zero-click fullscreen-on-connect. + // Cross-origin framing stays blocked (clickjacking); CSP frame-ancestors + // 'self' is the modern equivalent for browsers that honour it. + headers.insert("X-Frame-Options", "SAMEORIGIN".parse().unwrap()); headers.insert( "Referrer-Policy", "strict-origin-when-cross-origin".parse().unwrap(), @@ -672,7 +694,7 @@ async fn security_headers( ); headers.insert( "Content-Security-Policy", - "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' wss: ws:".parse().unwrap(), + CONTENT_SECURITY_POLICY.parse().unwrap(), ); if tls.0 .0 { headers.insert( @@ -881,6 +903,7 @@ async fn run_server(config: Config, database: Db) { let oidc_enabled = OidcEnabled(oidc_state.is_some()); let vault_configured = VaultConfigured(config.vault.is_some()); + let ab_version = api::AddressBookVersion::default(); let credential_default_scope = CredentialDefaultScope(config.user_credentials_default_scope.clone()); let drive_configured = DriveConfigured(config.drive.is_some()); @@ -1278,6 +1301,19 @@ async fn run_server(config: Config, database: Db) { "/api/addressbook/folders/{scope}/{folder}/subfolders", get(api::ab_list_subfolders), ) + .route( + "/api/addressbook/folders/{scope}/{folder}/defaults", + get(api::ab_get_folder_defaults), + ) + .route( + "/api/addressbook/folders/{scope}/{folder}/defaults", + put(api::ab_put_folder_defaults), + ) + .route( + "/api/addressbook/folders/{scope}/{folder}/defaults", + delete(api::ab_delete_folder_defaults), + ) + .route("/api/addressbook/version", get(api::ab_version)) .route( "/api/addressbook/folders/{scope}/{folder}/entries", get(api::ab_list_entries), @@ -1310,11 +1346,15 @@ async fn run_server(config: Config, database: Db) { .merge(session_create_route) .with_state(manager.clone()) .layer(middleware::from_fn(auth::require_auth)) + // Inside auth: only requests allowed to change the address book + // count. The counter itself is layered below so this sees it. + .layer(middleware::from_fn(api::ab_version_bump)) .layer(Extension(ws_ticket_store.clone())) .layer(Extension(vault_client.clone())) .layer(Extension(vault_configured.clone())) .layer(Extension(credential_default_scope.clone())) - .layer(Extension(database.clone())); + .layer(Extension(database.clone())) + .layer(Extension(ab_version.clone())); // Applied OUTSIDE require_auth so requests with bad credentials are // counted too: a flood of bogus keys is throttled before each one // queues on the database lock to be looked up. @@ -1379,7 +1419,13 @@ async fn run_server(config: Config, database: Db) { .route("/reports.html", get(serve_branded_page)) .route("/admin.html", get(serve_branded_page)) .route("/tokens.html", get(serve_branded_page)) - .route("/docs.html", get(serve_branded_page)); + .route("/docs.html", get(serve_branded_page)) + // Optional auth only: an anonymous request still gets the page (the + // page itself sends the user to log in), but a logged-in one gets + // the variant with its navigation already right. + .layer(middleware::from_fn(auth::optional_auth)) + .layer(Extension(ws_ticket_store.clone())) + .layer(Extension(database.clone())); // Build full router (all Router<()> at this point) let mut app: Router<()> = Router::new() @@ -1565,23 +1611,63 @@ fn build_guacd_tls(config: &Config) -> Option { Some(tokio_rustls::TlsConnector::from(Arc::new(tls_config))) } -/// One pre-branded HTML page and the ETag its bytes hash to. +/// One pre-branded HTML page, in one variant per role level, each with the +/// ETag its bytes hash to. /// /// The pages are branded once at startup and cannot change until a restart, -/// so the tag is computed there too -- serving one is a string compare. +/// so the tags are computed there too -- serving one is a string compare. +/// +/// The variants differ only in the `data-role-level` attribute written on +/// ``: the stylesheet gates navigation links and admin-only controls on +/// it, so the header is right on first paint for whoever the page is served +/// to, instead of hiding everything gated and showing it after `/api/me` +/// answers, which flashed and reflowed on every navigation. struct BrandedPage { + /// Indexed by role level, 0 (anonymous) to 4 (admin). + variants: Vec, +} + +struct BrandedVariant { html: String, etag: String, } +/// Highest role level, the index of the last page variant. +const MAX_ROLE_LEVEL: u8 = 4; + impl BrandedPage { fn new(html: String) -> Self { use sha2::{Digest, Sha256}; - // Quoted, because an ETag is a quoted-string on the wire. A bare hash - // tends to be ignored rather than rejected, which would look exactly - // like the stale-cache bug this exists to fix. - let etag = format!("\"{}\"", hex::encode(Sha256::digest(html.as_bytes()))); - Self { html, etag } + let variants = (0..=MAX_ROLE_LEVEL) + .map(|level| { + let html = role_variant_html(&html, level); + // Quoted, because an ETag is a quoted-string on the wire. A + // bare hash tends to be ignored rather than rejected, which + // would look exactly like the stale-cache bug this exists to + // fix. + let etag = format!("\"{}\"", hex::encode(Sha256::digest(html.as_bytes()))); + BrandedVariant { html, etag } + }) + .collect(); + Self { variants } + } + + fn variant(&self, level: u8) -> &BrandedVariant { + &self.variants[level.min(MAX_ROLE_LEVEL) as usize] + } +} + +/// Write the caller's role level onto the page's `` tag. A page that +/// has no bare `` start tag is returned unchanged. Extracted for test. +fn role_variant_html(html: &str, level: u8) -> String { + match html.find("") { + Some(i) => format!( + "{}{}", + &html[..i], + level, + &html[i + "".len()..] + ), + None => html.to_string(), } } @@ -1607,10 +1693,15 @@ fn etag_matches(if_none_match: &str, etag: &str) -> bool { /// costs a 304 and a rebuilt one lands on the next load. Without it these /// responses carry no freshness information at all and Chrome caches them /// heuristically, serving a stale page long after a restart. -fn page_response(page: &BrandedPage, headers: &axum::http::HeaderMap) -> axum::response::Response { +fn page_response( + page: &BrandedPage, + level: u8, + headers: &axum::http::HeaderMap, +) -> axum::response::Response { use axum::http::{header, StatusCode}; use axum::response::IntoResponse; + let page = page.variant(level); let cache_headers = [ (header::CACHE_CONTROL, "no-cache"), (header::ETAG, page.etag.as_str()), @@ -1728,7 +1819,12 @@ fn asset_hash( if !url.starts_with('/') || url.starts_with("//") || url.contains('?') || url.contains('#') { return None; } - if !(url.ends_with(".js") || url.ends_with(".css")) { + if !(url.ends_with(".js") + || url.ends_with(".css") + || url.ends_with(".svg") + || url.ends_with(".png") + || url.ends_with(".ico")) + { return None; } let rel = url.trim_start_matches('/'); @@ -1757,14 +1853,21 @@ fn asset_hash( /// Serve a branded HTML page from the pre-processed in-memory map. async fn serve_branded_page( Extension(pages): Extension, + identity: Option>, request: axum::extract::Request, ) -> axum::response::Response { use axum::http::StatusCode; use axum::response::IntoResponse; let path = request.uri().path().trim_start_matches('/'); let key = if path.is_empty() { "index.html" } else { path }; + // Whoever the page is for decides which navigation it carries. An API-key + // user sends no credential on the page GET and gets the anonymous + // variant; the page reconciles that from /api/me. + let level = identity + .map(|Extension(id)| auth::role_level(id.role())) + .unwrap_or(0); match pages.get(key) { - Some(page) => page_response(page, request.headers()), + Some(page) => page_response(page, level, request.headers()), None => StatusCode::NOT_FOUND.into_response(), } } @@ -1777,7 +1880,7 @@ async fn serve_client_page( ) -> axum::response::Response { use axum::response::IntoResponse; match pages.get("client.html") { - Some(page) => page_response(page, &headers), + Some(page) => page_response(page, 0, &headers), // Unreachable: the map always carries the embedded copy. None => Html(include_str!("../static/client.html")).into_response(), } @@ -1818,6 +1921,45 @@ mod tests { use super::*; + #[test] + fn role_variant_writes_level_on_html_tag_only() { + let page = "\n\nxtext"; + let v = role_variant_html(page, 4); + assert!(v.starts_with("\n\n")); + // only the first bare start tag is touched; the literal later on is content + assert_eq!(v.matches("data-role-level").count(), 1); + assert_eq!( + role_variant_html("

no html tag

", 2), + "

no html tag

" + ); + let branded = BrandedPage::new(page.to_string()); + assert_eq!(branded.variants.len(), 5); + assert_ne!(branded.variant(0).etag, branded.variant(4).etag); + assert_eq!( + branded.variant(9).etag, + branded.variant(4).etag, + "levels clamp to admin" + ); + } + + #[test] + fn test_csp_allows_data_and_blob_images() { + // Safari and Firefox draw Guacamole image streams through data: URIs + // (#249). Chromium uses ImageDecoder and would not catch a regression. + let directives: Vec<&str> = CONTENT_SECURITY_POLICY.split(';').map(str::trim).collect(); + let img_src = directives + .iter() + .find(|d| d.starts_with("img-src ")) + .expect("CSP must carry an explicit img-src directive"); + let sources: Vec<&str> = img_src.split_whitespace().skip(1).collect(); + assert!(sources.contains(&"'self'"), "img-src must allow 'self'"); + assert!(sources.contains(&"data:"), "img-src must allow data: URIs"); + assert!(sources.contains(&"blob:"), "img-src must allow blob: URLs"); + // The string continuation must not leave stray whitespace runs. + assert!(!CONTENT_SECURITY_POLICY.contains(" ")); + assert!(!CONTENT_SECURITY_POLICY.contains('\n')); + } + #[test] fn test_version_assets_rewrites_local_js_and_css() { // Tests run from the crate root, so this is the real static tree. @@ -1835,6 +1977,21 @@ mod tests { assert!(out.ends_with(">")); } + #[test] + fn test_version_assets_versions_the_logo() { + // The logo used to be revalidated on every page load, which blanked + // it for a moment on each navigation in some browsers. It is content + // addressed like the scripts now. + let static_path = std::path::Path::new("static"); + let mut hashes = std::collections::HashMap::new(); + let out = version_assets( + "\"\"", + static_path, + &mut hashes, + ); + assert!(out.contains("src=\"/logo.svg?v="), "{out}"); + } + #[test] fn test_version_assets_leaves_everything_else_alone() { let static_path = std::path::Path::new("static"); @@ -1845,8 +2002,8 @@ mod tests { "", // Already says something about this URL. "", - // Not a kind that goes stale behind a stable name. - "", + // Not a kind we version (a page, a download). + "", // No such file: a hash of nothing is worse than none. "", // Traversal, which these paths have no business doing. @@ -1896,6 +2053,7 @@ mod tests { fn test_branded_page_etag_tracks_content() { let a = BrandedPage::new("one".to_string()); let b = BrandedPage::new("two".to_string()); + let (a, b) = (a.variant(0), b.variant(0)); assert_ne!(a.etag, b.etag); // Quoted, or caches ignore it. assert!(a.etag.starts_with('"') && a.etag.ends_with('"')); diff --git a/src/session.rs b/src/session.rs index ef28dcbb..8651c8ae 100644 --- a/src/session.rs +++ b/src/session.rs @@ -918,6 +918,8 @@ impl SessionManager { session_id = %session_id, hostname = %hostname, username = %username, + width, + height, "Creating new SSH session" ); @@ -2009,6 +2011,35 @@ impl SessionManager { result } + /// Find a live session this user can be sent back to instead of opening a + /// fresh one for the same address-book entry. Reusing a `Pending` session + /// (guacd connected, owner never attached) stops repeat Connect clicks from + /// piling up orphans; only a Pending session is reusable, since an owner + /// connection consumes the guacd stream and cannot be re-owned. + pub async fn find_reusable_session( + &self, + created_by: &str, + entry_key: &str, + ) -> Option { + let sessions = self.sessions.read().await; + for session in sessions.values() { + let s = session.lock().await; + if s.status == SessionStatus::Pending + && s.guacd_stream.is_some() + && s.created_by == created_by + && s.address_book_entry.as_deref() == Some(entry_key) + // Only a freshly-created Pending session, so a repeat click + // within a few seconds reattaches to the one just made, while + // an older Pending (whose guacd may have since died) is left + // for the pending-timeout reaper and a fresh one is created. + && (Utc::now() - s.created_at).num_seconds() < 15 + { + return Some(s.info()); + } + } + None + } + /// Get a specific session's info. pub async fn get_session(&self, id: Uuid) -> Option { let sessions = self.sessions.read().await; diff --git a/src/vault.rs b/src/vault.rs index 1cc97ab8..4e8341d9 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -66,6 +66,11 @@ pub struct FolderConfig { pub allowed_users: Vec, #[serde(default)] pub description: String, + /// Human-readable title shown instead of the folder key (e.g. the NetBox + /// tenant name over its sanitised key). Omitted from the stored config + /// when empty, so existing configs are unchanged. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub display_name: String, /// When true, if the folder's own `allowed_groups` doesn't grant access, /// the access check walks up the parent path and tries each ancestor's /// config. New folders default to `true` in the UI; legacy configs @@ -83,6 +88,68 @@ pub struct FolderSubject<'a> { pub groups: &'a [String], } +/// Credentials a folder lends to entries that have none of their own, keyed +/// by session type (`ssh`, `rdp`, `vnc`, `web`, ...). Stored at +/// `/.defaults` beside `.config`, so an entry created without secrets +/// (a NetBox webhook, a bulk import) is connectable the moment it exists, +/// with no second pass to fill it in. A subfolder without its own `.defaults` +/// inherits its nearest ancestor's, per type. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct DefaultCredentials { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub username: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub password: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub private_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub domain: Option, +} + +impl DefaultCredentials { + /// Whether these defaults carry something that can authenticate (a + /// username alone does not). + pub fn has_secret(&self) -> bool { + self.password.as_deref().is_some_and(|p| !p.is_empty()) + || self.private_key.as_deref().is_some_and(|k| !k.is_empty()) + } +} + +/// Per-session-type default credentials of one folder. +pub type FolderDefaults = std::collections::BTreeMap; + +/// Whether the entry carries a password or private key of its own. +pub fn entry_has_own_credentials(e: &AddressBookEntry) -> bool { + e.password.as_deref().is_some_and(|p| !p.is_empty()) + || e.private_key.as_deref().is_some_and(|k| !k.is_empty()) +} + +/// Give an entry without credentials of its own the folder defaults for its +/// session type. The entry's own username and domain win when set; the +/// defaults' password and private key are taken as they are. Returns whether +/// anything was applied: false when the entry already has a secret, when the +/// folder has no defaults for the type, or when those defaults have no secret. +pub fn apply_folder_defaults(entry: &mut AddressBookEntry, defaults: &FolderDefaults) -> bool { + if entry_has_own_credentials(entry) { + return false; + } + let Some(d) = defaults.get(&entry.session_type) else { + return false; + }; + if !d.has_secret() { + return false; + } + if entry.username.as_deref().is_none_or(str::is_empty) && d.username.is_some() { + entry.username = d.username.clone(); + } + if entry.domain.as_deref().is_none_or(str::is_empty) && d.domain.is_some() { + entry.domain = d.domain.clone(); + } + entry.password = d.password.clone().filter(|p| !p.is_empty()); + entry.private_key = d.private_key.clone().filter(|k| !k.is_empty()); + true +} + impl FolderConfig { /// Whether this folder's own settings let `who` in: a shared group, or /// their email in `allowed_users`. Inheritance is the caller's concern. @@ -378,8 +445,13 @@ pub struct EntryInfo { pub kdc_url: Option, /// Whether to prompt for credentials at connect time. pub prompt_credentials: Option, - /// Whether the entry has a stored password or private key. + /// Whether the entry can authenticate without prompting: a stored + /// password or private key of its own, or folder defaults for its type. pub has_credentials: bool, + /// True when `has_credentials` comes from folder defaults rather than + /// the entry itself. Omitted when false. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub inherited_credentials: bool, /// VNC color depth. pub color_depth: Option, /// SSH tunnel jump hosts (no credentials exposed). @@ -528,6 +600,22 @@ pub struct EntryInfo { pub wol_wait_time: Option, } +impl EntryInfo { + /// Mark an entry as connectable through folder defaults when it has no + /// secret of its own and the defaults cover its session type. + pub fn apply_inherited(&mut self, e: &AddressBookEntry, defaults: Option<&FolderDefaults>) { + if self.has_credentials { + return; + } + if let Some(d) = defaults.and_then(|d| d.get(&e.session_type)) { + if d.has_secret() { + self.has_credentials = true; + self.inherited_credentials = true; + } + } + } +} + impl From<(&str, &AddressBookEntry)> for EntryInfo { fn from((name, e): (&str, &AddressBookEntry)) -> Self { let jump_hosts = e.jump_hosts.as_ref().map(|hops| { @@ -558,8 +646,8 @@ impl From<(&str, &AddressBookEntry)> for EntryInfo { auth_pkg: e.auth_pkg.clone(), kdc_url: e.kdc_url.clone(), prompt_credentials: e.prompt_credentials, - has_credentials: e.password.as_ref().is_some_and(|p| !p.is_empty()) - || e.private_key.as_ref().is_some_and(|k| !k.is_empty()), + has_credentials: entry_has_own_credentials(e), + inherited_credentials: false, color_depth: e.color_depth, jump_hosts, remote_app: e.remote_app.clone(), @@ -623,6 +711,9 @@ impl From<(&str, &AddressBookEntry)> for EntryInfo { pub struct FolderInfo { pub name: String, pub description: String, + /// Title to show for the folder; empty when none is set (show the name). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub display_name: String, /// "shared" or "instance" pub scope: String, /// Full path from scope root (e.g. "Clients/Acme"). Same as name for top-level folders. @@ -864,6 +955,7 @@ impl VaultClient { folders.push(FolderInfo { name: name.to_string(), description: String::new(), + display_name: String::new(), scope: scope.to_string(), path: Some(name.to_string()), has_children: None, // enriched below @@ -880,6 +972,7 @@ impl VaultClient { for folder in &mut folders { if let Ok(config) = self.get_folder_config(&folder.scope, &folder.name).await { folder.description = config.description; + folder.display_name = config.display_name; } // Check for subfolders by listing children if let Ok(children) = self.list_children(&folder.scope, &folder.name).await { @@ -919,6 +1012,7 @@ impl VaultClient { let mut info = FolderInfo { name: name.to_string(), description: String::new(), + display_name: String::new(), scope: scope.to_string(), path: Some(full_path.clone()), has_children: None, @@ -926,6 +1020,7 @@ impl VaultClient { // Enrich with description if let Ok(config) = self.get_folder_config(scope, &full_path).await { info.description = config.description; + info.display_name = config.display_name; } // Check for grandchildren if let Ok(grandchildren) = self.list_children(scope, &full_path).await { @@ -999,13 +1094,17 @@ impl VaultClient { } } - /// List entry names in a folder (excludes .config). + /// List entry names in a folder (excludes the `.config` and `.defaults` + /// sentinels). pub async fn list_entries(&self, scope: &str, folder: &str) -> Result, VaultError> { validate_path(folder)?; let scope_prefix = self.resolve_scope_prefix(scope)?; let path = format!("{}/", self.metadata_path(&scope_prefix, folder)); let keys = self.kv_list(&path).await?; - Ok(keys.into_iter().filter(|k| k != ".config").collect()) + Ok(keys + .into_iter() + .filter(|k| k != ".config" && k != ".defaults") + .collect()) } /// Get a full entry (with credentials). @@ -1148,6 +1247,115 @@ impl VaultClient { } } + /// Read a folder's `.defaults` (see `FolderDefaults`). + pub async fn get_folder_defaults( + &self, + scope: &str, + folder: &str, + ) -> Result { + validate_path(folder)?; + let scope_prefix = self.resolve_scope_prefix(scope)?; + let path = self.data_path(&scope_prefix, &format!("{}/{}", folder, ".defaults")); + let resp = self.request(reqwest::Method::GET, &path, None).await?; + + match resp.status().as_u16() { + 200 => { + let json: serde_json::Value = resp.json().await?; + let data = &json["data"]["data"]; + serde_json::from_value(data.clone()) + .map_err(|e| VaultError::Parse(format!("invalid .defaults: {}", e))) + } + 404 => Err(VaultError::NotFound), + 403 => Err(VaultError::Forbidden), + s => Err(VaultError::Parse(format!("unexpected status {}", s))), + } + } + + /// Write a folder's `.defaults`. An empty map removes the key instead, + /// so a folder with nothing to lend has no sentinel at all. + pub async fn put_folder_defaults( + &self, + scope: &str, + folder: &str, + defaults: &FolderDefaults, + ) -> Result<(), VaultError> { + if defaults.is_empty() { + return self.delete_folder_defaults(scope, folder).await; + } + validate_path(folder)?; + let scope_prefix = self.resolve_scope_prefix(scope)?; + let path = self.data_path(&scope_prefix, &format!("{}/{}", folder, ".defaults")); + let body = serde_json::json!({ "data": defaults }); + let resp = self + .request(reqwest::Method::POST, &path, Some(&body)) + .await?; + + match resp.status().as_u16() { + 200 | 204 => Ok(()), + 403 => Err(VaultError::Forbidden), + s => { + let text = resp.text().await.unwrap_or_default(); + Err(VaultError::Parse(format!( + "put folder defaults failed ({}): {}", + s, text + ))) + } + } + } + + /// Remove a folder's `.defaults`. Absent is fine. + pub async fn delete_folder_defaults( + &self, + scope: &str, + folder: &str, + ) -> Result<(), VaultError> { + validate_path(folder)?; + let scope_prefix = self.resolve_scope_prefix(scope)?; + let path = self.metadata_path(&scope_prefix, &format!("{}/{}", folder, ".defaults")); + let resp = self.request(reqwest::Method::DELETE, &path, None).await?; + match resp.status().as_u16() { + 200 | 204 | 404 => Ok(()), + 403 => Err(VaultError::Forbidden), + s => Err(VaultError::Parse(format!( + "delete folder defaults failed ({})", + s + ))), + } + } + + /// The defaults that apply to entries in `folder`: its own `.defaults`, + /// then each ancestor's for any session type still unset. `None` when no + /// folder on the path has any. + pub async fn effective_folder_defaults( + &self, + scope: &str, + folder: &str, + ) -> Result, VaultError> { + validate_path(folder)?; + let mut merged = FolderDefaults::new(); + let mut path = folder.to_string(); + loop { + match self.get_folder_defaults(scope, &path).await { + Ok(d) => { + for (session_type, creds) in d { + merged.entry(session_type).or_insert(creds); + } + } + Err(VaultError::NotFound) => {} + Err(e) => return Err(e), + } + match path.rfind('/') { + Some(i) => path.truncate(i), + None => break, + } + } + Ok(if merged.is_empty() { + None + } else { + Some(merged) + }) + } + /// Delete an entire folder and its subtree (all entries + .config at every /// level). Pre-v1.6.0 this only cleared the top folder, which silently /// left subfolder keys in Vault; post-subfolders the UI would then refresh @@ -1187,8 +1395,13 @@ impl VaultClient { let _ = self.delete_entry(scope, path, &entry).await; entry_count += 1; } - let cfg_path = self.metadata_path(&scope_prefix, &format!("{}/{}", path, ".config")); - let _ = self.request(reqwest::Method::DELETE, &cfg_path, None).await; + for sentinel in [".config", ".defaults"] { + let sentinel_path = + self.metadata_path(&scope_prefix, &format!("{}/{}", path, sentinel)); + let _ = self + .request(reqwest::Method::DELETE, &sentinel_path, None) + .await; + } } let subfolder_count = queue.len().saturating_sub(1); @@ -1563,7 +1776,7 @@ fn validate_name(name: &str) -> Result<(), VaultError> { if name.is_empty() || name.len() > 64 { return Err(VaultError::BadName("name must be 1-64 characters".into())); } - if name == ".config" || name == "." || name == ".." { + if name == ".config" || name == ".defaults" || name == "." || name == ".." { return Err(VaultError::BadName("reserved name".into())); } if name.contains('/') || name.contains('\\') { @@ -1703,6 +1916,7 @@ mod tests { allowed_groups: groups.iter().map(|s| s.to_string()).collect(), allowed_users: users.iter().map(|s| s.to_string()).collect(), description: String::new(), + display_name: String::new(), inherit_from_parent: false, } } @@ -2186,6 +2400,111 @@ mod tests { assert!(is_credential_variable("$has-dashes")); // hyphens allowed since v0.8.0 } + #[test] + fn folder_defaults_fill_only_entries_without_secrets() { + let mut defaults = FolderDefaults::new(); + defaults.insert( + "ssh".into(), + DefaultCredentials { + username: Some("root".into()), + private_key: Some("-----BEGIN KEY-----".into()), + ..Default::default() + }, + ); + defaults.insert( + "web".into(), + DefaultCredentials { + password: Some("router-pw".into()), + ..Default::default() + }, + ); + // username only: nothing to authenticate with + defaults.insert( + "rdp".into(), + DefaultCredentials { + username: Some("Administrator".into()), + ..Default::default() + }, + ); + + // ssh entry without secrets: takes the key, keeps its own username + let mut e = AddressBookEntry { + session_type: "ssh".into(), + username: Some("admin".into()), + ..Default::default() + }; + assert!(apply_folder_defaults(&mut e, &defaults)); + assert_eq!(e.username.as_deref(), Some("admin")); + assert_eq!(e.private_key.as_deref(), Some("-----BEGIN KEY-----")); + assert!(e.password.is_none()); + + // no username at all: the default's username fills it + let mut e = AddressBookEntry { + session_type: "web".into(), + ..Default::default() + }; + assert!(apply_folder_defaults(&mut e, &defaults)); + assert_eq!(e.password.as_deref(), Some("router-pw")); + assert!(e.username.is_none(), "web defaults carry no username"); + + // an entry with its own secret is left alone + let mut e = AddressBookEntry { + session_type: "ssh".into(), + password: Some("own".into()), + ..Default::default() + }; + assert!(!apply_folder_defaults(&mut e, &defaults)); + assert_eq!(e.password.as_deref(), Some("own")); + assert!(e.private_key.is_none()); + + // defaults with no secret do nothing; a type without defaults too + let mut e = AddressBookEntry { + session_type: "rdp".into(), + ..Default::default() + }; + assert!(!apply_folder_defaults(&mut e, &defaults)); + assert!(e.username.is_none()); + let mut e = AddressBookEntry { + session_type: "vnc".into(), + ..Default::default() + }; + assert!(!apply_folder_defaults(&mut e, &defaults)); + } + + #[test] + fn entry_info_marks_inherited_credentials() { + let mut defaults = FolderDefaults::new(); + defaults.insert( + "ssh".into(), + DefaultCredentials { + private_key: Some("k".into()), + ..Default::default() + }, + ); + let e = AddressBookEntry { + session_type: "ssh".into(), + ..Default::default() + }; + let mut info = EntryInfo::from(("box", &e)); + assert!(!info.has_credentials); + info.apply_inherited(&e, Some(&defaults)); + assert!(info.has_credentials && info.inherited_credentials); + + // own secret: has_credentials without the inherited flag + let e = AddressBookEntry { + session_type: "ssh".into(), + password: Some("p".into()), + ..Default::default() + }; + let mut info = EntryInfo::from(("box", &e)); + info.apply_inherited(&e, Some(&defaults)); + assert!(info.has_credentials && !info.inherited_credentials); + + // the JSON omits the flag when false + let v = serde_json::to_value(&info).unwrap(); + assert!(v.get("inherited_credentials").is_none()); + } + #[test] fn test_entry_credential_variables() { let entry = AddressBookEntry { diff --git a/static/admin.html b/static/admin.html index 5dea3b79..f173c835 100644 --- a/static/admin.html +++ b/static/admin.html @@ -16,12 +16,13 @@

rustguac

@@ -155,6 +156,7 @@

Connections Audit Log

return res.json(); }) .then(function(data) { + if (data && data.role) document.documentElement.setAttribute('data-role-level', String({admin:4,poweruser:3,operator:2,viewer:1}[data.role] || 0)); if (!data || data.role !== 'admin') { window.location.href = '/connections.html'; return; @@ -296,10 +298,12 @@

Connections Audit Log

delBtn.textContent = 'delete'; delBtn.addEventListener('click', (function(email) { return function() { - if (!confirm('Delete user ' + email + '?')) return; - fetch('/api/users/' + encodeURIComponent(email), { - method: 'DELETE', headers: apiHeaders(), credentials: 'same-origin' - }).then(function() { loadUsers(); }); + rgConfirm('Delete user ' + email + '?', { danger: true, confirmText: 'Delete' }).then(function(ok) { + if (!ok) return; + fetch('/api/users/' + encodeURIComponent(email), { + method: 'DELETE', headers: apiHeaders(), credentials: 'same-origin' + }).then(function() { loadUsers(); }); + }); }; })(u.email)); actTd.appendChild(delBtn); @@ -454,16 +458,18 @@

Connections Audit Log

revokeBtn.textContent = 'revoke'; revokeBtn.addEventListener('click', (function(id, name, email) { return function() { - if (!confirm('Revoke token "' + name + '" for ' + email + '?')) return; - fetch('/api/admin/user-tokens/' + id, { - method: 'DELETE', - headers: apiHeaders(), - credentials: 'same-origin' - }) - .then(function(res) { - if (!res.ok) return res.json().then(function(d) { showError(d.error); }); - loadAllTokens(); - loadAuditLog(); + rgConfirm('Revoke token "' + name + '" for ' + email + '?', { danger: true, confirmText: 'Revoke' }).then(function(ok) { + if (!ok) return; + fetch('/api/admin/user-tokens/' + id, { + method: 'DELETE', + headers: apiHeaders(), + credentials: 'same-origin' + }) + .then(function(res) { + if (!res.ok) return res.json().then(function(d) { showError(d.error); }); + loadAllTokens(); + loadAuditLog(); + }); }); }; })(t.id, t.name, t.email || '')); @@ -651,5 +657,7 @@

Connections Audit Log

// Auto-refresh setInterval(function() { loadUsers(); loadMappings(); loadAllTokens(); }, 10000); + + diff --git a/static/client.html b/static/client.html index cf477195..d42b1c0c 100644 --- a/static/client.html +++ b/static/client.html @@ -68,6 +68,38 @@ cursor: pointer; } #banner-continue:hover { background: var(--primary-hover); } + /* Fullscreen-on-connect gate: covers the session until the browser + is fullscreen, so the first picture the user sees is the one that + fits (the fullscreen desktop), not a scaled-down windowed fit. */ + #fs-gate { + display: none; + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: var(--bg); + z-index: 3100; /* above the side tabs (3001); hidden before the disconnect overlay (3000) is shown */ + justify-content: center; + align-items: center; + cursor: pointer; + } + #fs-gate.visible { display: flex; } + #fs-gate-box { + text-align: center; + font-family: monospace; + color: var(--text); + max-width: 600px; + padding: 2em 3em; + } + #fs-gate-spinner { + width: 30px; height: 30px; margin: 0 auto 1.2em; + border: 3px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: fs-gate-spin 0.8s linear infinite; + } + @keyframes fs-gate-spin { to { transform: rotate(360deg); } } + #fs-gate-title { font-size: 1.3em; font-weight: bold; margin-bottom: 0.5em; } + #fs-gate-status { color: var(--text-dim); font-size: 0.95em; min-height: 1.2em; letter-spacing: 0.02em; } + #fs-gate-status.connected { color: var(--accent); } #disconnected-overlay { display: none; position: fixed; @@ -239,6 +271,13 @@

Session Ended

+
+
+
+
+
+
+
+ diff --git a/static/connections.html b/static/connections.html index 8802f42e..4df3d3af 100644 --- a/static/connections.html +++ b/static/connections.html @@ -17,7 +17,7 @@ border-right: 1px solid var(--border); padding-right: var(--s-4); } - .main { flex: 1; min-width: 0; } + .main { flex: 1; min-width: 0; padding: 0 var(--s-5); } /* Compact "open in new window" companion to the Connect button — a small icon, not a full-size button */ @@ -66,7 +66,6 @@ } .tree-chevron:hover { color: var(--primary); background: var(--input); } .tree-chevron.expanded { color: var(--primary); } - .tree-chevron.leaf { visibility: hidden; cursor: default; } .tree-children { padding-left: var(--s-4) !important; margin-left: 15px !important; @@ -117,6 +116,9 @@ .entries-table tbody tr:last-child td { border-bottom: none; } .entries-table td:last-child { text-align: right; white-space: nowrap; } + .entries-table .actions-cell { white-space: nowrap; text-align: right; } + .entries-table .actions-cell .btn-small { margin-left: var(--s-1); } + /* Bulk selection (admins). */ .entries-table .sel-cell { width: 2em; text-align: center; padding-right: 0; } .entries-table .sel-cell input { width: auto; margin: 0; cursor: pointer; } @@ -152,17 +154,64 @@ /* Folder action bar */ .folder-actions { - display: flex; align-items: center; gap: var(--s-2); - margin-bottom: var(--s-4); padding-bottom: var(--s-3); + display: flex; align-items: flex-start; gap: var(--s-3); + padding-bottom: var(--s-2); border-bottom: 1px solid var(--border); flex-wrap: wrap; } .folder-actions strong { font-size: var(--fz-xl); color: var(--text); - font-weight: bold; letter-spacing: 0.02em; line-height: 1.3; + font-weight: bold; letter-spacing: 0.02em; line-height: 1.2; + } + .folder-actions-btns { + display: flex; align-items: center; gap: var(--s-2); + flex-wrap: wrap; flex-shrink: 0; margin-left: auto; + } + .entries-heading { + flex: 1 1 auto; min-width: 0; + display: flex; flex-direction: column; gap: 2px; } + .entries-title-row { + display: flex; align-items: baseline; gap: var(--s-3); + flex-wrap: wrap; min-width: 0; + } + .entries-key { color: var(--text-dim); font-size: var(--fz-xs); letter-spacing: 0.04em; } + .entries-key:empty { display: none; } + .folder-desc.folder-key { letter-spacing: 0.04em; } .folder-actions .desc { - color: var(--text-muted); font-size: var(--fz-md); font-style: italic; + color: var(--text-muted); font-size: var(--fz-sm); font-style: italic; min-width: 0; + } + + /* Folder modal: default credentials */ + .fm-defaults { margin: var(--s-3) 0; border: 1px solid var(--border); border-radius: var(--radius); padding: var(--s-2) var(--s-3); } + .fm-defaults summary { cursor: pointer; color: var(--text); font-size: var(--fz-md); } + .fm-defaults-table { width: 100%; border-collapse: collapse; margin-top: var(--s-2); } + .fm-defaults-table th { text-align: left; font-weight: normal; color: var(--text-muted); font-size: var(--fz-sm); padding: var(--s-1) var(--s-2) var(--s-1) 0; width: 3em; vertical-align: top; } + .fm-defaults-table td { padding: var(--s-1); vertical-align: top; } + .fm-defaults-table input, .fm-defaults-table textarea { width: 100%; margin: 0; font-size: var(--fz-sm); } + .fm-defaults-table textarea { min-height: var(--ctl-sm); resize: vertical; } + .fmd-clear-label { display: flex; align-items: center; gap: var(--s-1); font-size: var(--fz-xs); color: var(--text-dim); white-space: nowrap; padding-top: var(--s-2); } + + /* Narrow desktops (a 1710px-wide MacBook display, 1600px monitors): + the 18px monospace table plus three padded action cells overran + the main column by ~300px, with "delete" cut off at the right. + Tighten the chrome rather than hide columns: smaller side + padding and sidebar, 16px cells with less padding, a narrower + Connect button. Everything then fits with ~250px to spare. */ + @media (max-width: 1800px) { + /* page padding at this width lives in rustguac.css, shared by + every page, so switching tabs does not shift the gutter */ + .layout { gap: var(--s-4); } + .sidebar { min-width: 240px; max-width: 280px; padding-right: var(--s-3); } + .entries-table th, .entries-table td { + padding: var(--s-2) var(--s-3); + font-size: var(--fz-sm); + } + .entries-table th { font-size: var(--fz-xs); letter-spacing: 0.08em; } + .btn-connect { min-width: 110px; padding: 0 var(--s-3); } + .btn-connect-win { min-width: 0; padding: 0 var(--s-1); } + .folder-actions strong { font-size: var(--fz-lg); } + .folder-actions .desc { font-size: var(--fz-sm); } } /* Active sessions strip */ @@ -186,8 +235,6 @@ 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 { @@ -348,6 +395,241 @@ padding: 1px var(--s-1); font-size: 0.85em; color: var(--text); } .onboard-hl { color: var(--accent); font-weight: bold; } + + /* UX (#254) layout: sidebar scroll, indent, density */ + /* Sidebar is sticky and scrolls on its own so a long folder tree never + drags the entries table off screen; .main keeps scrolling with the page. */ + .sidebar { + position: sticky; top: var(--s-3); + align-self: flex-start; + max-height: calc(100vh - var(--s-3) * 2); + overflow-y: auto; overflow-x: hidden; + overscroll-behavior: contain; + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; + } + .sidebar::-webkit-scrollbar { width: 6px; } + .sidebar::-webkit-scrollbar-track { background: transparent; } + .sidebar::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } + /* Header + tools slot pinned at the top of the sidebar while the list scrolls */ + .sidebar-pin { + position: sticky; top: 0; z-index: 2; + display: flex; flex-direction: column; + background: var(--bg); + } + .sidebar-pin .sidebar-header { margin-bottom: var(--s-2); } + #sidebar-tools:not(:empty) { padding-bottom: var(--s-2); } + + /* Rows start flush with the sidebar edge: the 3px accent bar is the only + thing before the content. A chevron is rendered only for folders with + children, so leaves carry no dead indent. */ + .folder-row { + gap: var(--s-1); + padding: var(--s-1) var(--s-2) var(--s-1) var(--s-2); + min-height: 40px; + font-size: var(--fz-sm); + color: var(--text-muted); + transition: background .1s, color .1s; + } + .folder-list li:hover > .folder-row { color: var(--text); } + .folder-list li.selected > .folder-row { color: var(--text); } + .folder-list li.selected > .folder-row .folder-name { color: var(--text); font-weight: bold; } + .tree-chevron { + width: 22px; height: 28px; margin-left: -2px; + font-size: 13px; + } + .tree-children { + padding-left: 0 !important; + margin-left: 14px !important; + margin-top: 1px; + } + .folder-row-main { gap: 0; line-height: 1.3; } + .folder-name-row { gap: var(--s-2); } + .folder-name-row > .folder-name { font-size: var(--fz-sm); } + .folder-count { + flex-shrink: 0; + min-width: 1.6em; padding: 0 var(--s-1); + border-radius: 999px; + background: var(--surface); + color: var(--text-muted); + font-size: var(--fz-xs); line-height: 1.5; + text-align: center; letter-spacing: 0; + transition: background .1s, color .1s; + } + .folder-count::before { content: none; } + .folder-count[data-count="0"] { color: var(--text-dim); background: transparent; box-shadow: inset 0 0 0 1px var(--border); } + .folder-list li.selected > .folder-row .folder-count { background: var(--bg); color: var(--text); } + .folder-row .folder-scope { + width: 16px; height: 16px; + font-size: 16px; + color: var(--text-dim); + } + .folder-list li:hover > .folder-row .folder-scope, + .folder-list li.selected > .folder-row .folder-scope { color: var(--accent); } + .folder-desc { line-height: 1.25; } + + /* UX (#254) selection feedback: jump to top, change animation, skeleton */ + /* Title slides in from the left and the action bar's bottom border + flashes accent when the selected folder changes. The .changed class + is added by announceFolderShown() and removed on animationend so + it can replay on the next selection. */ + #entries-header.changed #entries-title { + animation: rg-title-in 350ms ease-out both; + } + #entries-header.changed .folder-actions { + animation: rg-header-flash 700ms ease-out both; + } + @keyframes rg-title-in { + from { opacity: 0; transform: translateX(-4px); } + to { opacity: 1; transform: none; } + } + @keyframes rg-header-flash { + 0% { border-bottom-color: var(--accent); } + 60% { border-bottom-color: var(--accent); } + 100% { border-bottom-color: var(--border); } + } + /* Skeleton: a real .entries-table so column padding, header height and + the 38px Connect-button row height are identical to the loaded + table; only the cells are replaced by shimmering bars. */ + .entries-table.skeleton tbody tr:hover { background: inherit; } + .sk-bar { + display: inline-block; vertical-align: middle; + height: 1em; width: 60%; max-width: 100%; + border-radius: var(--radius); + background: var(--surface) + linear-gradient(90deg, transparent 0%, var(--input) 50%, transparent 100%) + no-repeat; + background-size: 45% 100%; + animation: rg-shimmer 1.2s ease-in-out infinite; + } + .sk-bar.sk-btn { height: var(--ctl-sm); width: 130px; } + .sk-bar.sk-badge { height: 1.4em; width: 3.2em; } + .sk-bar.sk-check { height: 16px; width: 16px; } + .entries-table.skeleton tbody tr:nth-child(2) td > .sk-bar:only-child:not([class*=" sk-"]) { width: 72%; } + .entries-table.skeleton tbody tr:nth-child(3) td > .sk-bar:only-child:not([class*=" sk-"]) { width: 48%; } + .entries-table.skeleton tbody tr:nth-child(4) td > .sk-bar:only-child:not([class*=" sk-"]) { width: 66%; } + @keyframes rg-shimmer { + from { background-position: -100% 0; } + to { background-position: 250% 0; } + } + @media (prefers-reduced-motion: reduce) { + #entries-header.changed #entries-title, + #entries-header.changed .folder-actions, + .sk-bar { animation: none; } + /* Still mark the change without motion: a static accent border + while .changed is on (flashEntriesHeader clears it by timer). */ + #entries-header.changed .folder-actions { border-bottom-color: var(--accent); } + } + + /* UX (#254) filter + keyboard */ + #sidebar-tools { margin-bottom: var(--s-3); } + #sidebar-tools:empty { display: none; } + .folder-filter-wrap { position: relative; display: flex; align-items: center; } + #folder-filter { + flex: 1 1 auto; width: 100%; min-width: 0; + height: var(--ctl-sm); padding: 0 var(--s-2); + font-family: monospace; font-size: var(--fz-sm); + } + /* Native clear glyph is replaced by #folder-filter-clear (Firefox has none) */ + #folder-filter::-webkit-search-cancel-button { -webkit-appearance: none; display: none; } + #folder-filter-count { + position: absolute; right: calc(var(--s-1) + 26px); top: 50%; transform: translateY(-50%); + font-size: var(--fz-xs); color: var(--text-dim); white-space: nowrap; + pointer-events: none; letter-spacing: 0.02em; + } + #folder-filter-count.no-match { color: var(--primary); } + #folder-filter-clear { + position: absolute; right: var(--s-1); top: 50%; transform: translateY(-50%); + width: 24px; height: 24px; padding: 0; margin: 0; + display: inline-flex; align-items: center; justify-content: center; + background: transparent; border: 0; border-radius: var(--radius); + color: var(--text-muted); font-family: monospace; font-size: var(--fz-md); line-height: 1; + cursor: pointer; + } + #folder-filter-clear:hover { color: var(--primary); background: var(--input); } + .folder-filter-wrap:not(.filtering) #folder-filter-count, + .folder-filter-wrap:not(.filtering) #folder-filter-clear { display: none; } + .folder-list li.filtered-out { display: none; } + .folder-row:focus { outline: none; } + .folder-row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } + + /* UX (#254) app shell: the header stays put, the sidebar and the + entries area are two independent scroll panes that always fit the + viewport. The earlier sticky sidebar was sized from the viewport, + not from where it starts below the header, so at the top of the + page its bottom hung off screen and the page had to be scrolled + before the list could be scrolled to its end. */ + html, body { height: 100%; } + /* overflow: clip, not hidden: hidden still lets scripts scroll the + body (scrollIntoView on anything inside), which moved the header + out of view; clip makes the body unscrollable for good. The border + box is the full height so the page padding is inside the viewport. */ + body { display: flex; flex-direction: column; overflow: clip; min-height: 0; box-sizing: border-box; } + #main-content { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; } + #main-content > :not(.layout) { flex: none; } + .layout { flex: 1 1 auto; min-height: 0; align-items: stretch; } + .sidebar { + position: static; top: auto; align-self: auto; + max-height: none; height: auto; min-height: 0; + display: flex; flex-direction: column; + overflow-y: auto; overflow-x: hidden; + padding: 0; margin: 0; + border: 1px solid var(--border); border-radius: var(--radius); + background: rgba(255,255,255,0.015); + } + .main { min-height: 0; overflow-y: auto; overflow-x: hidden; padding-left: var(--s-5); padding-right: var(--s-5); } + /* The folder header (name, description, search, actions, filters) + pins to the top of the entries pane, so it stays under the nav and + the active-sessions strip while a long table scrolls beneath it. */ + #entries-header { + position: sticky; top: 0; z-index: 30; + background: var(--bg); + padding: var(--s-3) var(--s-4) var(--s-2); + margin-bottom: var(--s-3); + box-shadow: 0 6px 10px -8px rgba(0, 0, 0, 0.6); + } + #entries-header .folder-actions { margin: 0; } + .entries-toolbar { + display: flex; align-items: center; gap: var(--s-3); + flex-wrap: wrap; margin-top: var(--s-2); + } + .entries-toolbar #entries-filters { margin: 0; } + .entries-toolbar .folder-actions-btns { margin-left: auto; } + #entries-header .list-tools { margin-bottom: 0; } + + /* Sidebar panel: one pinned header band (label, new-folder button, + filter) on a surface tone, then the list with hairline dividers. + Every band shares the same 12px horizontal padding so edges line up. */ + .sidebar-pin { + background: var(--surface); + border-bottom: 1px solid var(--border); + padding: var(--s-2) var(--s-3); + gap: var(--s-2); + } + .sidebar-pin .sidebar-header { + margin: 0; padding: 0; border: 0; + min-height: 30px; + justify-content: space-between; + } + .sidebar-pin .sidebar-header strong { font-size: var(--fz-xs); letter-spacing: 0.14em; } + #btn-new-folder { height: 30px; padding: 0 var(--s-2); font-size: var(--fz-xs); } + #sidebar-tools { margin: 0; } + #sidebar-tools:not(:empty) { padding-bottom: 0; } + #folder-filter { + height: 34px; font-size: var(--fz-sm); + background: var(--bg); border: 1px solid var(--border); + } + .folder-list { padding: var(--s-1) 0 var(--s-2); } + .folder-list > li { margin: 0; } + .folder-list > li + li > .folder-row { border-top: 1px solid rgba(255,255,255,0.045); } + .folder-row { + padding: var(--s-2) var(--s-3) var(--s-2) calc(var(--s-3) - 3px); + min-height: 46px; + } + .folder-list li.selected > .folder-row { border-top-color: transparent; } + .folder-row-main { gap: 2px; } + .folder-desc { font-size: 12px; } + .tree-children { margin-left: var(--s-3) !important; } @@ -358,13 +640,13 @@

rustguac