Add an autohide_side_tabs option (Option<bool>, default off) on the address
book entry, threaded through the same path as fullscreen_on_connect
(AddressBookEntry, EntryInfo, CreateSessionRequest, Session, SessionInfo, the
API connect/quick-connect builders, and import defaults). When set, client.html
slides the left-edge Clipboard and Files tabs off screen when idle and brings
them back when the pointer nears the left edge; defaults preserve the current
always-visible behaviour. Checkbox added to the entry editor.
These three RDP visual flags were hardcoded off in guacd.rs. Expose them
as per-connection options (Option<bool>, default false) threaded through
the same path as enable_desktop_composition: RdpParams, the session
request, Vault entry + response, the API connect/quick-connect builders,
import defaults, and the connections.html entry editor (Video Performance
section). Defaults preserve existing behavior; VDI sessions stay off.
Cherry-picked from pletch/rustguac@da3cfda
Backend stores timestamps as SQLite datetime('now') (UTC, no zone marker) and the
admin page printed them verbatim, so last-login/created/last-used/audit times read
as GMT. Add a localTime() helper that tags the unzoned string as UTC and renders
toLocaleString(); apply it to all full date-time cells. Date-only token columns
are left as UTC dates (localizing a 23:59:59Z expiry could roll the date a day).
Cherry-picked from pletch/rustguac@b5ea32e
The recordings page rendered every recording into one table, which got
unwieldy with a large backlog and pushed the SSH Typescripts section far
down the page. Add client-side pagination (50/page, Prev/Next) to both
the recordings and typescripts lists; it composes with the existing
search and sort, and auto-refresh preserves the active filter + page.
Also surface where typescripts live on disk: /api/typescripts now returns
{path, items} (endpoint is new in this release, so no compatibility
break) and the typescript section shows "Stored at <path> on the rustguac
host" — useful since the content is intentionally not downloadable.
No new endpoint.
Typescript recording is now per-connection opt-in, off by default. Adds a
record_typescript flag on the address-book entry (Vault), threaded through
EntryInfo / CreateSessionRequest, and a "Enable typescript recording for
this session" checkbox in the connection editor's Recording Settings (SSH
entries only). The SSH branch records a typescript only when the entry has
opted in AND [recording].typescript_path is configured globally. Ad-hoc
SSH sessions (no entry) never record.
Docs: document the per-connection opt-in, and add a LUKS-at-rest recipe
(point typescript_path at a subdir of the LUKS-encrypted drive volume
rustguac already mounts) as the recommended way to encrypt typescripts at
rest with no extra infrastructure.
Add GET /api/typescripts (poweruser+) and an "SSH Typescripts" section on
the recordings page. List-only by design: it shows that a session was
recorded (name, size, time) but never serves or downloads the content.
Typescripts capture full terminal output, which can include passwords
typed at prompts or secrets printed to screen, so exposing the text via
the web UI would widen its blast radius. A poweruser gets accountability
(a session was recorded) while retrieving the actual log still requires
direct access to the rustguac host or storage. There is deliberately no
serve or delete endpoint, hence no name parameter and no path-traversal
surface. The .timing sidecar is filtered out so one row == one session.
The v1.7.2 floating "⛶ Fullscreen" corner button at 0.45 opacity was
still 80px of permanent clutter in the top-right of the remote session
display before fullscreen was entered. This PR moves the manual
fullscreen action into the existing Ctrl+Alt+Shift session-menu panel
(next to the Home button), removing the floating overlay entirely.
The per-entry `fullscreen_on_connect` flag and the in-fullscreen top
bar (entry name + Exit + Disconnect) are unchanged. Esc-key forwarding
via navigator.keyboard.lock still applies.
Also adds an "In-session keyboard shortcuts" section to
docs/web-sessions.md documenting the Ctrl+Alt+Shift panel toggle,
Ctrl+V clipboard paste-sync, Esc behaviour, and the disable_copy /
disable_paste interaction.
Closes#156.
The in-fullscreen top bar covered the remote desktop's own menubar
(xfce4 panel, Windows taskbar). Match the mstsc.exe pattern that #154
referenced: show briefly on fullscreen entry, then slide up out of
view. Reveals when the mouse hits the top 4px edge; hides again ~600ms
after the mouse moves below the bar area. Hysteresis between 36 and 48
pixels keeps a jittering pointer from flickering the bar.
Per-entry boolean fullscreen_on_connect flag. When set, the client enters
browser fullscreen on the first user gesture after CONNECTED and locks
the Escape key (Chromium navigator.keyboard.lock API) so it reaches the
remote session instead of exiting fullscreen. Firefox / Safari fall back
to standard fullscreen with a one-time toast explaining Esc will exit.
A small floating "Fullscreen" toggle in the top-right corner lets any
user enter fullscreen at any time once the session is connected. In
fullscreen mode a thin top bar shows the entry name plus Exit and
Disconnect buttons.
Closes#154.
Themes were a Vec hardcoded in src/config.rs (builtin_presets()) -
every new preset required editing Rust, recompiling, and shipping a
new release, for what is purely presentation data. PR #148 from
@dav0l surfaced this nicely by failing to compile on a brace count
in the array.
This change adds a config::load_themes(&static_path) loader that
starts from the eight built-in presets (unchanged) and then merges
in any *.toml files from <static_path>/themes/. Disk themes can
add new entries or override a built-in by using the same name; the
existing builtin_presets() remains as the always-available fallback
when the themes directory is missing or empty.
File format: flat TOML table, one file per theme, filename (minus
extension) is the theme id. See static/themes/catppuccin-macchiato.toml
for a full example. Theme names are validated against the same
allowlist we use for Vault entry names ([a-zA-Z0-9_-]{1,64}) so they
are safe to render in the UI picker and in log lines, and can't be
used for path traversal or homoglyph mischief via crafted filenames.
dav0l's Catppuccin Macchiato palette lands here as
static/themes/catppuccin-macchiato.toml - their submission is the
first user-contributed theme under the new mechanism. Closes#148.
Backward compatibility: explicit. ThemeConfig::resolve() is now a
thin wrapper over resolve_with(builtins), so existing test callers
and any production callers see no behavioural change. Existing
[theme] sections in user config.toml files - preset only, preset +
overrides, overrides only, empty section, typo'd preset - all
resolve byte-equal to 1.7.0 (verified by the new
existing_user_config_with_theme_section_keeps_working_after_upgrade
test). Eight new tests in total cover the loader, the override
behaviour, the filename validation, and the upgrade scenario.
Docs broken out: themes get docs/themes.md (the full reference);
docs/configuration.md is trimmed to a brief stub and pointer.
No build-system changes needed - debian/rules, install.sh and the
Dockerfile all use recursive `cp -r static/` so the new themes
subdirectory is picked up automatically.
Adds diagnostic infrastructure for #118 (black tile investigation) and
similar protocol-level rendering bugs. The browser is the most honest
observer of what guacd actually emits, so tapping the instruction stream
here lets us see drawing-op-level detail without changing guacd or
chasing the resize-callback theory.
The previous logger printed each opcode + arg count once per session,
which doesn't help locate a black region. This logs the meaningful args
of draw opcodes (rect, cfill, copy, img, dispose, size) into a 2000-entry
ring buffer, decoded into {op, layer, rect, note}. Coverage is zero-cost
in the steady state; the buffer is consulted only when the user calls
the dump helper from the DevTools console.
Two entry points:
rustguacDumpDraws() Print all recent draw ops as a console.table.
rustguacDumpDraws(x, y) Filter to ops whose rectangle covers that
pixel - point at a black tile's top-left to
see exactly what painted there.
`cfill` with rgba(0,0,0,255) is annotated `<BLACK>` so guacd-emitted
black fills jump out. `copy` ops carry the source layer + coords in the
note so we can see when guac_display's search_for_copies optimisation
sources a region that was itself black or uninitialised.
For live per-op logging append `?debug=draw` to the client URL; the
ring buffer is recorded regardless.
Ctrl+V (and any modifier chord) could leave Ctrl/Alt/Shift stuck "down"
on the remote. The Ctrl+V paste path calls navigator.clipboard.readText(),
which shifts focus to a clipboard-permission prompt; the subsequent
modifier keyup then lands off-page and Guacamole.Keyboard never sees it,
so the keyup is never forwarded to the remote. The modifier stays held
until pressed again.
Adds blur + visibilitychange handlers that call keyboard.reset(), which
releases every tracked key (firing onkeyup -> sendKeyEvent(0, ...) for
each). This is the canonical Guacamole guard for focus-loss key sticking
and also covers alt-tab-with-modifier-held and clicking away to another
app. We already reset() on clipboard/file panel close; this extends the
same guard to window focus loss.
Closes#132.
VDI containers come in two patterns: ones whose entrypoint reads
VDI_USERNAME / VDI_PASSWORD env vars and provisions an account
matching them (the contrib/vdi-test-image style), and ones with a
baked-in fixed account that ignore those env vars. Pre-change, only
the first worked; users with baked-image containers had to log in
manually inside the session because rustguac's auto-derived RDP
credentials never matched the container's actual account.
- AddressBookEntry gains optional container_username and
container_password fields, persisted to Vault.
- When set, session.rs uses those values for the RDP connect into
the container instead of deriving the username from the
operator's identity and generating a per-connect password.
- VDI_USERNAME / VDI_PASSWORD env vars are still injected with the
resolved values - images that read them get consistent state;
images that ignore them keep using their baked-in account.
- The container name derives from the resolved username, so an
entry with a fixed container_username produces a container
shared by all operators connecting through that entry. Documented.
- EntryInfo exposes container_username back to the editor;
container_password is never serialised to clients (has_container_password
boolean indicates whether one is stored).
- The entry update handler preserves container_password when not
supplied on update (same pattern as password / private_key).
- Both fields go through entry_credential_variables and
resolve_credential_variables, so the actual values can be sourced
from each operator's saved credential variables ($corp_username etc).
- Connections UI gains the two fields with explanatory text linking
out to the VDI docs and credential variables docs.
- docs/vdi.md restructured around the two patterns (Pattern A:
env-var driven, Pattern B: baked-in account) with the
container-sharing note for Pattern B.
- docs/credential-variables.md gains an explicit "where variables
can be used" table covering the new fields.
Subtle side fix: env merge in session.rs used to call
env.entry(K).or_insert(V), which meant a user-supplied
VDI_USERNAME in container_env would silently win over the
auto-derived one - opposite of the documented intent
("Don't let user-provided env override the core VDI vars").
Switched to env.insert() so the resolved values always win.
Adds a find-as-you-type search input to the Connections page entries-header
with global search over every entry the user has access to. Search runs
client-side against an in-memory index built from a new endpoint.
Backend (GET /api/addressbook/search-index):
- Iterative tree walk (BFS over (scope, path) queue) using list_folders +
list_subfolders.
- Subfolder traversal is unconditional because resolve_folder_access permits
a child to grant access independently of a denied parent; ACL is enforced
per folder before its entries are emitted.
- Returns flat {entries: [{scope, folder_path, entry: EntryInfo}]}.
- Operator role required, admin bypass.
Frontend (static/connections.html):
- Search input lives in .folder-actions between folder title/desc and admin
buttons; auto right margin keeps add/edit/delete folder buttons hard-right.
- loadSearchIndex runs once after loadFolders; placeholder shows "Indexing..."
until ready.
- Tokenized substring matcher with simple scoring (name-prefix > name-substring
> host > folder-path); cap at 50 results with "+N more" footer.
- Results render in entries-table styling with a Folder breadcrumb column,
inline Connect, and an "open folder" link. Matched substrings highlighted
with <mark>.
- Connect from search results looks up the entry in searchIndex (not
currentEntries) when searchActive is true.
- "open folder" walks the tree, expands ancestors via loadSubfolders chain,
selects the target, scrolls into view, clears search.
- Keyboard: / focuses the input (skipped in inputs/textareas/modals); Esc
clears the query then blurs.
CSS (static/rustguac.css):
- .connections-search styling, mark highlight, breadcrumb cell,
search-open-folder link, and search-more footer.
The previous commit (Fix zombie WebSocket) wired tunnel.onerror /
onstatechange into client.onerror so the disconnected overlay
appears when a mid-path WS drop is detected. That fixes the
"frozen tab" symptom but leaves the Reconnect button doing
window.location.reload(), which re-opens client.html?session=<id>
against a session that no longer exists. By the time the WS
proxy task in src/websocket.rs returns BrowserEnded, disconnect_viewer
has decremented active_connections to 0 and complete_session has
flipped status to Completed; the guacd stream was dropped when the
proxy task ended. So a reload just walks back into the same overlay.
Reconnect now relaunches against the original Connections entry
when one exists, which is the user's actual intent. The fetch at
the top of setupClient already pulls /api/sessions/:id; we now
stash address_book_entry, address_book_folder, and
entry_display_name from that response into a relaunchInfo object.
On Reconnect click:
- shareToken viewer: reload (unchanged; nothing better to offer).
- relaunchInfo present: POST to
/api/addressbook/folders/{scope}/{folder}/entries/{entry}/connect
with the current window dims and DPI, then navigate to
data.client_url + ?name=<display>. Fresh session id, fresh
guacd connection, RDP/xrdp resumes the desktop state on the
server side.
- relaunchInfo absent (ad-hoc) or any non-2xx (412 missing creds,
403 forbidden, 404 entry deleted) or network error: redirect to
/connections.html so the user can fill in what is missing or
pick somewhere else to go.
Splitting scope/folder/entry from the composite entry_key relies
on the constraint enforced in vault.rs validate_name: entry names
cannot contain slashes. Folder paths can, but only ever sit
between scope and entry, so first-slash gives scope and last-slash
gives entry.
Bundled fix: client.onerror now clearInterval(_thumbInterval) when
it runs. That closes the secondary leak called out in the prior
commit message (thumbnail XHR uploader continuing to 404 against
an already cleaned-up session) for users who walk away from the
overlay rather than clicking Close or Reconnect immediately.
Web sessions (Chromium + Xvnc) are entry-backed too and will
relaunch via the same path. The new session means a fresh
Chromium process with empty tab state, but that matches what the
user expects from Reconnect on a web session anyway.
When a mid-path middlebox silently drops the TCP between the browser
and HAProxy, HAProxy closes the backend which rustguac logs as
"Connection reset without closing handshake" (termination state CD--
in HAProxy's log). Firefox's WS socket then fires onclose on the
browser side, Tunnel.js's close_tunnel() runs, and the tunnel's
internal state transitions to CLOSED.
But upstream Guacamole's Client.js doesn't listen for tunnel.onerror
or tunnel.onstatechange — the Apache webapp's AngularJS wiring does
that externally. Our lean client.html inherited Tunnel.js + Client.js
verbatim from upstream but missed that glue, so tunnel errors fired
into the void and the Guacamole client stayed in CONNECTED forever.
Effect on users: a dead session that looks like a freeze. Mouse moves
locally, clicks don't register (they're being sent into a closed
WebSocket whose send() silently no-ops at the browser layer). The
"Session Ended" overlay never appears. Meanwhile the thumbnail
uploader keeps running on its own XHR stream, getting 404s from the
already-cleaned-up session.
Fix: wire tunnel.onerror to forward into the existing client.onerror
handler, and wire tunnel.onstatechange so CLOSED also triggers the
overlay (and UNSTABLE updates the status text). This reuses the
existing overlay wiring at client.html:895; no new UI, no new state
machine, no new heartbeat. The upstream-inherited 5s nop keepalive
and 15s receiveTimeout in Tunnel.js are already present and
correct — the missing piece was just propagating their output into
the client's state cascade.
Triggered a lot of detective work chasing false leads (H.264 decoder
closed-state hypothesis, h2 bridging bug, HAProxy timeout tuning,
client/server heartbeats). The actual cause was much simpler and
the diff is six lines of JavaScript.
### RDP NTLM default
New [rdp] config section with default_auth_pkg. The resolver in
session.rs walks entry value -> config default -> hardcoded "ntlm".
Kerberos/Negotiate are still selectable per-entry or via the config
override, but the default is NTLM because Kerberos needs a KDC
reachable via DNS (often over TCP) and its failure mode is a silent
RDP hang that looks exactly like an unrelated network issue.
Existing entries and Guacamole-imported entries that stored
auth_pkg = None now resolve to NTLM automatically. Admins who do
run Kerberos-integrated hosts can set default_auth_pkg =
"kerberos" or "negotiate" in config.toml to restore the old
behaviour.
UI: the entry modal's NLA dropdown now says "Server default (NTLM)"
instead of "Default (negotiate)" so the behaviour matches the
label. Added an explicit "Negotiate (Kerberos first, NTLM fallback)"
option for completeness.
5 unit tests cover the resolver matrix (entry wins, empty entry
falls through, no entry falls through, empty config default falls
through, server default wins when entry is None).
### Connections tree persistence
Folder expansion state and the selected folder are now persisted
to localStorage, so reopening the page / logging back in no longer
collapses the whole tree or snaps you back to the alphabetical
first folder.
- `rustguac_connections_expanded`: {scope|path: true} map, saved
on every toggleFolder() and on the auto-expand-on-subfolder-
create path.
- `rustguac_connections_selected`: {scope, path}, saved on every
selectedFolder assignment (click, new folder, new subfolder,
delete-to-null, move entry).
On page load, loadFolders() now chains: fetch top-level folders ->
restoreExpandedTree() walks saved keys shallowest-first so deeper
paths can resolve via findFolder() after their ancestors populate
subfolderCache -> try restoring saved selection -> fall back to
the current auto-select-first behaviour only if nothing restored.
Stale keys (deleted folders, ACL-revoked folders) are dropped
opportunistically during the restore walk. Per-browser, not
per-user; try/catch wraps every storage call so private-mode / quota
errors degrade silently to the pre-persistence behaviour.
New SQLite table addressbook_audit_log records destructive and
mutating actions against the connections tree: create_folder,
update_folder, delete_folder, create_entry, update_entry,
delete_entry. Each row captures user_email, action, scope,
folder_path, optional entry_name, client IP, a small JSON details
blob, and created_at.
Details is deliberately headline-only: counts for delete_folder
(subfolders_deleted, entries_deleted), allowed_groups_count +
inherit_from_parent for folder writes, session type for entry
writes. Entry field values (password, private_key, hostname) and
full request bodies are never written. Audit rows live in SQLite,
not Vault, so logging content would leak Vault-only secrets onto
disk.
The six handlers in api.rs gain ConnectInfo + HeaderMap +
TrustedProxies + Db extractors so client IP can be resolved
through the existing auth::client_ip helper (respecting trusted
proxies) and the audit row can be written post-success.
New admin-only endpoint GET /api/admin/addressbook-audit mirrors
the existing token audit shape (limit + email filter, capped at
1000). admin.html gains a "Connections Audit Log" section below
the token audit, auto-loaded on page show.
cleanup_old_audit_log now also sweeps the new table on the same
retention window as token_audit_log.
Prior behaviour: delete_folder only cleared entries and .config of
the named folder; subfolder .config keys were left behind. Pre-v1.6.0
that was fine because subfolders didn't exist. Post-subfolders the
UI would refresh after delete, list_children would still find the
orphaned subfolder markers, and the folder would appear stuck (the
DELETE request returned 204, no error surfaced, nothing looked
broken except the folder refused to go away).
delete_folder now BFS-collects every folder path in the subtree
before wiping entries and .config at each level. Return type changes
from Result<(), _> to Result<(usize, usize), _> so the endpoint can
report (subfolders_deleted, entries_deleted) back to the UI; the
single caller in api.rs is updated.
UI: confirmation prompt now explicitly mentions "AND all its
subfolders and entries" when the selected folder has_children is
true, so admins don't nuke a subtree by accident. After a successful
delete, a transient banner reports the number of entries (and
subfolders when > 0) that got swept up.
FolderConfig gains `inherit_from_parent: bool` (default false via
#[serde(default)] so existing deployments keep their per-folder-only
semantics). When true, if a folder's own allowed_groups doesn't grant
the caller, the access check walks up the slash-separated path and
evaluates each ancestor the same way. Admins still bypass all checks.
Inheritance stops at any folder with the flag off, preserving the
ability to lock down a specific subtree.
New resolve_folder_access helper in vault.rs centralises the walk-up
logic; check_folder_access, ab_list_folders, and ab_list_all in
api.rs all route through it so access semantics live in one place.
Import: new --allowed-groups flag writes the ACL onto the root import
folder only; subfolders are created with inherit_from_parent=true so
the whole imported tree picks up the same rules without per-folder
writes. Matches the pattern admins will want after an initial
Guacamole migration where every connection group should be visible
to the same OIDC group.
Related fix: list_credential_variables in api.rs no longer scans only
top-level folders — it now recurses into subfolders so variable
references buried in an imported tree show up on the My Credentials
page (previously they stayed invisible post-v1.6.0 when subfolders
became first-class).
UI: folder modal gets an "Inherit permissions from parent folder"
checkbox. Defaults: unchecked for new top-level folders (no parent
to inherit from), checked for new subfolders, and reflects the
stored value when editing.
Feature #103: single-entry auto-connect
- New `auto_open_if_singleton: Option<bool>` on AddressBookEntry and
EntryInfo. Admin ticks it per entry in the Connections modal;
importer initialises to None.
- After the /api/addressbook batch resolves on the Connections page,
if the user sees exactly one entry and it has the flag set, the
page fetches /connect and navigates the current tab to the new
session. Same-tab navigation (not a popup) because browsers block
window.open after an async fetch without a user gesture.
- A sessionStorage flag gates the auto-open to once per browser
session — an accidental refresh of Connections doesn't re-spawn
sessions. Logout clears the flag so the next login fires again.
- Admins never meet the singleton condition (they have many
entries) so this is effectively kiosk-only.
Escape hatch: client.html Home button
- Ctrl+Alt+Shift panel header now has a 🏠 Home button next to the
close ×. Takes the tab back to /connections.html — primary route
for a kiosk user whose session auto-opened into this tab.
Bug fix: folder-modal group picker silently added a group on save
- addFmGroupFromInput used to fall through to picking the combo's
highlighted suggestion when the input was empty. fm-save calls
addFmGroupFromInput to flush any typed-but-not-chipped text; with
an empty input that meant every Save on an existing folder tacked
on the first unselected known group (e.g. an operator group) —
visible only on the next edit. Split the "pick suggestion when
empty" behaviour out to an explicit helper that only fires from
Enter-with-empty-input, never from Save.
Per-entry Share gating
- AddressBookEntry gains an `allow_sharing: Option<bool>` (default
None = off). Admin opts in per entry via an Allow session sharing
checkbox in the entry modal.
- EntryInfo exposes the field so the modal prefills on edit.
- CreateSessionRequest carries it through; the ab_connect_entry
quick-connect and legacy quick-connect paths propagate the entry's
value.
- Session gets a share_allowed bool. SessionInfo.share_url is only
populated when share_allowed is true, so the Connections
Active Sessions Share button auto-hides without any new UI logic.
- Default behaviour on create:
* explicit allow_sharing on the request → honoured
* entry-derived session without flag → off (admin opt-in)
* ad-hoc session (no address_book_entry) → on
The ad-hoc default preserves the long-standing API-key
session-creation flow where external callers expect share_url in
the POST /api/sessions response.
Modal checkbox alignment (side-effect fix)
- The generic .modal input rule in rustguac.css was forcing every
input — checkboxes included — to 44px height + block + 100% width,
which misaligned every tickbox/label pair across the entry, folder,
and onboarding modals.
- Added carve-outs for input[type=checkbox] / [type=radio] that keep
native size, inline, with a small right margin.
- Labels that directly contain a checkbox/radio (via :has()) now flex
the control + text on one baseline with a clean gap, and drop the
uppercase letter-spacing used for full-width field labels.
Modified-stage-4: always-visible buttons, no hover-reveal.
- Each active session card now has a Terminate button next to Share.
Confirmation dialog on click, DELETE /api/sessions/{id}, reloads
the grid on success. Hover state uses .btn-danger (primary red).
- Share button still hides when share_url is absent so the upcoming
per-entry allow_sharing toggle gates it automatically.
- Share modal's caution text was a dim .field-hint that was easy to
miss. It now renders as a proper warning banner: 1px border +
bumped left edge in --status-pending (theme-aware yellow), ⚠ icon,
and larger body text for legibility.
Polish for the Sessions page now that it's the admin/poweruser
management view (share has moved to Connections, shadow landed in
stage 2).
- Page heading becomes a .section-head strip with a right-aligned
count span ("12 active · 3 yours" for admins seeing others,
"3 active" when the caller only sees their own).
- Empty state is role-aware: admins get the plain "No active
sessions", non-admins get a nudge toward Connections / the ad-hoc
form.
- Owner column is dimmed for own sessions and accent-teal bold for
others — lets admins eye-scan own vs others at a glance.
- Delete button is hidden on rows the caller can't delete (non-admin
viewing someone else's session). Backend check is still the
authoritative gate; this just tidies the UI.
Connections
- Clicking an active session card used to POST /connect for entries
with an address_book_entry, which minted a new session every time.
Confirmed in the log: a web-session owner clicked back to their
active card after a shadow join and got two duplicate sessions.
- Now the click just opens s.client_url — which attaches to the
existing session. Dormant VDI containers below still need /connect
(no live session to attach to), so that branch is untouched.
Tokens page
- Operator/viewer roles saw the Sessions nav link on the Tokens page
(every other page correctly hid it for level < 3). Clicking it
redirected straight back out because sessions.html rejects level < 3.
- Added the same hide-on-level-<3 logic as the other pages; also
reveal the Reports link for level >= 3 in the same pass.
Part of the shadow-sessions plan (stage 2). Admins can now join any
active session from the Sessions page without the user having to
share — the backend mints a short-lived token and every mint is
logged to token_audit_log.
Backend
- Session struct gets a shadow_tokens: Vec<ShadowToken> (sha256 hex
of the raw token, issuing admin, expiry). Expired entries are
pruned on mint.
- validate_share_token now accepts either the owner's share_token or
any non-expired shadow token, so the existing viewer path works
unchanged.
- POST /api/sessions/{id}/shadow (admin-only) mints a raw token,
pushes its hash + a 10-minute expiry onto the session, writes a
token_audit_log row (action = "shadow_session", details includes
session_id, owner, expiry, caller IP) and returns the viewer URL.
Frontend (Sessions page)
- /api/me fetched on load so we know the caller's display_name and
role before rendering. API-key users default to admin and fetch
/api/me to learn their name.
- Own active sessions still render "open" (anchor to client_url).
- Others' active sessions render "shadow" as an anchor (not a button)
so both action cells line up in the same column. Shadow uses
--status-pending (warning yellow) instead of the neutral accent,
hovers to --primary, and shows a "minting..." busy state while
the POST is in flight.
Part of the Sessions-page rework (shadow-sessions plan, stage 1).
The Sessions page is becoming an admin/poweruser management view;
user-initiated share lives with the user's own active sessions in
Connections.
Connections
- Each active session card gets a Share button in a new action row
below the thumbnail/meta.
- Share opens a themed modal with the full share URL pre-selected
and a Copy button (async clipboard with execCommand fallback).
- Clicking the card still reconnects — Share handler stops
propagation so the two actions don't collide.
- Overlay click or Close dismisses the modal.
Sessions page
- Share column removed (was columns 10 of 10 — down to 9).
- Dead CSS (.btn-share, .share-url) and JS (expandedShares,
shareFullUrl, rowId, the share/copy click handlers) removed.
- Ad-hoc jump-host styling (.btn-add-hop) stays.
Three pieces of v1.6.0 work that happened together and are easier to
review as one save point.
Rename: Address Book → Connections
- static/addressbook.html renamed to static/connections.html
- Nav links, page titles, empty states, onboarding, and prose updated
across all 8 static pages (connections, admin, docs, index,
recordings, reports, sessions, tokens).
- README, CLAUDE.md, and every file under docs/ updated.
- src/main.rs: connections.html added to the branded-page map and
route list; /addressbook.html returns a 308 permanent redirect so
existing bookmarks keep working.
- Backend API paths, Rust types, and Vault storage paths are
deliberately unchanged — internal only.
Folder allowed_groups picker
- New SQLite table `seen_groups` tracks OIDC groups observed in any
user login; OIDC callback upserts after extracting groups.
- `GET /api/auth/known-groups` (admin-only) returns the union of
group_role_mappings and seen_groups.
- `GET /api/addressbook/folders/{scope}/{folder}/config` adds the
missing endpoint the frontend was already calling — existing
allowed_groups now prefill the edit-folder modal.
- Folder modal swaps the free-text comma-separated input for a chip
picker with a themed combobox dropdown: autocomplete over known
groups, keyboard nav, "+ add custom" row for unlisted groups.
Active session visibility (GitHub #102)
- `GET /api/sessions` scopes to the caller's own sessions by default;
`?all=true` lets admins opt in (used by the Sessions page).
- `GET /api/sessions/{id}` and the thumbnail GET/PUT endpoints are
now owner-or-admin, returning 404 for other callers so session
existence isn't leaked.
- Connections' Active Sessions strip is now always owner-scoped —
admins still manage everyone via the Sessions page.
Frontend for #101 — subfolder support (backend landed in c2a3822) — plus
a site-wide visual overhaul extracted into a single shared stylesheet.
Address book
- Folder sidebar renders as a tree with lazy-loaded children via the
/api/addressbook/folders/{scope}/{path}/subfolders endpoint.
- Scope badge is now an icon with hover tooltip: ⊕ shared, ▣ instance.
- New "+ subfolder" button creates a nested folder under the selection.
- Move-entry dropdown includes any loaded subfolders.
- Batch folder API now returns path + has_children so the tree can
render chevrons without a second request per folder.
Design system (rustguac.css)
- Extracted ~700 lines of near-duplicate CSS from each page into a
shared stylesheet linked by every page.
- 18px body, strict 38/44/54px control heights, uppercase letter-spaced
section labels, zebra table rows, active-nav underline bar.
- Uniform button ladder: primary (red) / accent (connect, teal) /
ghost (+ buttons) / small (edit/delete chrome).
- Generic status colors, type badges, pagination, token-reveal,
summary cards, hop cards, and flow diagram now live in one place.
Per-page updates
- addressbook, admin, docs, index, recordings, reports, sessions,
tokens: style blocks reduced to page-specific layouts only.
- reports/recordings/sessions: bare <strong> page titles promoted
to <h2> for proper heading hierarchy.
- Stripped inline padding/font-size attributes that were overriding
the shared ladder.
- Switch default theme from dark to aurora across server config,
all 9 static HTML pages, example config, and docs
- Fix theme docs: list all 8 presets (was 6, missing jaguar/aurora),
add missing type_vdi_bg/type_vdi_fg fields
- Add Vault/address book setup as recommended post-install step in
installation docs — the address book is the primary user-facing
feature and requires Vault
- Renumber subsequent install steps
- Redact share_url from session listings for non-owners — previously
any authenticated user could enumerate share tokens and join sessions
they didn't create (share_url now only returned to session creator
and admins)
- Always rate-limit OIDC login/callback (1/sec burst 5 per IP)
regardless of rate_limit config, preventing brute-force on auth
- Fix disconnect instruction detection: use instruction boundary
matching instead of substring contains — clipboard content or typed
text containing "10.disconnect;" could falsely trigger VDI container
destruction
- Restrict sudoers chown rule to rustguac:rustguac only, preventing
arbitrary ownership changes on the LUKS mount point
check_server_key no longer blindly accepts all keys. Jump hosts now
support a host_key field stored in Vault alongside credentials.
- New POST /api/ssh/probe-host-key endpoint probes an SSH server and
returns its public key, fingerprint, and algorithm
- Address book UI: "Verify Host Key" button per jump host probes the
server, shows fingerprint for confirmation, stores key on save
- TunnelHandler verifies the server key against the stored key on
connect — rejects with detailed error on mismatch
- Unpinned keys accepted with TOFU warning log for backward compat
- host_key preserved through credential merging on entry update
Closes#95
Frame dropping at queue depth 5 triggered during normal operation and
broke the H.264 reference chain, corrupting output until the next
keyframe. Remove frame dropping entirely — sync gating alone provides
sufficient flow control by delaying the sync response until decodes
complete, giving guacd accurate backpressure.
The H.264 decode path bypassed the Guacamole sync mechanism — the sync
response fired immediately without waiting for WebCodecs to finish
decoding. guacd thought the client was keeping up and sent at full rate,
causing the decode queue to grow without bound (30+ seconds of lag
observed over time).
Fix: gate the sync response on H.264 decode completion so guacd gets
real backpressure. Also drop delta frames when the decode queue exceeds
5 frames as a safety valve for transient overload (tab backgrounding).
- Add pending-decode tracking and per-frame position capture to
H264Decoder.js (fixes shared mutable state race)
- Add waitForPending() with 1s safety timeout for sync gating
- Add frame dropping when decodeQueueSize > 5 (never drops keyframes)
- Add stats() method for console debugging (__guac_client._h264Decoder.stats())
- Gate sync response in Client.js on H.264 decode completion
Closes#93
Logout link moved inside the float-right user-menu-wrapper so both
Logout and Settings appear on the far right of the nav bar. Fixed
addressbook.html Settings font size to match other pages (1.3em).
Logout is now a standalone nav link next to Settings (Logout Settings
order, right-aligned). Applied consistently across all 7 HTML pages.
Removed Logout from the settings dropdown menu.
- New Session form collapsed by default (click to expand)
- Added VDI (Docker) to session type dropdown with container image field
- Jump hosts hidden for VDI sessions (local containers)
Address book entries can now set container_idle_timeout_mins to override
the global idle_timeout_mins. Stored as a Docker label on the container
so the reaper reads it without needing session state. Reaper also cleans
up VDI thumbnails when removing idle containers.
Client captures display thumbnail every 10s (JPEG, 320px), uploads
to server. Address book shows "Active Sessions" section with thumbnail
grid — click to reconnect via address book connect flow.
- PUT/GET /api/sessions/{id}/thumbnail endpoints
- GET /api/vdi/containers — list running VDI containers for current user
- VDI container labels: rustguac.entry, rustguac.image for reconnect
- Thumbnail copied to container-keyed file on disconnect (persists)
- Dormant VDI containers shown with last thumbnail
- Cache-busting on thumbnail images for live updates
- Moved logout button out of settings dropdown
- Fixed: VDI containers endpoint in authenticated route group
- Fixed: thumbnail click uses address book connect (not stale session URL)
Show a centered overlay when the Guacamole client disconnects or
errors, instead of leaving a frozen canvas. Offers Reconnect (reload)
and Close (navigate to home) buttons. Applies to all session types.
API key users now exchange their key for a single-use 30-second
ticket via POST /api/ws-ticket before connecting. The ticket is
passed as ?ticket= in the WebSocket URL instead of the raw API key.
Prevents API key exposure in reverse proxy logs, browser history,
and Referer headers. Legacy ?key= still works as fallback.
Phase 1-3 of H.264 passthrough for premium RDP video performance.
guacamole-server patch (patches/004-h264-passthrough.patch):
- Enable GfxH264 and GfxAVC444 in FreeRDP settings when GFX is on
- Intercept AVC420/AVC444 SurfaceCommand before GDI decode
- Extract raw H.264 NAL units and send as new "h264" instruction
- Keyframe detection via Annex B start code + NAL type parsing
- Original GDI decode path still runs as fallback
Browser-side (static/guac/):
- H264Decoder.js: WebCodecs VideoDecoder, hardware-accelerated decode
- Client.js: "h264" instruction handler, base64→ArrayBuffer→decode
- Feature detection: falls back gracefully if WebCodecs unavailable
rustguac:
- Advertise video/h264 in guacd handshake