Commit Graph

513 Commits

Author SHA1 Message Date
Jad Seifeddine f432b2bc07 docker: ship the seccomp profile in the image
Copy contrib/docker/seccomp-chromium.json to
/opt/rustguac/seccomp-chromium.json so users of the published image can
extract the profile that matches their image version, the same way the
docs already extract config.toml.default:

  docker run --rm --entrypoint cat <image> \
    /opt/rustguac/seccomp-chromium.json > seccomp-chromium.json

The file is only read by the Docker client when the container is
created; nothing inside the container uses it.

Refs #307
2026-10-19 14:48:27 +07:00
Jad Seifeddine 397770fd72 browser: name the cause when Chromium's sandbox cannot start
When Chromium cannot create its sandbox it aborts at start-up, and the
session fails with Chromium's raw stderr, e.g. "Failed to move to new
namespace ... errno = Operation not permitted" followed by a zygote
FATAL. Nothing in that points at the container's seccomp profile, which
is the usual cause under Docker.

Match the four messages Chromium prints for the ways its sandbox can
fail to start, and put the likely cause and the fix in front of the
stderr in both the log and the API error:

- namespaces refused (Docker's default seccomp profile): use
  contrib/docker/seccomp-chromium.json
- the SUID helper blocked by no_new_privs (no-new-privileges)
- the SUID helper present but not root-owned with mode 4755
- no namespaces and no helper ("No usable sandbox!")

Each message links the sandbox section of the web sessions docs. The
classification runs on the full stderr, before it is truncated for the
log. Unrelated start-up failures are reported as before.

Refs #307
2026-10-19 14:39:26 +07:00
Jad Seifeddine fbd249dd55 docker: seccomp profile that allows Chromium's sandbox
Docker's default seccomp profile only allows clone() and unshare() with
namespace flags when the container has CAP_SYS_ADMIN. Chromium's
namespace sandbox needs new user, PID and network namespaces, so under
the default profile it falls back to the SUID chrome-sandbox helper.
That fails too, because the container has no CAP_SYS_ADMIN, and every
web session aborts with "Failed to move to new namespace ... errno =
Operation not permitted". Until now the only way round it was to run
the container with seccomp=unconfined.

contrib/docker/seccomp-chromium.json is Docker's default profile
(moby/profiles seccomp/v0.2.3) with two changes:

- the clone rules also accept CLONE_NEWUSER, CLONE_NEWPID and
  CLONE_NEWNET; mount, UTS, IPC and cgroup namespaces stay refused
- a matching rule allows unshare() for the same three namespace types

With either change alone Chromium still fails to start. Requiring
CLONE_NEWUSER on every such call is too strict, because the zygote
later forks nested PID namespaces from inside its user namespace. Every
other rule is unchanged, and the profile grants no capability.

Refs #307
2026-10-19 14:35:32 +07:00
Dave Kempe bc9e8c6017 v1.12.1 v1.12.1 2026-10-19 13:44:28 +11:00
Dave Kempe b90be5c6f4 SECURITY.md: report vulnerabilities privately through GitHub 2026-10-19 13:43:09 +11:00
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 13a19c61b3 v1.12.0 v1.12.0 2026-10-18 13:24:54 +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 994f138685 packaging: build guacenc and ship it in every package
guacenc, from guacamole-server, converts .guac recordings to video and is
what MP4 downloads (#241) run. Stop passing --disable-guacenc in
build-deb.sh, the release workflow, install.sh, the Dockerfile and
dev.sh, and ship /opt/rustguac/bin/guacenc in the .deb (debian/rules),
the Docker runtime image and the release tarball (install-release.sh
installs it when present). It needs only the ffmpeg libraries guacd is
already built and shipped with, so no new dependencies.

configure leaves guacenc out quietly when an ffmpeg library is missing,
like the protocols, so check-guacd-plugins.sh now also fails the build
when guacenc was not built.
2026-10-18 13:23:14 +11:00
Dave Kempe 5b2f00bfe0 sessions: an ad-hoc request cannot claim to be a saved entry
POST /api/sessions read address_book_entry, address_book_folder,
entry_display_name, max_recordings and record_typescript from the
request body. A poweruser could name a real entry with a rotation limit
of 1, and when the recorded session ended per-entry rotation deleted
that entry's older recordings; the same fields put a false entry name
on the session in reports and recordings, and turned on a plaintext
typescript the entry's admin had not chosen. For anyone below admin
these fields are now cleared, as the network fence already is.
2026-10-18 13:23:14 +11:00
Dave Kempe 6cc7eef218 websocket: terminating a session stops its traffic
The proxy raced its two pump tasks against the cancel token with select!,
which consumed the JoinHandles. Dropping a handle only detaches its task,
so when terminate (or disabling a user) fired the token, or when one
direction ended, the other task kept forwarding: traffic still reached
the target and the guacd connection stayed open. Race through a helper
that aborts both tasks afterwards, whichever way it ended. Tested with
tasks that never finish on their own, for cancellation and for one side
ending.
2026-10-18 13:23:14 +11:00
Dave Kempe fbe5dbc714 vdi: refuse container usernames that are not plain names
The username is a directory under home_base that is bind-mounted into
the container. An override could carry ../ and the guard compared paths
as text, so it passed. container_username must now be 1-32 letters,
digits, '.', '_' or '-', not starting with '.' or '-'; the Docker driver
also requires exactly one path component and compares real paths, so a
symlink in home_base cannot redirect the mount either.
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 68bce184df packaging: build SPICE into the .deb, and fail any build missing a protocol
The release workflow built guacd for the .deb without --with-spice and
without libspice-client-glib-2.0-dev, so every .deb from v1.9.0 to
v1.11.0 lacked SPICE and Proxmox consoles failed with "Support for
protocol spice is not installed". Docker and install.sh were fine.

guacd's configure leaves out a protocol whose library is missing even
when asked for it, which is why nothing failed. scripts/check-guacd-plugins.sh
now fails the build unless rdp, ssh, vnc and spice were all built; it
runs after make install in release.yml, build-deb.sh, install.sh and the
Dockerfile.

Also:
- --disable-kubernetes, not --without-kubernetes, which configure ignored,
  so every build shipped an unused Kubernetes plugin
- the Dockerfile's trailing || true covered the whole guacd build, so a
  failed compile still produced an image; it now covers only the
  optional FreeRDP plugin copy it was meant for
2026-10-18 07:51:28 +11:00
Dave Kempe f750cc07f1 v1.11.0 v1.11.0 2026-10-17 15:15:39 +11:00
Dave Kempe 220f78674c deps: sha2 0.11.1, toml 1.1.8, serde_json 1.0.154, tokio-util 0.7.20; ci: setup-node v7
Dependabot #287-#291, taken together.
2026-10-17 15:13:33 +11:00
Dave Kempe 6175cda36a client: leaving an embedded session moves the whole tab
In a fullscreen-on-connect session the client runs in a frame on the
Connections tab, and every other page refuses to load in a frame. Home in
the Ctrl+Alt+Shift panel, reconnect and the disconnected overlay only
moved the frame, so the browser showed "cannot open this page" in place
of the session. Every navigation now goes through rgGo(), which moves the
whole tab when embedded, and Close session hands back to the launcher.
tests/client-navigation.mjs fails if anything navigates around it.
2026-10-17 13:56:05 +11:00
Dave Kempe 7a70f623a7 connect: leaving fullscreen keeps the session, with a button back
The launcher tore the session down whenever the tab left fullscreen, so
holding Escape, F11 or a gesture from the OS ended it. Leaving fullscreen
now keeps the session at the tab's size and shows a Fullscreen button
that goes back; only Exit, or the session ending, closes it. Tested with
the browser's fullscreen events faked, since headless Chromium refuses
the real request.
2026-10-17 13:52:06 +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
Jad Seifeddine d1d2cdf859 connect: zero-click fullscreen via a same-tab iframe, sized to the measured viewport
A browser only lets the tab that holds the Connect gesture go fullscreen;
opening a new tab drops that gesture, so a fullscreen-on-connect entry
always fell back to a manual click. Connect now fullscreens the CURRENT
tab on the click — the one moment a valid gesture exists — and loads the
session in a full-viewport same-origin iframe (rgLaunchFullscreen in
search.js, shared by the connections page and the global search): overlay,
spinner and an Exit button, torn down on Exit, on fullscreen exit or on a
session-end message from the embedded client. The client in ?embedded=1
mode skips all in-client fullscreen logic (the parent tab owns it) and
posts ready/end to the parent. X-Frame-Options becomes SAMEORIGIN (was
DENY) with CSP frame-ancestors 'self', so a same-origin page can host the
client in a frame while cross-origin framing stays blocked.

The final viewport size cannot be predicted (a fullscreen viewport on a
notched Mac is shorter than the screen), so it is measured: the launcher
enters fullscreen, waits until the viewport has held one size for 150ms,
then sends the connect with that width and height; a refused request
connects at the tab's size at once and a 2.5s cap never leaves the user on
a spinner. The embedded client shows its connect cover from load and lifts
it — then runs the post-connect fit sends — only on the launcher's
'viewport' message, so the user sees only the final picture. The ⧉ (open
in a new window) button opens the client explicitly windowed (fs=0) on a
small splash page, static/launch.html, which measures its own viewport
once stable and posts it to the opener (rgOpenSessionWindow); the session
is created at that size, so nothing resizes on load. No screen_* is sent
for a popup. When the browser refuses the load-time request, the next
click or key enters fullscreen, taken in the capture phase and never sent
to the remote, and everything is released once fullscreen lands.

Fixes #257

tests: same-origin framing only, and the zero-click fullscreen launcher

The CSP test pins frame-ancestors to exactly 'self'. The browser spec drives
Connect on a fullscreen-on-connect entry: the launcher overlay opens in the
same tab, the connect request carries the measured viewport and no screen
size, the embedded client loads in the frame, and Exit tears it down.

(cherry picked from commit ea456b696f)
2026-10-17 13:43:33 +11:00
Dave Kempe 168634379e search: key the cached index to the server process, recheck on live changes
The cached index was keyed to the address-book version alone, which
starts again at 0 when the server restarts, so a tab could keep an index
from before the restart whose version happened to match. Key it to the
epoch as well (#270), tested. The connections page's live refresh now
calls rgSearchRecheck() when the book changes, so the index is checked
at once rather than at the next 30 s poll.
2026-10-17 12:37:49 +11:00
Jad Seifeddine 32581f30ae search: one global connection search, as a floating overlay on every page
The '/' search box leaves the connections folder header for a bigger,
centred box in the brand row, inline with the logo. Results open in a dim,
blurred overlay over an untouched folder view: a centred panel with the
summary, the Type and Folder facets and the results table (fixed columns,
one line per cell, folder shown by display name over its key, Connect, new
window and open-folder gathered into one actions cell). Focusing the box or
pressing / opens it and lists every connection the user can see; typing
narrows it; Escape from anywhere, a mousedown outside the box and panel, or
opening a folder closes it (an open facet menu takes the first Escape).

static/search.js is the single implementation, used on every page
including connections, which dropped ~16k of duplicated markup, CSS and JS;
the styles live once in rustguac.css so the results look identical
everywhere. The index is cached in sessionStorage and only re-fetched when
the address book's version changes, so moving between pages is instant; the
connections page keeps the folder-specific hooks it needs (rgSelectFolder
selects a folder in place, rgSearchIndex feeds the credentials banner) and
opening a folder from another page navigates to connections with
class, and the module stands down cleanly on a page with no brand row.

Connections also gets a more generous content gutter, the folder action
buttons move down to the filter row beside Type, the folder title is inset
to align with the table, and the Connect button keeps its size and spins
in place instead of growing to 'Connecting...'.

Part of #254

tests: the global search overlay on every page

search: keep page modals above the brand-row search box

The global search lifts the brand row to z-index 300 so the box is not
dimmed by its own results overlay (200), but the page modals sat at 100, so
the search input showed through the ad-hoc session dialog and could still
be focused. Modal overlays now sit at 1000, above the search chrome and
below the themed dialogs (4000) and the embedded session (5000). A spec
pins it on the ad-hoc and folder modals.

search: never on the login page, nor for anyone the server does not recognise

The global search was included on index.html, so the login page carried
the search box, and because the index was cached in sessionStorage it
kept answering with the whole address book after logout, or to the next
login in the same tab.

- index.html no longer loads search.js at all.
- search.js only renders for an operator or above: the role the server
  bakes on <html data-role-level> decides on first paint, and an API-key
  user (served the anonymous variant) is confirmed through /api/me first.
  Anonymous or viewer: nothing injected, cache swept.
- The cached index is keyed to the identity /api/me returns; a cache left
  by a different login is dropped rather than shown.
- A 401/403 from the index or version endpoints removes the box, the
  overlay and the cache; the logout link clears the cache before the
  login page shows.

e2e: login page has no box and never loads search.js; anonymous and
viewer callers get nothing and a stale cache is wiped; a different
login's cache is dropped; the same login keeps its cache across pages;
logout and expiry forget it.

(cherry picked from commit b8c5c0f9ee)
2026-10-17 12:36:40 +11:00
Jad Seifeddine 97ba471ea8 ui: themed confirm/alert dialogs in place of the native ones
static/ui.js provides promise-based rgConfirm/rgAlert that show a themed
modal matching the app, injected self-contained so it works on any page
including the standalone client. Every native alert()/confirm() is
replaced: delete user and revoke token (admin), revoke token (tokens),
delete recording (recordings), popup-blocked (client), and terminate
session, delete entry, delete folder and the SSH host-key probe
(connections). Destructive actions get a red confirm button, the host-key
trust prompt an accent one.

From an independent review of the module: rgConfirm/rgAlert resolve any
already-open dialog before replacing it, so a second call can't leave the
first promise hanging; Enter acts on the focused button (Cancel cancels),
and Tab is trapped between the two buttons.

Part of #254

tests: themed confirm/alert dialogs replace the native ones
(cherry picked from commit f8779d083b)
2026-10-17 12:30:48 +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
Jad Seifeddine cc46b7db31 folders: optional display name beside the folder key
folders: optional display name in the folder config (#254)

A folder can carry a display_name beside its description. It is stored
in the Vault .config (omitted when empty, so existing configs are
unchanged), returned in folder listings and GET .../config, and accepted
by POST /api/addressbook/folders and PUT .../folders/:scope/:folder.
The NetBox tenant sync sets it to the tenant name, so the connections
page can show 'Dani & Fiori's Gourmet Pizzeria' over the folder key
instead of three spellings of the tenant in one line.

A folder can carry a display_name beside its description. It is stored in
the Vault .config (omitted when empty, so existing configs are unchanged),
returned in folder listings and GET .../config, accepted by POST
/api/addressbook/folders and PUT .../folders/:scope/:folder, and carried
in the search index as folder_display_name so results can show it the way
the folder view does. The NetBox tenant sync sets it to the tenant name.

On the connections page a folder with a display name shows it as the title
with the folder key as a sub-header, in the header and the sidebar, so
'Dani & Fiori's Gourmet Pizzeria' reads over the key instead of three
spellings of the tenant in one line. The folder modal gets a Display name
field, asked for before the description.

Part of #254

tests: folder display name is optional in storage and shown over the key

A legacy .config without display_name loads with an empty one and is
stored unchanged; a set name round-trips. The browser spec checks the header,
the sidebar and the folder modal field.

(cherry picked from commit 46ac163761)
2026-10-17 12:25:42 +11:00
Jad Seifeddine a818ce7f43 pages: shared facet filters and one list toolbar on every list page
reports: show entry names as host over a tenant subline

Session history and the most-used table showed "host (Tenant)" names
wrapped across three lines; they now read as the host over a quieter
tenant line, as on Connections and Recordings.

Part of #254

sessions, recordings: show the session type with the shared badge (#254)

The sessions page printed the type as plain text and the recordings page
as a coloured word; connections and reports already used .type-badge
from rustguac.css. All four now use the same badge. The Proxmox badge
colour moves into the shared stylesheet with the others.

Shared facet filters (static/filters.js) on the sessions, recordings,
reports and connections pages: a dropdown per facet (Type, Folder, User,
Owner, Status as each page has them), multi-select with counts that narrow
as other facets apply, All/None, a find box for long lists, a Clear filters
link, and selections kept per page in sessionStorage. They sit beside the
existing text search and combine with it; the buttons are compacted to the
height and padding of the other controls and the dropdown reserves room for
its scrollbar so it no longer sits over the counts.

The list pages share one toolbar: a borderless row with the search box, the
facets and the page action on the right (New ad-hoc session, Export CSV),
placeholders standardised to 'Search <noun> (fields…)'. Sessions gains a
search (user, host, owner, ID, type) and a User facet; Reports moves from
its own .filter-bar to the shared one. The redundant page titles go — the
nav already names and marks the active page — and section headings centre
their heading and action button on one line.

Connections: the folder header is sticky at the top of the entries pane, so
it stays under the nav and the active-sessions strip while a long table
scrolls. All four pages show the session type with the shared .type-badge
(the Proxmox colour moves into rustguac.css), reports and recordings show
"host (Tenant)" names as the host over a quieter tenant line, and the key
glyph before Credentials in the nav is sized to the text.

Part of #254

tests: shared facet filters and the list toolbar on every list page
(cherry picked from commit be866264bd)
2026-10-17 12:21:59 +11:00
Jad Seifeddine e1746b4077 sessions: move the ad-hoc session form into a modal
The ad-hoc session form sat in a collapsible framed box above the session
list. It now opens as a modal from a button on the right of the Sessions
heading, dropping the page's framed form box inside the dialog, with the
primary button first and Cancel at the same height as Connect, matching the
other modals. Section headings centre their heading and action button on
one line.

Part of #254

tests: the ad-hoc session form opens as a modal
(cherry picked from commit 7048ca6860)
2026-10-17 12:13:06 +11:00
Dave Kempe c421515a79 connections: folder defaults writes refresh everything, explicitly
PUT/DELETE .../defaults already fell through to the catch-all, which is
right: a folder's defaults also decide what subfolders sharing its
audience lend, so no narrower change is correct. Say so with its own arm
and pin it in the attribution test.
2026-10-17 10:01:50 +11:00
Dave Kempe 4214d9bfc1 tests: version stub answers like the server, changes [] after the baseline
Without changes every stubbed poll read as "refresh everything". It now
returns null for the baseline poll and [] after it, with an epoch.
2026-10-17 10:00:22 +11:00
Dave Kempe a80fdf4680 main: say where the address-book change tracker really sits
It is added after require_auth, so it runs outside it and also sees
refused requests, which fail and are not logged.
2026-10-17 10:00:22 +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 a659b645bc tests: drop an em-dash from a sizing test comment 2026-10-17 09:56:48 +11:00
Dave Kempe 0f6eea9966 session reuse: fingerprint the whole request, not a list of fields
The reuse fingerprint covered the target and the credentials only, so an
entry whose admin had just turned recording on, changed the security
mode or enabled the drive could hand back a Pending session made without
it, and every field added later had to be remembered there by hand.
Hash a canonical JSON encoding of the whole request instead, leaving out
only the fields about how the session is drawn in this browser (width,
height, dpi, banner), plus the API-set fence_all_targets flag. Renamed to
reuse_fingerprint since it is no longer only about credentials.
2026-10-17 09:56:48 +11:00
Jad Seifeddine 9f819efec2 api: create a session at the size it will be shown at, and reuse a fresh Pending one
Sizing. The entry connect request now carries the screen's size as well
as the window's (screen_width/screen_height, sent by every connect site
in connections.html and the client's relaunch). initial_session_size()
picks the size the session will be shown at: an RDP/VNC entry that opens
fullscreen on connect is created at the screen's size, everything else at
the window's, and every RDP size is rounded down to the 16px grid that
guacd's display channel (patches/007) rounds the first post-connect size
send to. Creation and that send therefore agree, guacd coalesces them,
and there is no resize, no H.264 decoder rebuild and no flash on
connect. The client's post-connect size send stays as it is.

Reuse. A repeat Connect on the same entry within 15 s no longer opens a
duplicate: a Pending session (guacd connected, owner never attached) this
user created for the entry is handed back instead. The match is made on
the fully built CreateSessionRequest and includes its credentials:
CreateSessionRequest::credential_fingerprint() is a SHA-256 over the
target and every credential field (username, password, private key,
domain, auth package, KDC, jump hosts and their credentials, container
and Proxmox credentials), stored on the Session and compared, never
logged or served. A retry with a different password, key or domain, such
as a mistyped prompt, gets a new session rather than the old Pending one
with the old credentials. Reuse is also scoped to the same user, the
same entry key, Pending status with a live guacd stream, and sessions
younger than SessionManager::REUSABLE_PENDING_SECS.

#238 (native/HiDPI resolution, display_scale) has not merged: both sizes
here are CSS pixels, matching today's post-connect send. When it lands
the mod-16 rounding must be applied to the device-pixel size, after the
devicePixelRatio multiply; the rounding site carries that note.

Tests: fullscreen_sizing_applies_to_bitmap_sessions_only and
round_down_mod16_floors_at_sixteen (api);
credential_fingerprint_tracks_target_and_credentials,
fresh_pending_session_reused_for_same_entry_and_credentials,
retry_with_different_credentials_gets_a_new_session and
reuse_is_scoped_to_user_entry_status_and_age (session).

Part 1/2 of #257.

(cherry picked from commit 4496f05e8a)

# Conflicts:
#	src/api.rs
2026-10-17 09:56:48 +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 e535e7f3b9 pages: Vary on the cookie, no WebSocket tickets on page routes
The served page now depends on the session cookie, so say so with
Vary: Cookie and no cache hands one role's copy to another. The page
routes don't need the WebSocket ticket store: without it optional_auth
skips the ?ticket= path, so a page URL can't use up a single-use ticket.
The .entry-name styles now live in rustguac.css, so drop the copy in
connections.html.
2026-10-17 08:23:16 +11:00
Jad Seifeddine 6fbf590c1d pages: one navigation on every page, with the caller's role baked into the served page
pages: bake the caller's role into the served page, version the logo

The header flashed on every navigation: gated links (Sessions, Reports,
Tokens, Admin) were hidden in the markup and shown after /api/me
answered, the logo was revalidated on each page load, and the nav
differed between pages, so switching tabs reflowed the header.

Serve each page in five variants, one per role level, that differ only
in a data-role-level attribute on <html>; optional auth now runs on the
page routes so a logged-in request gets its variant with the navigation
already right on first paint. The ETag is per variant. The logo joins
scripts and stylesheets in content-addressed, immutable caching.

Part of #254

pages: one navigation for every page, gated by role in CSS

Every page carries the same eight links in the same order, including
the Credentials entry (which opens the dialog on the connections page
and deep-links to it from elsewhere), with data-min-role on the gated
ones instead of inline display:none. rustguac.css shows them from the
data-role-level the server writes on <html>; the pages set that
attribute themselves from /api/me for API-key users, whose page GET
carries no credential. Admin-only controls follow the same gate, so
nothing pops in after the first paint. Recordings shows "host (Tenant)"
entry names as the host over a quieter tenant line.

Part of #254

tests: one navigation on every page, gated by data-role-level
(cherry picked from commit 5eed92c6ec)
2026-10-17 08:23:16 +11:00
Dave Kempe 18227d835b deps: rustls 0.23.46 (RUSTSEC-2026-0346..0349), wnaf 0.14.1 (0.14.0 yanked)
Also bring the fuzz lockfile's h2, quinn-proto and chacha20 up to their
fixed versions.
2026-10-17 08:23:00 +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
Dave Kempe e15c2d8d16 connections: don't adopt a hand-made entry when the external_id is already elsewhere
With ?move=true, an existing entry at the target with no external_id was
adopted in place without looking for the id elsewhere in the scope. A
device moving into a folder where someone had made the same name by hand
then left its id on two entries, and every later sync of it was refused
as ambiguous. Adoption now scans first and refuses with a 409, nothing
changed, if another entry already carries the id.
2026-10-17 06:39:26 +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
Dave Kempe 325926e64f connections: style the "host (Tenant)" entry name lines
entryNameHtml() splits the name into .entry-name and .entry-sub spans,
but nothing styled them, so the two ran together on one line
("web01.acme.internalAcme Holdings"). Show the tenant on its own quieter
line, and test it.
2026-10-14 11:24:10 +11:00
Jad Seifeddine e627b0c9f6 connections: app shell with independent panes, sidebar panel, tenant sublines
The sticky sidebar was sized from the viewport rather than from where
it starts under the header, so at the top of the page its bottom hung
off screen and the page had to be scrolled before the list could be
scrolled to its end. The page is now an app shell: the header stays,
and the sidebar and the entries area are two independent scroll panes
that always fit the viewport. Selecting a folder scrolls the entries
pane, not the page.

The sidebar is a bordered panel: one pinned header band on a surface
tone with the Folders label, a compact new-folder button and the filter
box, then the list with hairline dividers, 46px rows and one 12px edge
for every band. "host (Tenant)" names read as the host over a quieter
tenant line in the entries and search tables, and a role-gate
reconciliation covers API-key users.

Part of #254

connections: scroll only the entries pane on selection, clip the body

jumpToEntriesHeader() used scrollIntoView(), which scrolls every
scrollable ancestor, and a body with overflow hidden can still be
scrolled by script: selecting a folder pushed the brand row and the nav
off the top of the page. Scroll the entries pane to its top instead, and
make the body overflow: clip, which no script can scroll. The body's
border box now spans the full height so the page padding stays inside
the viewport.

Part of #254

tests: app shell with two independent scroll panes and a clipped body (#254)
(cherry picked from commit d7ad9e93e9)
2026-10-14 10:18:47 +11:00
Dave Kempe f5007af051 fuzz: bump rustls to 0.23.45 (RUSTSEC TLS 1.3 encryption level boundary) 2026-10-14 10:14:51 +11:00
Jad Seifeddine 9d7d675e80 tests: folder filter box, ARIA tree and keyboard navigation (#254)
(cherry picked from commit 6f230697ab)
2026-10-14 09:55:27 +11:00