534 Commits

Author SHA1 Message Date
Dave Kempe d93cb6b629 v1.13.0 v1.13.0 2026-10-20 09:00:34 +11:00
Dave Kempe 6a66a41165 connections: don't offer ad-hoc users options the server will drop
In the editor's ad-hoc mode a poweruser was offered file transfer even
when [drive] has it off, and VDI CPU, memory and idle limits above the
site defaults, all of which apply_adhoc_policy drops. The auth status
now says whether the drive is enabled (not only configured), and the
editor hides file transfer from ad-hoc users below admin when it is off
and says the VDI limits are capped. Admins see everything as before.
2026-10-20 08:58:44 +11:00
Dave Kempe 66ede0c29f connections: an ad-hoc connect is not an address-book change
POST /api/addressbook/adhoc/connect fell through to the change log's
catch-all, so every ad-hoc connect was logged as the whole address book
changing and every open Connections tab reloaded its folder list and
search index. Treat it like connecting to an entry: no change.
2026-10-20 08:57:02 +11:00
Dave Kempe fba79f313c docs: say what the Chromium seccomp profile relaxes for the whole container 2026-10-20 08:56:37 +11:00
Dave Kempe 9e0441b991 docs: show the SPICE and Proxmox VE page in the in-app Docs 2026-10-20 08:56:29 +11:00
Dave Kempe 0da5dcaeee spice: refuse a CA certificate that holds no readable certificate
guacd hands a PEM CA to spice-gtk, which quietly falls back to the
system trust store if the PEM holds no certificate, so a corrupt CA in
an entry (or from Proxmox) meant trusting every public CA instead of
failing. Each block must now parse as a real certificate, or the session
is refused. Tested with a real certificate and with three corrupt ones.
2026-10-20 08:56:17 +11:00
Jad Seifeddine d75446f040 connections: open ad-hoc connections in the entry editor
Ad-hoc sessions had their own form on the Sessions page with a subset
of the options a connection entry has. "Connect to a host" now opens
the Connections entry editor in an ad-hoc mode instead, so an ad-hoc
connection has every option an entry has, now and as entries gain more.

The button sits beside the global search box on every page that has
one. It is shown to poweruser and above under the same rules as the
search box, and opens the editor in place on Connections or goes to
/connections.html#adhoc elsewhere. Ad-hoc mode has no name or folder,
Connect instead of Save, and for admins a "Save as entry" action.

POST /api/addressbook/adhoc/connect takes the entry as the editor
builds it. It resolves only the caller's own credential variables,
never lends folder defaults, and goes through apply_adhoc_policy and
the session rate limit like every other ad-hoc route.
POST /api/sessions is unchanged.

session_request_from_entry() replaces the two copies of the entry to
session request conversion, used by the Connections connect endpoint
and /api/connect, so an option added to entries works in all three.
can_connect_adhoc() is the one place that decides who may connect
ad-hoc.

The Sessions ad-hoc form is removed. loadFolders() now runs its
callback, so #open= links work again.

Refs sol1/rustguac#295

(cherry picked from commit dd40bf08ec)
2026-10-20 08:54:46 +11:00
Jad Seifeddine 7d6ca5c5d3 docs: refresh screenshots for v1.12 with default branding and synthetic data
The screenshots showed v1.9.5 with a custom title, logo and theme, and
folder names that looked like a real instance, so they no longer matched
what a new install shows.

Replace them with captures of v1.12.1 using the default title, logo and
aurora theme, against fully fictional data (example.com users, example
hosts and documentation address ranges), made with contrib/screenshots.
The existing file names are kept so outside links still work. Three views
are new: a live SSH session, a live Web session and the same page in four
built-in themes. The light/ folder holds the twelve page views in the
light theme. The PNGs are compressed with oxipng.

screenshots.md now describes what each image shows, without the
per-version notes.

Fixes #305

(cherry picked from commit 5aae69a7cf)
2026-10-20 08:54:46 +11:00
Jad Seifeddine 5a566a810b contrib: reproducible screenshot environment
The screenshots/ folder had no record of how it was made, so every
refresh meant setting up an instance by hand and inventing data again.

contrib/screenshots/ starts rustguac (any image, sol1/rustguac:1.12.1 by
default) with OpenBao in dev mode, a mock OIDC provider and throwaway SSH
and web targets, all on a private compose network with no host ports. A
Playwright container signs in as an admin pre-provisioned with
`rustguac add-user`, creates users, folders, entries, credential
variables and API tokens through the API, runs real SSH and Web sessions
for the recordings and history, and captures the screenshots/ set in the
aurora and light themes. Nothing is written to the database directly,
and all names, hosts and addresses are reserved example data.

The rustguac container runs with seccomp=unconfined because Chromium's
sandbox needs it for Web sessions (#307); the README says so.

Refs #305

(cherry picked from commit 586cfd3e32)
2026-10-20 08:54:46 +11:00
Jad Seifeddine 443d4513a8 docs: web sessions under Docker
The Docker instructions started the container under Docker's default
seccomp profile, where every web session fails because Chromium's
sandbox cannot create its namespaces, and nothing said why.

- web-sessions.md: a Chromium sandbox section covering the namespace
  sandbox, the SUID helper fallback and the new start-up error, and a
  Running under Docker subsection with the shipped profile, a Compose
  snippet, the user-namespace requirement on the host, and the
  alternatives (seccomp=unconfined, CAP_SYS_ADMIN) with their cost;
  a troubleshooting entry for the error
- installation.md, README.md, deployment-guide.md: extract the profile
  from the image and pass it with --security-opt in the docker run and
  Compose examples
- security.md: the sandbox section said Chromium uses the SUID helper;
  it prefers its namespace sandbox and uses the helper only as a
  fallback. Point Docker users at the profile rather than unconfined

Fixes #307

(cherry picked from commit 305bb6e195)
2026-10-20 08:54:41 +11:00
Jad Seifeddine 74abe53c3c 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

(cherry picked from commit f432b2bc07)
2026-10-20 08:54:31 +11:00
Jad Seifeddine 67c91b98d7 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

(cherry picked from commit 397770fd72)
2026-10-20 08:54:31 +11:00
Jad Seifeddine 0c836d9d18 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

(cherry picked from commit fbd249dd55)
2026-10-20 08:54:31 +11:00
Jad Seifeddine dd6ddf3763 docs: link the SPICE and Proxmox VE page from the other docs
Add the page to the Documentation lists in the README and overview, and
link it where SPICE and Proxmox come up: the session types in the
overview, the session and entry fields in the API reference, the
vnc_allowed_networks row in the configuration reference, and the entry
types and jump-host table in integrations. Those two lists also now name
SPICE and Proxmox VE, which they had left out, and no longer claim that
every session type can use a jump host, since VDI cannot.

(cherry picked from commit 638c579402)
2026-10-20 08:54:31 +11:00
Jad Seifeddine 4a0fb448fc docs: SPICE and Proxmox VE sessions
SPICE and Proxmox VE are headline session types, but what the docs said
about them was scattered across the API field tables and the allowlist
notes. Nothing covered the entry editor, the Proxmox API token, node
auto-detection, the one-time ticket and SPICE proxy flow, TLS options,
whether a given package ships guacd's SPICE plugin, or the common
failures from #195 and #218.

docs/spice-and-proxmox.md covers each of these, checked against the
session, PVE client, guacd and editor code and the build scripts.

Fixes #302

(cherry picked from commit ddd3c23178)
2026-10-20 08:54:31 +11:00
Jad Seifeddine 20a1da28ca spice: verify against the entry's PEM CA, not a file path
The SPICE CA certificate field and the Proxmox spiceproxy response
both carry PEM text, which rustguac sends as guacd's ca-cert arg. The
SPICE plugin passed that value to spice-gtk's ca-file property, which
is a path. OpenSSL could not open a file named after the certificate,
spice-gtk logged "loading ca certs from -----BEGIN CERTIFICATE-----
... failed", fell back to the system trust store, and rejected any
server signed by a private CA, so verification only worked with
"ignore certificate" turned on.

Add guacd patch 011: when ca-cert contains a PEM header, hand it to
spice-gtk's in-memory ca property (a GByteArray of PEM data, available
since spice-gtk 0.15), which adds each certificate to the TLS trust
store. The certificate never touches disk. Any other value is still
passed as ca-file, so a path keeps working. The settings field is
renamed ca_file to ca_cert to match what it holds.

Fixes #308

(cherry picked from commit 6bfc5b38f2)
2026-10-20 08:54:31 +11:00
Jad Seifeddine 30913f586d spice: blank TLS port means the entry's port
The connection editor labels a blank SPICE TLS port "(same as Port)",
and the jump-host path already tunnels tls_port.unwrap_or(port). The
direct path disagreed: with TLS on it sent an empty plain port (so
spice-gtk does not try plaintext) and an empty tls-port when none was
set, and guacd's SPICE plugin then defaulted the TLS port to 5900. A
TLS-only server on any other port was unreachable unless TLS port was
filled in by hand.

Add SpiceParams::dial_port(), which returns the TLS port with TLS on,
falling back to the entry's port, and use it for both the tls-port
connect arg and the jump-host tunnel target so the two paths agree.
The SPICE arg mapping moves into spice_arg_value() so it can be unit
tested, and a stale comment claiming the password is delivered over
argv is dropped.

Proxmox consoles always set tls_port from the spiceproxy response and
are unaffected.

Fixes #309

(cherry picked from commit f5c893ab25)
2026-10-20 08:54:31 +11:00
Jad Seifeddine 1450164eca docs: use reserved example names in API and audit log samples
A few examples used real-world names instead of reserved ones:

- the web session example pointed at www.saucedemo.com, a real
  third-party site, with that site's demo login; it now uses
  app.example.com and neutral credentials;
- the jump host examples used *.corp.local, and .local is the mDNS
  domain, so copied settings resolve oddly; they now use
  *.example.internal, as does the Squid proxy example;
- the move-entry response showed the folder "mrgelato"; now
  "customer-a";
- the CLI audit log sample showed the user "dave"; now "alice", with
  the columns kept aligned;
- the commented-out Knocknoc redirect in 403.http suggested
  knocknoc.mycompany.com, a registered domain; now knocknoc.example.com.

Fixes #304

(cherry picked from commit b43de8678a)
2026-10-20 08:54:31 +11:00
Jad Seifeddine 23b31f1f78 docs: number the post-install steps in order, one h1 in migration.md
installation.md: the Debian post-install steps ran 1-4, 6, 7. No step
was lost: when the Vault step was added as step 4 in 77c7c53, the drive
step after it was renumbered to 6 instead of 5. Number them 5 and 6.

migration.md: "Splitting to multiple Vaults (disaster recovery)" was a
second h1, so the page read as two documents and its Step 1-3 headings
sat at the same level as the Guacamole import's. Make it an h2 with its
steps at h3 and Options at h4. Heading text is unchanged, so every
anchor on the page stays the same. Point the cross-links between this
section and configuration.md's "Multiple Vault backends" at each
other's sections rather than the top of the page.

Fixes #303

(cherry picked from commit 5eebf39839)
2026-10-20 08:54:31 +11:00
Jad Seifeddine 80d6bf82ed docs: list SPICE, Proxmox VE and VDI in the jump host session types
The supported session types table for jump hosts listed only SSH, RDP,
VNC and web, although session creation also tunnels SPICE and Proxmox
VE. Add rows for them as the code behaves: SPICE forwards to the TLS
port when TLS is on, and Proxmox VE tunnels the PVE API and the SPICE
proxy separately, with certificate checks as they apply to each hop.
VDI is marked not applicable, since the container is local and the UI
hides the jump host section for it.

Also note how the network allowlists apply through a chain: the target
is resolved by the bastion, and only the first jump host is checked
locally, for ad-hoc sessions from non-admins.

Fixes #301

(cherry picked from commit 15390667b8)
2026-10-20 08:54:30 +11:00
Jad Seifeddine dd120aaf9e docs: pull the Docker Hub image in the deployment guide
The Docker section of the deployment guide pulled and ran
ghcr.io/sol1/rustguac:latest, but release.yml only pushes to Docker
Hub (sol1/rustguac:<version> and :latest), so the first command failed.
Use sol1/rustguac:latest, as the README and installation.md already do.

Fixes #300

(cherry picked from commit 1a034fc77a)
2026-10-20 08:54:30 +11:00
Dave Kempe 26c75748b6 docker: stop the container when guacd or rustguac exits
The entrypoint waited for both processes, so if guacd died the container
kept running with every session failing, and a restart policy never
fired (and the same if rustguac died). Wait for whichever exits first
with bash's wait -n, stop the other, and exit with its status. Tested:
docker stop in 0.3s; killing guacd or rustguac now stops the container.

(cherry picked from commit 30f384e6efa92539c30e80263aa1afd1a21ae370)
2026-10-20 08:54:30 +11:00
Zoran Regvart 324021ed71 Refine startup/shutdown
Adds tini to manage processes launched from entrypoint.sh, namely guacd
and rustguac.

(cherry picked from commit 6d30f08176)
2026-10-20 08:54:30 +11:00
Zoran Regvart 2315c9608b Leave shell as PID 1
When rustguac replaces the PID 1 the shell will never invoke the trap
on exit. This leaves the shell as PID 1 for the trap to be invoked
on exit.

(cherry picked from commit 1e31d74312)
2026-10-20 08:54:30 +11: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