83 Commits

Author SHA1 Message Date
Dave Kempe 70763932ca fix(drive): honour cleanup_on_close and retention_secs at session end
Closes #123 (parts 2 + 3 only; upload disconnect and drag-drop UX
postponed for further testing).

cleanup_browser hardcoded retention_secs=0 and never consulted
cleanup_on_close, so the [drive] config flags were dead code at
end-of-session teardown. This was visible to operators as
"cleanup_on_close = false has no effect" and "retention_secs = 0 wins
over cleanup_on_close = false" - the same root cause for both reports.

  - cleanup_browser now takes (cleanup_on_close, retention_secs) and
    only calls drive::cleanup_session_dir when cleanup_on_close = true,
    passing retention_secs through. session.drive_path is still cleared
    either way so subsequent reads don't think we still own the dir.
  - drive_cleanup_settings() centralises the resolution from the
    optional [drive] config; absent config uses the legacy "always
    wipe immediately" defaults so installs that never enabled drive
    keep prior behaviour.
  - All four cleanup_browser call sites updated (delete, complete,
    error, pending-timeout).
  - Three new unit tests covering the resolution helper.
  - Docs gain a "Cleanup behaviour" subsection in the Drive section
    explaining the precedence (retention_secs only matters when
    cleanup_on_close=true) and the per-session UUID subdir model that
    means files do not persist across sessions even with cleanup
    disabled. The cross-session "personal drive" model would be a
    separate feature.
2026-05-06 15:57:26 +10:00
Dave Kempe b155ed7318 fix(oidc): allow client_secret from OIDC_CLIENT_SECRET env var alone
Closes #121.

OidcConfig.client_secret was a non-Optional String, so a config.toml with
no `client_secret = "..."` line failed TOML parsing with `missing field
client_secret` before the env-var override at the bottom of Config::load
could fill it in. The override path was effectively dead code unless
the operator put a placeholder in the file first.

  - client_secret is now Option<String> with #[serde(default)], so the
    field can be omitted from config.toml.
  - At the end of Config::load, if [oidc] is present, OIDC_CLIENT_SECRET
    fills the slot when set; if it's still empty after that, exit(1)
    with an actionable error pointing at both config.toml and the env
    file.
  - oidc.rs unwraps the Option at OidcState construction with a clear
    error string. The validation at config-load time means this should
    never trigger in normal use; it's defensive against a partially
    constructed config.
  - Debug impl now shows None / Some([REDACTED]) so secret-redaction
    behaviour is preserved either way.

Three new tests:
  - oidc_config_parses_without_client_secret  (the regression)
  - oidc_config_parses_with_client_secret
  - oidc_config_debug_redacts_client_secret
2026-05-06 15:57:26 +10:00
Dave Kempe 46d79165f5 fix(websocket): align guacd->browser writes to instruction boundary
Bug from the prior commit: tunnel ping echoes could splice into the middle
of an in-flight guacd instruction, breaking the browser's parser. The
parser concatenates every Message::Text into one rolling buffer with no
message-boundary semantics, so two halves of a guacd instruction sent
either side of a ping echo got parsed as one corrupted instruction:

  [rustguac] tunnel error: Element terminator of instruction was not
  ";" nor ",". code=512

Reproduced reliably on sol1-remoteconsole post-upgrade, after AudioContext
resumed (the larger audio-init burst made mid-instruction reads more
likely).

Fix: every Message::Text from rustguac to the browser now ends at a true
Guacamole instruction boundary. guacd_to_ws holds an incremental carry
buffer; after each TCP read it flushes only up to the last complete
instruction (length-prefix-aware so embedded `;` in clipboard text or
text streams is not treated as a terminator), and holds the partial tail
for the next read. Once each Message::Text is boundary-clean, the ping
echo and any future writer can interleave safely.

Adds protocol::last_instruction_boundary plus 11 unit tests covering
empty buffers, partial frames, embedded `;`, multibyte truncation, and
trailing garbage. Total test count is 218.

Carry is force-flushed at 16 MiB to bound memory if upstream sends
something pathological — well above any real instruction.
2026-05-06 15:57:26 +10:00
Dave Kempe 03c8766eb3 fix(websocket): echo Guacamole tunnel pings, add TCP keepalive
Two stability fixes for "tunnel unstable" / "Connection lost" events on
long-idle sessions, particularly behind reverse proxies.

ws_to_guacd now intercepts the empty-opcode ping the Guacamole client
sends every 500ms (`0.,4.ping,<ts>;`) and echoes it back over the same
WebSocket, mirroring Apache's GuacamoleWebSocketTunnelEndpoint filter.
Previously these pings were forwarded to guacd, which silently drops
unknown opcodes (libguac/user-handlers.c), so during any 1.5s of guacd
quiet time the client tunnel went UNSTABLE; at 15s it closed with
UPSTREAM_TIMEOUT. The browser-side WebSocket sink is now an Arc<Mutex>
so both proxy halves can write to it.

TCP keepalive (30s idle, 10s probe, 3 retries) is now applied to:
  - the inbound listener (Linux inherits to accepted sockets)
  - both rustguac->guacd connect sites (initial + viewer-join)

Catches silent NAT/firewall path drops within ~60s on either leg of the
proxy. TLS path now uses axum_server::from_tcp_rustls so the std listener
can have keepalive set before serving.

Adds socket2 0.6 dependency.
2026-05-06 10:11:17 +10:00
Dave Kempe a046db06b3 deps: RustCrypto family + rand 0.10 batch upgrade
Coordinated bump of the RustCrypto stack and rand. These crates share
digest 0.11 traits and could not be bumped one at a time; pbkdf2 0.13.0
shipping stable was the trigger to harvest the group.

Cargo.toml:
- aes      0.8  -> 0.9
- cbc      0.1  -> 0.2
- hmac     0.12 -> 0.13
- pbkdf2   0.12 -> 0.13
- sha1     0.10 -> 0.11
- rand     0.9  -> 0.10

API call-site fixes:
- src/browser.rs (Chromium password encryption pipeline): cbc 0.2 renamed
  the BlockEncryptMut trait to BlockModeEncrypt and the encrypt_padded_mut
  method to encrypt_padded (now takes self by value).
- src/db.rs and src/session.rs: rand 0.10 renamed the Rng trait to RngExt;
  swap the import. fill() and random() call sites are otherwise unchanged.

The Chromium password encryption tests (5) all pass after the bump,
confirming the v10/PBKDF2/AES-128-CBC pipeline is bytewise unchanged.
cargo test (207 tests), clippy --all-targets -D warnings, and cargo audit
all clean.

Closes #107 (aes), #109 (pbkdf2), #111 (hmac), #113 (cbc), #117 (tracking),
#108 (rand).
2026-04-29 14:54:14 +10:00
Dave Kempe fe3d3adccf Connections: quick-find search across all entries
Adds a find-as-you-type search input to the Connections page entries-header
with global search over every entry the user has access to. Search runs
client-side against an in-memory index built from a new endpoint.

Backend (GET /api/addressbook/search-index):
- Iterative tree walk (BFS over (scope, path) queue) using list_folders +
  list_subfolders.
- Subfolder traversal is unconditional because resolve_folder_access permits
  a child to grant access independently of a denied parent; ACL is enforced
  per folder before its entries are emitted.
- Returns flat {entries: [{scope, folder_path, entry: EntryInfo}]}.
- Operator role required, admin bypass.

Frontend (static/connections.html):
- Search input lives in .folder-actions between folder title/desc and admin
  buttons; auto right margin keeps add/edit/delete folder buttons hard-right.
- loadSearchIndex runs once after loadFolders; placeholder shows "Indexing..."
  until ready.
- Tokenized substring matcher with simple scoring (name-prefix > name-substring
  > host > folder-path); cap at 50 results with "+N more" footer.
- Results render in entries-table styling with a Folder breadcrumb column,
  inline Connect, and an "open folder" link. Matched substrings highlighted
  with <mark>.
- Connect from search results looks up the entry in searchIndex (not
  currentEntries) when searchActive is true.
- "open folder" walks the tree, expands ancestors via loadSubfolders chain,
  selects the target, scrolls into view, clears search.
- Keyboard: / focuses the input (skipped in inputs/textareas/modals); Esc
  clears the query then blurs.

CSS (static/rustguac.css):
- .connections-search styling, mark highlight, breadcrumb cell,
  search-open-folder link, and search-more footer.
2026-04-29 13:35:40 +10:00
Dave Kempe 0aa4722ab6 v1.6.6
Headline changes since v1.6.5:

- Fix zombie WebSocket: wire tunnel.onerror / onstatechange in
  client.html so a mid-path WS drop surfaces the disconnected
  overlay instead of leaving the tab frozen.
- Reconnect button now relaunches the original Connections entry
  via /api/addressbook/.../connect rather than reloading a
  Completed session URL. Ad-hoc and shareToken paths fall back
  cleanly. Bonus: client.onerror clears the thumbnail upload
  interval so the secondary leak (XHR 404s against a dead session)
  stops the moment the overlay shows.
- v1.6.6 polish (already on main): OIDC discovery error wrapping
  for trailing-slash mismatches, contrib/setup-xrdp-gfx.sh adds
  xrdp to ssl-cert and normalises key.pem perms, aurora theme
  applies when [theme] is absent (not just empty), new
  docs/reverse-proxies.md covering nginx / Caddy / Apache /
  Traefik with the %2F-decoding gotcha.
- Dependency bumps: rustls-webpki 0.103.13 (RUSTSEC-2026-0104,
  CRL-parse panic + URI excluded-subtree fix), rustls 0.23.39,
  russh 0.60.1, libc 0.2.186, plus matching /fuzz mirrors.
- Test cleanup: drop format!("{}", ...) and field-reassign-after-
  Default patterns flagged by clippy 1.93.

Deferred to v1.6.7:
- RustCrypto batch (aes 0.9 + cbc 0.2 + hmac 0.13 + pbkdf2 0.13)
  tracked in #117. They share digest 0.11 traits and have to land
  together; individual dependabot PRs (#107/#109/#111/#113) closed
  in favour of one coordinated commit.
- rand 0.10 (#108): API breaking, no security pressure, will get
  picked up next time token generation paths are touched.
2026-04-25 15:52:46 +10:00
Dave Kempe 542474dc3a v1.6.6 polish: OIDC error clarity, xrdp TLS perms, aurora default, reverse-proxy docs
### OIDC discovery error wrap

src/oidc.rs now reshapes the openidconnect crate's "unexpected issuer
URI `X` (expected `Y`)" validation error into something an operator
can act on without having to reason about which side is the config
and which is the provider. For the overwhelmingly common
trailing-slash case the message collapses to a single actionable
line; for the rarer case where the two URIs differ materially (wrong
tenant, copy-pasted authorise URL, Keycloak path change), it falls
back to a config/provider/fix three-liner. Raw Debug output is
preserved for any non-mismatch discovery failure. Works for any
OIDC provider, not just JumpCloud.

### xrdp TLS key permissions (contrib/setup-xrdp-gfx.sh)

After rebuilding xrdp from sid, the `adduser xrdp ssl-cert` step
from the Debian postinst doesn't always re-apply cleanly, leaving
the xrdp user unable to read /etc/xrdp/key.pem. xrdp then falls
back to "classic RDP security" and FreeRDP drops the connection
with a MAC checksum error. New Step 9 in Phase 3 explicitly adds
xrdp to ssl-cert and normalises the key's ownership/mode. Step
list in the help text renumbered to match.

### Aurora default when [theme] is absent

src/main.rs resolved the theme via ThemeConfig::resolve() only when
config.theme was Some(..), and otherwise fell through to a hardcoded
("dark", builtin_presets()[0].1.clone()) pair. That meant aurora
only kicked in when a [theme] section was present in config.toml,
even an empty one. ThemeConfig now derives Default (all fields
Option<String>, so zero-cost), and main.rs resolves via
config.theme.clone().unwrap_or_default().resolve() so absent and
empty [theme] behave identically. Regression test added.

### docs/reverse-proxies.md

New doc covering nginx, Caddy, Apache, and Traefik with per-proxy
configs and the %2F-decoding gotcha that causes 404s on nested
subfolder paths when a proxy normalises the URI before forwarding.
The issue was surfaced by @mauroparente in #105 — thanks for the
repro and the nginx config that made the root cause obvious.
deployment-guide.md Step 3 and integrations.md HAProxy section
both link to the new doc.
2026-04-25 15:40:08 +10:00
Dave Kempe e79883224f RDP: default to NTLM + persist Connections tree state
### RDP NTLM default

New [rdp] config section with default_auth_pkg. The resolver in
session.rs walks entry value -> config default -> hardcoded "ntlm".
Kerberos/Negotiate are still selectable per-entry or via the config
override, but the default is NTLM because Kerberos needs a KDC
reachable via DNS (often over TCP) and its failure mode is a silent
RDP hang that looks exactly like an unrelated network issue.

Existing entries and Guacamole-imported entries that stored
auth_pkg = None now resolve to NTLM automatically. Admins who do
run Kerberos-integrated hosts can set default_auth_pkg =
"kerberos" or "negotiate" in config.toml to restore the old
behaviour.

UI: the entry modal's NLA dropdown now says "Server default (NTLM)"
instead of "Default (negotiate)" so the behaviour matches the
label. Added an explicit "Negotiate (Kerberos first, NTLM fallback)"
option for completeness.

5 unit tests cover the resolver matrix (entry wins, empty entry
falls through, no entry falls through, empty config default falls
through, server default wins when entry is None).

### Connections tree persistence

Folder expansion state and the selected folder are now persisted
to localStorage, so reopening the page / logging back in no longer
collapses the whole tree or snaps you back to the alphabetical
first folder.

- `rustguac_connections_expanded`: {scope|path: true} map, saved
  on every toggleFolder() and on the auto-expand-on-subfolder-
  create path.
- `rustguac_connections_selected`: {scope, path}, saved on every
  selectedFolder assignment (click, new folder, new subfolder,
  delete-to-null, move entry).

On page load, loadFolders() now chains: fetch top-level folders ->
restoreExpandedTree() walks saved keys shallowest-first so deeper
paths can resolve via findFolder() after their ancestors populate
subfolderCache -> try restoring saved selection -> fall back to
the current auto-select-first behaviour only if nothing restored.

Stale keys (deleted folders, ACL-revoked folders) are dropped
opportunistically during the restore walk. Per-browser, not
per-user; try/catch wraps every storage call so private-mode / quota
errors degrade silently to the pre-persistence behaviour.
2026-04-22 16:32:37 +10:00
Dave Kempe be3bea8b30 Connections audit log
New SQLite table addressbook_audit_log records destructive and
mutating actions against the connections tree: create_folder,
update_folder, delete_folder, create_entry, update_entry,
delete_entry. Each row captures user_email, action, scope,
folder_path, optional entry_name, client IP, a small JSON details
blob, and created_at.

Details is deliberately headline-only: counts for delete_folder
(subfolders_deleted, entries_deleted), allowed_groups_count +
inherit_from_parent for folder writes, session type for entry
writes. Entry field values (password, private_key, hostname) and
full request bodies are never written. Audit rows live in SQLite,
not Vault, so logging content would leak Vault-only secrets onto
disk.

The six handlers in api.rs gain ConnectInfo + HeaderMap +
TrustedProxies + Db extractors so client IP can be resolved
through the existing auth::client_ip helper (respecting trusted
proxies) and the audit row can be written post-success.

New admin-only endpoint GET /api/admin/addressbook-audit mirrors
the existing token audit shape (limit + email filter, capped at
1000). admin.html gains a "Connections Audit Log" section below
the token audit, auto-loaded on page show.

cleanup_old_audit_log now also sweeps the new table on the same
retention window as token_audit_log.
2026-04-21 17:45:38 +10:00
Dave Kempe 2b0d65e5e5 Fix clippy needless_borrow on resolve_folder_access calls
identity.groups() already returns &[String]; the extra & made it
&&[String] which clippy flags under -D warnings.
2026-04-21 15:17:37 +10:00
Dave Kempe 98b634f438 Fix: delete_folder recursively removes subfolders
Prior behaviour: delete_folder only cleared entries and .config of
the named folder; subfolder .config keys were left behind. Pre-v1.6.0
that was fine because subfolders didn't exist. Post-subfolders the
UI would refresh after delete, list_children would still find the
orphaned subfolder markers, and the folder would appear stuck (the
DELETE request returned 204, no error surfaced, nothing looked
broken except the folder refused to go away).

delete_folder now BFS-collects every folder path in the subtree
before wiping entries and .config at each level. Return type changes
from Result<(), _> to Result<(usize, usize), _> so the endpoint can
report (subfolders_deleted, entries_deleted) back to the UI; the
single caller in api.rs is updated.

UI: confirmation prompt now explicitly mentions "AND all its
subfolders and entries" when the selected folder has_children is
true, so admins don't nuke a subtree by accident. After a successful
delete, a transient banner reports the number of entries (and
subfolders when > 0) that got swept up.
2026-04-21 15:05:14 +10:00
Dave Kempe 2d554c731d Folder permission inheritance + import ACL flags
FolderConfig gains `inherit_from_parent: bool` (default false via
#[serde(default)] so existing deployments keep their per-folder-only
semantics). When true, if a folder's own allowed_groups doesn't grant
the caller, the access check walks up the slash-separated path and
evaluates each ancestor the same way. Admins still bypass all checks.
Inheritance stops at any folder with the flag off, preserving the
ability to lock down a specific subtree.

New resolve_folder_access helper in vault.rs centralises the walk-up
logic; check_folder_access, ab_list_folders, and ab_list_all in
api.rs all route through it so access semantics live in one place.

Import: new --allowed-groups flag writes the ACL onto the root import
folder only; subfolders are created with inherit_from_parent=true so
the whole imported tree picks up the same rules without per-folder
writes. Matches the pattern admins will want after an initial
Guacamole migration where every connection group should be visible
to the same OIDC group.

Related fix: list_credential_variables in api.rs no longer scans only
top-level folders — it now recurses into subfolders so variable
references buried in an imported tree show up on the My Credentials
page (previously they stayed invisible post-v1.6.0 when subfolders
became first-class).

UI: folder modal gets an "Inherit permissions from parent folder"
checkbox. Defaults: unchecked for new top-level folders (no parent
to inherit from), checked for new subfolders, and reflects the
stored value when editing.
2026-04-21 14:42:03 +10:00
Dave Kempe 37837fa247 cargo fmt 2026-04-21 12:46:46 +10:00
Dave Kempe d052042bd1 Fix three bugs found in internal testing
- db: auto-repair db file ownership when init_db runs as root against a
  data dir owned by a non-root user. Fixes "attempt to write a readonly
  database" after operators run `rustguac add-admin` under sudo, which
  previously left the sqlite file owned by root:root and unwritable by
  the rustguac service user.

- import-guacamole: preserve Guacamole connection-group hierarchy as
  real Vault subfolders instead of flattening into hyphen-joined entry
  names. Each ancestor group gets its own .config so empty parents
  still render in the Connections tree. Per-folder dedup so the same
  connection name in two groups no longer collides.

- config: fatal, loud, line-annotated error on malformed config.toml.
  Previously a TOML parse error emitted a tracing::warn before the
  subscriber was initialised and silently fell back to built-in
  defaults, leaving the operator debugging a "working" server that
  ignored their entire file. Errors now print via eprintln with the
  toml crate's column-pointed snippet and exit(1) whenever a config
  path was explicitly given (or /opt/rustguac/config.toml exists).
2026-04-21 12:39:14 +10:00
Dave Kempe 9dbdc17843 Security hardening + test harness expansion
Security audit (v1.6.1 cycle) findings:

- Shadow tokens: each use now writes a shadow_used entry to
  token_audit_log with the connecting IP. Prior behaviour audited
  only the mint, so a leaked token could be replayed within its
  10-minute TTL with no observable record.

- OIDC groups claim: bound per-name length to 256 bytes (UTF-8
  boundary preserved) and array size to 64. A misconfigured or
  compromised IdP could previously bloat the seen_groups table
  unbounded.

- .cargo/audit.toml formalises the rsa (RUSTSEC-2023-0071) and
  rustls-pemfile (RUSTSEC-2025-0134) advisory ignores with
  rationale, replacing the inline --ignore flag in CI. New
  advisories on those crates will still surface.

Small defence-in-depth: WebSocket Origin/Host compare is now
case-insensitive (DNS is canonical) — previously exact-match.

Test harness grows from 150 to 215 tests:

- Pure-function regression coverage: OIDC groups cap, shadow-token
  validation matrix (owner/shadow/invalid/expired/IDOR), Vault
  path traversal, VDI username sanitization, recording-name
  hardening, JPEG magic bytes, Guacamole protocol parser
  adversarial cases (overflow length, UTF-8 split, malformed
  frames, buffer cap, streaming boundaries), Origin/Host matcher.

- Async + in-memory state: SessionManager test helper bypassing
  disk/browser, owner/shadow validation end-to-end, mint prunes
  expired, shadow is session-scoped (IDOR guard), disconnect_viewer
  saturating decrement, seen_groups DB bounds, rate-limit layer
  burst -> 429 (proves tower_governor is actually applied).

Behaviour-preserving refactors to support testing:
check_share_token_match (from validate_share_token),
is_jpeg_magic (from thumbnail PUT), origin_host_matches (from
ws_handler CSWSH check).
2026-04-19 14:46:07 +10:00
Dave Kempe a0c582dd45 cargo fmt 2026-04-18 22:08:08 +10:00
Dave Kempe e791383375 Issue #103: auto-open singleton entry + client Home button + group picker fix
Feature #103: single-entry auto-connect
- New `auto_open_if_singleton: Option<bool>` on AddressBookEntry and
  EntryInfo. Admin ticks it per entry in the Connections modal;
  importer initialises to None.
- After the /api/addressbook batch resolves on the Connections page,
  if the user sees exactly one entry and it has the flag set, the
  page fetches /connect and navigates the current tab to the new
  session. Same-tab navigation (not a popup) because browsers block
  window.open after an async fetch without a user gesture.
- A sessionStorage flag gates the auto-open to once per browser
  session — an accidental refresh of Connections doesn't re-spawn
  sessions. Logout clears the flag so the next login fires again.
- Admins never meet the singleton condition (they have many
  entries) so this is effectively kiosk-only.

Escape hatch: client.html Home button
- Ctrl+Alt+Shift panel header now has a 🏠 Home button next to the
  close ×. Takes the tab back to /connections.html — primary route
  for a kiosk user whose session auto-opened into this tab.

Bug fix: folder-modal group picker silently added a group on save
- addFmGroupFromInput used to fall through to picking the combo's
  highlighted suggestion when the input was empty. fm-save calls
  addFmGroupFromInput to flush any typed-but-not-chipped text; with
  an empty input that meant every Save on an existing folder tacked
  on the first unselected known group (e.g. an operator group) —
  visible only on the next edit. Split the "pick suggestion when
  empty" behaviour out to an explicit helper that only fires from
  Enter-with-empty-input, never from Save.
2026-04-18 21:56:20 +10:00
Dave Kempe 3b97dfbcdf Per-entry allow_sharing toggle + modal checkbox alignment fix
Per-entry Share gating
- AddressBookEntry gains an `allow_sharing: Option<bool>` (default
  None = off). Admin opts in per entry via an Allow session sharing
  checkbox in the entry modal.
- EntryInfo exposes the field so the modal prefills on edit.
- CreateSessionRequest carries it through; the ab_connect_entry
  quick-connect and legacy quick-connect paths propagate the entry's
  value.
- Session gets a share_allowed bool. SessionInfo.share_url is only
  populated when share_allowed is true, so the Connections
  Active Sessions Share button auto-hides without any new UI logic.
- Default behaviour on create:
    * explicit allow_sharing on the request → honoured
    * entry-derived session without flag → off (admin opt-in)
    * ad-hoc session (no address_book_entry) → on
  The ad-hoc default preserves the long-standing API-key
  session-creation flow where external callers expect share_url in
  the POST /api/sessions response.

Modal checkbox alignment (side-effect fix)
- The generic .modal input rule in rustguac.css was forcing every
  input — checkboxes included — to 44px height + block + 100% width,
  which misaligned every tickbox/label pair across the entry, folder,
  and onboarding modals.
- Added carve-outs for input[type=checkbox] / [type=radio] that keep
  native size, inline, with a small right margin.
- Labels that directly contain a checkbox/radio (via :has()) now flex
  the control + text on one baseline with a clean gap, and drop the
  uppercase letter-spacing used for full-width field labels.
2026-04-18 21:56:20 +10:00
Dave Kempe 635bc061cc Shadow sessions: admin read-only viewer tokens
Part of the shadow-sessions plan (stage 2). Admins can now join any
active session from the Sessions page without the user having to
share — the backend mints a short-lived token and every mint is
logged to token_audit_log.

Backend
- Session struct gets a shadow_tokens: Vec<ShadowToken> (sha256 hex
  of the raw token, issuing admin, expiry). Expired entries are
  pruned on mint.
- validate_share_token now accepts either the owner's share_token or
  any non-expired shadow token, so the existing viewer path works
  unchanged.
- POST /api/sessions/{id}/shadow (admin-only) mints a raw token,
  pushes its hash + a 10-minute expiry onto the session, writes a
  token_audit_log row (action = "shadow_session", details includes
  session_id, owner, expiry, caller IP) and returns the viewer URL.

Frontend (Sessions page)
- /api/me fetched on load so we know the caller's display_name and
  role before rendering. API-key users default to admin and fetch
  /api/me to learn their name.
- Own active sessions still render "open" (anchor to client_url).
- Others' active sessions render "shadow" as an anchor (not a button)
  so both action cells line up in the same column. Shadow uses
  --status-pending (warning yellow) instead of the neutral accent,
  hovers to --primary, and shows a "minting..." busy state while
  the POST is in flight.
2026-04-18 21:56:20 +10:00
Dave Kempe 0d69e8fed4 Rename Address Book → Connections; allowed_groups picker; session privacy (#102)
Three pieces of v1.6.0 work that happened together and are easier to
review as one save point.

Rename: Address Book → Connections
- static/addressbook.html renamed to static/connections.html
- Nav links, page titles, empty states, onboarding, and prose updated
  across all 8 static pages (connections, admin, docs, index,
  recordings, reports, sessions, tokens).
- README, CLAUDE.md, and every file under docs/ updated.
- src/main.rs: connections.html added to the branded-page map and
  route list; /addressbook.html returns a 308 permanent redirect so
  existing bookmarks keep working.
- Backend API paths, Rust types, and Vault storage paths are
  deliberately unchanged — internal only.

Folder allowed_groups picker
- New SQLite table `seen_groups` tracks OIDC groups observed in any
  user login; OIDC callback upserts after extracting groups.
- `GET /api/auth/known-groups` (admin-only) returns the union of
  group_role_mappings and seen_groups.
- `GET /api/addressbook/folders/{scope}/{folder}/config` adds the
  missing endpoint the frontend was already calling — existing
  allowed_groups now prefill the edit-folder modal.
- Folder modal swaps the free-text comma-separated input for a chip
  picker with a themed combobox dropdown: autocomplete over known
  groups, keyboard nav, "+ add custom" row for unlisted groups.

Active session visibility (GitHub #102)
- `GET /api/sessions` scopes to the caller's own sessions by default;
  `?all=true` lets admins opt in (used by the Sessions page).
- `GET /api/sessions/{id}` and the thumbnail GET/PUT endpoints are
  now owner-or-admin, returning 404 for other callers so session
  existence isn't leaked.
- Connections' Active Sessions strip is now always owner-scoped —
  admins still manage everyone via the Sessions page.
2026-04-18 21:56:20 +10:00
Dave Kempe 88b754a11c Address book: subfolder tree UI + shared design system
Frontend for #101 — subfolder support (backend landed in c2a3822) — plus
a site-wide visual overhaul extracted into a single shared stylesheet.

Address book
- Folder sidebar renders as a tree with lazy-loaded children via the
  /api/addressbook/folders/{scope}/{path}/subfolders endpoint.
- Scope badge is now an icon with hover tooltip: ⊕ shared, ▣ instance.
- New "+ subfolder" button creates a nested folder under the selection.
- Move-entry dropdown includes any loaded subfolders.
- Batch folder API now returns path + has_children so the tree can
  render chevrons without a second request per folder.

Design system (rustguac.css)
- Extracted ~700 lines of near-duplicate CSS from each page into a
  shared stylesheet linked by every page.
- 18px body, strict 38/44/54px control heights, uppercase letter-spaced
  section labels, zebra table rows, active-nav underline bar.
- Uniform button ladder: primary (red) / accent (connect, teal) /
  ghost (+ buttons) / small (edit/delete chrome).
- Generic status colors, type badges, pagination, token-reveal,
  summary cards, hop cards, and flow diagram now live in one place.

Per-page updates
- addressbook, admin, docs, index, recordings, reports, sessions,
  tokens: style blocks reduced to page-specific layouts only.
- reports/recordings/sessions: bare <strong> page titles promoted
  to <h2> for proper heading hierarchy.
- Stripped inline padding/font-size attributes that were overriding
  the shared ladder.
2026-04-18 21:56:20 +10:00
Dave Kempe c2a382211a Address book: subfolder support backend (#101)
Add hierarchical folder support to the Vault-backed address book.
Folders can now be nested (e.g., Clients/Acme/Servers) using Vault
KV v2's natural path hierarchy.

- Add validate_path() for multi-segment folder paths, replacing
  validate_name() for folder parameters. Each segment validated
  individually — blocks traversal, reserved names, special chars.
- FolderInfo gains path and has_children fields for tree UI support
- New list_subfolders() and list_children() methods on VaultClient
- New GET /api/addressbook/folders/{scope}/{folder}/subfolders endpoint
- Existing flat folder operations unchanged (backward compatible)
- Client percent-encodes folder paths: Clients%2FAcme in URL decodes
  to Clients/Acme — no wildcard routes needed

Tested on sol1-remoteconsole: subfolder CRUD, entry CRUD in subfolders,
has_children detection, and existing flat folder compatibility verified.
2026-04-16 19:46:59 +10:00
Dave Kempe 77c7c535f4 Docs cleanup, default theme to aurora, Vault install guidance
- Switch default theme from dark to aurora across server config,
  all 9 static HTML pages, example config, and docs
- Fix theme docs: list all 8 presets (was 6, missing jaguar/aurora),
  add missing type_vdi_bg/type_vdi_fg fields
- Add Vault/address book setup as recommended post-install step in
  installation docs — the address book is the primary user-facing
  feature and requires Vault
- Renumber subsequent install steps
2026-04-16 16:54:25 +10:00
Dave Kempe 84616f5730 Session limits and completed session cleanup (#99)
- Add max_sessions (default 500) and max_sessions_per_user (default 50)
  config options. Session creation is rejected with a clear error when
  limits are reached. Set to 0 for unlimited (backward compatible).
- Add background reaper that removes completed/error/expired sessions
  from the in-memory HashMap after session_cleanup_delay_secs (default
  300s). Session history in SQLite is not affected.
- Prevents resource exhaustion from unbounded session creation and
  memory leak from accumulated completed sessions.

Closes #99
2026-04-12 07:40:32 +10:00
Dave Kempe 58ba74ff51 WebSocket Origin check: compare hostnames only, ignore ports
Avoids false rejections behind reverse proxies where the Host header
may include an explicit port (e.g. :443) that the browser's Origin
omits as a default port.
2026-04-12 07:23:57 +10:00
Dave Kempe 2074895e90 Security hardening: 7 audit findings fixed
From OWASP-based security audit (categories 2-12):

- Vault path traversal: validate folder names on read operations
  (get_folder_config, list_entries, get_entry) — write operations
  already validated but reads did not
- SSH host key: reject connection when stored key fails to parse,
  instead of silently accepting (was bypassing verification)
- Config secret redaction: custom Debug impls for OidcConfig and
  VaultConfig that redact client_secret, role_id, and client_key
- Branding XSS: HTML-escape site_title and logo_url config values
  before injecting into page templates
- WebSocket CSWSH: validate Origin header against Host header on
  WebSocket upgrade, reject cross-origin requests
- VDI bind mounts: add nosuid,nodev mount options to home directory
  bind mounts to prevent setuid binary attacks
- Recording access: restrict list/serve endpoints to poweruser+ role
  (previously any authenticated user including viewers could access
  all recordings)
2026-04-12 07:19:16 +10:00
Dave Kempe 622569290a Security hardening: share URL redaction, auth rate limiting, fixes
- Redact share_url from session listings for non-owners — previously
  any authenticated user could enumerate share tokens and join sessions
  they didn't create (share_url now only returned to session creator
  and admins)
- Always rate-limit OIDC login/callback (1/sec burst 5 per IP)
  regardless of rate_limit config, preventing brute-force on auth
- Fix disconnect instruction detection: use instruction boundary
  matching instead of substring contains — clipboard content or typed
  text containing "10.disconnect;" could falsely trigger VDI container
  destruction
- Restrict sudoers chown rule to rustguac:rustguac only, preventing
  arbitrary ownership changes on the LUKS mount point
2026-04-11 21:49:03 +10:00
Dave Kempe a330c0cda0 deps: bump bollard 0.18 to 0.20, update VDI Docker driver
Bollard 0.20 moved container option types from bollard::container to
bollard::query_parameters, replaced Config<T> with ContainerCreateBody,
and changed several fields to Option types. Update all imports and call
sites in the VDI Docker driver accordingly.
2026-04-11 21:11:38 +10:00
Dave Kempe 22c3638d00 cargo fmt 2026-04-09 21:26:13 +10:00
Dave Kempe 958b675fd2 SSH tunnel: host key verification with UI-driven pinning
check_server_key no longer blindly accepts all keys. Jump hosts now
support a host_key field stored in Vault alongside credentials.

- New POST /api/ssh/probe-host-key endpoint probes an SSH server and
  returns its public key, fingerprint, and algorithm
- Address book UI: "Verify Host Key" button per jump host probes the
  server, shows fingerprint for confirmation, stores key on save
- TunnelHandler verifies the server key against the stored key on
  connect — rejects with detailed error on mismatch
- Unpinned keys accepted with TOFU warning log for backward compat
- host_key preserved through credential merging on entry update

Closes #95
2026-04-09 21:22:36 +10:00
Dave Kempe 2e046ea4a8 OIDC: native CA trust, custom CA cert, TLS skip-verify, better errors
Switch reqwest from rustls-tls-webpki-roots to rustls-tls-native-roots
so the OS trust store is used by default — private CAs installed
system-wide now work without extra config.

Add ca_cert and tls_skip_verify options to [oidc] for environments
where the system store isn't available or for debugging TLS issues.
Improve OIDC discovery error formatting to surface the actual cause
(issuer mismatch, TLS failure, etc.) instead of opaque "Request failed".

Closes #94
2026-04-08 06:31:14 +10:00
Dave Kempe 75eae2b43f cargo fmt 2026-04-05 10:33:01 +10:00
Dave Kempe a9a966e675 VDI: detect image change and clear stale thumbnails
- start_or_reuse checks if existing container's image differs from
  the requested image. If so, stops the old container and creates
  a new one with the new image.
- Clear stale VDI thumbnail when starting a new session, so old
  screenshots don't linger from previous images/failed sessions.
2026-04-05 08:33:42 +10:00
Dave Kempe c0e194fa6a v1.5.0: VDI Docker desktop containers
Major release adding ephemeral Docker desktop containers (VDI), session
thumbnails with active sessions UI, and session-ended overlay.

VDI:
- New session type: VDI (Docker) — per-user ephemeral desktop containers
- VdiDriver trait for downstream extensibility (JumpboxVDI)
- DockerDriver: bollard API, deterministic naming, start/reuse/stop
- Container lifecycle: persist after disconnect, reap after idle timeout
- Per-entry idle timeout override via address book
- Persistent home dirs via bind mount (home_base config)
- Server disconnect detection (10.disconnect;) stops container on logout
- Test image: contrib/vdi-test-image (Debian + xrdp + xfce4)

Thumbnails & Active Sessions:
- Client captures display screenshot every 10s (JPEG, 320px)
- Active Sessions section in address book with thumbnail grid
- Dormant VDI containers shown with last captured thumbnail
- Click thumbnail to reconnect via address book connect flow

UI:
- Session ended overlay with Reconnect/Close buttons (all session types)
- Sessions page: collapsible ad-hoc form, VDI option
- Logout button moved out of settings dropdown

Security:
- Thumbnail endpoints require authentication
- JPEG magic byte validation on upload
- Path traversal protection on container thumbnails
- Env var name/value validation for containers
- Home base path traversal check

Dependencies:
- Added bollard 0.18 (Docker API client)
- Updated russh 0.59, tokio 1.51, toml 1.1.2, libc 0.2.184
2026-04-05 08:09:02 +10:00
Dave Kempe b5566f7ce8 Security hardening for VDI and thumbnails
- Move thumbnail endpoints to authenticated routes (was unauthenticated)
- Validate JPEG magic bytes on thumbnail upload
- Require session to exist before accepting thumbnail
- Path traversal protection on VDI container thumbnail endpoint
- Validate container env var names (alphanumeric + underscore only)
- Reject env var values containing newlines
- Path traversal check on home_base bind mount
2026-04-05 08:05:34 +10:00
Dave Kempe 3273e47bbc VDI: per-entry idle timeout for containers
Address book entries can now set container_idle_timeout_mins to override
the global idle_timeout_mins. Stored as a Docker label on the container
so the reaper reads it without needing session state. Reaper also cleans
up VDI thumbnails when removing idle containers.
2026-04-05 07:55:50 +10:00
Dave Kempe 697b6f7d75 Active Sessions with thumbnails, dormant VDI containers
Client captures display thumbnail every 10s (JPEG, 320px), uploads
to server. Address book shows "Active Sessions" section with thumbnail
grid — click to reconnect via address book connect flow.

- PUT/GET /api/sessions/{id}/thumbnail endpoints
- GET /api/vdi/containers — list running VDI containers for current user
- VDI container labels: rustguac.entry, rustguac.image for reconnect
- Thumbnail copied to container-keyed file on disconnect (persists)
- Dormant VDI containers shown with last thumbnail
- Cache-busting on thumbnail images for live updates
- Moved logout button out of settings dropdown
- Fixed: VDI containers endpoint in authenticated route group
- Fixed: thumbnail click uses address book connect (not stale session URL)
2026-04-05 07:51:00 +10:00
Dave Kempe bfadf34a9b VDI: detect server-side disconnect to stop container on logout
Sniff for guacd's 10.disconnect; instruction in the proxy stream to
distinguish server-initiated disconnects (user logout, crash) from
browser-initiated disconnects (tab close, network drop).

- Logout from desktop → container stopped and removed immediately
- Tab close / network drop → container persists for reconnection
- Normal RDP/SSH/VNC sessions unaffected (flag only acted on for VDI)
2026-04-05 07:03:34 +10:00
Dave Kempe 690997d4eb VDI: persistent home dirs and logout stops container
- Add home_base config: bind-mounts {home_base}/{username} into container
  so user files persist across container restarts
- When guacd ends the connection (user logout / session crash), stop and
  remove the VDI container immediately
- Browser disconnect (tab close, network drop) keeps container running
  for reconnection — idle reaper handles eventual cleanup
2026-04-05 06:44:33 +10:00
Dave Kempe 501bd14a0d VDI Docker driver: ephemeral container desktops
Add 5th session type (Vdi) that spawns Docker containers with xrdp
and connects via RDP through guacd. Containers persist after disconnect
for configurable idle timeout, allowing reconnection to same desktop.

- VdiDriver trait (dyn-compatible) for downstream extensibility
- DockerDriver: bollard API, deterministic container naming per user,
  start/reuse/stop lifecycle, TCP+settle readiness polling
- Background reaper removes idle containers after timeout
- Address book: container_image, cpu/mem limits, env vars fields
- Config: [vdi] section with enabled, docker_socket, limits, timeouts
- Test image: contrib/vdi-test-image (Debian trixie + xrdp + xfce4)
2026-04-05 06:28:03 +10:00
Dave Kempe fc1e2552aa Fix formatting (cargo fmt) 2026-03-28 22:38:40 +11:00
Dave Kempe 8e9edce3a0 Security hardening: 8 fixes from audit
- Recording name: reject on canonicalization failure (not accept)
- Protocol parser: 1MB buffer limit prevents OOM from rogue guacd
- UTF-8 validation: reject invalid bytes instead of silent replacement
- Login scripts: remove credential env vars (stdin-only)
- Chromium profiles: 0700 permissions on temp directories
- Share tokens: constant-time comparison (subtle::ConstantTimeEq)
- WebSocket: drop binary messages instead of forwarding to guacd
- Login scripts API: require operator+ role
2026-03-28 22:28:30 +11:00
Dave Kempe 211ff9e99a WebSocket ticket auth: keep API keys out of WS URLs
API key users now exchange their key for a single-use 30-second
ticket via POST /api/ws-ticket before connecting. The ticket is
passed as ?ticket= in the WebSocket URL instead of the raw API key.

Prevents API key exposure in reverse proxy logs, browser history,
and Referer headers. Legacy ?key= still works as fallback.
2026-03-28 22:22:05 +11:00
Dave Kempe 6269f51bf2 Hash OIDC session tokens in database (security hardening)
Session tokens stored as SHA-256 hashes instead of plaintext.
Migration drops old auth_sessions table on upgrade.
Same pattern as admin API key storage.
2026-03-28 22:13:19 +11:00
Dave Kempe 799cdefd57 v1.0.0: H.264 passthrough, per-entry toggle, Docker fix
H.264 passthrough for RDP via guac_display worker integration:
- Raw H.264 NAL units from xrdp x264 pass through to browser
  WebCodecs VideoDecoder, bypassing server-side decode/re-encode
- Frame queue preserves all H.264 frames across deferred flushes
- Per-entry toggle in address book (enable_h264 field)
- Keyframe gate ensures decoder initializes correctly
- Per-connection callback (no static global concurrency bug)
- Queue capped at 120 frames to prevent unbounded growth

guacd patch (004-h264-display-worker):
- display-priv.h: h264 frame queue on guac_display_layer
- display-plan.c: flush h264 queue during plan_apply, skip IMG ops
- display-layer.c: set_h264 API with queue append and cap
- rdpgfx.c: SurfaceCommand wrapper saves NAL before GDI handler
- settings.c/h: enable-h264 connection parameter, conditional GfxH264

Other changes:
- Docker: fix FreeRDP plugin path for drive/audio channels (#87)
- contrib/setup-xrdp-gfx.sh: full xrdp x264 rebuild from Debian sid
- docs/rdp-video-performance.md: H.264 passthrough documentation
- Address book UI: H.264 checkbox with dependency descriptions
2026-03-28 21:52:28 +11:00
Dave Kempe b013678e42 H.264 passthrough: guacd patch + WebCodecs browser decoder
Phase 1-3 of H.264 passthrough for premium RDP video performance.

guacamole-server patch (patches/004-h264-passthrough.patch):
- Enable GfxH264 and GfxAVC444 in FreeRDP settings when GFX is on
- Intercept AVC420/AVC444 SurfaceCommand before GDI decode
- Extract raw H.264 NAL units and send as new "h264" instruction
- Keyframe detection via Annex B start code + NAL type parsing
- Original GDI decode path still runs as fallback

Browser-side (static/guac/):
- H264Decoder.js: WebCodecs VideoDecoder, hardware-accelerated decode
- Client.js: "h264" instruction handler, base64→ArrayBuffer→decode
- Feature detection: falls back gracefully if WebCodecs unavailable

rustguac:
- Advertise video/h264 in guacd handshake
2026-03-28 13:02:06 +11:00
Dave Kempe b2892e404f v0.9.2: Dependency updates
- sha2 0.11.0-rc.5 → 0.11.0 (stable release)
- rusqlite 0.38.0 → 0.39.0 (bundled SQLite 3.51.3)
- clap 4.5.60 → 4.6.0
- toml 1.0.4 → 1.1.0
- libc 0.2.182 → 0.2.183
- pulldown-cmark 0.13.1 → 0.13.3
- tracing-subscriber 0.3.22 → 0.3.23
- uuid 1.22.0 → 1.23.0
2026-03-28 12:27:42 +11:00
Dave Kempe 3bf1762d87 v0.9.0: RDP audio, GFX pipeline, video performance
Audio:
- RDP audio output now works through guacamole. Advertise audio/L16
  and audio/L8 mimetypes in the guacd handshake, and explicitly set
  disable-audio=false. Fixed mimetype mismatch that silently prevented
  guacd from creating audio streams.
- Browser AudioContext auto-resumed on user interaction (click/keydown)
  to comply with autoplay policy.

Video performance:
- Per-entry GFX pipeline toggle (enable_gfx) — enables RemoteFX codec
- Per-entry desktop composition toggle (enable_desktop_composition)
- Per-entry force lossless toggle (force_lossless) — PNG-only mode
- WebSocket proxy buffer increased from 8KB to 64KB
- Video Performance section in address book UI for RDP entries

Documentation:
- RDP Video Performance guide with Windows server tuning (AVC444,
  60fps, GPU encoding) and Linux xrdp setup (Debian 13)
- contrib/setup-xrdp-gfx.sh — automated GFX/H.264 setup for xrdp
- contrib/setup-xrdp-audio.sh — automated PulseAudio module build
2026-03-24 20:25:05 +11:00
Dave Kempe 7acbb302fa v0.8.5: Reports, session history, enriched recordings
Session history:
- New session_history SQLite table tracks all sessions with user,
  entry, folder, hostname, duration, and recording file
- Automatic cleanup via session_history_retention_days config (default 90)
- 8 new unit tests for session history DB functions

Reports page (poweruser+ only):
- Summary cards: total sessions, hours, unique users, active now
- Sortable/filterable session history table with pagination
- Top Connections and Top Users leaderboards
- CSV export for session history with filters

Enriched recordings:
- Recording .meta sidecars now include user, folder, entry name, session type
- Recordings API returns enriched metadata from .meta files
- Recordings page shows User, Entry, Folder columns

Other:
- Reports nav link on all pages, hidden for operator/viewer roles
- Reports documentation in docs/reports.md
- Onboarding wizard for new users (role-scoped, dismissable)
- Settings menu label visibility improvement
- Fixed stale credential variable test (hyphen support)
2026-03-22 20:36:26 +11:00