134 Commits

Author SHA1 Message Date
Dave Kempe bdcf58dd1c docker: let rustguac decide first run, so db_path can move (#240)
The entrypoint created the docker-admin key whenever no file existed at
/opt/rustguac/data/rustguac.db. With db_path set anywhere else, for
example on a Kubernetes persistent volume, that file never exists, so
every container start tried to create the admin again and failed with
"UNIQUE constraint failed: admins.name".

add-admin gains --if-none: create the admin only when no admin account
exists, otherwise say so and exit successfully. rustguac opens the
database db_path names, so the entrypoint no longer needs to know where
it is. Any existing admin counts, so a docker-admin an operator removed
after creating their own does not come back. The save-the-key banner is
printed only when a key was actually created.

Reported by @hit-rich.
2026-10-06 10:41:39 +11:00
Dave Kempe 44d17d7ca1 connections: move and clone entries server side, credentials included
Moving an entry to another folder was done in the browser as "create
a copy from the edit form, then delete the original". The page never
receives an entry's secrets, so the copy had no password, private key,
jump host credentials, Proxmox token or container password, and a
$variable reference stored in the password field was lost the same way.
The original was then deleted permanently. Clone had the same flaw.
(#232)

Two admin-only endpoints now do it from the stored entry:

- POST .../entries/{entry}/move copies the full entry to the target and
  only then removes the original. If that removal fails it says the
  entry was copied, rather than reporting a plain failure.
- POST .../entries/{entry}/copy copies it under a new name. Clone uses
  it and then applies the form's edits as an ordinary update, which
  keeps stored secrets it is not given.

The target is written with Vault check-and-set (cas 0), so an existing
entry is never overwritten, even by a concurrent writer. Scope, folder
and names go through the existing Vault validation (only shared and
instance scopes, no traversal, .config reserved, so a copy cannot
replace a folder's access settings), and those failures are 400s. Both
actions are audited, headline only. A note on the shared helper records
that any future per-folder editing (#231) must require rights on both
the source and the target folder.
2026-10-03 16:26:42 +10:00
Dave Kempe d7e4349d0b style: rustfmt the asset versioning picked from the fork 2026-10-03 12:00:59 +10:00
pletch e1cc4a9887 fix(http): let the browser find out a page has been rebuilt
The branded HTML handlers returned `Html(...)` and nothing else: no ETag,
no Last-Modified, no Cache-Control. With no freshness information at all
Chrome caches heuristically and does not revalidate, so a restart served
the old page for hours -- incognito was the only reliable way to see a
change. The static assets had the same gap from the other side: ServeDir
already sent ETag and Last-Modified and honoured both, it was just never
asked, so a rebuilt /guac/*.js was invisible too.

The pages are branded once at startup into a map, so the tag is computed
there as well -- one sha256 over each, against a rewrite already walking
the same strings. A request is then a string compare, and an unchanged
page costs a 304 rather than 136K of client.html or 255K of
connections.html. `no-cache` is the revalidate directive, not
do-not-store: the browser keeps the body and asks. Tags are
content-derived, not boot-derived, so a restart that changes nothing
still answers 304.

Revalidating every asset is correct but not free, and this is reached
over a WAN. The same startup pass therefore rewrites the 36 script tags
and the stylesheet to carry a hash of the bytes on disk, and a URL
bearing one is served with a year and `immutable`. The round trip it
saves is a single one, since the browser-to-proxy leg is HTTP/2 and the
conditional requests go out together -- but client.html reloads on every
session launch and relaunch, so it is a round trip paid exactly when the
link is worst. Only a 2xx or 304 earns the long directive: a 404 under a
versioned URL is a deployment that has gone wrong, and pinning it in the
cache until next year would make it outlive its cause.

Query strings rather than hashed filenames because there is no build step
to rename anything and the rewrite already existed; the old caution about
proxies declining to cache them has not been true in many years.

The cost is that editing an asset now needs a restart where a reload was
enough, since the URL and the bytes it names are produced together --
hence RUSTGUAC_NO_ASSET_VERSIONING, which leaves everything revalidating.

Verified against a running instance: matching and weak If-None-Match give
304 on the HTML, a stale tag gives 200, a versioned asset is immutable on
both its 200 and its 304, a bare one and a 404 are not, the kill switch
emits bare URLs, and API responses are untouched -- the middleware is on
the ServeDir fallback only.

(cherry picked from commit 966aba765c8a8db043b5f62603abdb6a18dd0eb5)
2026-10-03 11:57:45 +10:00
pletch 73e0b6ddf2 fix: reports in local time, jump-host tunnels, and resizes that change no pixels
Unrelated fixes carried by the fork: session times and date filters in local
time, no local resolution of the target when a jump host is set, no resize
request that changes no pixels, and rustfmt on three hunks left unformatted.

(cherry picked from commit d9b361db6eb216d871f3fdac41c423a586e2d559)

Upstream changes on pick: the client.html resize skip is kept but
without the native-resolution sizing (displayScale/deviceSize), which
belongs to the HiDPI commit not taken here, so sizes stay in CSS pixels.
The oidc.rs hunk was formatting only and is superseded.
2026-10-03 11:57:23 +10:00
pletch 84ac7180d9 feat(oidc): lazy provider discovery with retry, and callback diagnostics
Provider discovery is lazy with retry, so SSO recovers on its own once the
provider comes up, and /api/auth/status reports availability separately from
enablement. Includes the callback diagnostics for state-cookie mismatch.

(cherry picked from commit dffe34660601a223021819c8efbed2ad503bfc14)

Upstream changes on pick: resolved against the ID token diagnostics from
#237 (the provider key ids now travel with the cached client and are
logged on rediscovery), and the unauthenticated /api/auth/status warm-up
is single-flight via OidcState::warm_in_background, so polling it while
the provider is down cannot queue up unbounded discovery tasks.
2026-10-03 11:56:10 +10:00
Dave Kempe 45df2bac70 session: keep Wake-on-LAN to connection entries and bound it
guacd sends the magic packet to whatever broadcast address and UDP port
it is given and then waits the given time before connecting. On an
ad-hoc request from a non-admin those came from the caller, so a
poweruser could have guacd send UDP to an arbitrary address and port,
or hold a guacd process for as long as they liked. Wake-on-LAN is now
ignored on those requests (it is configured on connection entries), the
wait is capped at 10 minutes, and the SSH font size is clamped to 6-72.
2026-10-03 10:13:36 +10:00
pletch 9b3b11fd09 feat(connections): Wake-on-LAN, configurable SSH font + HiDPI fix, onboarding close button
Wake-on-LAN (per entry): new wol_send_packet/wol_mac_addr/wol_broadcast_addr/
wol_udp_port/wol_wait_time fields on AddressBookEntry, threaded through
CreateSessionRequest into the SSH/VNC/RDP guacd param structs and emitted as
guacd's native wol-* connection args (guacd sends the magic packet and polls the
target before connecting). New 'Wake-on-LAN' section in the entry editor;
Guacamole imports carry existing wol-* params over.

SSH terminal font: new per-entry ssh_font_size (default 12), replacing the
hardcoded font-size. SSH DPI is now pinned to 96 so terminal text no longer
renders oversized on HiDPI displays — the client auto-scales the canvas, so a
fixed baseline keeps text size consistent across normal and HiDPI screens.
(RDP/VNC keep the device-scaled DPI.)

Onboarding modal: add a corner close button that permanently dismisses the
welcome tour (re-show still available via Settings -> Welcome Tour).

(cherry picked from commit e881bbc1e8933b46f7f2a8de691c86343dbbf15b)
2026-10-03 10:12:32 +10:00
Dave Kempe 0f91b27799 oidc: log why ID token verification failed
openidconnect reports every signature problem as "Signature
verification failed" and keeps the reason (no matching key, crypto
error, disallowed algorithm) in the error's source chain, which we
logged with Display and so dropped. Log the whole chain, plus the
token's alg and kid from its header and whether that kid is among the
provider keys fetched at startup. When it is not, say so and name the
two usual causes: a key rotation since startup, and an Entra app with
an app-specific signing key (#237).
2026-10-03 10:05:44 +10:00
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