A desktop wider than the window, such as a two-monitor VNC console viewed
from a laptop, was always scaled to fit and came out too small to read. The
Controls panel now has a View choice: Fit to window (the default) or Actual
size, with scroll bars, which draws it at 100% inside a scrolling container.
The choice is remembered per browser. The scroll bars are styled so they
stay visible where the platform would hide them until you scroll.
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).
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.
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)
The Docker instructions started the container under Docker's default
seccomp profile, where every web session fails because Chromium's
sandbox cannot create its namespaces, and nothing said why.
- web-sessions.md: a Chromium sandbox section covering the namespace
sandbox, the SUID helper fallback and the new start-up error, and a
Running under Docker subsection with the shipped profile, a Compose
snippet, the user-namespace requirement on the host, and the
alternatives (seccomp=unconfined, CAP_SYS_ADMIN) with their cost;
a troubleshooting entry for the error
- installation.md, README.md, deployment-guide.md: extract the profile
from the image and pass it with --security-opt in the docker run and
Compose examples
- security.md: the sandbox section said Chromium uses the SUID helper;
it prefers its namespace sandbox and uses the helper only as a
fallback. Point Docker users at the profile rather than unconfined
Fixes#307
(cherry picked from commit 305bb6e195)
Add the page to the Documentation lists in the README and overview, and
link it where SPICE and Proxmox come up: the session types in the
overview, the session and entry fields in the API reference, the
vnc_allowed_networks row in the configuration reference, and the entry
types and jump-host table in integrations. Those two lists also now name
SPICE and Proxmox VE, which they had left out, and no longer claim that
every session type can use a jump host, since VDI cannot.
(cherry picked from commit 638c579402)
SPICE and Proxmox VE are headline session types, but what the docs said
about them was scattered across the API field tables and the allowlist
notes. Nothing covered the entry editor, the Proxmox API token, node
auto-detection, the one-time ticket and SPICE proxy flow, TLS options,
whether a given package ships guacd's SPICE plugin, or the common
failures from #195 and #218.
docs/spice-and-proxmox.md covers each of these, checked against the
session, PVE client, guacd and editor code and the build scripts.
Fixes#302
(cherry picked from commit ddd3c23178)
The connection editor labels a blank SPICE TLS port "(same as Port)",
and the jump-host path already tunnels tls_port.unwrap_or(port). The
direct path disagreed: with TLS on it sent an empty plain port (so
spice-gtk does not try plaintext) and an empty tls-port when none was
set, and guacd's SPICE plugin then defaulted the TLS port to 5900. A
TLS-only server on any other port was unreachable unless TLS port was
filled in by hand.
Add SpiceParams::dial_port(), which returns the TLS port with TLS on,
falling back to the entry's port, and use it for both the tls-port
connect arg and the jump-host tunnel target so the two paths agree.
The SPICE arg mapping moves into spice_arg_value() so it can be unit
tested, and a stale comment claiming the password is delivered over
argv is dropped.
Proxmox consoles always set tls_port from the spiceproxy response and
are unaffected.
Fixes#309
(cherry picked from commit f5c893ab25)
A few examples used real-world names instead of reserved ones:
- the web session example pointed at www.saucedemo.com, a real
third-party site, with that site's demo login; it now uses
app.example.com and neutral credentials;
- the jump host examples used *.corp.local, and .local is the mDNS
domain, so copied settings resolve oddly; they now use
*.example.internal, as does the Squid proxy example;
- the move-entry response showed the folder "mrgelato"; now
"customer-a";
- the CLI audit log sample showed the user "dave"; now "alice", with
the columns kept aligned;
- the commented-out Knocknoc redirect in 403.http suggested
knocknoc.mycompany.com, a registered domain; now knocknoc.example.com.
Fixes#304
(cherry picked from commit b43de8678a)
installation.md: the Debian post-install steps ran 1-4, 6, 7. No step
was lost: when the Vault step was added as step 4 in 77c7c53, the drive
step after it was renumbered to 6 instead of 5. Number them 5 and 6.
migration.md: "Splitting to multiple Vaults (disaster recovery)" was a
second h1, so the page read as two documents and its Step 1-3 headings
sat at the same level as the Guacamole import's. Make it an h2 with its
steps at h3 and Options at h4. Heading text is unchanged, so every
anchor on the page stays the same. Point the cross-links between this
section and configuration.md's "Multiple Vault backends" at each
other's sections rather than the top of the page.
Fixes#303
(cherry picked from commit 5eebf39839)
The supported session types table for jump hosts listed only SSH, RDP,
VNC and web, although session creation also tunnels SPICE and Proxmox
VE. Add rows for them as the code behaves: SPICE forwards to the TLS
port when TLS is on, and Proxmox VE tunnels the PVE API and the SPICE
proxy separately, with certificate checks as they apply to each hop.
VDI is marked not applicable, since the container is local and the UI
hides the jump host section for it.
Also note how the network allowlists apply through a chain: the target
is resolved by the bastion, and only the first jump host is checked
locally, for ad-hoc sessions from non-admins.
Fixes#301
(cherry picked from commit 15390667b8)
The Docker section of the deployment guide pulled and ran
ghcr.io/sol1/rustguac:latest, but release.yml only pushes to Docker
Hub (sol1/rustguac:<version> and :latest), so the first command failed.
Use sol1/rustguac:latest, as the README and installation.md already do.
Fixes#300
(cherry picked from commit 1a034fc77a)
clear_entry_only_fields() missed several fields an ad-hoc caller below
admin could still set, and GET /api/connect's ad-hoc mode applied none
of the checks. apply_adhoc_policy() now runs on both ad-hoc routes. For
callers below admin it fences every target, drops the fields only a
saved entry may set (now also the VDI account, the Kerberos cache path,
sharing and Wake-on-LAN), lets recording be turned on but never off when
it is enabled site-wide, stops the drive being turned on when [drive]
leaves it off, and caps VDI CPU, memory and idle timeout at the [vdi]
defaults. Admins and saved entries are unchanged. Unit tests cover each
rule.
On top of the workload JWT login from #285:
- token_command: a program and its arguments (run directly, never
through a shell) that prints the JWT, run again at every login, with a
timeout. For issuers that hand out a token on request, such as a cloud
metadata service. Its output never reaches an error or the log.
- import-guacamole and vault-migrate log in with jwt_auth too; they
insisted on VAULT_SECRET_ID. One helper, VaultConfig::login_secret,
now decides the secret for the server and both commands.
- jwt_auth is checked before any login: the mount goes into the login
URL, so plain path segments only, and exactly one of path and
token_command.
- Docs: the jwt_auth keys, and setting it up with Nomad workload
identity, SPIRE (spiffe-helper), and cloud VM identity tokens, with a
note on when JWT login really removes the stored secret.
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.
Users are found by email, and with auto_create_users = false the email
becomes the gate. An email alone is not proof of identity, since some
providers let people set it, so a user is now bound to the OIDC subject
of their first login and a login with the same email but a different
subject is refused (and logged). A login the provider explicitly marks
email_verified: false is refused too.
New CLI-only command reset-oidc-subject releases the binding, for moving
to a different identity provider or a recreated provider account; it is
written to the CLI audit log.
Emails match ignoring case, so an admin typing JSmith@Example.com no
longer locks out a provider that sends jsmith@example.com, nor creates a
second account with auto-create on; a user cannot be added twice in
different case. add-user and POST /api/users check the email and name,
and create_user reports "already exists" as a type instead of matching
"UNIQUE" in the error text.
Tests for the login rules, and CLI integration tests for add-user and
reset-oidc-subject including their audit rows. Docs: how a login is
matched, moving providers, POST /api/users, the CLI commands, and the
login page message (a redirect, not a 403).
With `[oidc] auto_create_users = false`, OIDC login no longer creates
an account for a user who isn't in the database yet. Only users an
admin has added beforehand can log in. The option defaults to true, so
existing deployments behave as before.
An admin can add users ahead of their first login with any of:
- CLI: `rustguac add-user --email <email> --role <role> [--name <name>]`
- API: `POST /api/users` (admin only), returns 201, or 409 if the user exists
- Admin page: an "Add User" form under the users table
A user added this way keeps the role the admin gave them; default_role
is not applied. Group-to-role mappings still apply on every login.
A rejected SSO login now redirects to the login page with an error code
instead of returning JSON. This covers a user with no account
(sso_error=no_account) and a disabled account (sso_error=disabled).
SSO errors are now shown under the SSO button: they used to go into the
API key form, which is hidden when SSO is on, so "SSO login failed" was
never visible. The "provider unavailable" notice uses the same element,
and all SSO messages are defined in one place in index.html.
(cherry picked from commit 9bfdc52155)
#281 relaxed framing to same-origin on every page. Keep DENY and
frame-ancestors 'none' everywhere, and send SAMEORIGIN with
frame-ancestors 'self' only on the client page of a session opened from
a saved entry with fullscreen on connect turned on, the one case the
Connections page frames it. An ad-hoc session cannot opt itself in, and
an unknown or ended session is not frameable. Tested end to end through
the header middleware.
docs: a framing and clickjacking section in security.md with the
approaches weighed, the corrected CSP row, and a note in
reverse-proxies.md to give rustguac its own hostname.
A folder PUT that left display_name out wiped the stored title, so any
client or script that predates the field erased it by saving the
folder. Treat it like allowed_users: omitted keeps what is stored, ""
clears it. Document the field on create and update.
Three fixes on top of the live refresh:
- Folder names. GET /api/addressbook/version returned every changed
folder to any operator, so one granted only teamA saw paths in folders
they cannot open as soon as an admin or a webhook wrote there. For
callers below admin an entries row is now kept only for a folder they
can open directly (the check_folder_access rule), and tree rows lose
their path: a folder that came, went or changed access may be one they
cannot see. The page treats a path-less tree row like `all`. Folder
config is read only when there are rows to answer, so an empty poll
still costs no Vault read, and a burst of more than 32 rows is answered
with one path-less row instead of a check per folder.
- Restarts. The version counter starts again at 0, so a tab that saw v5
and polled after the new process reached v5 or more was answered from a
different log and could miss changes. The answer now carries a random
per-process epoch; a tab that sees it change refreshes what it shows.
The docs no longer claim a restart always answers null.
- Moves. A PUT ...?move=true that moved an entry logged only the target
folder, so a tab showing the source kept the moved entry. It now
reports both, and so does a move that copied the entry but could not
remove the original (502), for this path and POST .../move: the tracker
records a failed request when its handler reports what it changed.
connections: live refresh from a per-folder address-book change counter
The Connections page keeps itself current without reloading. rustguac
keeps an in-memory change log of the address book: every successful
write through its API (entries, folders, folder config, moves, copies,
bulk) is logged against the folders it touched under a version counter.
The page polls GET /api/addressbook/version?since=N every 3 seconds
while visible and gets back only what changed after the version it last
saw: one row per folder, saying whether its entries or the folder itself
moved. The poll is answered from memory; it reads nothing from Vault.
A tab acts only on what it shows. Entries changed in the open folder:
that folder is re-read. Entries changed elsewhere: the cached copy is
forgotten and nothing is read until the folder is opened. A folder
created, deleted or reconfigured: the folder list is re-read (no
entries), plus the open folder if it was in that subtree. The search
index is never rebuilt on a poll: it is marked stale and rebuilt the
next time the box is focused or typed in; while results are showing,
only the changed folder's rows are re-read and patched in. So one
change costs a tab at most one folder's entries plus one folder-list
listing, never the whole book, however many tabs are open. There is no
timed refresh. A null answer (first poll, restart, or a tab further
behind than the 512-entry log reaches) refreshes what is shown once,
under the same bound.
Folders are attributed from the request path; the four routes whose
folders only the body knows (create folder, move, copy, bulk) report
them from the handler through a response extension, and a handler that
reports nothing is logged as `all` so a page refreshes rather than
misses it. A user's own credential variables (/api/me/credentials) are
not address-book changes and never reach the log: the saving tab
reloads its own status, and no other tab needs to know.
Hidden tabs do not poll and catch up with an immediate check when
shown; a tab with a dialog open does not poll either, so the change
waits for the dialog instead of being missed.
Tests: the attribution table, the log's since/dedup/eviction/restart
answers, the middleware on a router (a credentials save logs nothing, a
failed write logs nothing, reported folders are logged precisely), and
a plain-node test pinning what the page re-reads for each change.
Closes#253 (part 3/3).
(cherry picked from commit da82239076)
Folder access is a union, so a subfolder that inherits from its parent
but also grants groups or users of its own admits people the parent
doesn't. The defaults walk only stopped at inherit_from_parent: false,
so a contractor added to corp/partner through allowed_users could
connect with corp's default key. A parent's defaults now flow only into
a subfolder that inherits and grants nobody of its own
(FolderConfig::shares_parent_audience), tested.
Also:
- an entry set to prompt is no longer listed as using folder credentials
- PUT .../defaults on a folder that doesn't exist is a 404, rather than
writing a .defaults that makes an admin-only phantom folder
- the folder dialog takes a domain for RDP defaults
- docs: the new boundary, and that a $variable in a default resolves per
user
A folder can lend a username, password, private key and domain per session
type to entries that have no password or private key of their own, so an
entry created without secrets (a NetBox webhook, a bulk import) connects the
moment it exists. The defaults live at `<folder>/.defaults` beside
`.config`, are reserved like it (never listed, not a valid entry name) and
are removed with the folder. `GET/PUT/DELETE .../defaults` manage them
(secrets write-only, audited as update_folder_defaults /
delete_folder_defaults) and the folder dialog gains a Default credentials
section. Listings report such entries as `has_credentials: true` with
`inherited_credentials: true`. An entry's own credentials always win and an
entry set to prompt still prompts.
A subfolder without its own `.defaults` takes its nearest ancestor's, per
type, but the walk follows the access rule: it only continues past a folder
whose `.config` has `inherit_from_parent: true`. That flag is how a folder
is isolated for access, so a key set on a parent must not reach a subfolder
shared with someone else through `allowed_users`. The isolated folder's own
`.defaults` still apply to its entries. A folder with no `.config`, or one
written before the flag existed, counts as not inheriting, exactly as
`resolve_folder_access` treats it. The walk is `resolve_effective_defaults`
over a small `DefaultsSource` trait so it is tested without a Vault.
Both connect paths resolve defaults through one helper,
`lend_folder_defaults`: the Connections endpoint
(`POST .../entries/:entry/connect`) and `/api/connect` quick links, so an
entry that works from the page works from a link. `rustguac vault-migrate`
copies each folder's `.defaults` alongside its `.config`, so a DR split keeps
them (the dry run lists them as `[defaults]`).
Docs cover the storage layout, the inheritance rule and its boundary, the
migrate behaviour, and the warning that a web entry using a default
password has that password typed into a page the user can read.
Part 2/3 of #253.
(cherry picked from commit cc01469b07)
Cover what the ?move=true upsert changes for existing NetBox setups: the
409 when a hand-made entry would adopt an id already elsewhere, creating
tenant folders before syncing into them, how entries from the old POST
webhook take their external_id, the first-sight scan, and using the same
computed folder in the delete webhook.
PUT /api/addressbook/folders/{scope}/{folder}/entries/{entry}?move=true
is for sync sources such as the NetBox webhook, where the folder (one
per tenant) or the name is computed from the device and can change.
Entries get a stored external_id (the webhook sends netbox:device:<id>)
and the flag matches the device on that, never on its name:
- already at folder/entry with that id, or with no id (an entry made by
hand before syncing began): updated in place;
- stored elsewhere in the scope under that id: moved to folder/entry by
the same server-side move as POST .../move (full entry copied, then
the original removed), so its password, key, jump host credentials
and credential variable references come along, and the update is
applied on top;
- stored nowhere: created.
Same-named entries in other folders are never touched: two tenants can
each have a fw01, and tenant A's webhook must not delete tenant B's. A
name held by a different device in the target folder, or an id stored
on more than one entry, is a 409 and nothing is changed. Without the
flag the PUT is the plain name upsert it always was.
The merge keeps every secret the request omits (the edit form never
receives them, a sync source never has them) and now keeps external_id
the same way, so a form edit does not cut a synced entry loose. A
failed read of the existing entry is a 502 rather than a blind write,
so a payload without secrets can no longer replace an entry that has
them.
Compared with the first version of this change: nothing is deleted by
name and there is no name matching across folders; the match is on the
stored NetBox id, and a moved entry goes through the existing
copy-then-delete path, so no credential is lost (#232).
The locate / move / merge sequence sits behind a small EntryStore
trait with an in-memory store in the tests, which cover: the same name
in two folders is untouched, a move carries credentials, a rename
within the folder, an update without secrets keeps them, an untagged
entry is adopted in place, a missing id is refused, a name clash and an
ambiguous id are refused with nothing changed, and an unfinished move
is reported as such.
Part 1/3 of #253.
(cherry picked from commit 353882b246)
The Content-Security-Policy had no img-src, so default-src 'self'
governed images and refused the data: URIs the Guacamole client draws
every tile from on browsers without WebCodecs ImageDecoder. Safari and
Firefox rendered SSH and RDP sessions black with only rect fills and the
cursor cell showing; Chrome decodes straight from the stream and never
fetches a URL, which hid it. The same directive blocks the remote mouse
cursor (cursor: url(data:...)) and blob: image URLs.
Move the policy into a documented constant, add img-src 'self' data:
blob:, and add a unit test that fails if img-src loses either source.
Document the proxy-side header replacement for releases up to v1.10.5.
Fixes#249
(cherry picked from commit 04597c20cd)
VeNCrypt, RealVNC and macOS Screen Sharing need a username as well as a
password. Pass it to guacd's VNC `username` arg and expose it in the
connection editor, the connect-time credentials prompt and ad-hoc
sessions.
(cherry picked from commit fac8d0aec5)
Folder access was by OIDC group only, so giving one person one VM meant
a group per person. Folders now also take allowed_users, a list of
login emails, next to allowed_groups. A user can open a folder if a
group matches or their email is listed (ignoring case). They need not
have logged in yet: access is decided from their identity when they
use rustguac. The list lives in the folder's config in Vault, so it
applies on every instance sharing that Vault.
Every folder access decision goes through resolve_folder_access, which
now takes the user (email and groups) rather than just groups, so the
compiler enforced the change at every call site: the tree, listings,
search, credential variables and connect.
The tree now also shows a folder the user cannot open when it leads to
one they can, at the top level and in subfolder listings, as the code
already intended one level down. Without that, a grant on users/jsmith
was unreachable from the tree when users itself was admin-only (this
also applied to group grants). Such a folder is listed without entries,
and opening it says it has nothing shared with you directly.
The allowed_users list is only returned to admins (folder config);
listings that operators read leave it out, so a user does not learn who
else has access. Saving a folder without the field keeps the stored
list, so older clients cannot wipe it. Entries are trimmed, repeats
dropped, and anything with spaces or control characters refused, up to
500 per folder. The folder dialog gains an "Allowed users" box, and the
bulk move dialog's access warning compares users as well as groups.
Docs: folder access rewritten, including the correction that a folder
with no allowed groups is admin-only (the docs said it was open to
every authenticated user), and the API reference.
Requested by @3zAlb.
Re-filing more than a handful of entries meant opening each one. Admins
now get a checkbox column on the entries table (select all, shift-click
for ranges) and an action bar with Move to, Copy to and Delete. One
dialog handles all three: a filterable list of every folder, a preview
naming any entries that already exist in the target, a choice to leave
those or give them a new name, warnings when the move crosses between
shared and instance or the folders have different access groups, and a
result per entry afterwards.
POST /api/addressbook/bulk does the work, up to 1000 entries a request,
8 at a time. Each entry goes through the same stored-entry copy as the
single move: credentials included, Vault check-and-set so nothing is
overwritten, and a move removes the original only after the copy is
written. Clashes are skipped or renamed (name-2 .. name-99, within the
64-character limit); there is no overwrite option. Repeated items are
acted on once. Each entry is audited on its own, headline only. The
route alone accepts a 256KB body, since 1000 entries is about 100KB;
it is admin-only and authentication happens before the body is read.
GET /api/addressbook/folder-paths lists every folder for the picker; the
existing "Move to folder" list only knew about folders already expanded
in the tree.
Delete now checks the entry exists first, in both the bulk and single
endpoints. Vault reports success for deleting a path that is not there,
so a missing entry was answered, and audited, as deleted.
Moving an entry to another folder was done in the browser as "create
a copy from the edit form, then delete the original". The page never
receives an entry's secrets, so the copy had no password, private key,
jump host credentials, Proxmox token or container password, and a
$variable reference stored in the password field was lost the same way.
The original was then deleted permanently. Clone had the same flaw.
(#232)
Two admin-only endpoints now do it from the stored entry:
- POST .../entries/{entry}/move copies the full entry to the target and
only then removes the original. If that removal fails it says the
entry was copied, rather than reporting a plain failure.
- POST .../entries/{entry}/copy copies it under a new name. Clone uses
it and then applies the form's edits as an ordinary update, which
keeps stored secrets it is not given.
The target is written with Vault check-and-set (cas 0), so an existing
entry is never overwritten, even by a concurrent writer. Scope, folder
and names go through the existing Vault validation (only shared and
instance scopes, no traversal, .config reserved, so a copy cannot
replace a folder's access settings), and those failures are 400s. Both
actions are audited, headline only. A note on the shared helper records
that any future per-folder editing (#231) must require rights on both
the source and the target folder.
Unrelated fixes carried by the fork: session times and date filters in local
time, no local resolution of the target when a jump host is set, no resize
request that changes no pixels, and rustfmt on three hunks left unformatted.
(cherry picked from commit d9b361db6eb216d871f3fdac41c423a586e2d559)
Upstream changes on pick: the client.html resize skip is kept but
without the native-resolution sizing (displayScale/deviceSize), which
belongs to the HiDPI commit not taken here, so sizes stay in CSS pixels.
The oidc.rs hunk was formatting only and is superseded.
The network allowlists are what fence powerusers creating ad-hoc
sessions, and several paths went around them:
- POST /api/ssh/probe-host-key dialled any host:port, a port scanner and
banner grabber for anything rustguac can reach (guacd, Vault, metadata
services). Non-admin probes are now checked against
ssh_allowed_networks and dialled at the checked address.
- Jump hosts were never checked, though rustguac dials the first one
itself. For non-admin ad-hoc sessions it is now fenced by
ssh_allowed_networks and pinned.
- Ad-hoc Proxmox sessions called any proxmox_url and let guacd dial
whatever SPICE proxy that API returned. For non-admin ad-hoc sessions
both are now fenced by vnc_allowed_networks, as direct SPICE is.
The jump host and Proxmox fences are deliberately limited to non-admin
ad-hoc requests: connection entries and admins name bastions and
clusters that sit outside the target lists, and on the canary every
entry-based Proxmox and jump host session would otherwise have broken.
The flag is serde(skip), so a request body cannot set it.
The allowlist passed a hostname if any resolved address was allowed and
then handed guacd the hostname to resolve again, so a name whose DNS the
caller controls could pass the check and connect elsewhere. SSH and VNC
now dial the checked address when there are no jump hosts. RDP, SPICE
and web keep the hostname (TLS names, Kerberos) and the residual risk is
documented. Docs also no longer claim every resolved address must match.
client_ip took the LEFTMOST X-Forwarded-For entry from a trusted proxy.
Behind any proxy that appends to an incoming header (nginx
$proxy_add_x_forwarded_for, as our own reverse-proxy doc recommended, or
Apache mod_proxy), that entry is whatever the client sent, so a client
could pick an address inside an IP allowlist and walk through it. The
header is now walked from the right, skipping trusted proxies, across
every header line, split on raw bytes so a non-UTF-8 left entry cannot
hide the address a proxy appended.
Rate limiters used tower_governor's SmartIpKeyExtractor, which believes
forwarded headers from any peer: a different forged X-Forwarded-For per
request meant a fresh bucket each time, including on the always-on OIDC
limiter. They now key on the same trusted-proxy resolution. When
rate_limit is enabled, the API and WebSocket limiters sit outside
authentication so requests with bad credentials are counted. The OIDC
burst goes from 5 to 20 to cover an office behind one NAT address.
Docs: nginx recipe overwrites X-Forwarded-For with $remote_addr, and the
rate-limit section now states what is actually limited and when.
The first WebSocket to a pending session takes its guacd stream, already
logged in to the target. Any operator could claim another user's pending
session that way, and session ids are visible in reports. Now only the
session's creator may make that connection, with no admin bypass: an admin
"shadowing" a pending session used to silently become its owner and lock
the real owner out, so shadow now refuses sessions that have not started
(409). handle_ws only takes the stream when the owner check passed at
upgrade time.
POST /api/sessions accepts an admin-only `owner` field so a backend using
an API key can create a session for a user who then connects with their
own login. That user is recorded as the creator.
Share links and shadow tokens gain an optional read-only mode; the default
stays read/write. Each shareable session gets a second token
(share_url_readonly), and shadow takes {"read_only": true}. guacd enforces
it per joining user via the read-only connect arg, which was previously
hardcoded to false. The Share dialog offers both links, the Sessions page
offers watch and shadow, and docs no longer claim a read-only mode that
did not exist.
Documents the map-group subcommand in the group-to-role mappings section:
creating a mapping, the one-mapping-per-group rule and the --force re-map,
and that mappings are applied at the user's next OIDC login rather than
immediately, which matters in both directions when revoking access.
Documents the new cli_audit_log table: what is and is not recorded, that
the actor prefers SUDO_USER so sudo names the person rather than root,
that only successful actions are logged, how to read it with sqlite3, and
the 90 day retention.
Also corrects two pre-existing errors found while writing the above:
- deployment-guide.md showed an [oidc.group_role_mappings] config table.
No such config field exists, so anyone following the guide would add it
and silently get no mappings at all. Replaced with the CLI commands that
actually create them. The same block had session_ttl_secs under [oidc];
the real key is auth_session_ttl_secs and it is top level.
- roles-and-access-control.md documented an enable-user subcommand that
does not exist. Re-enabling is done from the Admin page or the API, so
the doc now says that, with a warning about disabling your own account.
Restores GfxAVC444=TRUE, skips the server-side H.264 decode, forwards both
AVC444 views (new 'view' field on the h264 instruction + Yuv444.js WebGL2
4:4:4 reconstruction), and fixes blank H.264 recording playback. Review
findings folded in (f16eed7). guacd CPU ~100%/90% of a core -> ~2%.
Adds a per-entry RDP keyboard layout, wiring guacd's `server-layout`
parameter (previously hardcoded to empty) through the address-book entry,
the connect request, and the guacd handshake, with a dropdown in the
connections editor and a migration mapping from Guacamole's `server-layout`
on import. Empty keeps guacd's default (en-us-qwerty).
H.264 passthrough had two independent defects. On Windows hosts it
negotiated no H.264 at all, and where it did engage it delivered about
half the saving it should.
GfxAVC444 = FALSE was set to make the client advertise AVC420-only,
which is not what the combination does. FreeRDP gates the entire RDPGFX
V10+ capability block on
!GfxH264 || GfxAVC444
(rdpgfx_main.c:254, rdpgfx_send_caps_advertise_pdu), so with GfxH264 set
and GfxAVC444 clear no V10 capability set is sent at all -- the client
advertises only V8 and V8.1. Windows will not offer H.264 at V8.1: it
falls back to CLEARCODEC and CAPROGRESSIVE, which guacd then decodes and
re-encodes as JPEG/WebP, exactly the cost the feature exists to avoid.
The display stays correct, so the regression is invisible without
measuring CPU or counting codecs on the wire. Over a minute of 1080p
video on Windows 11 Pro: 4050 CLEARCODEC + 1447 CAPROGRESSIVE + zero
H.264 frames before, all frames codec 15 after. xrdp is unaffected,
which is why this went unnoticed -- it accepts AVC420 at V8.1 and never
needed the V10 capsets.
AVC444 was disabled because forwarding it was broken, not because
AVC420 was wanted: only bitstream[0] reached the client, rendering as a
green/magenta split. Forwarding one view cannot work -- both bitstreams
decode through the same H264_CONTEXT in FreeRDP's avc444_decompress, so
they are one H.264 sequence with alternating views. Dropping either
breaks reference frames for the other, and the decoder reports no error
because what arrives is well formed. Both views are now forwarded,
tagged with a view field on the h264 instruction; the client decodes
both, draws only view 0, and combines the auxiliary view's chroma into
full 4:4:4 in a WebGL2 pass, falling back to 4:2:0 if it cannot.
The GDI decode is now skipped for captured H.264 commands. Previously
the wrapper always called through to the original handler, so only the
re-encode was saved. RemoteFX, planar and progressive still decode
normally, so a server mixing codecs within a frame stays correct.
guacd CPU with 1080p video playing, sampled per-thread over 30s:
xrdp (AVC420) ~100% of a core -> 2.0%
Windows 11 (AVC444) 90.6% of a core -> 2.1%
Configuration is unchanged: the per-connection enable-h264 argument is
the only switch, and there are no environment variables.
Maps Apache Guacamole passthrough tokens (${GUAC_USERNAME} etc.) to rustguac
credential variables during import, so imported connections arrive wired to
My Credentials. Repeatable FROM=TO; applied to the username, password,
domain, and private_key fields. Adds tests and docs.
- configuration.md: [vault_shared]/[vault_local] backends, the DR rationale,
user_credentials_default_scope, and the VAULT_SHARED/LOCAL_SECRET_ID env vars.
- credential-variables.md: Credentials nav link, scroll/filter/collapsible
editor, resumable focused prompt, needed-pill + banner, and the shared/local
per-credential scope with its outage trade-off.
- migration.md: vault-migrate runbook (dry-run, copy, cutover ordering).
- CLAUDE.md: multi-Vault backend summary under the Vault section.
The Guacamole import parser scanned line by line, so it missed the
multi-row INSERT layout that modern mariadb-dump/mysqldump emit by
default (VALUES on its own line, one tuple per line). Valid dumps
reported "No connections found". Parse whole statements instead, so both
the default multi-line format and single-row dumps import correctly.
String literals, backtick identifiers, and SQL comments are skipped so
embedded semicolons and apostrophes cannot split or corrupt a statement.
Adds a 'Connecting to a session' section covering the owner-vs-join
distinction, the three owner-auth modes (OIDC cookie, sessionStorage key,
ws-ticket URL), POST /api/ws-ticket, and the end-to-end headless integration
recipe (mint a ticket, open /client/{id}?ticket=...). Documents the new
spice/proxmox/vdi session types and their spice_*/proxmox_* fields, and fixes
the stale share_url response example (was &key=; now ?token= with ws_url/status).
- Bump pin 2980cf0 -> 6719b20d in Dockerfile, install.sh, release.yml,
docs/installation.md. -Werror verified clean on the new base (the
GUACAMOLE-2221 pin reason no longer applies).
- Drop patch 006 (terminal OSC-consume): upstreamed as GUACAMOLE-2213
(guac_terminal_unknown_osc).
- Rebase patch 004 (H.264 display worker) onto the refactored libguac
display internals: the queued-H.264-frame free moved into the deferred
guac_display_free_removed_layers path.
- Patches 001/002/003/005/007 unchanged (apply clean on new base).
Local build green under -Werror (guacd + rdp/ssh/vnc). H.264 passthrough
still needs runtime verification on an xrdp+x264 target.
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.