From aae1163dd45e2ffee0c012fcba09d4a5aabb1f30 Mon Sep 17 00:00:00 2001 From: Jad Seifeddine Date: Sun, 11 Oct 2026 03:30:17 +0700 Subject: [PATCH 1/4] csp: allow data: and blob: images so Safari and Firefox can draw The Content-Security-Policy had no img-src, so default-src 'self' governed images and refused the data: URIs the Guacamole client draws every tile from on browsers without WebCodecs ImageDecoder. Safari and Firefox rendered SSH and RDP sessions black with only rect fills and the cursor cell showing; Chrome decodes straight from the stream and never fetches a URL, which hid it. The same directive blocks the remote mouse cursor (cursor: url(data:...)) and blob: image URLs. Move the policy into a documented constant, add img-src 'self' data: blob:, and add a unit test that fails if img-src loses either source. Document the proxy-side header replacement for releases up to v1.10.5. Fixes #249 --- docs/reverse-proxies.md | 33 +++++++++++++++++++++++++++++++++ src/main.rs | 37 ++++++++++++++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 1 deletion(-) 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/main.rs b/src/main.rs index 4bd2991b..06f018b9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -653,6 +653,23 @@ 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:"; + async fn security_headers( tls: Extension, request: Request, @@ -672,7 +689,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( @@ -1818,6 +1835,24 @@ mod tests { use super::*; + #[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. From 27f9b18c53de67ce3b83f38f4f4726d193a9cfac Mon Sep 17 00:00:00 2001 From: Jad Seifeddine Date: Sun, 11 Oct 2026 04:11:10 +0700 Subject: [PATCH 2/4] client: fill a small framebuffer shortfall instead of leaving a strip Windows RDP without GFX rounds the requested width down to a multiple of 16, so a 1710-wide window gets a 1696-wide desktop. The uniform fit in scaleDisplay() then keeps the scale at 1 (the height matches) and the missing 14px shows as a strip of bare page down the right edge. After the uniform fit, stretch the display element by the remaining fraction on each axis when it is at most 2.5%, and divide the same factors out of pointer coordinates before sendMouseState. A larger gap is a real aspect mismatch and keeps the letterbox. Multi-monitor pop-outs reset the fill. Fixes #251 --- static/client.html | 40 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/static/client.html b/static/client.html index cf477195..b69f23ac 100644 --- a/static/client.html +++ b/static/client.html @@ -1835,12 +1835,46 @@

Session Ended

if (document.hidden) keyboard.reset(); }); - // Mouse input + // Mouse input. Pointer positions come in screen pixels of the + // display element; sendMouseState divides out the uniform display + // scale, and the fill factors from scaleDisplay() are divided out + // here, since they are a transform the display knows nothing about. + var fillX = 1, fillY = 1; var mouse = new Guacamole.Mouse(client.getDisplay().getElement()); mouse.onEach(['mousedown', 'mousemove', 'mouseup'], function(e) { - client.sendMouseState(e.state, true); + var state = e.state; + if (fillX !== 1 || fillY !== 1) { + state = new Guacamole.Mouse.State(state); + state.x /= fillX; + state.y /= fillY; + } + client.sendMouseState(state, true); }); + // Absorb a small shortfall between the framebuffer and the + // viewport. The uniform fit in scaleDisplay() makes one axis match + // exactly and leaves the other short when the server could not + // honour the requested size: Windows RDP without GFX rounds the + // width down to a multiple of 16, so a 1710-wide window gets a + // 1696-wide desktop and a 14px strip of bare page down the right + // edge (#251). A stretch of that last percent or two is invisible; + // the strip is not. A larger gap is a real aspect mismatch (fixed + // resolution, no resize support) and keeps the letterbox. + var FILL_MAX = 1.025; + function applyFill(el, w, h, availW, availH) { + fillX = 1; + fillY = 1; + if (w > 0 && w < availW && availW / w <= FILL_MAX) fillX = availW / w; + if (h > 0 && h < availH && availH / h <= FILL_MAX) fillY = availH / h; + if (fillX !== 1 || fillY !== 1) { + el.style.transformOrigin = '0 0'; + el.style.transform = 'scale(' + fillX + ', ' + fillY + ')'; + } else { + el.style.transformOrigin = ''; + el.style.transform = ''; + } + } + // Show the remote pointer image as the browser's own pointer, and // only fall back to the display's software cursor layer where CSS // cursors are unsupported. Guacamole.Display puts that layer in @@ -1918,10 +1952,12 @@

Session Ended

// Monitor 0 is at combined (0,0); no negative offset needed. el.style.marginLeft = '0'; el.style.marginTop = '0'; + applyFill(el, 0, 0, 0, 0); } else { // Single display: scale the whole framebuffer to fit. display.scale(Math.min(availW / dw, availH / dh)); + applyFill(el, dw * display.getScale(), dh * display.getScale(), availW, availH); displayEl.style.overflow = ''; displayEl.style.position = ''; displayEl.style.width = ''; From 7513c5108d535ed87f8df2d4e6ca7b5d1bc53165 Mon Sep 17 00:00:00 2001 From: Jad Seifeddine Date: Sun, 11 Oct 2026 04:11:10 +0700 Subject: [PATCH 3/4] connections: fit the entries table on 1710px-wide displays The 18px table with 20px cell padding and a separate cell each for edit, clone and delete overran the main column by ~300px on a 1710px MacBook display, cutting "delete" off the right edge. Put the three admin actions in one cell, and add a max-width: 1800px breakpoint on the connections page: 20px page padding, 240-280px sidebar, 16px cells with tighter padding, 14px headers, a 110px Connect button and a smaller folder title. The icon-only "open in new window" button keeps its compact size. Nothing is hidden; at 1710px the table fits with about 250px to spare. Fixes #252 --- static/connections.html | 37 +++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/static/connections.html b/static/connections.html index 8802f42e..a75bc525 100644 --- a/static/connections.html +++ b/static/connections.html @@ -117,6 +117,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; } @@ -165,6 +168,27 @@ color: var(--text-muted); font-size: var(--fz-md); font-style: italic; } + /* 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) { + body { padding: var(--s-4) var(--s-4); } + .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 */ .active-sessions { margin-bottom: var(--s-5); } .session-grid { display: flex; gap: var(--s-3); overflow-x: auto; padding-bottom: var(--s-2); } @@ -2394,7 +2418,7 @@

Enter Credentials

var html = ''; if (isAdmin) html += ''; html += ''; - if (isAdmin) html += ''; + if (isAdmin) html += ''; html += ''; entries.forEach(function(e, idx) { @@ -2435,9 +2459,14 @@

Enter Credentials

html += ''; if (isAdmin) { - html += ''; - html += ''; - html += ''; + // One cell for the three admin actions: three cells cost + // two extra runs of cell padding on every row, which is + // what pushed "delete" off the right edge at 1710px wide. + html += ''; } html += ''; }); From 91639a99261e6140c091e3a6a129df62874840f4 Mon Sep 17 00:00:00 2001 From: Jad Seifeddine Date: Sun, 11 Oct 2026 05:29:38 +0700 Subject: [PATCH 4/4] connections: live refresh, folder default credentials, moving upserts Three things kept the Connections page a stale picture of NetBox and Vault: a device moved between tenants kept its entry in both folders, a synced entry waited on a five-minute timer for its credentials, and the page never refreshed until it was reloaded. PUT .../entries/:entry?move=true deletes same-named entries from every other folder of the scope after the write, each audited, so an upsert keyed on a stable name follows its device when the folder changes. Folders get default credentials: a per-session-type username, password or private key at /.defaults, lent at connect time to entries with no secret of their own, subfolders inheriting per type. Listings report such entries as has_credentials with inherited_credentials, so they show Connect from the moment a webhook creates them. Admin API (GET/PUT/DELETE .../defaults, secrets write-only, audited) and a section in the folder dialog. GET /api/addressbook/version is a counter bumped by middleware after every successful change through the API; it costs no Vault read. The page polls it every three seconds and reloads folders, entries, the search index and the credential status only when it moves, plus an unconditional refresh every two minutes for changes made elsewhere. Nothing reloads while a dialog is open or the tab is hidden. Fixes #253 --- docs/api.md | 49 +++- docs/netbox.md | 19 +- src/api.rs | 513 +++++++++++++++++++++++++++++++++++++++- src/main.rs | 20 +- src/vault.rs | 322 ++++++++++++++++++++++++- static/connections.html | 156 +++++++++++- 6 files changed, 1057 insertions(+), 22 deletions(-) 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/src/api.rs b/src/api.rs index cfe236da..f586c144 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, @@ -2583,10 +2969,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); } } @@ -2674,12 +3067,19 @@ pub async fn ab_search_index( Ok(n) => n, Err(_) => continue, }; + let defaults = vault + .effective_folder_defaults(&scope, &path) + .await + .ok() + .flatten(); 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)), + "entry": info, })); } } @@ -2722,10 +3122,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); } } @@ -2875,6 +3282,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 { @@ -3470,6 +3903,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 +3916,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 +3985,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 +6241,48 @@ fn html_escape(s: &str) -> String { #[cfg(test)] mod tests { use super::*; + + #[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/main.rs b/src/main.rs index 06f018b9..3fef1f9e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -898,6 +898,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()); @@ -1295,6 +1296,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), @@ -1327,11 +1341,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. diff --git a/src/vault.rs b/src/vault.rs index 1cc97ab8..afe16d96 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -83,6 +83,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 +440,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 +595,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 +641,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(), @@ -999,13 +1082,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 +1235,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 +1383,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 +1764,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('\\') { @@ -2186,6 +2387,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/connections.html b/static/connections.html index a75bc525..d609acdd 100644 --- a/static/connections.html +++ b/static/connections.html @@ -168,6 +168,16 @@ color: var(--text-muted); font-size: var(--fz-md); font-style: italic; } + /* 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. @@ -500,6 +510,13 @@

New Folder

Inherit permissions from parent folder
When ticked, if this folder's own groups list doesn't grant a user access, the check walks up to the parent (and further ancestors if they also inherit). Uncheck to enforce this folder's groups list on its own. Top-level folders have no parent so this has no effect.
+
+ Default credentials +
Lent to entries in this folder that have no password or key of their own, per session type, so an entry created without secrets (a NetBox webhook, an import) connects straight away. An entry's own credentials always win; subfolders inherit per type. Secrets are write-only: leave a field blank to keep what is stored, tick clear to drop a type.
+
NameTypeHostUser
' + '' + + '' + + '' + + '' + + '
+ +
+