22 Commits

Author SHA1 Message Date
Dave Kempe 429268cc3a rdp: a keyboard layout of your own, for connections that don't set one (#318)
The RDP keyboard layout could only be set per connection, and VDI desktops
never got one, so a Swiss or German user on a desktop set up for their
layout typed with a US keymap: Y and Z swapped and no umlauts.

Settings > My keyboard layout on the Connections page is remembered in that
browser and sent with each connect as keyboard_layout. The server uses it
for an RDP entry left at the server default and for VDI desktops; a layout
set on the entry still wins, and a name guacd has no keymap for is dropped.
The entry editor also lists the nine guacd layouts it was missing.
2026-10-21 13:49:24 +11:00
Dave Kempe 87fffebe27 vnc: network presets, like UltraVNC Viewer's quick options
A VNC connection can choose how the server encodes its screen for
rustguac: Auto (default, unchanged), LAN (Ultra/ZRLE/Hextile first, low
compression, top JPEG quality), WAN (Tight/ZRLE, compression and JPEG
quality 6) or Slow link (Tight, compression 9, JPEG quality 3, 16-bit
colour unless the connection sets a colour depth). They set guacd's
encodings, compress-level and quality-level; Auto sends none of them.
An unknown preset is refused rather than ignored. Web sessions are
unchanged.

Docs: the VNC section of the overview now covers screen size, the
presets, and which VNC authentication methods work (VNC password,
VeNCrypt, macOS, UltraVNC MS-Logon II) and which don't (RSA-AES,
UltraVNC encryption plugins, MS-Logon III).
2026-10-20 15:37:43 +11:00
Dave Kempe 70e8bf4b85 vnc: don't ask the server to resize unless the entry says it can
guacd asks a VNC server to resize its screen to the browser window by
default. A physical console (UltraVNC, RealVNC, x11vnc) can't, and
libvncclient then stops requesting updates while it waits for a resize
that never comes ("Skipping Update - resize in progress"), so the
session all but freezes. Seen with UltraVNC on a two-monitor console.

VNC entries and ad-hoc VNC now send disable-display-resize unless the
new vnc_resize option is on ("Resize the remote desktop to fit the
window" in the editor), and guacd scales the screen instead. Web
sessions, on our own Xvnc, still resize. The VNC argument mapping moves
into vnc_arg_value() so it can be tested.
2026-10-20 15:19:21 +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
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
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 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
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
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 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
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 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
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 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
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