Commit Graph

92 Commits

Author SHA1 Message Date
Jad Seifeddine 772f215660 sessions: one policy for every ad-hoc session from a non-admin
clear_entry_only_fields() missed several fields an ad-hoc caller below
admin could still set, and GET /api/connect's ad-hoc mode applied none
of the checks. apply_adhoc_policy() now runs on both ad-hoc routes. For
callers below admin it fences every target, drops the fields only a
saved entry may set (now also the VDI account, the Kerberos cache path,
sharing and Wake-on-LAN), lets recording be turned on but never off when
it is enabled site-wide, stops the drive being turned on when [drive]
leaves it off, and caps VDI CPU, memory and idle timeout at the [vdi]
defaults. Admins and saved entries are unchanged. Unit tests cover each
rule.
2026-10-19 13:22:57 +11:00
Dave Kempe 8df2f254eb vault: JWT token_command, JWT login in the CLI tools, checked settings
On top of the workload JWT login from #285:

- token_command: a program and its arguments (run directly, never
  through a shell) that prints the JWT, run again at every login, with a
  timeout. For issuers that hand out a token on request, such as a cloud
  metadata service. Its output never reaches an error or the log.
- import-guacamole and vault-migrate log in with jwt_auth too; they
  insisted on VAULT_SECRET_ID. One helper, VaultConfig::login_secret,
  now decides the secret for the server and both commands.
- jwt_auth is checked before any login: the mount goes into the login
  URL, so plain path segments only, and exactly one of path and
  token_command.
- Docs: the jwt_auth keys, and setting it up with Nomad workload
  identity, SPIRE (spiffe-helper), and cloud VM identity tokens, with a
  note on when JWT login really removes the stored secret.
2026-10-18 13:23:14 +11:00
Christopher Svensson df5afd9742 Add Vault and OpenBao workload JWT authentication
(cherry picked from commit 5c453f708d)
2026-10-18 13:23:14 +11:00
Dave Kempe 2dec42d019 recordings: download as MP4, converted on request with guacenc (#241)
A .guac recording only plays in rustguac's own player. The Recordings
page now has an mp4 button per recording: it starts a conversion,
shows progress, and downloads the video when ready. The file is a
standard MP4 that plays in VLC and browsers.

- POST /api/recordings/:name/mp4 starts it (202 running, 200 ready,
  409 while the session is live, 503 without guacenc),
  GET .../mp4/status reports it, GET .../mp4 downloads it. Same access
  and name checks as the recording download (poweruser or admin).
- guacenc writes <input>.m4v beside its input, so each job links the
  recording into a work directory inside the cache and moves the
  finished file to <cache>/<stem>.mp4; the recording directory is never
  written. guacenc exits 0 even on failure, so success is the output
  existing and not being empty. Runs with a timeout, killed on timeout.
- At most mp4_max_jobs at once (default 1). The video is the
  recording's own size (its largest layer 0 size), scaled down to fit
  1920x1080 and rounded to even.
- Cached MP4s go after mp4_cache_hours (24), when their recording goes,
  and all of them before rotation removes any recording for disk space.
  Leftover work directories are cleared at startup and hourly.
- The listing carries each recording's mp4 state only when conversion
  is available, so the button is hidden on a build without guacenc.

Config: mp4_enabled, guacenc_path, mp4_cache_path, mp4_cache_hours,
mp4_max_jobs, mp4_timeout_secs, mp4_bitrate, mp4_max_width/height under
[recording]. Docs in configuration.md (MP4 downloads, including
converting by hand) and api.md, which also had the recordings role as
operator; it is poweruser.

Tests: the converter (cache layout, failure leaves nothing, live and
missing refused, purge, size parsing and fitting) with a stand-in
guacenc, the shared access check, and a browser spec for the button.
2026-10-18 13:23:14 +11:00
Dave Kempe 2fa9877d85 oidc: bind users to their provider subject, match email ignoring case
Users are found by email, and with auto_create_users = false the email
becomes the gate. An email alone is not proof of identity, since some
providers let people set it, so a user is now bound to the OIDC subject
of their first login and a login with the same email but a different
subject is refused (and logged). A login the provider explicitly marks
email_verified: false is refused too.

New CLI-only command reset-oidc-subject releases the binding, for moving
to a different identity provider or a recreated provider account; it is
written to the CLI audit log.

Emails match ignoring case, so an admin typing JSmith@Example.com no
longer locks out a provider that sends jsmith@example.com, nor creates a
second account with auto-create on; a user cannot be added twice in
different case. add-user and POST /api/users check the email and name,
and create_user reports "already exists" as a type instead of matching
"UNIQUE" in the error text.

Tests for the login rules, and CLI integration tests for add-user and
reset-oidc-subject including their audit rows. Docs: how a login is
matched, moving providers, POST /api/users, the CLI commands, and the
login page message (a redirect, not a 403).
2026-10-18 13:23:14 +11:00
Jarosław Dutka c25ee93b16 oidc: add option to disable automatic account creation
With `[oidc] auto_create_users = false`, OIDC login no longer creates
an account for a user who isn't in the database yet. Only users an
admin has added beforehand can log in. The option defaults to true, so
existing deployments behave as before.

An admin can add users ahead of their first login with any of:
- CLI: `rustguac add-user --email <email> --role <role> [--name <name>]`
- API: `POST /api/users` (admin only), returns 201, or 409 if the user exists
- Admin page: an "Add User" form under the users table

A user added this way keeps the role the admin gave them; default_role
is not applied. Group-to-role mappings still apply on every login.

A rejected SSO login now redirects to the login page with an error code
instead of returning JSON. This covers a user with no account
(sso_error=no_account) and a disabled account (sso_error=disabled).
SSO errors are now shown under the SSO button: they used to go into the
API key form, which is hidden when SSO is on, so "SSO login failed" was
never visible. The "provider unavailable" notice uses the same element,
and all SSO messages are defined in one place in index.html.

(cherry picked from commit 9bfdc52155)
2026-10-18 13:23:14 +11:00
Dave Kempe 279da6f17f pages: deny all framing, except the client of a fullscreen-on-connect entry
#281 relaxed framing to same-origin on every page. Keep DENY and
frame-ancestors 'none' everywhere, and send SAMEORIGIN with
frame-ancestors 'self' only on the client page of a session opened from
a saved entry with fullscreen on connect turned on, the one case the
Connections page frames it. An ad-hoc session cannot opt itself in, and
an unknown or ended session is not frameable. Tested end to end through
the header middleware.

docs: a framing and clickjacking section in security.md with the
approaches weighed, the corrected CSP row, and a note in
reverse-proxies.md to give rustguac its own hostname.
2026-10-17 13:46:27 +11:00
Dave Kempe b18dc3a158 folders: saving a folder without display_name keeps it
A folder PUT that left display_name out wiped the stored title, so any
client or script that predates the field erased it by saving the
folder. Treat it like allowed_users: omitted keeps what is stored, ""
clears it. Document the field on create and update.
2026-10-17 12:27:15 +11:00
Dave Kempe 13d07805ba connections: version poll names only folders the caller can open, epoch, moves
Three fixes on top of the live refresh:

- Folder names. GET /api/addressbook/version returned every changed
  folder to any operator, so one granted only teamA saw paths in folders
  they cannot open as soon as an admin or a webhook wrote there. For
  callers below admin an entries row is now kept only for a folder they
  can open directly (the check_folder_access rule), and tree rows lose
  their path: a folder that came, went or changed access may be one they
  cannot see. The page treats a path-less tree row like `all`. Folder
  config is read only when there are rows to answer, so an empty poll
  still costs no Vault read, and a burst of more than 32 rows is answered
  with one path-less row instead of a check per folder.
- Restarts. The version counter starts again at 0, so a tab that saw v5
  and polled after the new process reached v5 or more was answered from a
  different log and could miss changes. The answer now carries a random
  per-process epoch; a tab that sees it change refreshes what it shows.
  The docs no longer claim a restart always answers null.
- Moves. A PUT ...?move=true that moved an entry logged only the target
  folder, so a tab showing the source kept the moved entry. It now
  reports both, and so does a move that copied the entry but could not
  remove the original (502), for this path and POST .../move: the tracker
  records a failed request when its handler reports what it changed.
2026-10-17 10:00:22 +11:00
Jad Seifeddine f77e0d43c2 connections: live refresh driven by an address-book change counter
connections: live refresh from a per-folder address-book change counter

The Connections page keeps itself current without reloading. rustguac
keeps an in-memory change log of the address book: every successful
write through its API (entries, folders, folder config, moves, copies,
bulk) is logged against the folders it touched under a version counter.
The page polls GET /api/addressbook/version?since=N every 3 seconds
while visible and gets back only what changed after the version it last
saw: one row per folder, saying whether its entries or the folder itself
moved. The poll is answered from memory; it reads nothing from Vault.

A tab acts only on what it shows. Entries changed in the open folder:
that folder is re-read. Entries changed elsewhere: the cached copy is
forgotten and nothing is read until the folder is opened. A folder
created, deleted or reconfigured: the folder list is re-read (no
entries), plus the open folder if it was in that subtree. The search
index is never rebuilt on a poll: it is marked stale and rebuilt the
next time the box is focused or typed in; while results are showing,
only the changed folder's rows are re-read and patched in. So one
change costs a tab at most one folder's entries plus one folder-list
listing, never the whole book, however many tabs are open. There is no
timed refresh. A null answer (first poll, restart, or a tab further
behind than the 512-entry log reaches) refreshes what is shown once,
under the same bound.

Folders are attributed from the request path; the four routes whose
folders only the body knows (create folder, move, copy, bulk) report
them from the handler through a response extension, and a handler that
reports nothing is logged as `all` so a page refreshes rather than
misses it. A user's own credential variables (/api/me/credentials) are
not address-book changes and never reach the log: the saving tab
reloads its own status, and no other tab needs to know.

Hidden tabs do not poll and catch up with an immediate check when
shown; a tab with a dialog open does not poll either, so the change
waits for the dialog instead of being missed.

Tests: the attribution table, the log's since/dedup/eviction/restart
answers, the middleware on a router (a credentials save logs nothing, a
failed write logs nothing, reported folders are logged precisely), and
a plain-node test pinning what the page re-reads for each change.

Closes #253 (part 3/3).

(cherry picked from commit da82239076)
2026-10-17 10:00:22 +11:00
Dave Kempe b7596beffe folder defaults: stop at folders shared with anyone extra
Folder access is a union, so a subfolder that inherits from its parent
but also grants groups or users of its own admits people the parent
doesn't. The defaults walk only stopped at inherit_from_parent: false,
so a contractor added to corp/partner through allowed_users could
connect with corp's default key. A parent's defaults now flow only into
a subfolder that inherits and grants nobody of its own
(FolderConfig::shares_parent_audience), tested.

Also:
- an entry set to prompt is no longer listed as using folder credentials
- PUT .../defaults on a folder that doesn't exist is a 404, rather than
  writing a .defaults that makes an admin-only phantom folder
- the folder dialog takes a domain for RDP defaults
- docs: the new boundary, and that a $variable in a default resolves per
  user
2026-10-17 09:53:48 +11:00
Jad Seifeddine ccd2badafe connections: folder default credentials for entries without their own
A folder can lend a username, password, private key and domain per session
type to entries that have no password or private key of their own, so an
entry created without secrets (a NetBox webhook, a bulk import) connects the
moment it exists. The defaults live at `<folder>/.defaults` beside
`.config`, are reserved like it (never listed, not a valid entry name) and
are removed with the folder. `GET/PUT/DELETE .../defaults` manage them
(secrets write-only, audited as update_folder_defaults /
delete_folder_defaults) and the folder dialog gains a Default credentials
section. Listings report such entries as `has_credentials: true` with
`inherited_credentials: true`. An entry's own credentials always win and an
entry set to prompt still prompts.

A subfolder without its own `.defaults` takes its nearest ancestor's, per
type, but the walk follows the access rule: it only continues past a folder
whose `.config` has `inherit_from_parent: true`. That flag is how a folder
is isolated for access, so a key set on a parent must not reach a subfolder
shared with someone else through `allowed_users`. The isolated folder's own
`.defaults` still apply to its entries. A folder with no `.config`, or one
written before the flag existed, counts as not inheriting, exactly as
`resolve_folder_access` treats it. The walk is `resolve_effective_defaults`
over a small `DefaultsSource` trait so it is tested without a Vault.

Both connect paths resolve defaults through one helper,
`lend_folder_defaults`: the Connections endpoint
(`POST .../entries/:entry/connect`) and `/api/connect` quick links, so an
entry that works from the page works from a link. `rustguac vault-migrate`
copies each folder's `.defaults` alongside its `.config`, so a DR split keeps
them (the dry run lists them as `[defaults]`).

Docs cover the storage layout, the inheritance rule and its boundary, the
migrate behaviour, and the warning that a web entry using a default
password has that password typed into a page the user can read.

Part 2/3 of #253.

(cherry picked from commit cc01469b07)
2026-10-17 09:50:49 +11:00
Dave Kempe b4ef3920a2 docs: NetBox sync, per-tenant folders, upgrading from POST, delete webhook
Cover what the ?move=true upsert changes for existing NetBox setups: the
409 when a hand-made entry would adopt an id already elsewhere, creating
tenant folders before syncing into them, how entries from the old POST
webhook take their external_id, the first-sight scan, and using the same
computed folder in the delete webhook.
2026-10-17 07:59:00 +11:00
Jad Seifeddine 399bde2a2a connections: ?move=true moves the matched NetBox entry with its credentials
PUT /api/addressbook/folders/{scope}/{folder}/entries/{entry}?move=true
is for sync sources such as the NetBox webhook, where the folder (one
per tenant) or the name is computed from the device and can change.
Entries get a stored external_id (the webhook sends netbox:device:<id>)
and the flag matches the device on that, never on its name:

- already at folder/entry with that id, or with no id (an entry made by
  hand before syncing began): updated in place;
- stored elsewhere in the scope under that id: moved to folder/entry by
  the same server-side move as POST .../move (full entry copied, then
  the original removed), so its password, key, jump host credentials
  and credential variable references come along, and the update is
  applied on top;
- stored nowhere: created.

Same-named entries in other folders are never touched: two tenants can
each have a fw01, and tenant A's webhook must not delete tenant B's. A
name held by a different device in the target folder, or an id stored
on more than one entry, is a 409 and nothing is changed. Without the
flag the PUT is the plain name upsert it always was.

The merge keeps every secret the request omits (the edit form never
receives them, a sync source never has them) and now keeps external_id
the same way, so a form edit does not cut a synced entry loose. A
failed read of the existing entry is a 502 rather than a blind write,
so a payload without secrets can no longer replace an entry that has
them.

Compared with the first version of this change: nothing is deleted by
name and there is no name matching across folders; the match is on the
stored NetBox id, and a moved entry goes through the existing
copy-then-delete path, so no credential is lost (#232).

The locate / move / merge sequence sits behind a small EntryStore
trait with an in-memory store in the tests, which cover: the same name
in two folders is untouched, a move carries credentials, a rename
within the folder, an update without secrets keeps them, an untagged
entry is adopted in place, a missing id is refused, a name clash and an
ambiguous id are refused with nothing changed, and an unfinished move
is reported as such.

Part 1/3 of #253.

(cherry picked from commit 353882b246)
2026-10-17 06:36:14 +11:00
Jad Seifeddine cb425ee92c csp: allow data: and blob: images so Safari and Firefox can draw
The Content-Security-Policy had no img-src, so default-src 'self'
governed images and refused the data: URIs the Guacamole client draws
every tile from on browsers without WebCodecs ImageDecoder. Safari and
Firefox rendered SSH and RDP sessions black with only rect fills and the
cursor cell showing; Chrome decodes straight from the stream and never
fetches a URL, which hid it. The same directive blocks the remote mouse
cursor (cursor: url(data:...)) and blob: image URLs.

Move the policy into a documented constant, add img-src 'self' data:
blob:, and add a unit test that fails if img-src loses either source.
Document the proxy-side header replacement for releases up to v1.10.5.

Fixes #249

(cherry picked from commit 04597c20cd)
2026-10-12 19:29:49 +11:00
Jarosław Dutka 2773d0cad3 vnc: add optional username for servers with username auth
VeNCrypt, RealVNC and macOS Screen Sharing need a username as well as a
password. Pass it to guacd's VNC `username` arg and expose it in the
connection editor, the connect-time credentials prompt and ad-hoc
sessions.

(cherry picked from commit fac8d0aec5)
2026-10-09 13:47:23 +11:00
Dave Kempe b3ffbe2488 connections: share folders with individual users (#243)
Folder access was by OIDC group only, so giving one person one VM meant
a group per person. Folders now also take allowed_users, a list of
login emails, next to allowed_groups. A user can open a folder if a
group matches or their email is listed (ignoring case). They need not
have logged in yet: access is decided from their identity when they
use rustguac. The list lives in the folder's config in Vault, so it
applies on every instance sharing that Vault.

Every folder access decision goes through resolve_folder_access, which
now takes the user (email and groups) rather than just groups, so the
compiler enforced the change at every call site: the tree, listings,
search, credential variables and connect.

The tree now also shows a folder the user cannot open when it leads to
one they can, at the top level and in subfolder listings, as the code
already intended one level down. Without that, a grant on users/jsmith
was unreachable from the tree when users itself was admin-only (this
also applied to group grants). Such a folder is listed without entries,
and opening it says it has nothing shared with you directly.

The allowed_users list is only returned to admins (folder config);
listings that operators read leave it out, so a user does not learn who
else has access. Saving a folder without the field keeps the stored
list, so older clients cannot wipe it. Entries are trimmed, repeats
dropped, and anything with spaces or control characters refused, up to
500 per folder. The folder dialog gains an "Allowed users" box, and the
bulk move dialog's access warning compares users as well as groups.

Docs: folder access rewritten, including the correction that a folder
with no allowed groups is admin-only (the docs said it was open to
every authenticated user), and the API reference.

Requested by @3zAlb.
2026-10-09 11:58:01 +11:00
Dave Kempe 7c09e9a804 docs: document ssh_font_size and the Wake-on-LAN session fields 2026-10-09 07:39:41 +11:00
Dave Kempe 254b4595d6 connections: select entries and move, copy or delete them together
Re-filing more than a handful of entries meant opening each one. Admins
now get a checkbox column on the entries table (select all, shift-click
for ranges) and an action bar with Move to, Copy to and Delete. One
dialog handles all three: a filterable list of every folder, a preview
naming any entries that already exist in the target, a choice to leave
those or give them a new name, warnings when the move crosses between
shared and instance or the folders have different access groups, and a
result per entry afterwards.

POST /api/addressbook/bulk does the work, up to 1000 entries a request,
8 at a time. Each entry goes through the same stored-entry copy as the
single move: credentials included, Vault check-and-set so nothing is
overwritten, and a move removes the original only after the copy is
written. Clashes are skipped or renamed (name-2 .. name-99, within the
64-character limit); there is no overwrite option. Repeated items are
acted on once. Each entry is audited on its own, headline only. The
route alone accepts a 256KB body, since 1000 entries is about 100KB;
it is admin-only and authentication happens before the body is read.

GET /api/addressbook/folder-paths lists every folder for the picker; the
existing "Move to folder" list only knew about folders already expanded
in the tree.

Delete now checks the entry exists first, in both the bulk and single
endpoints. Vault reports success for deleting a path that is not there,
so a missing entry was answered, and audited, as deleted.
2026-10-06 16:30:50 +11:00
Dave Kempe 3e6c1d2ce1 docs: add-admin --if-none, and the Entra Enterprise application signature failure (#237) 2026-10-06 10:41:51 +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
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
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 492e329670 docs: CLI group mapping, CLI audit log, and two stale OIDC docs
Documents the map-group subcommand in the group-to-role mappings section:
creating a mapping, the one-mapping-per-group rule and the --force re-map,
and that mappings are applied at the user's next OIDC login rather than
immediately, which matters in both directions when revoking access.

Documents the new cli_audit_log table: what is and is not recorded, that
the actor prefers SUDO_USER so sudo names the person rather than root,
that only successful actions are logged, how to read it with sqlite3, and
the 90 day retention.

Also corrects two pre-existing errors found while writing the above:

- deployment-guide.md showed an [oidc.group_role_mappings] config table.
  No such config field exists, so anyone following the guide would add it
  and silently get no mappings at all. Replaced with the CLI commands that
  actually create them. The same block had session_ttl_secs under [oidc];
  the real key is auth_session_ttl_secs and it is top level.
- roles-and-access-control.md documented an enable-user subcommand that
  does not exist. Re-enabling is done from the Admin page or the API, so
  the doc now says that, with a warning about disabling your own account.
2026-09-19 14:42:45 +10:00
Dave Kempe 833745ed6b Merge H.264 AVC444 passthrough CPU win from @pletch (#208)
Restores GfxAVC444=TRUE, skips the server-side H.264 decode, forwards both
AVC444 views (new 'view' field on the h264 instruction + Yuv444.js WebGL2
4:4:4 reconstruction), and fixes blank H.264 recording playback. Review
findings folded in (f16eed7). guacd CPU ~100%/90% of a core -> ~2%.
2026-08-31 07:34:20 +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
pletch 5b5661888b feat(rdp): negotiate AVC444, skip the server-side H.264 decode
H.264 passthrough had two independent defects. On Windows hosts it
negotiated no H.264 at all, and where it did engage it delivered about
half the saving it should.

GfxAVC444 = FALSE was set to make the client advertise AVC420-only,
which is not what the combination does. FreeRDP gates the entire RDPGFX
V10+ capability block on

    !GfxH264 || GfxAVC444

(rdpgfx_main.c:254, rdpgfx_send_caps_advertise_pdu), so with GfxH264 set
and GfxAVC444 clear no V10 capability set is sent at all -- the client
advertises only V8 and V8.1. Windows will not offer H.264 at V8.1: it
falls back to CLEARCODEC and CAPROGRESSIVE, which guacd then decodes and
re-encodes as JPEG/WebP, exactly the cost the feature exists to avoid.
The display stays correct, so the regression is invisible without
measuring CPU or counting codecs on the wire. Over a minute of 1080p
video on Windows 11 Pro: 4050 CLEARCODEC + 1447 CAPROGRESSIVE + zero
H.264 frames before, all frames codec 15 after. xrdp is unaffected,
which is why this went unnoticed -- it accepts AVC420 at V8.1 and never
needed the V10 capsets.

AVC444 was disabled because forwarding it was broken, not because
AVC420 was wanted: only bitstream[0] reached the client, rendering as a
green/magenta split. Forwarding one view cannot work -- both bitstreams
decode through the same H264_CONTEXT in FreeRDP's avc444_decompress, so
they are one H.264 sequence with alternating views. Dropping either
breaks reference frames for the other, and the decoder reports no error
because what arrives is well formed. Both views are now forwarded,
tagged with a view field on the h264 instruction; the client decodes
both, draws only view 0, and combines the auxiliary view's chroma into
full 4:4:4 in a WebGL2 pass, falling back to 4:2:0 if it cannot.

The GDI decode is now skipped for captured H.264 commands. Previously
the wrapper always called through to the original handler, so only the
re-encode was saved. RemoteFX, planar and progressive still decode
normally, so a server mixing codecs within a frame stays correct.

guacd CPU with 1080p video playing, sampled per-thread over 30s:

    xrdp (AVC420)        ~100% of a core  ->  2.0%
    Windows 11 (AVC444)   90.6% of a core ->  2.1%

Configuration is unchanged: the per-connection enable-h264 argument is
the only switch, and there are no environment variables.
2026-08-26 19:27:33 -04: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 ba207010b7 docs: document multi-Vault backends, per-credential scope, My Credentials UX
- configuration.md: [vault_shared]/[vault_local] backends, the DR rationale,
  user_credentials_default_scope, and the VAULT_SHARED/LOCAL_SECRET_ID env vars.
- credential-variables.md: Credentials nav link, scroll/filter/collapsible
  editor, resumable focused prompt, needed-pill + banner, and the shared/local
  per-credential scope with its outage trade-off.
- migration.md: vault-migrate runbook (dry-run, copy, cutover ordering).
- CLAUDE.md: multi-Vault backend summary under the Vault section.
2026-07-29 20:07:51 +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 6d1197fdc3 docs(api): document session connection flow, ws-ticket, and SPICE/Proxmox
Adds a 'Connecting to a session' section covering the owner-vs-join
distinction, the three owner-auth modes (OIDC cookie, sessionStorage key,
ws-ticket URL), POST /api/ws-ticket, and the end-to-end headless integration
recipe (mint a ticket, open /client/{id}?ticket=...). Documents the new
spice/proxmox/vdi session types and their spice_*/proxmox_* fields, and fixes
the stale share_url response example (was &key=; now ?token= with ws_url/status).
2026-07-26 08:51:24 +10:00
Dave Kempe 39075d8fa3 build(guacd): uplift pinned guacd 1.6.0-218 -> 1.6.0-302 (main 6719b20d)
- Bump pin 2980cf0 -> 6719b20d in Dockerfile, install.sh, release.yml,
  docs/installation.md. -Werror verified clean on the new base (the
  GUACAMOLE-2221 pin reason no longer applies).
- Drop patch 006 (terminal OSC-consume): upstreamed as GUACAMOLE-2213
  (guac_terminal_unknown_osc).
- Rebase patch 004 (H.264 display worker) onto the refactored libguac
  display internals: the queued-H.264-frame free moved into the deferred
  guac_display_free_removed_layers path.
- Patches 001/002/003/005/007 unchanged (apply clean on new base).

Local build green under -Werror (guacd + rdp/ssh/vnc). H.264 passthrough
still needs runtime verification on an xrdp+x264 target.
2026-07-23 09:38:56 +10:00
Dave Kempe 627c05074b docs(oidc): document outbound HTTP proxy (Squid) egress 2026-07-06 20:12:44 +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
Lindsay Harvey 8f67a668ea fix(client): move fullscreen control into session menu (#156)
The v1.7.2 floating "⛶ Fullscreen" corner button at 0.45 opacity was
still 80px of permanent clutter in the top-right of the remote session
display before fullscreen was entered. This PR moves the manual
fullscreen action into the existing Ctrl+Alt+Shift session-menu panel
(next to the Home button), removing the floating overlay entirely.

The per-entry `fullscreen_on_connect` flag and the in-fullscreen top
bar (entry name + Exit + Disconnect) are unchanged. Esc-key forwarding
via navigator.keyboard.lock still applies.

Also adds an "In-session keyboard shortcuts" section to
docs/web-sessions.md documenting the Ctrl+Alt+Shift panel toggle,
Ctrl+V clipboard paste-sync, Esc behaviour, and the disable_copy /
disable_paste interaction.

Closes #156.
2026-06-09 16:25:25 +10:00
Dave Kempe 0a6b27cc4b docs(installation): document non-Debian-13 install paths
Add 'Other Linux distributions' section explaining the FreeRDP ABI
mismatch that breaks drive/audio when running the Debian 13 .deb on
Ubuntu 24.04 (and likely other distros). Recommend the Docker image as
the supported path; provide an untested build-from-source recipe for
Ubuntu 24.04 against system FreeRDP 3.5. Mirror the pointer from
deployment-guide.md. Prompted by #153.
2026-06-04 13:26:14 +10:00
Dave Kempe 575d4ab798 docs(integrations): add Microsoft Entra ID setup guide
Covers the claim-vs-scope distinction that trips Entra setups when users
copy the Authentik example. Includes the AADSTS650053 error explainer
plus a troubleshooting section. Promised in #153.
2026-06-03 15:19:57 +10:00
Dave Kempe 354cc0e54f docs(themes): scrub em-dashes from themes guide 2026-05-29 10:57:46 +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 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 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 4aa18e1f32 docs(authentik): add missing groups scope mapping step
Closes #122.

Authentik does not ship a `groups` scope mapping by default, so the
existing instruction "Add the `groups` scope" in the provider's Advanced
protocol settings has nothing to select. Operators following the guide
end up with rustguac unable to read group memberships, which silently
breaks group-to-role mapping.

Adds a new step 1 explaining how to create a Scope Mapping under
Customisation > Property Mappings with the standard
`request.user.ak_groups` expression, and updates the provider step to
reference the newly-created mapping by name. Subsequent steps renumbered.
2026-05-06 15:57:26 +10:00
Dave Kempe c7554fec1d docs: emphasise vault/openbao as a dependency, point at quickstart
The Connections UI is the main user-facing rustguac feature, and it does
not work without Vault or OpenBao. The previous docs framed this as
"recommended" or "optional Vault-backed connections", which understated
how much of rustguac depends on the secret store being there.

  - README.md gains a Requirements table listing guacd (bundled),
    vault/openbao (required for Connections), OIDC (optional), Docker
    (optional). The Features bullet now points at it.
  - docs/installation.md upgrades step 4 from "(Recommended)" to
    "(Required for connections)" with explicit "without one of these,
    the Connections UI is unavailable" wording. Includes the
    quickstart one-liner and a --local example.
  - docs/overview.md drops "optional" from the Vault-backed connections
    description and adds a follow-up paragraph pointing at the
    integrations doc and the quickstart script.
  - docs/integrations.md adds a Quickstart subsection above the manual
    walkthrough with a mode table, three example invocations, and the
    --local on-disk-unseal security caveat reiterated where it'll be
    seen by anyone reading the section.
2026-05-06 15:57:26 +10:00
Dave Kempe 8a25869606 docs/overview: fix Apache Guacamole stack description
The "Key differences" table claimed Apache Guacamole's runtime was
"Java (Tomcat + Spring)" and that its database options included
LDAP. Both are inaccurate.

Per the upstream apache/guacamole-client pom.xml, the Java stack is
Tomcat (or any servlet container) plus Google Guice for DI and
Jersey for the JAX-RS endpoints, not Spring. LDAP is an authentication
backend in Guacamole's extension model, not a database; the actual
database options are MySQL and PostgreSQL.

Other rows in the table reference behaviours we should also verify
on a follow-up pass (OIDC support via guacamole-auth-sso, per-entry
clipboard control, etc.) but those need direct checks against
upstream rather than a guess; deferring for now.
2026-04-26 20:28:45 +10:00