316 Commits

Author SHA1 Message Date
Dave Kempe 383aea9926 v1.8.0 v1.8.0 2026-06-17 22:22:47 +10:00
Dave Kempe a18a507bcf feat(recordings): paginate lists + show typescript storage path
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.
2026-06-17 22:22:47 +10:00
Dave Kempe 5774586106 feat(ssh): per-connection typescript opt-in + LUKS-at-rest docs (#159)
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.
2026-06-17 21:52:23 +10:00
Dave Kempe 718010b2c0 deps: bump rusqlite 0.40.1, russh 0.61.2, uuid 1.23.3
russh also synced in /fuzz. Closes #160, #161, #162.
2026-06-17 21:18:27 +10:00
Dave Kempe d4999a1865 feat(recordings): list-only typescript register (#159)
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.
2026-06-17 21:13:53 +10:00
Dave Kempe 3e1dcccb83 feat(ssh): typescript recording passthrough (#159)
Expose guacd's SSH typescript recording via a [recording] config block:
typescript_path / typescript_name / create_typescript_path. When
typescript_path is set, guacd writes a plain-text log of the full
terminal session (scriptreplay-compatible, greppable) for every SSH
session. Aimed at audit/compliance on network gear.

guacd does not template the typescript filename (it uses the name
verbatim and only appends a numeric suffix to avoid clobbering), so
rustguac expands its own brace tokens before passing the name on:
{user} {connection} {host} {date} {time} {session}. Substituted values
are sanitised to [A-Za-z0-9_-], so OIDC emails and free-text entry
names can't produce path separators or traversal. Default template is
{connection}-{user}-{date}-{time} for identifiable audit filenames.

recording-include-keys (keystroke logging in guacd's graphical
recording, for guaclog) is intentionally not wired up: rustguac records
the proxied stream itself rather than driving guacd-side graphical
recording, so that flag would be a no-op. The typescript is the
supported text-audit path.

Docs in configuration.md. 5 unit tests covering token expansion,
the default template, sanitisation/traversal, empty-value fallback,
and unknown tokens.

Closes #159.
2026-06-17 21:00:33 +10:00
Dave Kempe 8b4ea823b1 v1.7.3 v1.7.3 2026-06-09 16:41:15 +10:00
Dave Kempe 9d23c2fe5f deps: bump uuid 1.23.1 to 1.23.2, chrono 0.4.44 to 0.4.45
Closes #157, #158.
2026-06-09 16:40:47 +10:00
Lindsay Harvey 8f67a668ea fix(client): move fullscreen control into session menu (#156)
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.
2026-06-09 16:25:25 +10:00
Dave Kempe 0a6b27cc4b docs(installation): document non-Debian-13 install paths
Add 'Other Linux distributions' section explaining the FreeRDP ABI
mismatch that breaks drive/audio when running the Debian 13 .deb on
Ubuntu 24.04 (and likely other distros). Recommend the Docker image as
the supported path; provide an untested build-from-source recipe for
Ubuntu 24.04 against system FreeRDP 3.5. Mirror the pointer from
deployment-guide.md. Prompted by #153.
2026-06-04 13:26:14 +10:00
Dave Kempe d02a9cffe0 v1.7.2 v1.7.2 2026-06-04 11:46:42 +10:00
Dave Kempe 5cb7804346 deps: bump russh in /fuzz to 0.61.1 (matches main tree)
Closes #155.
2026-06-04 11:46:42 +10:00
Dave Kempe d6a1adf10f fix(client): auto-hide fullscreen top bar (#154)
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.
2026-06-04 09:30:26 +10:00
Dave Kempe 575d4ab798 docs(integrations): add Microsoft Entra ID setup guide
Covers the claim-vs-scope distinction that trips Entra setups when users
copy the Authentik example. Includes the AADSTS650053 error explainer
plus a troubleshooting section. Promised in #153.
2026-06-03 15:19:57 +10:00
Dave Kempe bd1915fe4f feat(client): per-entry fullscreen-on-connect (#154)
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.
2026-06-03 15:19:57 +10:00
Dave Kempe ad92fe1527 deps: post-v1.7.1 batch — rusqlite 0.40, russh 0.61.1, socket2 0.6.4, aes 0.9.1
russh 0.61.1 carries security fixes:
- GHSA-wwx6-x28x-8259 (compression "ZIP bomb" bypassing max-packet checks)
- GHSA-hpv4-5h6f-wqr3 (server-side username-change state-reset)

This reverses the v1.7.0 decision in #144 to hold at the 0.60.x patch line.
The 0.61 channel API churn is unchanged from then, but the security fixes
make absorbing it the right call now.

Closes #149, #150, #151, #152.
2026-06-02 10:41:41 +10:00
Dave Kempe 354cc0e54f docs(themes): scrub em-dashes from themes guide 2026-05-29 10:57:46 +10:00
Dave Kempe 3acabe362f v1.7.1 v1.7.1 2026-05-29 10:43:26 +10:00
Dave Kempe a57581ceef feat(themes): load themes from static/themes/*.toml at runtime
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.
2026-05-29 10:29:13 +10:00
Dave Kempe d9d7b12f12 debug(client): always-on draw-op ring buffer + rustguacDumpDraws() helper
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.
2026-05-28 13:54:11 +10:00
Dave Kempe 9e6a0aa975 v1.7.0 v1.7.0 2026-05-28 07:50:45 +10:00
Dave Kempe a9bc21245b patches: add 005-rdp-resize-dirty-flush (RDP resize black regions)
Addresses #118. After a dynamic RDP resize, guac_rdp_gdi_desktop_resize()
resized the GDI buffer and display layer but never marked the layer dirty,
so close_raw() flushed nothing and newly-exposed/stale regions rendered as
solid black until something else repainted them.

The patch marks the whole layer dirty and issues a RefreshRect for the new
desktop area after the resize. Root cause diagnosed and fix supplied by
@Bails309 on the issue; reworked here without the downstream debug logging
and scoped to the resize function only (the end-paint handler shares the
same close_raw call and must not be touched).

Fixes the legacy bitmap path (rustguac's default, enable_gfx=false). The
RDPGFX surface cache ignores RefreshRect so GFX sessions aren't addressed,
but they have not reproduced the artifact in practice.

Verified: applies cleanly via git apply against the pinned guacd 2980cf0;
full guacd build with all five patches compiles and links the RDP plugin
with no errors (only pre-existing FreeRDP deprecation warnings).
2026-05-28 07:23:01 +10:00
Dave Kempe 89129a54b5 fix(connections): hide subfolders the user cannot access
Closes #147.

ab_list_subfolders checked access to the parent folder but then returned
every subfolder unfiltered, so a user who could open a parent saw all of
its children regardless of per-child group ACLs - clicking one they
weren't entitled to gave "no access". (Top-level folders were already
filtered; this was the subfolder gap.)

Subfolders are now filtered per child. A folder is shown if the user can
access it directly OR can access any descendant of it, so a deeper grant
(child with its own allowed_groups and inherit_from_parent=false under a
denied folder) is never orphaned out of the tree. Admins still see all.

New folder_or_descendant_accessible helper does the recursive (boxed
async) OR over resolve_folder_access, short-circuiting on the first
accessible folder. No Vault mock harness exists to unit-test the
Vault-backed path; verified via build + the logic being a thin recursive
wrapper over the already-shipping resolve_folder_access.

docs/roles-and-access-control.md gains explicit notes that inaccessible
folders are hidden (not shown-then-denied) at every level, the
descendant-visibility rule, and how inheritance interacts.
2026-05-28 06:43:43 +10:00
Dave Kempe 2095c7f2fb deps: batch bump pulldown-cmark 0.13.4, tower-http 0.6.11, serde_json 1.0.150, cbc 0.2.1, russh 0.60.3
Closes dependabot #146, #143, #142, #145, #141, #140.

All patch/minor bumps within existing Cargo.toml constraints; no source
changes. russh held at the 0.60.x patch line (0.60.3); the 0.61.0 minor
(#144) was declined for now to avoid absorbing its new-API churn without
a reason to. fuzz/Cargo.lock russh bumped to match (#140).

229 tests pass, clippy and fmt clean.
2026-05-28 06:29:06 +10:00
Dave Kempe 70185ad215 fix(client): release held keys on focus loss to prevent stuck modifiers
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.
2026-05-28 06:25:50 +10:00
Lindsay Harvey c469443851 contrib(vdi): PulseAudio and x264 image examples (#139)
Contributed by @vk2amv (Lindsay). Adds two example VDI container images under contrib/: one with PulseAudio audio, one with PulseAudio + x264/GFX. Contrib-only, no impact on shipping code.

Thanks Lindsay.
2026-05-27 20:48:28 +10:00
Lindsay Harvey 14c2b081e4 fix(vdi): include entry name in container names to avoid collisions (#138)
Contributed by @vk2amv (Lindsay). Fixes VDI container name collisions when a user has multiple VDI entries that resolve to the same in-container username. Container names now include the sanitized final segment of the address book entry key: rustguac-vdi-{username}-{entry}. Same user + same entry still reuses one container (reconnect works); same user + different entries now run independently.

Thanks Lindsay.
2026-05-27 20:48:24 +10:00
Dave Kempe d956a37fdf v1.6.9 v1.6.9 2026-05-20 19:40:34 +10:00
Lindsay Harvey 66bc3722fc feat(vdi): host port range + container lifecycle hooks (#137)
Contributed by @vk2amv (Lindsay). Adds two related operator features to the Docker VDI driver:

1. Bounded host port range via [vdi] port_range_start / port_range_end. Currently Docker picks an arbitrary high port for the container's RDP listener; this lets operators constrain it which matters for firewalls, reverse proxies, and identity-aware gates that need to know in advance which ports rustguac will use. Port selection inside the range is deterministic-from-username (FNV-1a hash), so reconnects from the same user get the same port. Falls through to the next port on collision.

2. Container lifecycle hook script via [vdi] container_hook_script. Called as 'up <port> <container_id> <container_name>' after Docker assigns the port, again as 'down ...' before removal. Lets deployments wire external preparation/cleanup (firewall opens, service mesh registration, identity-aware gates) without baking the logic into rustguac itself. Bounded timeout (default 10s). Script is invoked via Command::new (no shell), so no injection risk from container metadata.

3 new tests covering port-candidate behaviour. Docs in docs/configuration.md and docs/vdi.md.

Thanks Lindsay.
2026-05-20 18:52:22 +10:00
Dave Kempe 9cc78b4490 deps: batch bump bollard 0.21.0, tower-http 0.6.10, tokio 1.52.3, rcgen 0.14.8
Closes #125, #126, #128, #129.

bollard 0.20 -> 0.21 (Cargo.toml constraint widened to "0.21"); minor
release with no API impact on our usage of containers/exec/inspect.
tower-http 0.6.10 (bugfix). tokio 1.52.3 (bugfix). rcgen 0.14.8 plus
yasna 0.5.2 -> 0.6.0 transitive bump.

All 224 tests still pass. clippy and fmt clean.
2026-05-18 21:27:15 +10:00
Dave Kempe 3a37cb39d9 feat(vdi): per-entry container username/password override
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.
2026-05-18 20:21:41 +10:00
Dave Kempe 6f3a4a6ff8 fix(vdi): surface container_env/cpu/memory in entry list response
Closes #131.

The EntryInfo struct returned by GET /api/addressbook/folders/.../entries
only included container_image and container_idle_timeout_mins among the
VDI fields. container_env, container_cpu_limit, and container_memory_limit
were persisted to Vault correctly but never serialised back to the client,
so the connections UI saw an empty editor for those fields on edit even
though the Vault payload was intact.

The connections UI populates the entry editor from the list endpoint's
response (currentEntries), so missing fields there appear as "blanked
after save" to the operator.

Added the three fields to EntryInfo and copied them through the
From<(&str, &AddressBookEntry)> impl. No backend behaviour change; only
the read-side exposure to the UI.
2026-05-18 20:21:41 +10:00
Lindsay Harvey d2a63e0e33 contrib(xrdp): LMDE 7 support, Cinnamon option, audio loader (#130)
Contributed by @vk2amv (Lindsay). Adds Linux Mint Debian Edition 7 detection to the GFX/H.264 setup script, picks the right Debian source codename when LMDE reports its own, replaces the broken upstream PulseAudio sources helper with an inline equivalent that handles both distros, adds Cinnamon as a desktop option, and adds Microsoft Edge installer plus audio diagnostics and a per-session audio loader script.

contrib-only change; no impact on running rustguac deployments.

Thanks Lindsay.
2026-05-18 19:39:14 +10:00
Dave Kempe f53874ceef v1.6.8 v1.6.8 2026-05-06 15:58:17 +10:00
Dave Kempe 70763932ca fix(drive): honour cleanup_on_close and retention_secs at session end
Closes #123 (parts 2 + 3 only; upload disconnect and drag-drop UX
postponed for further testing).

cleanup_browser hardcoded retention_secs=0 and never consulted
cleanup_on_close, so the [drive] config flags were dead code at
end-of-session teardown. This was visible to operators as
"cleanup_on_close = false has no effect" and "retention_secs = 0 wins
over cleanup_on_close = false" - the same root cause for both reports.

  - cleanup_browser now takes (cleanup_on_close, retention_secs) and
    only calls drive::cleanup_session_dir when cleanup_on_close = true,
    passing retention_secs through. session.drive_path is still cleared
    either way so subsequent reads don't think we still own the dir.
  - drive_cleanup_settings() centralises the resolution from the
    optional [drive] config; absent config uses the legacy "always
    wipe immediately" defaults so installs that never enabled drive
    keep prior behaviour.
  - All four cleanup_browser call sites updated (delete, complete,
    error, pending-timeout).
  - Three new unit tests covering the resolution helper.
  - Docs gain a "Cleanup behaviour" subsection in the Drive section
    explaining the precedence (retention_secs only matters when
    cleanup_on_close=true) and the per-session UUID subdir model that
    means files do not persist across sessions even with cleanup
    disabled. The cross-session "personal drive" model would be a
    separate feature.
2026-05-06 15:57:26 +10:00
Dave Kempe 4aa18e1f32 docs(authentik): add missing groups scope mapping step
Closes #122.

Authentik does not ship a `groups` scope mapping by default, so the
existing instruction "Add the `groups` scope" in the provider's Advanced
protocol settings has nothing to select. Operators following the guide
end up with rustguac unable to read group memberships, which silently
breaks group-to-role mapping.

Adds a new step 1 explaining how to create a Scope Mapping under
Customisation > Property Mappings with the standard
`request.user.ak_groups` expression, and updates the provider step to
reference the newly-created mapping by name. Subsequent steps renumbered.
2026-05-06 15:57:26 +10:00
Dave Kempe b155ed7318 fix(oidc): allow client_secret from OIDC_CLIENT_SECRET env var alone
Closes #121.

OidcConfig.client_secret was a non-Optional String, so a config.toml with
no `client_secret = "..."` line failed TOML parsing with `missing field
client_secret` before the env-var override at the bottom of Config::load
could fill it in. The override path was effectively dead code unless
the operator put a placeholder in the file first.

  - client_secret is now Option<String> with #[serde(default)], so the
    field can be omitted from config.toml.
  - At the end of Config::load, if [oidc] is present, OIDC_CLIENT_SECRET
    fills the slot when set; if it's still empty after that, exit(1)
    with an actionable error pointing at both config.toml and the env
    file.
  - oidc.rs unwraps the Option at OidcState construction with a clear
    error string. The validation at config-load time means this should
    never trigger in normal use; it's defensive against a partially
    constructed config.
  - Debug impl now shows None / Some([REDACTED]) so secret-redaction
    behaviour is preserved either way.

Three new tests:
  - oidc_config_parses_without_client_secret  (the regression)
  - oidc_config_parses_with_client_secret
  - oidc_config_debug_redacts_client_secret
2026-05-06 15:57:26 +10:00
Dave Kempe c7554fec1d docs: emphasise vault/openbao as a dependency, point at quickstart
The Connections UI is the main user-facing rustguac feature, and it does
not work without Vault or OpenBao. The previous docs framed this as
"recommended" or "optional Vault-backed connections", which understated
how much of rustguac depends on the secret store being there.

  - README.md gains a Requirements table listing guacd (bundled),
    vault/openbao (required for Connections), OIDC (optional), Docker
    (optional). The Features bullet now points at it.
  - docs/installation.md upgrades step 4 from "(Recommended)" to
    "(Required for connections)" with explicit "without one of these,
    the Connections UI is unavailable" wording. Includes the
    quickstart one-liner and a --local example.
  - docs/overview.md drops "optional" from the Vault-backed connections
    description and adds a follow-up paragraph pointing at the
    integrations doc and the quickstart script.
  - docs/integrations.md adds a Quickstart subsection above the manual
    walkthrough with a mode table, three example invocations, and the
    --local on-disk-unseal security caveat reiterated where it'll be
    seen by anyone reading the section.
2026-05-06 15:57:26 +10:00
Dave Kempe d1adea5bea contrib: vault/openbao quickstart helper
Single shell script in contrib/ that takes a fresh box from "no Vault" to
"rustguac-ready Vault" without copy-pasting the integrations.md walkthrough.

Three modes:

  bootstrap (default)  Provision an existing Vault using $VAULT_ADDR and
                       $VAULT_TOKEN. Just creates the policy, AppRole, and
                       prints the rustguac config snippet.

  --dev                Spawn `<cli> server -dev` first (in-memory, root
                       token = "root"), then bootstrap it. For demos and
                       throwaway local development.

  --local              Install Vault or OpenBao as a systemd service on
                       this host with file-backed storage and on-disk
                       auto-unseal via an ExecStartPost helper. The unseal
                       key sits at <config_dir>/unseal-key (0400 root:root)
                       and a SECURITY.txt is written next to it spelling
                       out the convenience-over-security trade. Single-host
                       boxes only; for anything else use cloud-KMS unseal.

Auto-detects vault vs bao and picks the right filesystem layout, system
user, and service name for each (vault: /etc/vault.d, vault.service,
vault:vault; openbao: /etc/openbao, openbao.service, openbao:openbao).
Both flavors use the same HCL config grammar so the rest of the script is
shared.

Idempotent: re-running detects existing user, mount, policy, AppRole,
init bundle, and systemd unit. Drop-in is used if a unit already exists
(apt-installed vault) instead of clobbering it.

The script does NOT install the binary itself - it errors cleanly if
vault/bao isn't in PATH and tells the operator to install one.
2026-05-06 15:57:26 +10:00
Dave Kempe 46d79165f5 fix(websocket): align guacd->browser writes to instruction boundary
Bug from the prior commit: tunnel ping echoes could splice into the middle
of an in-flight guacd instruction, breaking the browser's parser. The
parser concatenates every Message::Text into one rolling buffer with no
message-boundary semantics, so two halves of a guacd instruction sent
either side of a ping echo got parsed as one corrupted instruction:

  [rustguac] tunnel error: Element terminator of instruction was not
  ";" nor ",". code=512

Reproduced reliably on sol1-remoteconsole post-upgrade, after AudioContext
resumed (the larger audio-init burst made mid-instruction reads more
likely).

Fix: every Message::Text from rustguac to the browser now ends at a true
Guacamole instruction boundary. guacd_to_ws holds an incremental carry
buffer; after each TCP read it flushes only up to the last complete
instruction (length-prefix-aware so embedded `;` in clipboard text or
text streams is not treated as a terminator), and holds the partial tail
for the next read. Once each Message::Text is boundary-clean, the ping
echo and any future writer can interleave safely.

Adds protocol::last_instruction_boundary plus 11 unit tests covering
empty buffers, partial frames, embedded `;`, multibyte truncation, and
trailing garbage. Total test count is 218.

Carry is force-flushed at 16 MiB to bound memory if upstream sends
something pathological — well above any real instruction.
2026-05-06 15:57:26 +10:00
Dave Kempe 82d9264732 deps: bump rustls 0.23.40, russh 0.60.2
Closes the open dependabot PRs #119 (russh) and #120 (rustls).

russh 0.60.2 fixes channel write ordering with pending data and excludes
SHA-1 MACs from negotiation defaults. rustls 0.23.40 brings ECH inner-name
padding fixes and FIPS-aware require_ems default.

Pulls in transitive churn from russh's pinned pre-release crypto crates
(p256/p384/p521, primefield, primeorder, rsa, spki, ecdsa, scrypt). All
217 tests still pass.
2026-05-06 10:12:31 +10:00
Dave Kempe 03c8766eb3 fix(websocket): echo Guacamole tunnel pings, add TCP keepalive
Two stability fixes for "tunnel unstable" / "Connection lost" events on
long-idle sessions, particularly behind reverse proxies.

ws_to_guacd now intercepts the empty-opcode ping the Guacamole client
sends every 500ms (`0.,4.ping,<ts>;`) and echoes it back over the same
WebSocket, mirroring Apache's GuacamoleWebSocketTunnelEndpoint filter.
Previously these pings were forwarded to guacd, which silently drops
unknown opcodes (libguac/user-handlers.c), so during any 1.5s of guacd
quiet time the client tunnel went UNSTABLE; at 15s it closed with
UPSTREAM_TIMEOUT. The browser-side WebSocket sink is now an Arc<Mutex>
so both proxy halves can write to it.

TCP keepalive (30s idle, 10s probe, 3 retries) is now applied to:
  - the inbound listener (Linux inherits to accepted sockets)
  - both rustguac->guacd connect sites (initial + viewer-join)

Catches silent NAT/firewall path drops within ~60s on either leg of the
proxy. TLS path now uses axum_server::from_tcp_rustls so the std listener
can have keepalive set before serving.

Adds socket2 0.6 dependency.
2026-05-06 10:11:17 +10:00
Dave Kempe 0cc1523454 v1.6.7
Headline changes since v1.6.6:

- Connections quick-find search. New search input in the entries
  header bar searches across every connection the user has access
  to (not just the selected folder). Tokenised substring matching
  with simple scoring (name-prefix > name-substring > host >
  folder-path), match highlighting, and a Folder breadcrumb column
  in results. Press / to focus, Esc to clear. The "open folder"
  link on each result expands ancestors, selects the target, and
  scrolls it into view. Backed by a new GET /api/addressbook/
  search-index endpoint that walks the full visible tree once and
  returns a flat list (entries credential-stripped via EntryInfo).
  Thanks to JSC for raising the request.

- RustCrypto family + rand 0.10 batch upgrade. aes 0.8 -> 0.9,
  cbc 0.1 -> 0.2, hmac 0.12 -> 0.13, pbkdf2 0.12 -> 0.13,
  sha1 0.10 -> 0.11, rand 0.9 -> 0.10. These crates share
  digest 0.11 traits across the family and could not be bumped
  individually; pbkdf2 0.13.0 shipping stable was the trigger.
  Closes #107, #108, #109, #111, #113, #117. API call-site fixes
  in src/browser.rs (Chromium password encryption: BlockEncryptMut
  -> BlockModeEncrypt, encrypt_padded_mut -> encrypt_padded) and
  src/db.rs / src/session.rs (rand Rng trait -> RngExt). The five
  Chromium password encryption tests pass after the bump,
  confirming the v10/PBKDF2/AES-128-CBC pipeline is bytewise
  unchanged.
v1.6.7
2026-04-29 15:03:22 +10:00
Dave Kempe a046db06b3 deps: RustCrypto family + rand 0.10 batch upgrade
Coordinated bump of the RustCrypto stack and rand. These crates share
digest 0.11 traits and could not be bumped one at a time; pbkdf2 0.13.0
shipping stable was the trigger to harvest the group.

Cargo.toml:
- aes      0.8  -> 0.9
- cbc      0.1  -> 0.2
- hmac     0.12 -> 0.13
- pbkdf2   0.12 -> 0.13
- sha1     0.10 -> 0.11
- rand     0.9  -> 0.10

API call-site fixes:
- src/browser.rs (Chromium password encryption pipeline): cbc 0.2 renamed
  the BlockEncryptMut trait to BlockModeEncrypt and the encrypt_padded_mut
  method to encrypt_padded (now takes self by value).
- src/db.rs and src/session.rs: rand 0.10 renamed the Rng trait to RngExt;
  swap the import. fill() and random() call sites are otherwise unchanged.

The Chromium password encryption tests (5) all pass after the bump,
confirming the v10/PBKDF2/AES-128-CBC pipeline is bytewise unchanged.
cargo test (207 tests), clippy --all-targets -D warnings, and cargo audit
all clean.

Closes #107 (aes), #109 (pbkdf2), #111 (hmac), #113 (cbc), #117 (tracking),
#108 (rand).
2026-04-29 14:54:14 +10:00
Dave Kempe fe3d3adccf Connections: quick-find search across all entries
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.
2026-04-29 13:35:40 +10:00
Dave Kempe 8a25869606 docs/overview: fix Apache Guacamole stack description
The "Key differences" table claimed Apache Guacamole's runtime was
"Java (Tomcat + Spring)" and that its database options included
LDAP. Both are inaccurate.

Per the upstream apache/guacamole-client pom.xml, the Java stack is
Tomcat (or any servlet container) plus Google Guice for DI and
Jersey for the JAX-RS endpoints, not Spring. LDAP is an authentication
backend in Guacamole's extension model, not a database; the actual
database options are MySQL and PostgreSQL.

Other rows in the table reference behaviours we should also verify
on a follow-up pass (OIDC support via guacamole-auth-sso, per-entry
clipboard control, etc.) but those need direct checks against
upstream rather than a guess; deferring for now.
2026-04-26 20:28:45 +10:00
Dave Kempe 0aa4722ab6 v1.6.6
Headline changes since v1.6.5:

- Fix zombie WebSocket: wire tunnel.onerror / onstatechange in
  client.html so a mid-path WS drop surfaces the disconnected
  overlay instead of leaving the tab frozen.
- Reconnect button now relaunches the original Connections entry
  via /api/addressbook/.../connect rather than reloading a
  Completed session URL. Ad-hoc and shareToken paths fall back
  cleanly. Bonus: client.onerror clears the thumbnail upload
  interval so the secondary leak (XHR 404s against a dead session)
  stops the moment the overlay shows.
- v1.6.6 polish (already on main): OIDC discovery error wrapping
  for trailing-slash mismatches, contrib/setup-xrdp-gfx.sh adds
  xrdp to ssl-cert and normalises key.pem perms, aurora theme
  applies when [theme] is absent (not just empty), new
  docs/reverse-proxies.md covering nginx / Caddy / Apache /
  Traefik with the %2F-decoding gotcha.
- Dependency bumps: rustls-webpki 0.103.13 (RUSTSEC-2026-0104,
  CRL-parse panic + URI excluded-subtree fix), rustls 0.23.39,
  russh 0.60.1, libc 0.2.186, plus matching /fuzz mirrors.
- Test cleanup: drop format!("{}", ...) and field-reassign-after-
  Default patterns flagged by clippy 1.93.

Deferred to v1.6.7:
- RustCrypto batch (aes 0.9 + cbc 0.2 + hmac 0.13 + pbkdf2 0.13)
  tracked in #117. They share digest 0.11 traits and have to land
  together; individual dependabot PRs (#107/#109/#111/#113) closed
  in favour of one coordinated commit.
- rand 0.10 (#108): API breaking, no security pressure, will get
  picked up next time token generation paths are touched.
v1.6.6
2026-04-25 15:52:46 +10:00
Dave Kempe 38943c9d4c Reconnect button: relaunch entry instead of reloading dead session
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.
2026-04-25 15:41:42 +10:00
Dave Kempe e70aebf81d Fix zombie WebSocket: wire tunnel.onerror/onstatechange in client.html
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.
2026-04-25 15:40:08 +10:00
Dave Kempe 542474dc3a v1.6.6 polish: OIDC error clarity, xrdp TLS perms, aurora default, reverse-proxy docs
### OIDC discovery error wrap

src/oidc.rs now reshapes the openidconnect crate's "unexpected issuer
URI `X` (expected `Y`)" validation error into something an operator
can act on without having to reason about which side is the config
and which is the provider. For the overwhelmingly common
trailing-slash case the message collapses to a single actionable
line; for the rarer case where the two URIs differ materially (wrong
tenant, copy-pasted authorise URL, Keycloak path change), it falls
back to a config/provider/fix three-liner. Raw Debug output is
preserved for any non-mismatch discovery failure. Works for any
OIDC provider, not just JumpCloud.

### xrdp TLS key permissions (contrib/setup-xrdp-gfx.sh)

After rebuilding xrdp from sid, the `adduser xrdp ssl-cert` step
from the Debian postinst doesn't always re-apply cleanly, leaving
the xrdp user unable to read /etc/xrdp/key.pem. xrdp then falls
back to "classic RDP security" and FreeRDP drops the connection
with a MAC checksum error. New Step 9 in Phase 3 explicitly adds
xrdp to ssl-cert and normalises the key's ownership/mode. Step
list in the help text renumbered to match.

### Aurora default when [theme] is absent

src/main.rs resolved the theme via ThemeConfig::resolve() only when
config.theme was Some(..), and otherwise fell through to a hardcoded
("dark", builtin_presets()[0].1.clone()) pair. That meant aurora
only kicked in when a [theme] section was present in config.toml,
even an empty one. ThemeConfig now derives Default (all fields
Option<String>, so zero-cost), and main.rs resolves via
config.theme.clone().unwrap_or_default().resolve() so absent and
empty [theme] behave identically. Regression test added.

### docs/reverse-proxies.md

New doc covering nginx, Caddy, Apache, and Traefik with per-proxy
configs and the %2F-decoding gotcha that causes 404s on nested
subfolder paths when a proxy normalises the URI before forwarding.
The issue was surfaced by @mauroparente in #105 — thanks for the
repro and the nginx config that made the root cause obvious.
deployment-guide.md Step 3 and integrations.md HAProxy section
both link to the new doc.
2026-04-25 15:40:08 +10:00