Commit Graph

469 Commits

Author SHA1 Message Date
Jad Seifeddine be866264bd 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
2026-10-16 16:51:36 +07:00
Jad Seifeddine 1ef48871e3 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 872cc61fce)
2026-10-16 16:51:35 +07:00
Jad Seifeddine 5eed92c6ec 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
2026-10-16 16:51:35 +07:00
Jad Seifeddine ba4d7fc9c8 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)
2026-10-16 16:51:35 +07: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
Jad Seifeddine 13cb6cebac connections: folder filter box, keyboard tree navigation and ARIA roles (#254)
The sidebar lists one folder per customer and gets long. Add a live
filter box (name, path, description; loaded subfolders included, with
ancestors of a match kept visible) mounted in a new #sidebar-tools div,
persisted in sessionStorage so live refreshes keep it. Make the tree
keyboard-navigable (arrows, Home/End, Enter/Space, Left/Right to
collapse/expand) with role=tree/treeitem, aria-selected/expanded and a
roving tabindex, and add an 'f' shortcut that mirrors the '/' one.

Implemented by post-processing the DOM renderFolders() builds and by
wrapping that function; its body is unchanged.

(cherry picked from commit 267ca7092d)
2026-10-14 09:55:27 +11:00
Jad Seifeddine 79e203676c tests: sidebar scrolls on its own, selected folder marked and kept in view (#254)
(cherry picked from commit b1568084ba)
2026-10-14 09:40:23 +11:00
Jad Seifeddine 8d25871423 connections: selection feedback and in-place sidebar updates (#254)
With a long folder list, clicking a folder appeared to do nothing: the
entries table was off screen, the only loading state was a text swap,
and nothing drew the eye to the changed title. loadEntries() also
called renderFolders() just to update one count badge, rebuilding the
whole sidebar on every click.

- Jump to the entries header when the shown folder changes (smooth,
  'auto' under prefers-reduced-motion); never on initial load, live
  refresh, forceRefresh after a mutation or search clear.
- .changed on #entries-header replays a 350ms title slide-in and an
  accent underline flash on the action bar; cleared on animationend.
- Skeleton table (4 shimmering rows, same geometry as the real table)
  while entries are fetched from the server; the cached path renders
  immediately with no skeleton.
- updateFolderCount() swaps the number in the existing .folder-count
  badge in place; renderFolders() only as a fallback when the badge
  does not exist yet.
- renderFolders() is wrapped (not edited) to preserve the sidebar
  scroll position across rebuilds and to reveal the selected row within
  the sidebar (nearest, no page scroll) when the selection changes or
  after loadFolders() rebuilds the tree.

(cherry picked from commit 6fd18394a7)
2026-10-14 09:40:23 +11:00
Jad Seifeddine 1421665bde connections: sticky self-scrolling sidebar, flush-left folder rows (#254)
With 20+ customer folders the sidebar shared the page scroll with the
entries table, so picking a folder far down the list changed content off
screen, and every leaf row was pushed ~90px in by a hidden chevron
placeholder while names were ellipsised.

- .sidebar is position: sticky with its own thin scrollbar and
  max-height tied to the viewport; .main keeps scrolling with the page.
- The FOLDERS header is wrapped in .sidebar-pin together with a new
  empty #sidebar-tools slot, both pinned at the top of the sidebar.
- buildFolderNode() renders a .tree-chevron only for folders with
  children; leaves get no placeholder (removed the .tree-chevron.leaf
  rule), so rows start right after the 3px accent bar.
- Name, entry-count pill (.folder-count, now just the number with a
  data-count attribute and "N entries" title) and a smaller scope glyph
  share one line; description sits beneath at --fz-xs; rows are ~40px.
- Nested .tree-children indent 14px behind the existing dashed guide.

All new CSS is in one block at the end of the page's <style>.

(cherry picked from commit 198226c4b7)
2026-10-14 09:40:23 +11:00
Dave Kempe ad4cb78045 deps: adopt 4 dependabot bumps
russh 0.64.1 (constraint widened from 0.63), tokio 1.53.2, libc 0.2.190
and uuid 1.28.0; fuzz/ follows russh. russh 0.64 drops parsing of a
non-standard ed25519 key format only russh before 0.43 wrote, which
rustguac never produced (keys come in as OpenSSH, ephemeral ones from
ssh-key); its other fixes are mostly server and agent side, plus a
client rekey-limit fix. No code changes. Verified live: host-key probe
and a session through a jump host (#244, #245, #246, #247).
2026-10-13 21:11:30 +11:00
Dave Kempe 4077522873 deps: quinn-proto 0.11.19 for RUSTSEC-2026-0338..0342
Five advisories against quinn-proto 0.11.16, two rated high (remote
peer panics). It is in the lockfile only as reqwest's optional HTTP/3
dependency, which rustguac does not enable, so the binary never
contained it; cargo audit reads the lockfile, so CI failed regardless.
Lockfile only.
2026-10-13 17:39:55 +11:00
Dave Kempe e4fb8ee552 pages: wrap long names so the tables fit at any font width (#252)
The 1710px spec from the fork passed against main as well as against the
change it was meant to pin: its fixture names are short enough to fit
either way under Linux's monospace font, and the overrun on the reporter's
Mac came from wider glyphs and longer real names. With realistic data
(dotted FQDNs, service accounts, long emails and folder paths, which have
no natural break point) main overran by 370px, and the tighter padding on
its own still overran Connections and Recordings.

Table cells on Connections, Recordings and Reports now wrap long text
inside the cell (overflow-wrap: anywhere), with the Connect and action
buttons kept on one line. The spec gains a long-data test per page: all
three fail on main, two fail with the padding change alone, all pass now.
The e2e README lists only the specs that exist.
2026-10-13 09:47:00 +11:00
Jad Seifeddine c934065a84 tests: Playwright end-to-end harness, with a 1710px layout spec
A browser test harness under tests/e2e: serve.mjs serves the real static
tree over HTTP and lib/api.mjs answers /api/* from a small fixture address
book, recording every request the page makes, so the shipped pages run
against data the test controls without Vault, guacd or a session. CI gains
an e2e job that installs Chromium and runs the suite.

The first spec pins #252: at 1710px the connections, recordings and reports
tables fit inside the viewport and nothing scrolls sideways.

(cherry picked from commit 4ae28efec0)
2026-10-13 08:55:19 +11:00
Jad Seifeddine 7ef4660bcc css: share the narrow-desktop page padding across pages
The 1800px breakpoint set a tighter page gutter on the connections page
only, so switching to Sessions or any other tab snapped the content
sideways. Move it to rustguac.css so every page moves together.

Part of #254

(cherry picked from commit b9db3ca2d2)
2026-10-13 08:55:19 +11:00
Jad Seifeddine 701f88eaa7 reports, recordings: fit the tables on 1710px-wide displays
The 18px tables with 20px cell padding ran past the right edge on a
1710px MacBook display, the recording link cut off. Same treatment as
the connections table: 16px cells with tighter padding, 14px headers,
actions kept on one line. Nothing is hidden.

Part of #254

(cherry picked from commit 1b93b0c8c1)
2026-10-13 08:55:19 +11:00
Jad Seifeddine 24eeafb856 connections: fit the entries table on 1710px-wide displays
The 18px table with 20px cell padding and a separate cell each for
edit, clone and delete overran the main column by ~300px on a 1710px
MacBook display, cutting "delete" off the right edge.

Put the three admin actions in one cell, and add a max-width: 1800px
breakpoint on the connections page: 20px page padding, 240-280px
sidebar, 16px cells with tighter padding, 14px headers, a 110px
Connect button and a smaller folder title. The icon-only "open in new
window" button keeps its compact size. Nothing is hidden; at 1710px the
table fits with about 250px to spare.

Fixes #252

(cherry picked from commit e7358ab9c9)
2026-10-13 08:55:19 +11:00
Dave Kempe a43b81f9d4 ci: run the plain-node client tests
tests/*.mjs pin browser-client behaviour (hardware H.264 fallback,
monitor pointer, coalesced drags, and now the #251 framebuffer fit) but
nothing ran them. The check job installs Debian's nodejs (20.x on
trixie) and runs each; they need no browser and no npm packages.
2026-10-12 19:31:23 +11:00
Dave Kempe 0b4bace8de client: name the real cause of the RDP mod-16 margin
The comments on the #251 fit said Windows RDP without GFX rounds the
width down. It is our own guacd: patches/007-rdp-disp-mod16 rounds both
dimensions to a multiple of 16, with or without GFX, to stop H.264's
padding showing as a green band along the edge. Say so where the margin
is handled, and that the rounding must not be relaxed to make the margin
go away, since that brings the band back.
2026-10-12 19:30:52 +11:00
Jad Seifeddine 41333041de client: keep the fullscreen gesture press off the remote, release all input after the transition
With fullscreen-on-connect, the first mousedown after CONNECTED is the
browser's fullscreen gesture, but Guacamole.Mouse forwarded it to guacd
as well, and the transition that followed swallowed its release. guacd
was left with the button held and the terminal selected text on every
pointer move; the same with a key press as the gesture.

Stop the gesture event before the display's handlers see it, and on
entering fullscreen release everything explicitly: keyboard.reset() and
a mouse state with no buttons at the last pointer position, so a release
lost for any other reason during the transition cannot stick either.

Fixes #256

(cherry picked from commit ee03b3e65c)
2026-10-12 19:30:05 +11:00
Jad Seifeddine 026b94911a tests: pin the framebuffer fit rules from #251
tests/client-fit.mjs lifts applyFill() out of client.html and runs it on a
stand-in element: a pixel-exact picture is never stretched, a resampled one
fills a gap of at most 2.5% per axis, a larger gap keeps the letterbox, and
nothing is ever shrunk through the fill.

(cherry picked from commit e7d43ecf28)

Upstream: the CI wiring for the e2e job is not carried (that job is not
in this tree); the test runs in the existing CI job instead.
2026-10-12 19:30:02 +11:00
Jad Seifeddine 7bfe0a3e53 client: keep a pixel-exact RDP picture crisp and centre the mod-16 margin
Stretching the display to fill a mod-16 shortfall costs sharpness when the
picture is already drawn one framebuffer pixel per physical pixel, and
nearly every RDP session hits the rounding. So the fill now applies only
when the picture is already being resampled (scale x devicePixelRatio != 1),
where it costs nothing; a pixel-exact session is never stretched, and the
framebuffer is centred in the available area so the small margin is split
evenly in the dark background instead of showing as a bare strip down one
edge. offsetLeft includes the centring margins and ignores the fill
transform, so the pointer maths that divides out fillX/fillY is unchanged.

Fixes #251

(cherry picked from commit d562946d0b)
2026-10-12 19:29:49 +11:00
Jad Seifeddine 53cfe8b943 client: fill a small framebuffer shortfall instead of leaving a strip
Windows RDP without GFX rounds the requested width down to a multiple
of 16, so a 1710-wide window gets a 1696-wide desktop. The uniform fit
in scaleDisplay() then keeps the scale at 1 (the height matches) and
the missing 14px shows as a strip of bare page down the right edge.

After the uniform fit, stretch the display element by the remaining
fraction on each axis when it is at most 2.5%, and divide the same
factors out of pointer coordinates before sendMouseState. A larger gap
is a real aspect mismatch and keeps the letterbox. Multi-monitor
pop-outs reset the fill.

Fixes #251

(cherry picked from commit ebfab63ea7)
2026-10-12 19:29:49 +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
Dave Kempe a68a2b76c2 v1.10.5 v1.10.5 2026-10-09 13:50:53 +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 48225e26f6 session: resolve target hosts without blocking the runtime
Every connect checks the target against its network allowlist, and the
hostname was resolved with the blocking system resolver straight from
the async request handler. With a DNS server that does not answer
("Temporary failure in name resolution"), each lookup held a tokio
worker thread for the resolver's whole retry budget. A few at once, one
user retrying or two users clicking, occupied every worker, and
rustguac stopped answering anything, health checks included, so the
proxy returned 503 for everyone until the lookups gave up.

Lookups now go through tokio::net::lookup_host, which runs the resolver
on the blocking thread pool, with a 5 second limit after which that one
connect fails with "DNS did not answer within 5 seconds".

slow_dns_does_not_stall_the_runtime reproduces it: with a resolver that
never answers, a 2-worker runtime managed 1 heartbeat in 3 seconds
before this change and runs normally after it. It needs that resolver,
so it is ignored by default; the comment has the command to run it.

Reported by Jon Sobels-Craven (@jons-c).
2026-10-09 11:29:53 +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 39d69bde6c v1.10.4 v1.10.4 2026-10-06 17:58:00 +11:00
Dave Kempe 41dfb0734d connections: show the bulk Delete button in the danger colour 2026-10-06 17:56:37 +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 932873414b v1.10.3 v1.10.3 2026-10-06 14:10:51 +11:00
Dave Kempe df7228fca3 deps: adopt 5 dependabot bumps
tokio-rustls 0.26.6, pageant 0.2.3 and rand 0.10.3 in the main build;
pageant 0.2.3 and russh 0.63.3 in fuzz/. Lockfile only (#228, #233,
#234, #235, #236).
2026-10-06 11:17:25 +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 bdcf58dd1c docker: let rustguac decide first run, so db_path can move (#240)
The entrypoint created the docker-admin key whenever no file existed at
/opt/rustguac/data/rustguac.db. With db_path set anywhere else, for
example on a Kubernetes persistent volume, that file never exists, so
every container start tried to create the admin again and failed with
"UNIQUE constraint failed: admins.name".

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

Reported by @hit-rich.
2026-10-06 10:41:39 +11:00
Dave Kempe 6f10925c2e Merge pull request #239 from pletch/fix/install-restart-guacd 2026-10-06 10:31:29 +11:00
pletch 1c127bdbd1 fix(install): restart guacd when it is running, not only rustguac
make install replaces guacd's binary and libraries while the old guacd is
still running, and the end of the install restarted rustguac alone.
Requires= starts a stopped guacd but never restarts a running one, so an
upgrade rebuilt guacd and then went on serving the previous build until
someone restarted it by hand -- which looks exactly like a guacd change
that did nothing.

guacd is now restarted, ahead of rustguac, whenever it is active. Nothing
is cut off by it: sessions go through rustguac, which is already stopped
for the upgrade or was never running. A first install still leaves both
stopped.
2026-10-03 11:55:59 -04:00
Dave Kempe 44d17d7ca1 connections: move and clone entries server side, credentials included
Moving an entry to another folder was done in the browser as "create
a copy from the edit form, then delete the original". The page never
receives an entry's secrets, so the copy had no password, private key,
jump host credentials, Proxmox token or container password, and a
$variable reference stored in the password field was lost the same way.
The original was then deleted permanently. Clone had the same flaw.
(#232)

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

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

The target is written with Vault check-and-set (cas 0), so an existing
entry is never overwritten, even by a concurrent writer. Scope, folder
and names go through the existing Vault validation (only shared and
instance scopes, no traversal, .config reserved, so a copy cannot
replace a folder's access settings), and those failures are 400s. Both
actions are audited, headline only. A note on the shared helper records
that any future per-folder editing (#231) must require rights on both
the source and the target folder.
2026-10-03 16:26:42 +10:00
Dave Kempe c7c1c387ed v1.10.2 v1.10.2 2026-10-03 13:25:20 +10:00
pletch 010f41d15a feat(mouse): replay the moves the browser coalesced away during a drag
A busy main thread does not delay pointer movement, it discards it: moves
arriving while a handler runs are merged into one, and what is dispatched
afterwards carries only the last position. A drag feels that immediately --
the path between two points is gone, so a dragged scrollbar thumb is dropped
rather than merely lagged -- while a hover or a video degrades gracefully
under the same load.

getCoalescedEvents() returns what was merged. Replaying it in order restores
the path, though not the timing: nothing running on a blocked thread can do
that, and the positions still arrive together once the thread is free. What
changes is that the remote end sees where the pointer went rather than only
where it ended up.

Deliberately additive. The mousemove listener is untouched and still delivers
every position it did before, so touch, the compatibility mouse events it
produces and the counter that swallows them all behave as they did; the
dispatched position arrives twice and the second is dropped by the
unchanged-coordinates check already in move(). Migrating this file to pointer
events outright is the tidier shape and a far larger change -- every listener
would have to move together, or synthetic events from touch would arrive with
nothing left counting them down.

Only while a button is held, because the path matters for a drag and the
endpoint is all that matters for a hover, and replaying every merged move of
an idle pointer would multiply a session's upstream mouse traffic for movement
nobody can perceive. Capped and evenly sampled, because a pointer reporting at
1000Hz behind a thread blocked for 100ms offers a hundred positions for one
drag, and answering a rendering problem with a traffic one is no answer. An
ordinary pointer never reaches the cap.

The pop-out monitor windows get the same replay. They bypass Guacamole.Mouse
entirely -- it does not track the X axis correctly in a popup -- and map the
pointer themselves, so a drag on a second head would otherwise lose its path
exactly as the first one did. The sampler moves to a static so there is one
definition of the rule rather than two that can drift, and that path needs one
thing the main display does not: it sends unconditionally, where move() drops
an unchanged position, so it keeps what it last sent and drops a repeat --
keyed on the buttons as well as the position, since a press and a release
happen at one place and are not repeats of each other. Feature-detected on the
event rather than on PointerEvent, because a pop-out window is a realm of its
own and the constructor this page can see is not the one that made the event
it is handed.

That pointer handling lives in Guacamole.MonitorPointer rather than inline in
client.html, because these windows cannot be opened without a live
multi-monitor session: while it sat in the page the only thing a test could do
was match the source text and hope, and the two things most worth checking
there are a coordinate mapping and a suppression rule -- exactly the kind that
look right in a diff and are off by a term. It takes the canvas, a function
returning this monitor's rectangle in the combined framebuffer (a call rather
than a captured value, since the layout changes under a running window) and a
function to send a state.

Writing those tests found a real defect in the wheel, which sends a momentary
press and release: it built one state object, sent it, mutated it and sent it
again, which works only while every consumer copies synchronously. It sends
two states now.

Tested rather than tried, because this path only runs once the browser has
already thrown something away: the case it exists for is the one that cannot
be reliably produced, and a broken version feels like the behaviour it
replaced. Twenty tests cover the mapping, the button mask, the repeat
suppression, the wheel's reset of it and the replay, plus a harness driving
real pointer input through the debugger with the main thread deliberately
blocked -- because the unit tests feed events shaped the way the code expects,
and that assumption is the one worth checking against a browser.

(cherry picked from commit d8ce743fe0801bd8c776281ee5541abf8040a339)
2026-10-03 13:00:09 +10:00
Dave Kempe d7e4349d0b style: rustfmt the asset versioning picked from the fork 2026-10-03 12:00:59 +10:00
pletch c940470b18 fix(h264): 'prefer-hardware' is not a hint, and refusing it lost the picture
A client with no hardware H.264 decoder lost H.264 altogether, and lost it in
the worst available way. Chrome reports a configuration carrying
hardwareAcceleration 'prefer-hardware' as unsupported outright rather than
falling back, so configure() failed asynchronously, the decoder closed, every
frame was then held waiting for a keyframe that cured nothing -- and since
guacd suppresses ordinary image operations for a layer carrying H.264, what
the user saw was a permanently black screen rather than a degraded picture.

Measured on Chrome 153 against an Intel UHD 770 with no VA-API decode
available: isConfigSupported() answers supported for avc1.4d4020 with no hint
and with 'prefer-software', and not supported with 'prefer-hardware'. The
same box then decoded the stream perfectly once the hint was dropped.

So ask for hardware once and, if that is refused, build again without asking.
Dropping the hint unconditionally is the smaller change and the wrong one: it
hands the choice to the browser on every client, including the ones this
whole path exists for, where a software decode costs far more than it saves.

It says so when it happens. A session quietly decoding H.264 in software is
worth knowing about -- it is the difference between the cost this feature was
built to avoid and the cost it was built around -- so the fallback reports
decoder_software_fallback rather than only appearing as a slow session.

Tested, because the hint reads like a preference and restoring it would look
harmless. The test drives the shape Chrome actually presents -- configure()
accepted, the refusal arriving later through the error callback -- and checks
that hardware is asked for first, asked for only once, that no frame is
stranded by the refusal, and that the fallback is audible.

Reachable on a VM, a machine whose driver is blocklisted, or a browser with
acceleration switched off, none of which are exotic.

(cherry picked from commit 59fc416e80daab20ac07bdac5dbebb6ff7eddf75)

Upstream: ported by hand, as the fork's decoder has diverged. The
fallback reports through console.warn only, since the fork's diagnostic()
channel is not carried here. tests/h264-hardware-fallback.mjs passes
unchanged (node tests/h264-hardware-fallback.mjs).
2026-10-03 11:59:36 +10:00
Dave Kempe 1e072468e6 install: keep operator files in static when removing stale ones
The mirror picked up from the fork (rsync --delete, or rm -rf without
rsync) would delete anything an operator put in $PREFIX/static, and
docs/configuration.md tells them to put a theme logo there. Each install
now records the files it copied in $PREFIX/.static-installed-files
(outside static/, so it is not served), and the next install removes only
files from that record that have left the repo. The stray nested
static/static copy, which predates any record, is removed by name.
2026-10-03 11:58:50 +10:00
pletch e3cec1b365 fix(client): show the remote pointer as the browser's own, not beside it
Guacamole.Display puts its software cursor layer in the DOM from
construction, and client.html never handed the pointer over -- no oncursor
handler, nothing hiding the browser's arrow -- so both were always drawn.
They overlap at rest and separate during fast movement: the browser's is
composited by the OS, while the software one is moved by the page on each
mousemove and trails by however long the main thread takes to get to it.

The remote pointer image now becomes the CSS cursor and the layer is hidden,
falling back to the layer only where a CSS cursor is refused. Declared
through image-set() at the display's scale rather than via
Guacamole.Mouse.setCursor(), which assumes one image pixel per CSS pixel
and would draw a HiDPI host's pointer at twice its size under Native
Resolution. Re-applied from scaleDisplay(), since the scale changes under
a pointer that does not.

(cherry picked from commit 9cc6f77f2ee3e48a10d38055d6fe8debffd37ef8)
2026-10-03 11:57:54 +10:00
pletch ea6e4e95cf fix(install): give the one prompt a flag, and say where the answers live
The encrypted drive for RDP file transfer is the only question this installer
asks, and the only way to answer it ahead of time was an environment variable
that --help did not mention. So the one thing a scripted install needs to know
was the one thing the usage text left out, next to --no-deps and --no-tls,
which are flags precisely because skipping a step is a normal thing to want.

--no-drive is an alias for RUSTGUAC_DRIVE_SETUP=no rather than a second switch
reaching the same place, so there is one variable to read when the answer comes
out wrong. Setting it during argument parsing also lets it win over an
inherited RUSTGUAC_DRIVE_SETUP=yes, which is the precedence an explicit flag
should have.

--help now lists all five drive variables and, more usefully, the three ways
the prompt already answers itself: an existing container, a missing cryptsetup,
or stdin that is not a terminal. Someone seeing the prompt on one machine and
not another is looking at one of those rather than at a bug, and someone not
seeing it where they expected it is looking at the first.

It also points at PREFIX/guacd.env, since the neighbouring trap is setting
something directly in a unit file the next install rewrites.

(cherry picked from commit 8d2f2c000a62d5125e71f2e5395c9edef08ea81a)
2026-10-03 11:57:46 +10:00
pletch e6b61915dc fix(install): stop before overwriting, mirror the assets, never block on a prompt
Three failures, all of which cost time during a day of deploys and all of
which present as something other than what they are.

The binary could not be overwritten while the service ran. cp reported "Text
file busy" and left the old binary in place, after the script had already said
it built the new one -- a deploy that changed nothing and looked like a deploy
that worked. It now stops the service first and restarts it afterwards, but
only when it was already running: a first install still leaves it down so an
admin can be created before anything is reachable, and it checks the service
actually came back rather than assuming.

The static assets were copied over the top, so nothing ever left. That is not
just clutter, because ServeDir is rooted at the install's static directory and
whatever sits there is reachable: a stray nested copy of the entire tree lived
at static/static from July until September 2026, serving two-month-old HTML
and JavaScript at /static/... to anyone who asked, and three files from an
abandoned branch outlived the branch the same way. Mirrored now, with rsync
where it exists and a replace where it does not, and copying `static/.`
instead of `static/*` -- the latter being one slip from `cp -r static dest/`,
which nests the tree rather than filling the directory, and is how that copy
got there.

And the optional drive prompt blocked for ever without a terminal. It sits
after the binary and the assets are installed, so the hang looked like a
completed deploy whose service simply had not come back. It is skipped when
stdin is not a tty, and says how to answer it from a script.

(cherry picked from commit b24d3c1b80c152a20558301a14dfeb3edd7a5a1a)
2026-10-03 11:57:45 +10:00
pletch e1cc4a9887 fix(http): let the browser find out a page has been rebuilt
The branded HTML handlers returned `Html(...)` and nothing else: no ETag,
no Last-Modified, no Cache-Control. With no freshness information at all
Chrome caches heuristically and does not revalidate, so a restart served
the old page for hours -- incognito was the only reliable way to see a
change. The static assets had the same gap from the other side: ServeDir
already sent ETag and Last-Modified and honoured both, it was just never
asked, so a rebuilt /guac/*.js was invisible too.

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

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

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

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

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

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

(cherry picked from commit d9b361db6eb216d871f3fdac41c423a586e2d559)

Upstream changes on pick: the client.html resize skip is kept but
without the native-resolution sizing (displayScale/deviceSize), which
belongs to the HiDPI commit not taken here, so sizes stay in CSS pixels.
The oidc.rs hunk was formatting only and is superseded.
2026-10-03 11:57:23 +10:00
pletch 5fccb4ceee fix(install): keep guacd settings across installs, and surface systemd drop-ins
The installer rewrites both unit files on every run, so anything set directly in
them -- or through "systemctl edit" -- is lost at the next install. guacd's
environment moves to $PREFIX/guacd.env, created only when absent and never
overwritten, and the unit reads it through EnvironmentFile. That is where
GUACD_LOG_LEVEL belongs, since trace is what prints per-surface-command codec
detail and debug what prints it per frame.

Systemd drop-ins are the other half of the same trap. They merge with the unit
just written and take precedence over it, but live in a directory this script
never touches, so a forgotten one silently overrides everything here --
including ExecStart, which discards the log-level wrapper entirely. Any that
exist are now listed with their contents, and the command that shows the unit
as systemd actually assembles it.

BUILD-CONTAINER.md records the container build this runs in, since guacd is
built from source against FreeRDP 3 rather than installed from a package, and
the environment a measurement was taken in is otherwise left to be guessed at.

(cherry picked from commit 30558b38be1ea807d1271fe7b3a89f7717c7abc4)

Upstream: BUILD-CONTAINER.md and the fork notes were left out.
2026-10-03 11:56:24 +10:00