125 Commits

Author SHA1 Message Date
Dave Kempe 6dfee70964 oidc: reject post-login redirects that browsers read as off-site
The login `next` check allowed anything starting with a single `/`.
Browsers treat `\` as `/` and drop tabs and newlines in Location, so
`/\evil.com`, and `/%09/evil.com` once the query was decoded, both
redirected to another site straight after a successful login on the
real login page. `next` must now be visible ASCII with no backslash,
and must not start with an encoded slash or backslash. That also keeps
it safe to place in the rustguac_next cookie unescaped, where a `;`
could previously add cookie attributes. Checked both when the cookie is
set and when it is consumed.
2026-10-01 22:04:17 +10:00
Dave Kempe 6f541e37ff session: fence probe, jump host and Proxmox; pin SSH/VNC address
The network allowlists are what fence powerusers creating ad-hoc
sessions, and several paths went around them:

- POST /api/ssh/probe-host-key dialled any host:port, a port scanner and
  banner grabber for anything rustguac can reach (guacd, Vault, metadata
  services). Non-admin probes are now checked against
  ssh_allowed_networks and dialled at the checked address.
- Jump hosts were never checked, though rustguac dials the first one
  itself. For non-admin ad-hoc sessions it is now fenced by
  ssh_allowed_networks and pinned.
- Ad-hoc Proxmox sessions called any proxmox_url and let guacd dial
  whatever SPICE proxy that API returned. For non-admin ad-hoc sessions
  both are now fenced by vnc_allowed_networks, as direct SPICE is.

The jump host and Proxmox fences are deliberately limited to non-admin
ad-hoc requests: connection entries and admins name bastions and
clusters that sit outside the target lists, and on the canary every
entry-based Proxmox and jump host session would otherwise have broken.
The flag is serde(skip), so a request body cannot set it.

The allowlist passed a hostname if any resolved address was allowed and
then handed guacd the hostname to resolve again, so a name whose DNS the
caller controls could pass the check and connect elsewhere. SSH and VNC
now dial the checked address when there are no jump hosts. RDP, SPICE
and web keep the hostname (TLS names, Kerberos) and the residual risk is
documented. Docs also no longer claim every resolved address must match.
2026-10-01 22:01:34 +10:00
Dave Kempe 220aec81ee auth: read X-Forwarded-For right to left; key limiters on it
client_ip took the LEFTMOST X-Forwarded-For entry from a trusted proxy.
Behind any proxy that appends to an incoming header (nginx
$proxy_add_x_forwarded_for, as our own reverse-proxy doc recommended, or
Apache mod_proxy), that entry is whatever the client sent, so a client
could pick an address inside an IP allowlist and walk through it. The
header is now walked from the right, skipping trusted proxies, across
every header line, split on raw bytes so a non-UTF-8 left entry cannot
hide the address a proxy appended.

Rate limiters used tower_governor's SmartIpKeyExtractor, which believes
forwarded headers from any peer: a different forged X-Forwarded-For per
request meant a fresh bucket each time, including on the always-on OIDC
limiter. They now key on the same trusted-proxy resolution. When
rate_limit is enabled, the API and WebSocket limiters sit outside
authentication so requests with bad credentials are counted. The OIDC
burst goes from 5 to 20 to cover an office behind one NAT address.

Docs: nginx recipe overwrites X-Forwarded-For with $remote_addr, and the
rate-limit section now states what is actually limited and when.
2026-10-01 21:28:44 +10:00
Dave Kempe f2cd9119d3 sessions: owner-only first connection, optional read-only joins
The first WebSocket to a pending session takes its guacd stream, already
logged in to the target. Any operator could claim another user's pending
session that way, and session ids are visible in reports. Now only the
session's creator may make that connection, with no admin bypass: an admin
"shadowing" a pending session used to silently become its owner and lock
the real owner out, so shadow now refuses sessions that have not started
(409). handle_ws only takes the stream when the owner check passed at
upgrade time.

POST /api/sessions accepts an admin-only `owner` field so a backend using
an API key can create a session for a user who then connects with their
own login. That user is recorded as the creator.

Share links and shadow tokens gain an optional read-only mode; the default
stays read/write. Each shareable session gets a second token
(share_url_readonly), and shadow takes {"read_only": true}. guacd enforces
it per joining user via the read-only connect arg, which was previously
hardcoded to false. The Share dialog offers both links, the Sessions page
offers watch and shadow, and docs no longer claim a read-only mode that
did not exist.
2026-10-01 21:21:35 +10:00
Dave Kempe 0ded1dd99b test: move the map-group CLI tests to an integration test
They call assert_cmd's cargo_bin, which needs CARGO_BIN_EXE_rustguac.
Cargo exports that only to integration tests under tests/, never to a
#[cfg(test)] module inside src/. As unit tests they passed locally purely
because a stale target/debug/rustguac was lying around for assert_cmd's
fallback to find; on a clean checkout, and so in CI, all five failed with
"`CARGO_BIN_EXE_rustguac` is unset". They have been broken since they were
written and nothing noticed, because the suite had never run in CI.

Caught by the cargo test step added in 83a9110, on its first run.

Database assertions now go through rusqlite directly rather than the
crate's own db helpers, since rustguac is a binary crate with no lib target
for an integration test to import. That is the better boundary regardless:
it checks the state the CLI actually leaves behind. rusqlite joins
dev-dependencies, which adds nothing to the tree as it is already a runtime
dependency.
2026-09-30 17:18:44 +10:00
josh 04ae52c16a config: only require the OIDC client secret for serve
`Config::load` exited with "[oidc] is configured but no client_secret
was provided" for every subcommand, including `add-admin`. The secret
normally lives in /opt/rustguac/env, which only systemd's
EnvironmentFile loads, so running the admin CLI from an interactive
shell on a freshly installed host failed unless the secret was also
copied into config.toml, which defeats the point of the env file.

Keep the OIDC_CLIENT_SECRET override in `Config::load`, but move the
presence check into `Config::validate_oidc_secret` and call it only on
the `serve` path. Admin subcommands touch just the SQLite database and
never contact the IdP. Add unit tests for the validation function.
2026-09-29 15:29:13 +10:00
Dave Kempe 2021508a99 map-group: friendly re-mapping, CLI audit log, hermetic tests
Follow-up to the map-group subcommand.

oidc_group is UNIQUE, so mapping a group that already had a mapping failed
on the constraint and surfaced a raw "UNIQUE constraint failed" error, with
no way to change a mapping from the CLI at all. The command now looks the
group up first: an unchanged mapping is a no-op, a conflicting one explains
itself and points at the new --force flag, and --force re-maps through
db::update_group_mapping. This matches the API, which already answers 409
rather than 500 on a duplicate.

CLI commands hold the database directly and so bypass the API's
authenticated audit trail, leaving role grants and account deletions made
from a shell with no record anywhere. A new cli_audit_log table and
db::log_cli_event record the admin, user-role and group-mapping mutations,
attributed to SUDO_USER where present so an action taken through sudo names
the person rather than root. Entries are headline only, never API keys or
passwords, and are covered by the existing retention cleanup.

Tests: assert_cmd's Command is aliased so it no longer shadows the clap
Command enum that use super::* brings into scope, and the CLI tests run in
a per-test scratch directory removed on drop rather than writing a database
into tests/cli inside the repo. Adds coverage for role rejection, the
duplicate path, --force re-mapping, and the audit row.
2026-09-18 14:35:20 +10:00
Zoran Regvart fcaae650d2 Create OIDC group mappings via CLI
Adds new CLI subcommand `map-group` to create a mapping of OIDC group to
a role.
2026-09-18 14:35:09 +10:00
Dave Kempe ce93fd1feb deps: adopt russh 0.63.1 and uuid 1.25.0
russh 0.63 changed the check_server_key callback to take
&PublicKeyOrCertificate; resolve it to a PublicKey via .public_key() in
both the tunnel and probe handlers, and widen the russh constraint to
0.63. Closes the retargeted dependabot PRs #204 (uuid) and #205 (russh).
2026-08-30 05:48:08 +10:00
Dave Kempe 93c2540afb feat(rdp): configurable keyboard layout (#209)
Adds a per-entry RDP keyboard layout, wiring guacd's `server-layout`
parameter (previously hardcoded to empty) through the address-book entry,
the connect request, and the guacd handshake, with a dropdown in the
connections editor and a migration mapping from Guacamole's `server-layout`
on import. Empty keeps guacd's default (en-us-qwerty).
2026-08-28 15:24:38 +10:00
Dave Kempe a28417a496 fix(credentials): surface My Credentials variables for entries granted at a subfolder
list_credential_variables walked from top-level folders using
check_folder_access + continue, short-circuiting the whole subtree when an
ancestor was not directly accessible. An operator granted access at a
subfolder level saw "No credential variables" in My Credentials even though
the connections tree showed the entry (the tree is descendant-aware). It now
descends past inaccessible ancestors, collecting variables only from folders
the user can directly access. Adds regression tests.
2026-08-28 15:24:26 +10:00
Dave Kempe 7e2cb39690 fix(lint): satisfy clippy 0.1.98
CI's runners updated to clippy 0.1.98, which enforces two lints that
existing code tripped under -D warnings. Rewrite the two late-init
if/else blocks in the SSH tunnel builder as tuple `let` bindings
(needless-late-init), and allow result-large-err on check_folder_access,
whose Err is an axum Response by design.
2026-08-28 15:23:53 +10:00
Dave Kempe 140418ca82 feat(import): add --map to rewrite credential fields on Guacamole import
Maps Apache Guacamole passthrough tokens (${GUAC_USERNAME} etc.) to rustguac
credential variables during import, so imported connections arrive wired to
My Credentials. Repeatable FROM=TO; applied to the username, password,
domain, and private_key fields. Adds tests and docs.
2026-08-11 11:32:26 +10:00
Dave Kempe d8cbf6d35c fix(recording): never rotate in-progress recordings; stop when a delete frees no space
Disk-based rotation deleted the oldest .guac unconditionally until statvfs
usage fell below max_disk_percent. Recordings for live sessions are held
open by the recording tee, so unlinking one frees no space and statvfs never
drops: the loop then deleted every recording in the directory in a single
pass while the disk stayed full (a production host lost all recordings this
way).

rotate() now excludes live-session recordings (SessionManager::
active_recording_paths) and stops if a deletion frees no space. Adds
regression tests.
2026-08-11 11:32:26 +10:00
Dave Kempe f5ccc35cdf feat(vault): per-credential shared/local scope for credential variables
Each per-user credential variable can live in the shared backend (fleet-
wide) or the local backend (this instance) - its location is the scope.
Reads merge both backends (local wins); writes partition by a per-key
scope sent from My Credentials, moving a key between backends when its
toggle changes and honouring "leave blank to keep". A "Shared across
sites" toggle appears per credential.

Quick-start preserved: with one Vault the shared and local cells alias the
default, creds_split() is false, the toggle is hidden, and everything is
written to the single store in one call - byte-identical to before. New
config user_credentials_default_scope (default "local") seeds new toggles.
The write partition is a pure function with unit tests covering single-
store, blank-keeps-existing, scope routing, cross-scope moves, and the
default-scope fallback.
2026-07-29 17:39:54 +10:00
Dave Kempe b8c8f55d9a feat(vault): surface unavailable scopes with a greyed tree node
list_all_folders now returns (folders, unavailable_scopes); a scope whose
dedicated backend is down is reported instead of silently dropped, and
GET /api/addressbook includes unavailable_scopes. The Connections tree
renders a non-interactive greyed "<scope> connections unavailable" node
so a partial outage is visible while other scopes stay usable; the 15s
/api/me poll refreshes it.

Single-Vault is unaffected: every scope aliases the one backend, so
unavailable_scopes is always empty while it is up (full outage still
returns 503 and the existing banner). Adds a test asserting a lone
[vault] block is sufficient (quick-start invariant).
2026-07-29 17:04:05 +10:00
Dave Kempe 93cc3493bb feat(vault): add vault-migrate subcommand for the DR split
Copies an address-book scope subtree (entries + every folder's .config,
so allowed_groups/inherit_from_parent come along) between two configured
backends, resolving each from config + its VAULT_*_SECRET_ID. Because the
scope->path layout is identical in every backend, this is a same-identity
server-to-server copy, not a path rewrite.

Flags: --scope shared|instance, --from/--to (vault|vault_shared|
vault_local), --dry-run, skip-existing by default with --overwrite, and
--users to bulk-copy users/* (documented as "makes them shared"). Exits
non-zero if any copy fails. Adds get/put_user_credentials_by_key and
list_user_keys to VaultClient for the --users path.
2026-07-29 16:14:22 +10:00
Dave Kempe a8a0a6be11 feat(vault): route address-book scopes to per-scope backends
Add optional [vault_shared] and [vault_local] config blocks so the shared
and instance scopes can each be served by a dedicated Vault, with its own
connection, retry loop, and token renewal. A bare [vault] is unchanged:
shared and local alias the default cell, so every scope resolves to the
one connection exactly as before.

VaultBackends gains scope-dispatching wrappers (get_entry, list_entries,
resolve_folder_access, ...) that route by the scope argument, plus a
fan-out list_all_folders across scopes tolerant of a down backend. Folder
listing is split into list_folders_in_scope; a new VaultError::Unavailable
marks a configured-but-unconnected backend. Per-user credential variables
and the LUKS key stay on the default backend for now.

Secret IDs: VAULT_SECRET_ID (default), VAULT_SHARED_SECRET_ID,
VAULT_LOCAL_SECRET_ID.
2026-07-29 15:53:36 +10:00
Dave Kempe 52c929a882 refactor(vault): introduce VaultBackends state (single backend, no behavior change)
Wrap the single Vault connection cell in a VaultBackends struct that will
hold per-scope backends (default/shared/local). Today all three alias one
cell, so behaviour is identical; this isolates the ~20-handler type
plumbing from the upcoming multi-Vault backend split.
2026-07-29 14:19:49 +10:00
Dave Kempe ca0327eee1 fix(import): parse multi-row and multi-line SQL dumps
The Guacamole import parser scanned line by line, so it missed the
multi-row INSERT layout that modern mariadb-dump/mysqldump emit by
default (VALUES on its own line, one tuple per line). Valid dumps
reported "No connections found". Parse whole statements instead, so both
the default multi-line format and single-row dumps import correctly.
String literals, backtick identifiers, and SQL comments are skipped so
embedded semicolons and apostrophes cannot split or corrupt a statement.
2026-07-29 12:45:58 +10:00
Dave Kempe 6cfec88f25 feat(multimon): RDP multi-monitor via the Display Control channel
Adds RDP multi-monitor, reusing the protocol-agnostic client work already used
by SPICE (secondary-monitors advertisement, multimon-layout cropping, pop-out
windows, native-mouse mapping).

guacd (patch 010): a "secondary-monitors" arg enables multi-monitor. The
Display Update module now tracks a per-monitor layout (tiled left-to-right,
top-aligned, widths rounded to RDP-valid geometry) and sends the full
DISPLAY_CONTROL_MONITOR_LAYOUT array via SendMonitorLayout rather than a single
monitor. The host extends its desktop across the monitors and streams one
combined framebuffer, so there is no client-side compositing (unlike SPICE).
guacd advertises secondary-monitors on user join and publishes the
multimon-layout so the client can split the framebuffer into per-monitor
windows.

rustguac: RdpParams gains secondary_monitors, wired from the entry's
max_monitors and sent as the secondary-monitors connect arg (resize-method is
already display-update, which the Display Control channel requires). The
address-book entry editor gains a Monitors field for RDP.

Validated against a Windows RDP host: ticking a second monitor extends the
desktop, the second head renders and resizes, and clicks land on both.
2026-07-28 08:42:23 +10:00
Dave Kempe 24e8aee5d4 feat(multimon): A3 per-monitor windows + entry storage for monitor count
client.html: per-monitor tick-boxes (contiguity-enforced) that open each
secondary monitor in its own window, blitting that monitor's region of the
combined framebuffer (rAF) and forwarding mouse (combined coords) + keyboard;
untick/close releases the monitor. Requests floor to a sane size if a popup
reports zero dimensions.

Entry storage: AddressBookEntry/EntryInfo gain max_monitors, ab_connect_entry
passes it through, and the Proxmox editor gets a Monitors field. Lets a saved
Proxmox/SPICE entry offer multiple monitors (previously only the ad-hoc API
path could).

KNOWN ISSUE: enabling multi-monitor (secondary-monitors>0) intermittently drops
the SPICE connection ~2-6s after connect, independent of the request. Under
investigation; single-monitor is unaffected. A3 end-to-end (tick -> activate ->
render) still needs a real-browser verification (Playwright can't size popups).
2026-07-27 11:52:31 +10:00
Dave Kempe c526c2f00d feat(multimon): A1 receive plumbing for multi-monitor (SPICE)
Client library (Client.js): new onmultimonlayout callback + a multimon-layout
layerPropertyHandler (mirrors multi-touch), and sendSize extended to carry the
optional per-monitor x_position/top_offset.

rustguac: SpiceParams.secondary_monitors + a secondary-monitors connect arg so
guacd advertises the allowed monitor count; CreateSessionRequest.max_monitors
(secondary = max-1) wired through both SPICE branches.

client.html: onargv reads the secondary-monitors count and onmultimonlayout
parses the layout JSON (logging for now; per-monitor windows are A2).

Verified on the canary vs VMID 300: client logs 'server allows 2 monitors' and
receives a multimon-layout. The layout is request-driven (guacd activates a
second guest head only when the client sends a size for monitor 1), so the
second monitor appears in A2.
2026-07-27 11:10:52 +10:00
Dave Kempe 00a8f42572 feat(spice): Proxmox VE console connection type (node auto-detect, split token, SSH tunneling)
Split SPICE into two connection types: "spice" (direct libvirt/QEMU) and
"proxmox" (PVE console brokered via the spiceproxy API). Both produce a guacd
SPICE connection.

- Deliver the SPICE ticket/password as a connect arg instead of a post-connect
  argv stream, so it is set before guacd authenticates. Fixes an auth race that
  produced intermittent "SPICE authentication failed".
- TLS-only SPICE sends an empty plain port so guacd connects via tls-port
  rather than plaintext against a TLS endpoint.
- Proxmox node is optional: resolve it from the VM id via /cluster/resources
  (as the PVE web UI does).
- Split the PVE API token into a visible Token ID (shown in the User column)
  and a masked secret; join them as "id=secret" for the API.
- Surface the PVE response body on non-2xx (safe: only a 2xx spiceproxy
  response carries a ticket), turning opaque 500s into actionable messages.
- SSH tunneling for Proxmox: tunnel both the PVE API call and the spiceproxy
  connection through the jump-host chain in-branch. Also rewrite tls_port
  (not port) for direct-SPICE TLS over a tunnel.
- Store proxmox fields on address book entries; populate Host/User columns;
  orange Proxmox badge. Runtime dep: libspice-client-glib-2.0-8.
2026-07-24 17:42:09 +10:00
Dave Kempe 8c70a23780 feat(spice): Proxmox VE SPICE broker (backend, API-level)
Add a just-in-time Proxmox broker for SPICE consoles. PVE issues one-time,
~30s SPICE tickets via its API, so they cannot be stored; the broker fetches
the config at connect time:
- src/pve.rs: minimal PVE API client. POSTs to
  /api2/json/nodes/{node}/qemu/{vmid}/spiceproxy with an API-token header,
  parses host / proxy / tls-port / password(ticket) / ca / host-subject, and
  unescapes the CA PEM newlines. Never logs the token or ticket, and never
  puts the response body (which carries the ticket) in an error.
- session.rs: CreateSessionRequest spice_pve_* fields (host/node/vmid/token/
  verify_tls); when spice_pve_host is set, the SPICE create_session branch
  calls the broker and maps the result onto SpiceParams (hostname=host, plus
  proxy, tls, tls-port, ca-cert, cert-subject, and the argv ticket).

API-testable now (POST /api/sessions with session_type:spice + spice_pve_*).
Address-book entry storage + a Proxmox UI are the next increment.
2026-07-24 12:59:30 +10:00
Dave Kempe b0c6958b2d feat(spice): add SPICE session type + rustguac plumbing
Wire SPICE as a first-class session type through the rustguac stack,
mirroring the VNC/RDP pattern:
- guacd.rs: SpiceParams + ConnectionParams::Spice + protocol select + arg
  mapping. SPICE credentials (password/username) are streamed to guacd via an
  argv stream after connect (send_argv), since guacd's SPICE client reads them
  from argv, not the connect args. SPICE has no width/height/dpi connect args
  (it sizes via the size instruction).
- session.rs: SessionType::Spice, CreateSessionRequest spice_* fields
  (tls/tls-port/ca-cert/cert-subject/proxy), a SPICE create_session branch,
  and tunnel host/port handling.
- vault.rs/api.rs/import.rs: AddressBookEntry + EntryInfo spice_* fields
  threaded through the connect / quick-connect / import paths.

The tls/ca-cert/cert-subject/proxy fields lay groundwork for brokered Proxmox
VE consoles. No connections.html UI yet (to follow); usable via the API.
2026-07-24 10:04:05 +10:00
Dave Kempe e7c515654e feat(client): per-entry auto-hide for the clipboard/files side tabs
Add an autohide_side_tabs option (Option<bool>, default off) on the address
book entry, threaded through the same path as fullscreen_on_connect
(AddressBookEntry, EntryInfo, CreateSessionRequest, Session, SessionInfo, the
API connect/quick-connect builders, and import defaults). When set, client.html
slides the left-edge Clipboard and Files tabs off screen when idle and brings
them back when the pointer nears the left edge; defaults preserve the current
always-visible behaviour. Checkbox added to the entry editor.
2026-07-20 15:19:35 +10:00
Dave Kempe 8a047c7296 fix(auth): fail closed on unparseable credential expiry
validate_api_key and validate_user_token enforced expires_at only when it
parsed as strict RFC 3339, silently ignoring any other format, so a malformed
value (e.g. "2026-12-31" or the SQLite "YYYY-MM-DD HH:MM:SS" timestamp the DB
itself writes) let the credential authenticate forever. Add parse_expires_at,
which accepts RFC 3339, ISO-without-zone, SQLite datetime and bare dates
(end-of-day UTC), and treat an unparseable value as expired. Reasonable
formats now enforce correctly rather than locking the credential out.
2026-07-20 11:36:59 +10:00
Dave Kempe 48d263b733 fix(api): require ownership for VDI container thumbnails (IDOR)
get_vdi_container_thumbnail served any container's live desktop screenshot
to any authenticated user: it took only the container name and did no
ownership check, and names are the deterministic rustguac-vdi-{user}. Add an
owner-or-admin gate mirroring get_session_thumbnail: a caller may only read a
container derived from their own username (rustguac-vdi-{user}[-{entry}]);
admins may read any. Returns 404 for non-owners so container existence is not
leaked.
2026-07-20 11:28:17 +10:00
pletch 9735cb50d2 feat(rdp): make wallpaper, theming, full-window-drag configurable per entry
These three RDP visual flags were hardcoded off in guacd.rs. Expose them
as per-connection options (Option<bool>, default false) threaded through
the same path as enable_desktop_composition: RdpParams, the session
request, Vault entry + response, the API connect/quick-connect builders,
import defaults, and the connections.html entry editor (Video Performance
section). Defaults preserve existing behavior; VDI sessions stay off.

Cherry-picked from pletch/rustguac@da3cfda
2026-07-12 20:37:20 +10:00
pletch 5446fe9267 perf(net): disable Nagle (TCP_NODELAY) on rustguac's TCP sockets
Both of rustguac's socket hops carry tiny, latency-sensitive writes; under
default settings Nagle coalesces them against delayed-ACK, stalling input
and frame/sync traffic by ~40ms (up to ~200ms):

- rustguac -> guacd (apply_keepalive, covers both connect sites): forwards
  mouse/keyboard input events.
- rustguac -> browser (both TLS and plain listeners): display frames and
  H.264 sync acks. Linux propagates the option to accepted sockets, matching
  how keepalive is already applied here.

socket2 exposes this as set_tcp_nodelay().

Cherry-picked from pletch/rustguac@a11e7a2
2026-07-12 15:37:17 +10:00
Dave Kempe a18a507bcf feat(recordings): paginate lists + show typescript storage path
The recordings page rendered every recording into one table, which got
unwieldy with a large backlog and pushed the SSH Typescripts section far
down the page. Add client-side pagination (50/page, Prev/Next) to both
the recordings and typescripts lists; it composes with the existing
search and sort, and auto-refresh preserves the active filter + page.

Also surface where typescripts live on disk: /api/typescripts now returns
{path, items} (endpoint is new in this release, so no compatibility
break) and the typescript section shows "Stored at <path> on the rustguac
host" — useful since the content is intentionally not downloadable.
No new endpoint.
2026-06-17 22:22:47 +10:00
Dave Kempe 5774586106 feat(ssh): per-connection typescript opt-in + LUKS-at-rest docs (#159)
Typescript recording is now per-connection opt-in, off by default. Adds a
record_typescript flag on the address-book entry (Vault), threaded through
EntryInfo / CreateSessionRequest, and a "Enable typescript recording for
this session" checkbox in the connection editor's Recording Settings (SSH
entries only). The SSH branch records a typescript only when the entry has
opted in AND [recording].typescript_path is configured globally. Ad-hoc
SSH sessions (no entry) never record.

Docs: document the per-connection opt-in, and add a LUKS-at-rest recipe
(point typescript_path at a subdir of the LUKS-encrypted drive volume
rustguac already mounts) as the recommended way to encrypt typescripts at
rest with no extra infrastructure.
2026-06-17 21:52:23 +10:00
Dave Kempe d4999a1865 feat(recordings): list-only typescript register (#159)
Add GET /api/typescripts (poweruser+) and an "SSH Typescripts" section on
the recordings page. List-only by design: it shows that a session was
recorded (name, size, time) but never serves or downloads the content.

Typescripts capture full terminal output, which can include passwords
typed at prompts or secrets printed to screen, so exposing the text via
the web UI would widen its blast radius. A poweruser gets accountability
(a session was recorded) while retrieving the actual log still requires
direct access to the rustguac host or storage. There is deliberately no
serve or delete endpoint, hence no name parameter and no path-traversal
surface. The .timing sidecar is filtered out so one row == one session.
2026-06-17 21:13:53 +10:00
Dave Kempe 3e1dcccb83 feat(ssh): typescript recording passthrough (#159)
Expose guacd's SSH typescript recording via a [recording] config block:
typescript_path / typescript_name / create_typescript_path. When
typescript_path is set, guacd writes a plain-text log of the full
terminal session (scriptreplay-compatible, greppable) for every SSH
session. Aimed at audit/compliance on network gear.

guacd does not template the typescript filename (it uses the name
verbatim and only appends a numeric suffix to avoid clobbering), so
rustguac expands its own brace tokens before passing the name on:
{user} {connection} {host} {date} {time} {session}. Substituted values
are sanitised to [A-Za-z0-9_-], so OIDC emails and free-text entry
names can't produce path separators or traversal. Default template is
{connection}-{user}-{date}-{time} for identifiable audit filenames.

recording-include-keys (keystroke logging in guacd's graphical
recording, for guaclog) is intentionally not wired up: rustguac records
the proxied stream itself rather than driving guacd-side graphical
recording, so that flag would be a no-op. The typescript is the
supported text-audit path.

Docs in configuration.md. 5 unit tests covering token expansion,
the default template, sanitisation/traversal, empty-value fallback,
and unknown tokens.

Closes #159.
2026-06-17 21:00:33 +10:00
Dave Kempe bd1915fe4f feat(client): per-entry fullscreen-on-connect (#154)
Per-entry boolean fullscreen_on_connect flag. When set, the client enters
browser fullscreen on the first user gesture after CONNECTED and locks
the Escape key (Chromium navigator.keyboard.lock API) so it reaches the
remote session instead of exiting fullscreen. Firefox / Safari fall back
to standard fullscreen with a one-time toast explaining Esc will exit.

A small floating "Fullscreen" toggle in the top-right corner lets any
user enter fullscreen at any time once the session is connected. In
fullscreen mode a thin top bar shows the entry name plus Exit and
Disconnect buttons.

Closes #154.
2026-06-03 15:19:57 +10:00
Dave Kempe a57581ceef feat(themes): load themes from static/themes/*.toml at runtime
Themes were a Vec hardcoded in src/config.rs (builtin_presets()) -
every new preset required editing Rust, recompiling, and shipping a
new release, for what is purely presentation data. PR #148 from
@dav0l surfaced this nicely by failing to compile on a brace count
in the array.

This change adds a config::load_themes(&static_path) loader that
starts from the eight built-in presets (unchanged) and then merges
in any *.toml files from <static_path>/themes/. Disk themes can
add new entries or override a built-in by using the same name; the
existing builtin_presets() remains as the always-available fallback
when the themes directory is missing or empty.

File format: flat TOML table, one file per theme, filename (minus
extension) is the theme id. See static/themes/catppuccin-macchiato.toml
for a full example. Theme names are validated against the same
allowlist we use for Vault entry names ([a-zA-Z0-9_-]{1,64}) so they
are safe to render in the UI picker and in log lines, and can't be
used for path traversal or homoglyph mischief via crafted filenames.

dav0l's Catppuccin Macchiato palette lands here as
static/themes/catppuccin-macchiato.toml - their submission is the
first user-contributed theme under the new mechanism. Closes #148.

Backward compatibility: explicit. ThemeConfig::resolve() is now a
thin wrapper over resolve_with(builtins), so existing test callers
and any production callers see no behavioural change. Existing
[theme] sections in user config.toml files - preset only, preset +
overrides, overrides only, empty section, typo'd preset - all
resolve byte-equal to 1.7.0 (verified by the new
existing_user_config_with_theme_section_keeps_working_after_upgrade
test). Eight new tests in total cover the loader, the override
behaviour, the filename validation, and the upgrade scenario.

Docs broken out: themes get docs/themes.md (the full reference);
docs/configuration.md is trimmed to a brief stub and pointer.

No build-system changes needed - debian/rules, install.sh and the
Dockerfile all use recursive `cp -r static/` so the new themes
subdirectory is picked up automatically.
2026-05-29 10:29:13 +10:00
Dave Kempe 89129a54b5 fix(connections): hide subfolders the user cannot access
Closes #147.

ab_list_subfolders checked access to the parent folder but then returned
every subfolder unfiltered, so a user who could open a parent saw all of
its children regardless of per-child group ACLs - clicking one they
weren't entitled to gave "no access". (Top-level folders were already
filtered; this was the subfolder gap.)

Subfolders are now filtered per child. A folder is shown if the user can
access it directly OR can access any descendant of it, so a deeper grant
(child with its own allowed_groups and inherit_from_parent=false under a
denied folder) is never orphaned out of the tree. Admins still see all.

New folder_or_descendant_accessible helper does the recursive (boxed
async) OR over resolve_folder_access, short-circuiting on the first
accessible folder. No Vault mock harness exists to unit-test the
Vault-backed path; verified via build + the logic being a thin recursive
wrapper over the already-shipping resolve_folder_access.

docs/roles-and-access-control.md gains explicit notes that inaccessible
folders are hidden (not shown-then-denied) at every level, the
descendant-visibility rule, and how inheritance interacts.
2026-05-28 06:43:43 +10:00
Lindsay Harvey 14c2b081e4 fix(vdi): include entry name in container names to avoid collisions (#138)
Contributed by @vk2amv (Lindsay). Fixes VDI container name collisions when a user has multiple VDI entries that resolve to the same in-container username. Container names now include the sanitized final segment of the address book entry key: rustguac-vdi-{username}-{entry}. Same user + same entry still reuses one container (reconnect works); same user + different entries now run independently.

Thanks Lindsay.
2026-05-27 20:48:24 +10:00
Lindsay Harvey 66bc3722fc feat(vdi): host port range + container lifecycle hooks (#137)
Contributed by @vk2amv (Lindsay). Adds two related operator features to the Docker VDI driver:

1. Bounded host port range via [vdi] port_range_start / port_range_end. Currently Docker picks an arbitrary high port for the container's RDP listener; this lets operators constrain it which matters for firewalls, reverse proxies, and identity-aware gates that need to know in advance which ports rustguac will use. Port selection inside the range is deterministic-from-username (FNV-1a hash), so reconnects from the same user get the same port. Falls through to the next port on collision.

2. Container lifecycle hook script via [vdi] container_hook_script. Called as 'up <port> <container_id> <container_name>' after Docker assigns the port, again as 'down ...' before removal. Lets deployments wire external preparation/cleanup (firewall opens, service mesh registration, identity-aware gates) without baking the logic into rustguac itself. Bounded timeout (default 10s). Script is invoked via Command::new (no shell), so no injection risk from container metadata.

3 new tests covering port-candidate behaviour. Docs in docs/configuration.md and docs/vdi.md.

Thanks Lindsay.
2026-05-20 18:52:22 +10:00
Dave Kempe 3a37cb39d9 feat(vdi): per-entry container username/password override
Closes #132.

VDI containers come in two patterns: ones whose entrypoint reads
VDI_USERNAME / VDI_PASSWORD env vars and provisions an account
matching them (the contrib/vdi-test-image style), and ones with a
baked-in fixed account that ignore those env vars. Pre-change, only
the first worked; users with baked-image containers had to log in
manually inside the session because rustguac's auto-derived RDP
credentials never matched the container's actual account.

  - AddressBookEntry gains optional container_username and
    container_password fields, persisted to Vault.
  - When set, session.rs uses those values for the RDP connect into
    the container instead of deriving the username from the
    operator's identity and generating a per-connect password.
  - VDI_USERNAME / VDI_PASSWORD env vars are still injected with the
    resolved values - images that read them get consistent state;
    images that ignore them keep using their baked-in account.
  - The container name derives from the resolved username, so an
    entry with a fixed container_username produces a container
    shared by all operators connecting through that entry. Documented.
  - EntryInfo exposes container_username back to the editor;
    container_password is never serialised to clients (has_container_password
    boolean indicates whether one is stored).
  - The entry update handler preserves container_password when not
    supplied on update (same pattern as password / private_key).
  - Both fields go through entry_credential_variables and
    resolve_credential_variables, so the actual values can be sourced
    from each operator's saved credential variables ($corp_username etc).
  - Connections UI gains the two fields with explanatory text linking
    out to the VDI docs and credential variables docs.
  - docs/vdi.md restructured around the two patterns (Pattern A:
    env-var driven, Pattern B: baked-in account) with the
    container-sharing note for Pattern B.
  - docs/credential-variables.md gains an explicit "where variables
    can be used" table covering the new fields.

Subtle side fix: env merge in session.rs used to call
env.entry(K).or_insert(V), which meant a user-supplied
VDI_USERNAME in container_env would silently win over the
auto-derived one - opposite of the documented intent
("Don't let user-provided env override the core VDI vars").
Switched to env.insert() so the resolved values always win.
2026-05-18 20:21:41 +10:00
Dave Kempe 6f3a4a6ff8 fix(vdi): surface container_env/cpu/memory in entry list response
Closes #131.

The EntryInfo struct returned by GET /api/addressbook/folders/.../entries
only included container_image and container_idle_timeout_mins among the
VDI fields. container_env, container_cpu_limit, and container_memory_limit
were persisted to Vault correctly but never serialised back to the client,
so the connections UI saw an empty editor for those fields on edit even
though the Vault payload was intact.

The connections UI populates the entry editor from the list endpoint's
response (currentEntries), so missing fields there appear as "blanked
after save" to the operator.

Added the three fields to EntryInfo and copied them through the
From<(&str, &AddressBookEntry)> impl. No backend behaviour change; only
the read-side exposure to the UI.
2026-05-18 20:21:41 +10:00
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