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.
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).
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)
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.
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.
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).
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.
New features:
- Native Chromium autofill: pre-populate Login Data SQLite before launch,
zero external deps (no Node.js/Playwright needed for simple login flows)
- Per-entry domain allowlisting: restrict which domains Chromium can reach
via --host-rules (separate from server-side web_allowed_networks CIDR)
- Per-entry clipboard control: disable-copy and disable-paste for all
session types (SSH, RDP, VNC, Web) via guacd native parameters
- Guacamole import: `rustguac import-guacamole` parses mysqldump SQL and
writes entries to Vault address book
Security hardening:
- Comprehensive Chromium managed policy deployed via install.sh, Dockerfile,
and debian/postinst (blocks DevTools, downloads, file dialogs, extensions,
dangerous URL schemes)
- Profile isolation: each web session gets a unique UUID-based profile dir
- Autofill credentials encrypted with Chromium's native os_crypt (AES-128-CBC)
Documentation:
- Updated README, docs/api.md, docs/security.md, docs/configuration.md,
docs/overview.md, docs/integrations.md with all new features
- Clarified two-layer domain restriction (web_allowed_networks vs allowed_domains)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When an address book entry has prompt_credentials: true or no stored
credentials, /api/connect now returns an inline credential form instead
of failing or connecting without auth. The form POSTs to the existing
connect endpoint and redirects to the client page.
Fix NetBox webhook body template docs: use "type" not "session_type"
(matches Vault storage format), replace regex_replace/cut filters with
standard Jinja2 equivalents (lower, split) since NetBox's Jinja2
environment doesn't include Ansible or Django template filters.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- New GET /api/connect quick-connect endpoint for external integrations
- Ad-hoc mode (poweruser+): hostname/protocol/port params, redirects to client
- Address book mode (operator+): scope/folder/entry params, credentials from Vault
- Unauthenticated users redirected through SSO login and back automatically
- OIDC deep-link support: login handler accepts ?next= param, stores as cookie,
callback redirects there instead of /addressbook.html after authentication
- New docs/netbox.md integration guide: Custom Fields, Custom Links with
console_enabled/console_mode gating, webhook-driven address book sync
- Updated docs/api.md with GET /api/connect endpoint documentation
- Updated screenshots/screenshots.md with descriptions for all screenshots
- Bump version to 0.3.1
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Multi-hop SSH tunnel chains allow routing any session type through
multiple bastion hosts. VNC is now a first-class session type.
Web browser sessions can tunnel through jump hosts with automatic
URL rewriting.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
User API tokens allow OIDC users to authenticate via bearer token for
automation and scripting. Powerusers and admins can create their own
tokens; admins can create tokens for operators. Tokens use SHA-256
hashing, optional max_role caps, optional expiry, and full audit
logging of create/revoke operations with client IPs.
- DB schema: user_api_tokens and token_audit_log tables
- Auth middleware: validates user tokens as fallback after admin keys
- API: 7 new endpoints (self-service + admin token management)
- UI: tokens.html (self-service) + admin.html token/audit sections
- Nav: Tokens link added to all pages (visible for operator+)
- Docs: API reference, security model, roles/access control updated
- Background cleanup: expired tokens + 90-day audit log retention
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>