Rename Address Book → Connections; allowed_groups picker; session privacy (#102)

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.
This commit is contained in:
Dave Kempe
2026-04-18 20:45:29 +10:00
parent 88b754a11c
commit 0d69e8fed4
29 changed files with 639 additions and 148 deletions
+8 -8
View File
@@ -25,10 +25,10 @@ rustguac is a lightweight Rust replacement for the Apache Guacamole Java webapp.
- `src/config.rs` — TOML config loading with defaults
- `src/auth.rs` — API key auth middleware (SHA-256, IP allowlists, expiry), role system
- `src/oidc.rs` — OIDC authentication (login, callback, logout, group extraction)
- `src/vault.rs` — Vault/OpenBao KV v2 client for address book (AppRole auth, token renewal)
- `src/vault.rs` — Vault/OpenBao KV v2 client for connections (AppRole auth, token renewal)
- `src/db.rs` — SQLite admin database (rusqlite, bundled)
- `static/client.html` — Guacamole JS client with auto-scaling display
- `static/addressbook.html` — Vault-backed address book UI (folder/entry management, connect)
- `static/connections.html` — Vault-backed connections UI (folder/entry management, connect)
- `static/recordings.html` — recording playback with auto-scaling
- `static/sessions.html` — session management dashboard
- `dev.sh` — development script (build guacd, run, deps)
@@ -39,9 +39,9 @@ rustguac is a lightweight Rust replacement for the Apache Guacamole Java webapp.
TOML config file (`config.local.toml` for dev, `--config` flag for production). Key settings: `listen_addr`, `guacd_addr`, `recording_path`, `static_path`, `db_path`, `xvnc_path`, `chromium_path`, `display_range_start/end`.
### Vault / Address Book
### Vault / Connections
Optional `[vault]` section enables the Vault-backed address book. Connection entries (SSH/RDP/Web) are stored in Vault KV v2 — credentials never touch disk or the browser.
Optional `[vault]` section enables the Vault-backed connections. Connection entries (SSH/RDP/Web) are stored in Vault KV v2 — credentials never touch disk or the browser.
```toml
[vault]
@@ -67,9 +67,9 @@ Optional `[oidc]` section enables OpenID Connect authentication. Key settings: `
### Roles
4-tier role hierarchy: `admin` (4) > `poweruser` (3) > `operator` (2) > `viewer` (1).
- **admin**: full access, address book folder/entry management
- **poweruser**: ad-hoc session creation + address book connect
- **operator**: address book connect only (no ad-hoc sessions)
- **admin**: full access, connections folder/entry management
- **poweruser**: ad-hoc session creation + connections connect
- **operator**: connections connect only (no ad-hoc sessions)
- **viewer**: read-only
## Deployment
@@ -114,7 +114,7 @@ Ephemeral per-user Docker desktop containers. `VdiDriver` trait in `src/vdi/mod.
- Credentials: auto-generated per session (username from OIDC, random hex password), `chpasswd` updates on reuse
- BYO image: any Docker image with xrdp on port 3389 accepting `VDI_USERNAME`/`VDI_PASSWORD` env vars
- Test image: `contrib/vdi-test-image/` (Debian trixie + xrdp + xorgxrdp + xfce4)
- Thumbnails: client captures display screenshot every 10s, shown in address book "Active Sessions"
- Thumbnails: client captures display screenshot every 10s, shown in connections "Active Sessions"
- Config: `[vdi]` section — `enabled`, `docker_socket`, `default_cpu_limit`, `default_memory_limit`, `ready_timeout_secs`, `idle_timeout_mins`, `allowed_images`, `home_base`
- Requires: `rustguac` user in `docker` group for socket access
+7 -7
View File
@@ -46,7 +46,7 @@ guacd (C, from guacamole-server)
- **OIDC single sign-on** — Authentik, Google, Okta, Keycloak, or any OpenID Connect provider
- **4-tier role system** — admin, poweruser, operator, viewer with OIDC group mapping
- **API key auth** — SHA-256 hashed keys with IP allowlists and expiry
- **Vault-backed address book** — credentials in HashiCorp Vault / OpenBao KV v2, never reach the browser
- **Vault-backed connections** — credentials in HashiCorp Vault / OpenBao KV v2, never reach the browser
- **TLS everywhere** — HTTPS for clients, TLS between rustguac and guacd
- **CIDR allowlists** — per-protocol network restrictions for session targets
- **Per-entry clipboard control** — disable copy and/or paste for data loss prevention
@@ -58,21 +58,21 @@ guacd (C, from guacamole-server)
- **Multi-hop SSH tunnels** — chain jump hosts/bastions to reach isolated networks (all session types)
- **Session sharing** — share tokens for read-only or collaborative access
- **Encrypted file transfer** — LUKS-encrypted per-session drive storage (RDP), SFTP (SSH)
- **Credential variables** — shared credentials across address book entries
- **Credential variables** — shared credentials across connections entries
### VDI desktop containers
- **Docker-based** — one container per user, deterministic naming, BYO image
- **Persist after disconnect** — reconnect to the same desktop within idle timeout
- **Logout detection** — desktop logout stops the container, tab close preserves it
- **Session thumbnails** — live preview in the address book, click to reconnect
- **Session thumbnails** — live preview in the connections, click to reconnect
- **Persistent home directories** — bind-mounted user data survives container restarts
- **Per-entry resource limits** — CPU, memory, idle timeout per address book entry
- **Per-entry resource limits** — CPU, memory, idle timeout per connections entry
- **VdiDriver trait** — extensible for downstream forks (Nomad, Proxmox, cloud)
### UI
- **Address book** with folder-based organisation and OIDC group access control
- **Connections** with folder-based organisation and OIDC group access control
- **Active Sessions** section with live thumbnail previews
- **Session ended overlay** with Reconnect/Close buttons
- **8 built-in themes** with CSS gradient backgrounds, or configure your own
@@ -126,7 +126,7 @@ sudo usermod -aG docker rustguac
sudo systemctl restart rustguac
```
Add `[vdi]` to your config and create a VDI entry in the address book. See [VDI Desktop Containers](docs/vdi.md) for image requirements and configuration.
Add `[vdi]` to your config and create a VDI entry in the connections. See [VDI Desktop Containers](docs/vdi.md) for image requirements and configuration.
## Documentation
@@ -145,7 +145,7 @@ Add `[vdi]` to your config and create a VDI entry in the address book. See [VDI
### Integration & reference
- [Integrations](docs/integrations.md) — Vault, LUKS drives, SSH tunnels, Kerberos, HAProxy, Knocknoc
- [NetBox](docs/netbox.md) — address book sync via custom fields and webhooks
- [NetBox](docs/netbox.md) — connections sync via custom fields and webhooks
- [Security](docs/security.md) — TLS, rate limiting, headers, audit logging, hardening
- [API Reference](docs/api.md) — REST API endpoints
- [Migration from Apache Guacamole](docs/migration.md) — MySQL/MariaDB to Vault
+9 -9
View File
@@ -18,7 +18,7 @@ Quick-connect endpoint for external integrations (e.g., NetBox Custom Links). Cr
/api/connect?hostname=10.0.1.50&protocol=ssh
**Address book mode** (operator+):
**Connections mode** (operator+):
/api/connect?scope=shared&folder=production&entry=web-server-01
@@ -29,16 +29,16 @@ Quick-connect endpoint for external integrations (e.g., NetBox Custom Links). Cr
| `port` | integer | Target port (uses protocol default if omitted) |
| `username` | string | Username (optional) |
| `url` | string | Target URL (web sessions) |
| `scope` | string | Address book scope: `shared` or `instance` |
| `folder` | string | Address book folder name |
| `entry` | string | Address book entry name |
| `scope` | string | Connections scope: `shared` or `instance` |
| `folder` | string | Connections folder name |
| `entry` | string | Connections entry name |
| `width` | integer | Display width in pixels |
| `height` | integer | Display height in pixels |
| `dpi` | integer | Display DPI |
When `scope`, `folder`, and `entry` are all provided, the endpoint connects via the address book (credentials from Vault). Otherwise it creates an ad-hoc session. No credentials are passed in the URL for ad-hoc mode — if the target requires authentication, the user will see guacd's login prompt.
When `scope`, `folder`, and `entry` are all provided, the endpoint connects via the connections (credentials from Vault). Otherwise it creates an ad-hoc session. No credentials are passed in the URL for ad-hoc mode — if the target requires authentication, the user will see guacd's login prompt.
If the address book entry has `prompt_credentials: true` or has no stored password/key, the endpoint returns an inline credential form instead of creating the session immediately. The user enters credentials, which are POSTed to the connect endpoint and used for that session only (never stored).
If the connections entry has `prompt_credentials: true` or has no stored password/key, the endpoint returns an inline credential form instead of creating the session immediately. The user enters credentials, which are POSTed to the connect endpoint and used for that session only (never stored).
See [NetBox Integration](netbox.md) for usage with NetBox Custom Links.
@@ -348,7 +348,7 @@ Update a mapping.
Delete a mapping.
## Address Book (requires Vault)
## Connections (requires Vault)
### `GET /api/addressbook/folders`
@@ -360,7 +360,7 @@ List entries in a folder. Scope is `shared` or `instance`. Requires folder group
### `POST /api/addressbook/folders/:scope/:folder/entries/:entry/connect`
Create a session from an address book entry. Reads credentials (including jump host credentials) from Vault server-side and creates a session. Requires **operator** role and folder group access.
Create a session from an connections entry. Reads credentials (including jump host credentials) from Vault server-side and creates a session. Requires **operator** role and folder group access.
Optional body to override or supply credentials at connect time:
@@ -422,7 +422,7 @@ Create a connection entry. The body includes a `name` field plus all entry field
}
```
**Address book entry fields:**
**Connections entry fields:**
| Field | Type | Used by | Description |
|-------|------|---------|-------------|
+1 -1
View File
@@ -119,7 +119,7 @@ Enables OpenID Connect authentication. When configured, the web UI shows a login
## `[vault]` section
Enables the Vault-backed address book. Requires `VAULT_SECRET_ID` environment variable.
Enables the Vault-backed connections. Requires `VAULT_SECRET_ID` environment variable.
| Key | Default | Description |
|-----|---------|-------------|
+5 -5
View File
@@ -1,12 +1,12 @@
# Credential Variables
Credential variables let address book entries reference shared credentials by name instead of storing passwords directly. Users maintain their own credential values in Vault via the **My Credentials** dialog (gear menu). When a session launches, rustguac substitutes the variables from the user's saved values.
Credential variables let connections entries reference shared credentials by name instead of storing passwords directly. Users maintain their own credential values in Vault via the **My Credentials** dialog (gear menu). When a session launches, rustguac substitutes the variables from the user's saved values.
This gives a similar experience to LDAP credential passthrough in Apache Guacamole — users log in once and sessions just work — without rustguac needing to bind to LDAP. Credentials stay in Vault, never on disk or in the browser.
## How it works
1. **Admin** creates address book entries with variable references like `$corp_username` and `$corp_password` in the credential fields
1. **Admin** creates connections entries with variable references like `$corp_username` and `$corp_password` in the credential fields
2. **Users** open **My Credentials** from the gear menu and fill in their values (stored per-user in Vault)
3. **At connect time**, rustguac substitutes the variables. If all are set, the session launches silently. If any are missing, the user is prompted.
@@ -27,7 +27,7 @@ The `<domain>` is a logical name chosen by the admin to group related credential
## Example
An admin creates two address book entries:
An admin creates two connections entries:
- **Production SSH** — username: `$corp_username`, password: `$corp_password`
- **Staging SSH** — username: `$corp_username`, password: `$corp_password`
@@ -38,7 +38,7 @@ An entry can also mix variables with static values. For example, an RDP entry mi
## My Credentials dialog
Access via the gear icon in the top-right corner of the address book page. The dialog:
Access via the gear icon in the top-right corner of the connections page. The dialog:
- Shows all credential variables used across entries the user has access to
- Groups variables by domain prefix
@@ -64,7 +64,7 @@ Each user gets a single Vault secret containing all their credential key-value p
### Required Vault policy
In addition to the existing address book policy, add:
In addition to the existing connections policy, add:
```hcl
# User credential variables (read/write own credentials)
+5 -5
View File
@@ -11,7 +11,7 @@ Internet
|
[HAProxy] ── TLS termination, rate limiting, Knocknoc ACL
|
[rustguac] ── session management, WebSocket proxy, address book
[rustguac] ── session management, WebSocket proxy, connections
|
[guacd] ── protocol translation (SSH, RDP, VNC)
|
@@ -181,7 +181,7 @@ The script runs in three phases:
Run `bash setup-xrdp-gfx.sh --help` for all options, or `bash setup-xrdp-gfx.sh --diagnose` to troubleshoot after setup.
In the rustguac address book, enable these settings on the RDP entry:
In the rustguac connections, enable these settings on the RDP entry:
- **Enable Graphics Pipeline (GFX)** -- checked
- **H.264 Passthrough** -- checked
- **Enable Desktop Composition** -- not needed for Linux (Windows-only DWM setting)
@@ -247,9 +247,9 @@ Once OIDC is working and you have an admin user, remove the initial API key:
API keys are powerful (full admin, no MFA). For day-to-day use, OIDC with group-based roles is more secure. If you need programmatic API access, create scoped [user API tokens](roles-and-access-control.md) instead.
## Step 6: Set Up the Address Book (Vault)
## Step 6: Set Up the Connections (Vault)
The address book stores connection entries in HashiCorp Vault or OpenBao. Credentials stay server-side — they never reach the browser.
The connections stores connection entries in HashiCorp Vault or OpenBao. Credentials stay server-side — they never reach the browser.
```toml
[vault]
@@ -365,7 +365,7 @@ Back up these paths:
- `/opt/rustguac/env` — secrets (Vault secret ID, OIDC client secret)
- `/opt/rustguac/recordings/` — session recordings (if needed for compliance)
The address book is in Vault — back up Vault separately.
The connections is in Vault — back up Vault separately.
### Security checklist
+4 -4
View File
@@ -32,13 +32,13 @@ sudo systemctl enable --now rustguac
This starts both `rustguac-guacd` (the protocol daemon) and `rustguac` (the web proxy).
4. **(Recommended) Set up the address book with Vault / OpenBao:**
4. **(Recommended) Set up the connections with Vault / OpenBao:**
The address book is rustguac's primary way to manage connections. It stores SSH, RDP, VNC, and web session entries in [HashiCorp Vault](https://www.vaultproject.io/) or [OpenBao](https://openbao.org/) KV v2. Credentials are stored server-side and never sent to the browser.
The connections is rustguac's primary way to manage connections. It stores SSH, RDP, VNC, and web session entries in [HashiCorp Vault](https://www.vaultproject.io/) or [OpenBao](https://openbao.org/) KV v2. Credentials are stored server-side and never sent to the browser.
Without Vault, rustguac can still create ad-hoc sessions via the API, but the address book UI (the main user-facing feature) will not be available.
Without Vault, rustguac can still create ad-hoc sessions via the API, but the connections UI (the main user-facing feature) will not be available.
See [Vault / OpenBao Address Book](integrations.md#vault--openbao-address-book) for full setup instructions, including Vault policy, AppRole configuration, and the `[vault]` config section.
See [Vault / OpenBao Connections](integrations.md#vault--openbao-address-book) for full setup instructions, including Vault policy, AppRole configuration, and the `[vault]` config section.
6. **(Optional) Set up encrypted drive storage:**
+12 -12
View File
@@ -37,7 +37,7 @@ chmod 600 /opt/rustguac/env
rustguac extracts group memberships from the OIDC ID token. The claim name is configurable (default: `groups`). Groups are used for:
- **Automatic role assignment** via group-to-role mappings (see [Roles and Access Control](roles-and-access-control.md))
- **Address book folder access** — folders can be restricted to specific OIDC groups
- **Connections folder access** — folders can be restricted to specific OIDC groups
If your provider requires additional scopes to include groups in the token, add them to `extra_scopes`:
@@ -112,9 +112,9 @@ Create groups in Authentik (e.g., `rustguac-admins`, `rustguac-operators`) and a
---
## Vault / OpenBao Address Book
## Vault / OpenBao Connections
The address book stores connection entries in [HashiCorp Vault](https://www.vaultproject.io/) or [OpenBao](https://openbao.org/) KV v2. Credentials are read server-side and never sent to the browser.
The connections stores connection entries in [HashiCorp Vault](https://www.vaultproject.io/) or [OpenBao](https://openbao.org/) KV v2. Credentials are read server-side and never sent to the browser.
### Vault setup
@@ -128,13 +128,13 @@ vault secrets enable -path=secret kv-v2
```bash
vault policy write rustguac - <<'EOF'
# Address book entries: create, read, update, soft-delete
# Connections entries: create, read, update, soft-delete
path "secret/data/rustguac/*" {
capabilities = ["create", "read", "update", "delete"]
}
# Folder/entry listing and permanent deletion
# - "list" + "read": browse the address book
# - "list" + "read": browse the connections
# - "delete": permanently remove entries and folders
# (KV v2 permanent deletes go through the metadata/ path, not data/)
path "secret/metadata/rustguac/*" {
@@ -239,7 +239,7 @@ This allows a fleet of rustguac instances to share common entries while maintain
### Entry types
Address book entries can be SSH, RDP, VNC, or Web connections. Each entry stores:
Connections entries can be SSH, RDP, VNC, or Web connections. Each entry stores:
- Connection type and target (hostname, port, URL)
- Credentials (username, password, private key)
@@ -259,7 +259,7 @@ Folder and entry names are validated: alphanumeric characters, hyphens, undersco
### Credential prompting
Address book entries can be configured to prompt users for credentials at connect time. This is useful for:
Connections entries can be configured to prompt users for credentials at connect time. This is useful for:
- **Entries without stored credentials** — e.g., RDP servers where each user has their own AD account. The admin creates the entry with just hostname/port, and users supply their own credentials when connecting.
- **Entries with stored credentials but prompt enabled** — e.g., a jump host where the stored credentials are a fallback, but users should normally use their own.
@@ -294,9 +294,9 @@ The chain is set up sequentially (each hop must connect before the next starts)
### Configuration
#### Address book entries
#### Connections entries
Admins configure jump hosts per entry in the address book editor. Click "Add Jump Host" to add hops to the chain. Each hop has its own credentials (username + password or private key). A visual flow diagram shows the tunnel path.
Admins configure jump hosts per entry in the connections editor. Click "Add Jump Host" to add hops to the chain. Each hop has its own credentials (username + password or private key). A visual flow diagram shows the tunnel path.
Jump host credentials are stored in Vault alongside the entry's other credentials and are never sent to the browser. When editing an entry, existing hop passwords and keys are preserved if the edit form omits them (per-hop credential merge by index).
@@ -365,7 +365,7 @@ FreeRDP's WinPR layer implements Windows SSPI on top of the system's MIT Kerbero
### Per-entry configuration
These settings are configured per address book entry in the admin UI:
These settings are configured per connections entry in the admin UI:
| Setting | Values | Description |
|---------|--------|-------------|
@@ -423,7 +423,7 @@ FreeRDP/libkrb5 finds the KDC in this order of priority:
**Option 1: KDC Proxy URL (simplest for remote networks)**
Set the **KDC URL** field on the address book entry to your KDC Proxy endpoint (e.g., `https://dc.example.com/KdcProxy`). This bypasses DNS SRV and krb5.conf entirely. Windows Server's KDC Proxy Service can serve this role.
Set the **KDC URL** field on the connections entry to your KDC Proxy endpoint (e.g., `https://dc.example.com/KdcProxy`). This bypasses DNS SRV and krb5.conf entirely. Windows Server's KDC Proxy Service can serve this role.
**Option 2: DNS SRV records (simplest for on-network)**
@@ -469,7 +469,7 @@ The **Domain** field should be the AD domain name (e.g., `EXAMPLE.COM`).
### Example: Kerberos RDP entry
Create an address book entry with:
Create an connections entry with:
- **Type**: RDP
- **Hostname**: `fileserver.corp.example.com` (must be FQDN)
+5 -5
View File
@@ -1,6 +1,6 @@
# Migrating from Apache Guacamole
rustguac can import connections from an Apache Guacamole MySQL/MariaDB database into its Vault-backed address book.
rustguac can import connections from an Apache Guacamole MySQL/MariaDB database into its Vault-backed connections.
## Prerequisites
@@ -67,15 +67,15 @@ rustguac --config /opt/rustguac/config.toml \
| Flag | Default | Description |
|------|---------|-------------|
| `--file` | (required) | Path to the mysqldump SQL file |
| `--folder` | `imported` | Target folder in the address book |
| `--folder` | `imported` | Target folder in the connections |
| `--scope` | `shared` | `shared` (visible to all instances) or `instance` (this instance only) |
| `--dry-run` | off | Preview without writing to Vault |
## What gets imported
The importer maps Guacamole connection parameters to rustguac address book fields:
The importer maps Guacamole connection parameters to rustguac connections fields:
| Guacamole parameter | Address book field |
| Guacamole parameter | Connections field |
|--------------------|--------------------|
| `hostname` | `hostname` |
| `port` | `port` |
@@ -113,7 +113,7 @@ Guacamole's connection group hierarchy is flattened into entry name prefixes. Fo
## After import
Once imported, connections appear in the address book UI. You can:
Once imported, connections appear in the connections UI. You can:
- Edit entries to add features not available in Guacamole (login scripts, autofill, domain allowlists)
- Move entries between folders
+9 -9
View File
@@ -11,20 +11,20 @@ Go to **Customization > Custom Fields** and create the following fields. Assign
| Name | Type | Default | Description |
|------|------|---------|-------------|
| `console_enabled` | Boolean | false | Opt-in: enables remote console links on the device page |
| `console_mode` | Selection: `addressbook`, `adhoc` | — | How to connect: via address book entry (Vault credentials) or ad-hoc (direct to IP) |
| `remote_protocol` | Selection: `ssh`, `rdp`, `vnc`, `web` | — | Protocol for ad-hoc connections (address book entries have their own) |
| `console_mode` | Selection: `addressbook`, `adhoc` | — | How to connect: via connections entry (Vault credentials) or ad-hoc (direct to IP) |
| `remote_protocol` | Selection: `ssh`, `rdp`, `vnc`, `web` | — | Protocol for ad-hoc connections (connections entries have their own) |
| `remote_port` | Integer | — | Port override for ad-hoc connections (leave blank for protocol default) |
The `console_enabled` field is the master switch — no links appear until it's checked. The `console_mode` field controls which link is shown:
- **`addressbook`** — connects via a Vault address book entry. Credentials are managed in Vault and never appear in the URL. Requires a matching entry name (lowercase device name). Minimum role: **operator**.
- **`addressbook`** — connects via a Vault connections entry. Credentials are managed in Vault and never appear in the URL. Requires a matching entry name (lowercase device name). Minimum role: **operator**.
- **`adhoc`** — connects directly to the device's primary IP. No stored credentials — the user sees guacd's login prompt. Minimum role: **poweruser**.
## Custom Links
Create **two** Custom Links in **Customization > Custom Links**. Each link only renders when `console_mode` matches its mode, so only one appears per device.
### Link 1: Address Book Console (green)
### Link 1: Connections Console (green)
| Setting | Value |
|---------|-------|
@@ -43,7 +43,7 @@ Create **two** Custom Links in **Customization > Custom Links**. Each link only
https://console.example.com/api/connect?scope=shared&folder=production&entry={{ object.name | lower }}
```
Replace `production` with your address book folder name. The entry name must match the lowercase device name in Vault.
Replace `production` with your connections folder name. The entry name must match the lowercase device name in Vault.
### Link 2: Ad-hoc SSH (blue outline)
@@ -78,12 +78,12 @@ Use NetBox's **bulk edit** to enable across multiple devices at once.
| Mode | Minimum role | Description |
|------|-------------|-------------|
| Address book | operator | Connects via Vault entry (credentials from Vault) |
| Connections | operator | Connects via Vault entry (credentials from Vault) |
| Ad-hoc | poweruser | Creates session directly to hostname |
## Webhook-Driven Address Book Sync
## Webhook-Driven Connections Sync
Automatically sync NetBox devices to rustguac's Vault-backed address book using Event Rules and Webhooks. This keeps address book entries in sync with NetBox — when a device is created or updated, the corresponding entry is created in Vault.
Automatically sync NetBox devices to rustguac's Vault-backed connections using Event Rules and Webhooks. This keeps connections entries in sync with NetBox — when a device is created or updated, the corresponding entry is created in Vault.
### Filtering: control what syncs
@@ -205,5 +205,5 @@ Both NetBox and rustguac support OIDC authentication. When configured with the s
3. Create the four custom fields (`console_enabled`, `console_mode`, `remote_protocol`, `remote_port`)
4. Create the two Custom Links (Console green, Quick SSH blue)
5. On devices you want to enable: check `console_enabled`, set `console_mode`
6. For address book mode: ensure matching entries exist in Vault (manually or via webhook sync)
6. For connections mode: ensure matching entries exist in Vault (manually or via webhook sync)
7. Users click the button on a device page and land in a session
+6 -6
View File
@@ -4,7 +4,7 @@
rustguac is a lightweight Rust replacement for the Apache Guacamole Java webapp. It provides browser-based remote access to SSH, RDP, VNC, web browser sessions, and VDI desktop containers through [guacd](https://github.com/apache/guacamole-server), the Guacamole protocol daemon.
rustguac sits between web browsers and guacd, proxying the Guacamole protocol over WebSockets. It manages session lifecycle, authentication, session recording, VDI container orchestration, and an optional Vault-backed address book.
rustguac sits between web browsers and guacd, proxying the Guacamole protocol over WebSockets. It manages session lifecycle, authentication, session recording, VDI container orchestration, and an optional Vault-backed connections.
## Why not Apache Guacamole?
@@ -14,7 +14,7 @@ Apache Guacamole is a mature, feature-rich platform. rustguac is a purpose-built
- **Security-first design** — CIDR allowlists, TLS everywhere, LUKS-encrypted file transfer, Vault integration, rate limiting, audit logging.
- **Simpler deployment** — one binary + guacd. Install with a single script or Docker image.
- **VDI desktops** — ephemeral Docker containers give each user an isolated Linux desktop on demand. No VM infrastructure required.
- **Address book in Vault** — connection credentials stored in HashiCorp Vault / OpenBao KV v2. Credentials never reach the browser.
- **Connections in Vault** — connection credentials stored in HashiCorp Vault / OpenBao KV v2. Credentials never reach the browser.
- **Zero-trust integration** — works with [Knocknoc](https://knocknoc.io) for identity-aware network access control at the HAProxy layer.
## Similarities to Apache Guacamole
@@ -113,7 +113,7 @@ Supports optional [multi-hop SSH tunnel chains](#ssh-tunnel--jump-hosts) to reac
Spawns an ephemeral Docker container running xrdp and a Linux desktop, then connects guacd via RDP to the container. Each user gets a dedicated container named `rustguac-vdi-{username}`. Containers persist after disconnect for reconnection and are automatically cleaned up after an idle timeout.
VDI sessions support persistent home directories, per-entry resource limits and idle timeouts, session thumbnails, and active session previews in the address book. See [VDI Desktop Containers](vdi.md) for configuration and image requirements.
VDI sessions support persistent home directories, per-entry resource limits and idle timeouts, session thumbnails, and active session previews in the connections. See [VDI Desktop Containers](vdi.md) for configuration and image requirements.
## SSH tunnel / jump hosts
@@ -126,10 +126,10 @@ You -> bastion-1:22 -> bastion-2:22 -> target:3389 RDP
```
Jump hosts can be configured:
- **Per address book entry** — admins configure the tunnel chain in the entry editor
- **Per connections entry** — admins configure the tunnel chain in the entry editor
- **Per ad-hoc session** — powerusers add jump hosts when creating sessions from the Sessions page
Each hop supports independent credentials (username + password or private key). Jump host credentials are stored in Vault alongside the address book entry and are never sent to the browser.
Each hop supports independent credentials (username + password or private key). Jump host credentials are stored in Vault alongside the connections entry and are never sent to the browser.
## Ports
@@ -186,7 +186,7 @@ scripts/ Utility scripts (drive-setup.sh)
### Integrations
- [Integrations](integrations.md) -- OIDC, Vault, SSH tunnels, Kerberos, HAProxy, Knocknoc, drive/LUKS
- [NetBox](netbox.md) -- address book sync via custom fields and webhooks
- [NetBox](netbox.md) -- connections sync via custom fields and webhooks
- [Migration from Apache Guacamole](migration.md) -- MySQL/MariaDB to Vault
### Reference
+2 -2
View File
@@ -2,7 +2,7 @@
Guide to optimizing RDP video quality and frame rate through rustguac, particularly for video monitoring workloads.
## Address Book Settings
## Connections Settings
Three per-entry settings control RDP video behaviour:
@@ -12,7 +12,7 @@ Three per-entry settings control RDP video behaviour:
- **Force Lossless** — Forces PNG-only encoding (no JPEG/WebP lossy compression). Better for text-heavy workloads where visual fidelity matters. Uses significantly more bandwidth — not recommended for video content.
These settings appear in the address book entry editor for RDP entries under "Video Performance".
These settings appear in the connections entry editor for RDP entries under "Video Performance".
## Windows RDP Server Tuning
+2 -2
View File
@@ -13,7 +13,7 @@ The top of the reports page shows four summary metrics:
## Session history
A searchable, sortable table of all past and current sessions. Each row includes the user who created the session, the address book entry and folder (if applicable), session type (SSH, RDP, VNC, Web), hostname, start time, duration, status, and a link to the recording if one exists.
A searchable, sortable table of all past and current sessions. Each row includes the user who created the session, the connections entry and folder (if applicable), session type (SSH, RDP, VNC, Web), hostname, start time, duration, status, and a link to the recording if one exists.
The table supports:
@@ -63,7 +63,7 @@ All endpoints require authentication (API key, user token, or OIDC session cooki
| Parameter | Description |
|-----------|-------------|
| `user` | Filter by username (partial match) |
| `entry` | Filter by address book entry name (partial match) |
| `entry` | Filter by connections entry name (partial match) |
| `type` | Filter by session type: `ssh`, `rdp`, `vnc`, `web` |
| `from` | Start date filter (ISO 8601, e.g. `2025-01-01T00:00:00Z`) |
| `to` | End date filter (ISO 8601) |
+6 -6
View File
@@ -6,9 +6,9 @@ rustguac implements a 4-tier role hierarchy:
| Role | Level | Description |
|------|-------|-------------|
| **admin** | 4 | Full access — manage users, address book, recordings, sessions, group mappings, all API tokens |
| **poweruser** | 3 | Ad-hoc session creation + address book connect + self-service API tokens |
| **operator** | 2 | Address book connect only (no ad-hoc sessions); can view own API tokens |
| **admin** | 4 | Full access — manage users, connections, recordings, sessions, group mappings, all API tokens |
| **poweruser** | 3 | Ad-hoc session creation + connections connect + self-service API tokens |
| **operator** | 2 | Connections connect only (no ad-hoc sessions); can view own API tokens |
| **viewer** | 1 | Read-only — view sessions and recordings; no API token access |
Roles are hierarchical: each role includes all permissions of lower roles. For example, a poweruser can do everything an operator can, plus create ad-hoc sessions.
@@ -54,13 +54,13 @@ OIDC users are assigned a role through three mechanisms (in order of precedence)
| `GET /api/sessions/:id` | operator | View session details |
| `DELETE /api/sessions/:id` | operator | Non-admins can only delete their own sessions |
### Address book
### Connections
| Endpoint | Required role | Notes |
|----------|--------------|-------|
| `GET /api/addressbook/folders` | operator | Filtered by OIDC group membership |
| `GET /api/addressbook/folders/:scope/:folder/entries` | operator | Requires folder group access |
| `POST .../entries/:entry/connect` | operator | Creates session from address book entry |
| `POST .../entries/:entry/connect` | operator | Creates session from connections entry |
| `POST /api/addressbook/folders` | admin | Create folders |
| `PUT /api/addressbook/folders/:scope/:folder` | admin | Update folder config |
| `DELETE /api/addressbook/folders/:scope/:folder` | admin | Delete folders |
@@ -125,7 +125,7 @@ Operators can view their tokens (created by an admin on their behalf) but cannot
## Folder access control
Address book folders have group-based access control. Each folder has an `allowed_groups` list stored in its `.config` entry in Vault.
Connections folders have group-based access control. Each folder has an `allowed_groups` list stored in its `.config` entry in Vault.
- **Admins** bypass group checks and see all folders
- **Operators and powerusers** see only folders where their OIDC groups intersect with the folder's `allowed_groups`
+5 -5
View File
@@ -177,7 +177,7 @@ Additionally, user API token operations are logged to a persistent `token_audit_
## Clipboard control
Clipboard copy (server → client) and paste (client → server) can be independently disabled per address book entry. This uses guacd's native `disable-copy` and `disable-paste` parameters, which work for all session types (SSH, RDP, VNC, and web browser sessions).
Clipboard copy (server → client) and paste (client → server) can be independently disabled per connections entry. This uses guacd's native `disable-copy` and `disable-paste` parameters, which work for all session types (SSH, RDP, VNC, and web browser sessions).
Use cases:
- **Disable copy** — prevents users from copying data out of sensitive sessions (data loss prevention)
@@ -210,7 +210,7 @@ Policies applied:
### Per-entry domain allowlisting
Address book entries can specify an `allowed_domains` list. When set, Chromium can only reach those domains (plus localhost). All other domains are blocked via Chromium's `--host-rules` flag, which prevents DNS resolution for non-allowed hosts.
Connections entries can specify an `allowed_domains` list. When set, Chromium can only reach those domains (plus localhost). All other domains are blocked via Chromium's `--host-rules` flag, which prevents DNS resolution for non-allowed hosts.
Subdomains are automatically included — adding `example.com` allows `*.example.com` as well.
@@ -243,7 +243,7 @@ All database queries use parameterised statements via rusqlite's `params!` macro
## Path traversal protection
Recording file access validates filenames to block path traversal (`/`, `\`, `..`). The Vault address book also validates entry and folder names (alphanumeric, hyphens, underscores, dots only; length 1-64).
Recording file access validates filenames to block path traversal (`/`, `\`, `..`). The Vault connections also validates entry and folder names (alphanumeric, hyphens, underscores, dots only; length 1-64).
## XSS protection
@@ -263,8 +263,8 @@ trusted_proxies = ["127.0.0.1/32"]
## Credential handling
- **Vault credentials** — address book entries are read server-side from Vault. Connection passwords and private keys are never sent to the browser.
- **SSH tunnel credentials** — jump host passwords and private keys are stored in Vault alongside the address book entry. They are read server-side when establishing the tunnel chain and are never sent to the browser. For ad-hoc sessions, jump host credentials are provided in the session creation request and exist only in memory during tunnel setup.
- **Vault credentials** — connections entries are read server-side from Vault. Connection passwords and private keys are never sent to the browser.
- **SSH tunnel credentials** — jump host passwords and private keys are stored in Vault alongside the connections entry. They are read server-side when establishing the tunnel chain and are never sent to the browser. For ad-hoc sessions, jump host credentials are provided in the session creation request and exist only in memory during tunnel setup.
- **API keys** — only the SHA-256 hash is stored. The plaintext key is shown once at creation and cannot be retrieved.
- **User API tokens** — same SHA-256 hash storage as admin API keys. The `rgu_` prefix enables secret scanning. Plaintext shown once at creation only.
- **OIDC client secret** — can be provided via `OIDC_CLIENT_SECRET` environment variable instead of the config file.
+5 -5
View File
@@ -4,7 +4,7 @@ rustguac can spawn ephemeral Docker desktop containers on demand. Each user gets
## How it works
1. An admin creates a VDI entry in the address book, specifying a Docker image
1. An admin creates a VDI entry in the connections, specifying a Docker image
2. When a user clicks Connect, rustguac creates a Docker container from that image
3. The container runs xrdp on port 3389, and guacd connects to it via RDP
4. The user sees a full Linux desktop in their browser
@@ -87,9 +87,9 @@ xrdp-sesman --nodaemon &
exec xrdp --nodaemon
```
## Address book setup
## Connections setup
1. Create a folder in the address book (or use an existing one)
1. Create a folder in the connections (or use an existing one)
2. Add a new entry with type **VDI (Docker)**
3. Set the **Container Image** (e.g. `rustguac-vdi-test:latest`)
4. Optionally set CPU limit, memory limit, environment variables, idle timeout
@@ -121,13 +121,13 @@ Each user gets `{home_base}/{username}` mounted as `/home/{username}` inside the
## Active Sessions
The address book shows an **Active Sessions** section with thumbnail previews of running sessions. Thumbnails are captured every 10 seconds from the browser display. Click a thumbnail to reconnect.
The connections shows an **Active Sessions** section with thumbnail previews of running sessions. Thumbnails are captured every 10 seconds from the browser display. Click a thumbnail to reconnect.
Dormant VDI containers (running but no active browser session) also appear with their last captured thumbnail.
## Per-entry settings
Each VDI address book entry can override:
Each VDI connections entry can override:
- **CPU limit** (cores) — overrides `default_cpu_limit`
- **Memory limit** (MB) — overrides `default_memory_limit`
+13 -13
View File
@@ -41,9 +41,9 @@ Xvnc (virtual display :100:199)
## Quick start
### Address book entry
### Connections entry
Create a web entry in the address book with at minimum:
Create a web entry in the connections with at minimum:
| Field | Value |
|-------|-------|
@@ -89,7 +89,7 @@ When the user clicks on a login form, Chromium shows its familiar autofill dropd
### Configuring autofill
The `autofill` field on an address book entry is a JSON string containing an array of credential objects:
The `autofill` field on an connections entry is a JSON string containing an array of credential objects:
```json
[
@@ -142,15 +142,15 @@ This is Chromium's own obfuscation layer for the headless Linux case (no keyring
### UI
In the address book entry editor, the **Autofill** section provides a visual builder. Click **"Add site"** to add credential rows. The URL field auto-populates with the entry's target URL. Save the entry and the UI serialises the rows to JSON.
In the connections entry editor, the **Autofill** section provides a visual builder. Click **"Add site"** to add credential rows. The URL field auto-populates with the entry's target URL. Save the entry and the UI serialises the rows to JSON.
## Domain allowlisting
Each address book entry can specify an `allowed_domains` list to restrict which websites the browser can reach. This is enforced inside Chromium via the `--host-rules` flag, which blocks DNS resolution for non-allowed domains.
Each connections entry can specify an `allowed_domains` list to restrict which websites the browser can reach. This is enforced inside Chromium via the `--host-rules` flag, which blocks DNS resolution for non-allowed domains.
### Configuring allowed domains
In the address book entry editor, expand the **Allowed Domains** section and add domain names:
In the connections entry editor, expand the **Allowed Domains** section and add domain names:
```
example.com
@@ -166,14 +166,14 @@ There are two separate mechanisms that control what a web session can access:
| Layer | Config | Applied | Scope |
|-------|--------|---------|-------|
| **`web_allowed_networks`** | `config.toml` (global) | Server-side, at session creation | CIDR ranges — controls which IPs rustguac will connect to |
| **`allowed_domains`** | Address book entry | Client-side, inside Chromium at runtime | Domain names — controls which sites the user can navigate to |
| **`allowed_domains`** | Connections entry | Client-side, inside Chromium at runtime | Domain names — controls which sites the user can navigate to |
They don't conflict — both can be active simultaneously for defense in depth:
- `web_allowed_networks` prevents rustguac from initiating connections to disallowed networks (SSRF protection)
- `allowed_domains` prevents the user from navigating to sites outside the allowlist within an already-running session
**Example:** Your config allows `10.0.0.0/8` for web sessions (server-side). An address book entry for the internal wiki sets `allowed_domains: ["wiki.internal.example.com"]`. The session can only reach the wiki — even though the server-side allowlist permits the entire `10.0.0.0/8` range.
**Example:** Your config allows `10.0.0.0/8` for web sessions (server-side). An connections entry for the internal wiki sets `allowed_domains: ["wiki.internal.example.com"]`. The session can only reach the wiki — even though the server-side allowlist permits the entire `10.0.0.0/8` range.
### API
@@ -196,7 +196,7 @@ A login script is a server-side executable that connects to the already-running
### How it works
1. The address book entry specifies a `login_script` filename (e.g., `portal-login.js`)
1. The connections entry specifies a `login_script` filename (e.g., `portal-login.js`)
2. When the session starts, Chromium is launched with `--remote-debugging-port={cdp_port}`
3. After Chromium is ready, rustguac spawns the script as a child process
4. The script connects to Chromium via CDP, performs automation, then exits
@@ -366,7 +366,7 @@ main().catch((err) => {
1. Save it to `/opt/rustguac/scripts/login-example.js`
2. Make it executable: `chmod +x /opt/rustguac/scripts/login-example.js`
3. Install Playwright: `cd /opt/rustguac/scripts && npm install playwright-core`
4. Set the `login_script` field on an address book entry to `login-example.js`
4. Set the `login_script` field on an connections entry to `login-example.js`
### Example: Shell script with curl
@@ -422,7 +422,7 @@ The autofill database is written before Chromium launches, and the login script
## Clipboard control
Clipboard copy and paste can be independently disabled per address book entry. This uses guacd's native `disable-copy` and `disable-paste` parameters.
Clipboard copy and paste can be independently disabled per connections entry. This uses guacd's native `disable-copy` and `disable-paste` parameters.
| Field | Effect |
|-------|--------|
@@ -511,9 +511,9 @@ POST /api/sessions
| `disable_paste` | boolean | No | Disable clipboard paste (default: false) |
| `jump_hosts` | array | No | SSH tunnel hops (see [SSH tunnels](#ssh-tunnels-for-web-sessions)) |
### Address book entry fields
### Connections entry fields
When creating entries via the Vault address book (UI or API), the same fields are available:
When creating entries via the Vault connections (UI or API), the same fields are available:
```json
{
+153 -3
View File
@@ -130,21 +130,51 @@ fn redact_share_url(
info
}
/// GET /api/sessions — List all sessions. All authenticated roles.
#[derive(Deserialize, Default)]
pub struct ListSessionsQuery {
/// When `true`, admins receive all active sessions instead of only
/// their own. Ignored for non-admins. Used by the Sessions
/// management page; the Address Book keeps the owner-only default.
#[serde(default)]
pub all: bool,
}
/// GET /api/sessions — List active sessions.
/// Default behaviour (GitHub #102): callers see only sessions they
/// created. The Sessions management page passes `?all=true` to let
/// admins see everyone.
pub async fn list_sessions(
State(manager): State<AppState>,
identity: Option<Extension<AuthIdentity>>,
axum::extract::Query(q): axum::extract::Query<ListSessionsQuery>,
) -> impl IntoResponse {
let is_admin = identity
.as_ref()
.map(|Extension(id)| id.has_role("admin"))
.unwrap_or(false);
let owner = identity
.as_ref()
.map(|Extension(id)| id.display_name().to_string());
let show_all = q.all && is_admin;
let sessions: Vec<_> = manager
.list_sessions()
.await
.into_iter()
.filter(|s| {
show_all
|| owner
.as_deref()
.map(|o| s.created_by == o)
.unwrap_or(false)
})
.map(|s| redact_share_url(s, &identity))
.collect();
Json(json!(sessions))
}
/// GET /api/sessions/:id — Get session info. All authenticated roles.
/// GET /api/sessions/:id — Get session info.
/// Non-admins can only inspect their own sessions (GitHub #102).
pub async fn get_session(
State(manager): State<AppState>,
Path(id): Path<Uuid>,
@@ -152,6 +182,21 @@ pub async fn get_session(
) -> impl IntoResponse {
match manager.get_session(id).await {
Some(info) => {
let is_admin = identity
.as_ref()
.map(|Extension(id)| id.has_role("admin"))
.unwrap_or(false);
let is_owner = identity
.as_ref()
.map(|Extension(id)| info.created_by == id.display_name())
.unwrap_or(false);
if !is_admin && !is_owner {
return (
StatusCode::NOT_FOUND,
Json(json!({ "error": "session not found" })),
)
.into_response();
}
let info = redact_share_url(info, &identity);
(StatusCode::OK, Json(json!(info))).into_response()
}
@@ -227,13 +272,27 @@ pub async fn delete_session(
/// PUT /api/sessions/:id/thumbnail — Upload a session thumbnail (JPEG).
/// Called by the client periodically to update the session preview.
/// Only the session owner (or an admin) can upload.
pub async fn put_session_thumbnail(
State(manager): State<AppState>,
Path(id): Path<Uuid>,
identity: Option<Extension<AuthIdentity>>,
body: axum::body::Bytes,
) -> impl IntoResponse {
// Validate: session must exist
if manager.get_session(id).await.is_none() {
let Some(info) = manager.get_session(id).await else {
return StatusCode::NOT_FOUND.into_response();
};
// Authorise: owner or admin only (privacy — GitHub #102)
let is_admin = identity
.as_ref()
.map(|Extension(id)| id.has_role("admin"))
.unwrap_or(false);
let is_owner = identity
.as_ref()
.map(|Extension(id)| info.created_by == id.display_name())
.unwrap_or(false);
if !is_admin && !is_owner {
return StatusCode::NOT_FOUND.into_response();
}
// Reject oversized thumbnails (100KB max)
@@ -262,10 +321,28 @@ pub async fn put_session_thumbnail(
}
/// GET /api/sessions/:id/thumbnail — Serve a session thumbnail (JPEG).
/// Only the session owner (or an admin) can read. Returns 404 for
/// non-owners so we don't leak session existence.
pub async fn get_session_thumbnail(
State(manager): State<AppState>,
Path(id): Path<Uuid>,
identity: Option<Extension<AuthIdentity>>,
) -> impl IntoResponse {
let Some(info) = manager.get_session(id).await else {
return StatusCode::NOT_FOUND.into_response();
};
let is_admin = identity
.as_ref()
.map(|Extension(id)| id.has_role("admin"))
.unwrap_or(false);
let is_owner = identity
.as_ref()
.map(|Extension(id)| info.created_by == id.display_name())
.unwrap_or(false);
if !is_admin && !is_owner {
return StatusCode::NOT_FOUND.into_response();
}
let path = manager.thumbnail_path(id);
match tokio::fs::read(&path).await {
Ok(data) => (
@@ -1345,6 +1422,36 @@ pub async fn list_group_mappings(
}
}
/// GET /api/auth/known-groups — List known OIDC groups (union of role
/// mappings + groups ever seen in a user's login claims). Admin only;
/// used by the folder modal to populate the allowed_groups picker.
pub async fn list_known_groups(
identity: Option<Extension<AuthIdentity>>,
Extension(database): Extension<Db>,
) -> impl IntoResponse {
if !identity
.as_ref()
.map(|Extension(id)| id.has_role("admin"))
.unwrap_or(false)
{
return (
StatusCode::FORBIDDEN,
Json(json!({"error": "admin role required"})),
)
.into_response();
}
let db_clone = database.clone();
match tokio::task::spawn_blocking(move || db::list_known_groups(&db_clone)).await {
Ok(Ok(groups)) => Json(json!({ "groups": groups })).into_response(),
_ => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "failed to list groups"})),
)
.into_response(),
}
}
/// POST /api/admin/group-mappings — Create a group-to-role mapping. Admin only.
pub async fn create_group_mapping(
identity: Option<Extension<AuthIdentity>>,
@@ -2158,6 +2265,49 @@ pub async fn ab_update_folder(
}
}
/// GET /api/addressbook/folders/:scope/:folder/config — Read the stored
/// FolderConfig (allowed_groups, description). Admin only — used by the
/// folder modal to prefill the allowed-groups picker on edit.
pub async fn ab_get_folder_config(
identity: Option<Extension<AuthIdentity>>,
Extension(vault): Extension<VaultState>,
Path((scope, folder)): Path<(String, String)>,
) -> impl IntoResponse {
let vault = match require_vault(&vault).await {
Ok(v) => v,
Err(resp) => return resp,
};
if !identity
.as_ref()
.map(|Extension(id)| id.has_role("admin"))
.unwrap_or(false)
{
return (
StatusCode::FORBIDDEN,
Json(json!({"error": "admin role required"})),
)
.into_response();
}
match vault.get_folder_config(&scope, &folder).await {
Ok(cfg) => Json(json!({
"allowed_groups": cfg.allowed_groups,
"description": cfg.description,
}))
.into_response(),
Err(crate::vault::VaultError::NotFound) => Json(json!({
"allowed_groups": Vec::<String>::new(),
"description": "",
}))
.into_response(),
Err(e) => (
StatusCode::BAD_GATEWAY,
Json(json!({"error": e.to_string()})),
)
.into_response(),
}
}
/// DELETE /api/addressbook/folders/:scope/:folder — Delete folder and all entries. Admin only.
pub async fn ab_delete_folder(
identity: Option<Extension<AuthIdentity>>,
+46
View File
@@ -115,6 +115,12 @@ pub fn init_db(path: &Path) -> rusqlite::Result<Db> {
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS seen_groups (
name TEXT PRIMARY KEY,
first_seen TEXT NOT NULL DEFAULT (datetime('now')),
last_seen TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS user_api_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
@@ -669,6 +675,46 @@ pub fn delete_group_mapping(db: &Db, id: i64) -> rusqlite::Result<bool> {
Ok(changed > 0)
}
/// Upsert OIDC groups observed in a login token, updating last_seen.
pub fn upsert_seen_groups(db: &Db, groups: &[String]) -> rusqlite::Result<()> {
if groups.is_empty() {
return Ok(());
}
let mut conn = db.lock().unwrap();
let tx = conn.transaction()?;
{
let mut stmt = tx.prepare(
"INSERT INTO seen_groups (name) VALUES (?1)
ON CONFLICT(name) DO UPDATE SET last_seen = datetime('now')",
)?;
for g in groups {
let trimmed = g.trim();
if !trimmed.is_empty() {
stmt.execute(params![trimmed])?;
}
}
}
tx.commit()?;
Ok(())
}
/// List all known OIDC groups — union of configured role-mappings and groups
/// ever seen in a user's login claims. Sorted case-insensitively.
pub fn list_known_groups(db: &Db) -> rusqlite::Result<Vec<String>> {
let conn = db.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT g FROM (
SELECT oidc_group AS g FROM group_role_mappings
UNION
SELECT name AS g FROM seen_groups
)
WHERE g IS NOT NULL AND g <> ''
ORDER BY g COLLATE NOCASE",
)?;
let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
rows.collect()
}
// ── User API tokens ──
/// Create a user API token. Returns the plaintext token (shown once).
+14 -2
View File
@@ -622,7 +622,7 @@ async fn run_server(config: Config, database: Db) {
// Disk-served HTML pages
for name in &[
"index.html",
"addressbook.html",
"connections.html",
"sessions.html",
"recordings.html",
"reports.html",
@@ -879,6 +879,7 @@ async fn run_server(config: Config, database: Db) {
.route("/api/users/{email}/enable", post(api::enable_user))
.route("/api/admin/group-mappings", get(api::list_group_mappings))
.route("/api/admin/group-mappings", post(api::create_group_mapping))
.route("/api/auth/known-groups", get(api::list_known_groups))
.route(
"/api/admin/group-mappings/{id}",
put(api::update_group_mapping),
@@ -922,6 +923,10 @@ async fn run_server(config: Config, database: Db) {
"/api/addressbook/folders/{scope}/{folder}",
delete(api::ab_delete_folder),
)
.route(
"/api/addressbook/folders/{scope}/{folder}/config",
get(api::ab_get_folder_config),
)
.route(
"/api/addressbook/folders/{scope}/{folder}/subfolders",
get(api::ab_list_subfolders),
@@ -1004,9 +1009,16 @@ async fn run_server(config: Config, database: Db) {
let html_routes = Router::new()
.route("/", get(serve_branded_page))
.route("/index.html", get(serve_branded_page))
.route("/addressbook.html", get(serve_branded_page))
.route("/connections.html", get(serve_branded_page))
// Legacy path — the page was renamed from Address Book → Connections.
// Permanent redirect so bookmarks keep working.
.route(
"/addressbook.html",
get(|| async { axum::response::Redirect::permanent("/connections.html") }),
)
.route("/sessions.html", get(serve_branded_page))
.route("/recordings.html", get(serve_branded_page))
.route("/reports.html", get(serve_branded_page))
.route("/admin.html", get(serve_branded_page))
.route("/tokens.html", get(serve_branded_page))
.route("/docs.html", get(serve_branded_page));
+8
View File
@@ -297,6 +297,14 @@ pub async fn callback(
let groups = extract_groups_from_jwt(&id_token.to_string(), &oidc.config.groups_claim);
if !groups.is_empty() {
tracing::info!(email = %email, groups = ?groups, "OIDC groups extracted");
let db_for_seen = database.clone();
let groups_for_seen = groups.clone();
let _ = tokio::task::spawn_blocking(move || {
if let Err(e) = db::upsert_seen_groups(&db_for_seen, &groups_for_seen) {
tracing::warn!(error = %e, "failed to persist seen OIDC groups");
}
})
.await;
}
// Resolve role from group-to-role mappings (highest matching wins).
+2 -2
View File
@@ -15,7 +15,7 @@
<h1>rustguac</h1>
</div>
<nav>
<a href="/addressbook.html">Address Book</a>
<a href="/connections.html">Connections</a>
<a href="/sessions.html" id="sessions-link">Sessions</a>
<a href="/recordings.html">Recordings</a>
<a href="/reports.html" id="reports-link" style="display:none">Reports</a>
@@ -140,7 +140,7 @@
})
.then(function(data) {
if (!data || data.role !== 'admin') {
window.location.href = '/addressbook.html';
window.location.href = '/connections.html';
return;
}
document.getElementById('reports-link').style.display = '';
@@ -2,15 +2,15 @@
<html>
<head>
<meta charset="UTF-8">
<title>rustguac - Address Book</title>
<title>rustguac - Connections</title>
<link rel="stylesheet" href="/rustguac.css">
<style>
/* ============================================================
Address book — page-specific styles only. Shared design
Connections — page-specific styles only. Shared design
system lives in /rustguac.css.
============================================================ */
/* Two-column address book layout */
/* Two-column connections layout */
.layout { display: flex; gap: var(--s-5); align-items: flex-start; }
.sidebar {
min-width: 280px; max-width: 340px; flex-shrink: 0;
@@ -143,6 +143,92 @@
.session-card.dormant { opacity: 0.75; }
.session-card.dormant .session-card-thumb { filter: brightness(0.7); }
/* Chip-based group picker */
.chip-picker {
margin-top: var(--s-1);
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--input);
padding: var(--s-2);
display: flex;
flex-direction: column;
gap: var(--s-2);
}
.chip-list { display: flex; flex-wrap: wrap; gap: var(--s-1); min-height: 0; }
.chip-list:empty { display: none; }
.chip {
display: inline-flex;
align-items: center;
gap: var(--s-1);
padding: 2px var(--s-2);
background: var(--surface);
border: 1px solid var(--accent);
border-radius: var(--radius);
color: var(--accent);
font-size: var(--fz-sm);
letter-spacing: 0.04em;
}
.chip-remove {
background: none; border: none; padding: 0 0 0 var(--s-1);
color: var(--text-muted); cursor: pointer;
font-family: monospace; font-size: var(--fz-md);
font-weight: normal; line-height: 1; height: auto;
}
.chip-remove:hover { color: var(--primary); background: none; border-color: transparent; }
.chip-input-wrap {
display: flex;
gap: var(--s-2);
align-items: center;
}
.chip-input-wrap .combo { flex: 1; position: relative; }
.chip-input-wrap .combo input {
width: 100%;
margin-top: 0;
height: var(--ctl-sm);
background: var(--bg);
text-transform: none;
letter-spacing: 0;
box-sizing: border-box;
}
.combo-menu {
display: none;
position: absolute;
left: 0;
right: 0;
top: calc(var(--ctl-sm) + 2px);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
max-height: 240px;
overflow-y: auto;
z-index: 120;
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
}
.combo.open .combo-menu { display: block; }
.combo-item {
padding: var(--s-2) var(--s-3);
font-size: var(--fz-sm);
color: var(--text);
cursor: pointer;
line-height: 1.3;
}
.combo-item:hover,
.combo-item.active {
background: var(--input);
color: var(--accent);
}
.combo-item.empty {
color: var(--text-dim);
cursor: default;
font-style: italic;
}
.combo-item.empty:hover { background: transparent; color: var(--text-dim); }
.combo-item .combo-hint {
color: var(--text-dim);
font-size: var(--fz-xs);
margin-left: var(--s-2);
}
/* Onboarding */
#onboard-body code { background: var(--input); padding: 1px var(--s-1); border-radius: var(--radius); font-size: 0.9em; }
.onboard-key {
@@ -160,7 +246,7 @@
<h1>rustguac</h1>
</div>
<nav>
<a href="/addressbook.html" class="active">Address Book</a>
<a href="/connections.html" class="active">Connections</a>
<a href="/sessions.html" id="sessions-link">Sessions</a>
<a href="/recordings.html">Recordings</a>
<a href="/reports.html" id="reports-link" style="display:none">Reports</a>
@@ -183,12 +269,12 @@
<div id="global-error"></div>
<div id="no-vault" class="no-vault" style="display:none">
Address book is not configured. A Vault/OpenBao backend is required.<br>
Connections is not configured. A Vault/OpenBao backend is required.<br>
See <a href="/docs.html">Docs</a> for setup instructions.
</div>
<div id="vault-unavailable" class="no-vault" style="display:none">
<strong>Address book is temporarily unavailable.</strong><br>
<strong>Connections is temporarily unavailable.</strong><br>
Cannot reach the Vault server. The service will retry automatically every 30 seconds.<br>
Check the rustguac logs for details: <code>journalctl -u rustguac -f</code>
</div>
@@ -245,10 +331,18 @@
<label>Description
<input type="text" id="fm-desc" placeholder="e.g. Infrastructure servers">
</label>
<label>Allowed groups (comma-separated)
<input type="text" id="fm-groups" placeholder="e.g. sysadmins,devops">
</label>
<div class="field-hint">OIDC groups that can see this folder. Leave empty for admin-only access.</div>
<label>Allowed groups</label>
<div class="chip-picker" id="fm-groups-picker">
<div class="chip-list" id="fm-groups-chips"></div>
<div class="chip-input-wrap">
<div class="combo" id="fm-groups-combo">
<input type="text" id="fm-groups-input" placeholder="Type or pick a group..." autocomplete="off" spellcheck="false">
<div class="combo-menu" id="fm-groups-menu" role="listbox"></div>
</div>
<button type="button" class="btn-add" id="fm-groups-add">Add</button>
</div>
</div>
<div class="field-hint">OIDC groups that can see this folder. Leave empty for admin-only access. Type to filter known groups or enter a custom one; Enter or Add to attach; &times; on a chip to remove.</div>
<label>Scope
<select id="fm-scope">
<option value="shared">shared (all instances)</option>
@@ -552,9 +646,9 @@
<div class="modal-overlay" id="my-creds-modal">
<div class="modal" style="max-width:500px">
<h3>My Credentials</h3>
<p style="color:var(--text-muted);font-size:0.85em;margin-top:0">Store your credentials securely in Vault. Address book entries that use credential variables will auto-fill from these values.</p>
<p style="color:var(--text-muted);font-size:0.85em;margin-top:0">Store your credentials securely in Vault. Connections entries that use credential variables will auto-fill from these values.</p>
<div id="my-creds-loading" style="color:var(--text-muted)">Loading...</div>
<div id="my-creds-empty" style="display:none;color:var(--text-muted);font-style:italic;margin:1em 0">No credential variables configured in address book entries yet.</div>
<div id="my-creds-empty" style="display:none;color:var(--text-muted);font-style:italic;margin:1em 0">No credential variables configured in connections entries yet.</div>
<div id="my-creds-list"></div>
<div id="my-creds-error" style="color:var(--primary);margin-top:0.5em"></div>
<div class="modal-actions">
@@ -617,7 +711,7 @@
var _ub=document.getElementById('user-menu-btn');if(_ub)_ub.addEventListener('click',function(e){e.stopPropagation();var m=document.getElementById('user-menu');m.style.display=m.style.display==='block'?'none':'block'});document.addEventListener('click',function(){var m=document.getElementById('user-menu');if(m)m.style.display='none'});
var _driveConfigured = false;
fetch('/api/auth/status').then(function(r){return r.json()}).then(function(d){
if(d.site_title){document.title=d.site_title+' - Address Book';document.querySelector('h1').textContent=d.site_title;}
if(d.site_title){document.title=d.site_title+' - Connections';document.querySelector('h1').textContent=d.site_title;}
_driveConfigured = !!d.drive_configured;
initTheme(d.theme);
});
@@ -657,6 +751,143 @@
function folderPath(f) { return f.path || f.name; }
var currentEntries = []; // entries for the currently selected folder
var loginScriptsCache = null; // cached list from /api/login-scripts
var knownGroupsCache = null; // cached list from /api/auth/known-groups
var fmGroups = []; // allowed_groups being edited in the folder modal
function loadKnownGroups(cb) {
if (knownGroupsCache !== null) { if (cb) cb(); return; }
fetch('/api/auth/known-groups', { headers: apiHeaders(), credentials: 'same-origin' })
.then(function(r) { return r.ok ? r.json() : { groups: [] }; })
.then(function(data) {
knownGroupsCache = (data && data.groups) ? data.groups : [];
if (cb) cb();
})
.catch(function() { knownGroupsCache = []; if (cb) cb(); });
}
function renderFmGroupChips() {
var list = document.getElementById('fm-groups-chips');
list.innerHTML = '';
fmGroups.forEach(function(g) {
var chip = document.createElement('span');
chip.className = 'chip';
chip.textContent = g;
var rm = document.createElement('button');
rm.type = 'button';
rm.className = 'chip-remove';
rm.textContent = '\u00D7';
rm.title = 'Remove';
rm.addEventListener('click', function() {
fmGroups = fmGroups.filter(function(x) { return x !== g; });
renderFmGroupChips();
populateFmGroupOptions();
});
chip.appendChild(rm);
list.appendChild(chip);
});
}
var fmComboActive = -1; // active combo-menu index for keyboard nav
function fmGroupOptionsFiltered() {
var q = (document.getElementById('fm-groups-input').value || '').trim().toLowerCase();
return (knownGroupsCache || []).filter(function(g) {
if (fmGroups.indexOf(g) !== -1) return false; // already selected
if (!q) return true;
return g.toLowerCase().indexOf(q) !== -1;
});
}
function populateFmGroupOptions() {
var menu = document.getElementById('fm-groups-menu');
menu.innerHTML = '';
var opts = fmGroupOptionsFiltered();
var q = (document.getElementById('fm-groups-input').value || '').trim();
fmComboActive = opts.length > 0 ? 0 : -1;
if (opts.length === 0 && !q) {
var empty = document.createElement('div');
empty.className = 'combo-item empty';
empty.textContent = (knownGroupsCache && knownGroupsCache.length === 0)
? 'No known groups yet — type a group name.'
: 'All known groups already selected.';
menu.appendChild(empty);
return;
}
opts.forEach(function(g, i) {
var item = document.createElement('div');
item.className = 'combo-item' + (i === fmComboActive ? ' active' : '');
item.setAttribute('role', 'option');
item.textContent = g;
item.addEventListener('mousedown', function(ev) {
ev.preventDefault(); // keep input focused
pickFmGroup(g);
});
item.addEventListener('mouseenter', function() {
fmComboActive = i;
updateFmComboActive();
});
menu.appendChild(item);
});
if (q && opts.indexOf(q) === -1 && fmGroups.indexOf(q) === -1) {
// Show "+ add custom" row so user knows Enter will create a custom group
var custom = document.createElement('div');
custom.className = 'combo-item';
custom.innerHTML = '+ Add "<strong>' + escapeHtml(q) + '</strong>" <span class="combo-hint">(custom)</span>';
custom.addEventListener('mousedown', function(ev) {
ev.preventDefault();
pickFmGroup(q);
});
menu.appendChild(custom);
}
}
function updateFmComboActive() {
var items = document.querySelectorAll('#fm-groups-menu .combo-item');
for (var i = 0; i < items.length; i++) {
items[i].classList.toggle('active', i === fmComboActive);
}
}
function openFmCombo() {
document.getElementById('fm-groups-combo').classList.add('open');
populateFmGroupOptions();
}
function closeFmCombo() {
document.getElementById('fm-groups-combo').classList.remove('open');
}
function pickFmGroup(g) {
g = (g || '').trim();
if (!g) return;
if (fmGroups.indexOf(g) === -1) fmGroups.push(g);
var inp = document.getElementById('fm-groups-input');
inp.value = '';
renderFmGroupChips();
populateFmGroupOptions();
inp.focus();
}
function addFmGroupFromInput() {
var inp = document.getElementById('fm-groups-input');
var raw = (inp.value || '').trim();
if (!raw) {
// Enter with nothing typed: pick the active combo item if any
var opts = fmGroupOptionsFiltered();
if (fmComboActive >= 0 && fmComboActive < opts.length) pickFmGroup(opts[fmComboActive]);
return;
}
// Allow comma-separated bulk paste
raw.split(',').forEach(function(part) {
var g = part.trim();
if (g && fmGroups.indexOf(g) === -1) fmGroups.push(g);
});
inp.value = '';
renderFmGroupChips();
populateFmGroupOptions();
}
function populateLoginScriptDropdown(selectedValue) {
var sel = document.getElementById('em-login-script');
@@ -800,7 +1031,7 @@
+ '<div class="card-meta">' + typeBadge + ' <span>' + escapeHtml(s.username || '') + '</span></div></div>';
card.addEventListener('click', function() {
if (s.address_book_entry) {
// Reconnect via address book connect (creates new session to same target)
// Reconnect via connections connect (creates new session to same target)
var parts = s.address_book_entry.split('/');
if (parts.length === 3) {
fetch('/api/addressbook/folders/' + encodeURIComponent(parts[0]) + '/' + encodeURIComponent(parts[1]) + '/entries/' + encodeURIComponent(parts[2]) + '/connect', {
@@ -833,7 +1064,7 @@
+ '<div class="card-meta"><span class="type-badge type-vdi">vdi</span> <span>dormant</span></div></div>';
if (c.entry_key) {
card.addEventListener('click', function() {
// Reconnect via address book connect
// Reconnect via connections connect
var parts = c.entry_key.split('/');
if (parts.length === 3) {
var scope = parts[0], folder = parts[1], entry = parts[2];
@@ -1386,10 +1617,13 @@
document.getElementById('fm-name').value = '';
document.getElementById('fm-name').disabled = false;
document.getElementById('fm-desc').value = '';
document.getElementById('fm-groups').value = '';
document.getElementById('fm-scope').value = 'shared';
document.getElementById('fm-scope').disabled = false;
document.getElementById('fm-error').textContent = '';
document.getElementById('fm-groups-input').value = '';
fmGroups = [];
renderFmGroupChips();
loadKnownGroups(populateFmGroupOptions);
document.getElementById('folder-modal').classList.add('active');
}
@@ -1401,10 +1635,13 @@
document.getElementById('fm-name').value = '';
document.getElementById('fm-name').disabled = false;
document.getElementById('fm-desc').value = '';
document.getElementById('fm-groups').value = '';
document.getElementById('fm-scope').value = subfolderParent.scope;
document.getElementById('fm-scope').disabled = true;
document.getElementById('fm-error').textContent = '';
document.getElementById('fm-groups-input').value = '';
fmGroups = [];
renderFmGroupChips();
loadKnownGroups(populateFmGroupOptions);
document.getElementById('folder-modal').classList.add('active');
}
@@ -1424,7 +1661,11 @@
document.getElementById('fm-scope').disabled = true;
document.getElementById('fm-error').textContent = '';
// Load current config for groups
// Reset chip state and kick off known-groups load, then fetch current config.
document.getElementById('fm-groups-input').value = '';
fmGroups = [];
renderFmGroupChips();
loadKnownGroups(populateFmGroupOptions);
fetch('/api/addressbook/folders/' + encodeURIComponent(selectedFolder.scope) + '/' + encodeURIComponent(selPath) + '/config', {
headers: apiHeaders(),
credentials: 'same-origin'
@@ -1432,12 +1673,13 @@
if (res.ok) return res.json();
return null;
}).then(function(data) {
if (data && data.allowed_groups) {
document.getElementById('fm-groups').value = data.allowed_groups.join(', ');
if (data && Array.isArray(data.allowed_groups)) {
fmGroups = data.allowed_groups.slice();
renderFmGroupChips();
populateFmGroupOptions();
}
}).catch(function() {});
document.getElementById('fm-groups').value = '';
document.getElementById('folder-modal').classList.add('active');
});
@@ -1445,10 +1687,42 @@
document.getElementById('folder-modal').classList.remove('active');
});
document.getElementById('fm-groups-add').addEventListener('click', addFmGroupFromInput);
(function wireFmCombo() {
var inp = document.getElementById('fm-groups-input');
inp.addEventListener('focus', openFmCombo);
inp.addEventListener('input', populateFmGroupOptions);
inp.addEventListener('keydown', function(ev) {
var opts = fmGroupOptionsFiltered();
if (ev.key === 'ArrowDown') {
ev.preventDefault();
openFmCombo();
if (fmComboActive < opts.length - 1) fmComboActive++;
updateFmComboActive();
} else if (ev.key === 'ArrowUp') {
ev.preventDefault();
if (fmComboActive > 0) fmComboActive--;
updateFmComboActive();
} else if (ev.key === 'Enter') {
ev.preventDefault();
addFmGroupFromInput();
} else if (ev.key === 'Escape') {
closeFmCombo();
inp.blur();
}
});
document.addEventListener('mousedown', function(ev) {
var combo = document.getElementById('fm-groups-combo');
if (combo && !combo.contains(ev.target)) closeFmCombo();
});
})();
document.getElementById('fm-save').addEventListener('click', function() {
var name = document.getElementById('fm-name').value.trim();
var desc = document.getElementById('fm-desc').value.trim();
var groups = document.getElementById('fm-groups').value.split(',').map(function(g) { return g.trim(); }).filter(Boolean);
// Include anything still typed in the group input but not chipped yet
addFmGroupFromInput();
var groups = fmGroups.slice();
var scope = document.getElementById('fm-scope').value;
var errEl = document.getElementById('fm-error');
@@ -2600,7 +2874,7 @@
},
{ title: 'Navigation', minRole: 1, body:
'The nav bar at the top links to:' +
'<br>&bull; <span class="onboard-hl">Address Book</span> &mdash; your connection entries' +
'<br>&bull; <span class="onboard-hl">Connections</span> &mdash; your connection entries' +
'<br>&bull; <span class="onboard-hl">Sessions</span> &mdash; active and recent sessions' +
'<br>&bull; <span class="onboard-hl">Recordings</span> &mdash; session playback' +
'<br>&bull; <span class="onboard-hl">Docs</span> &mdash; built-in documentation' +
@@ -2611,7 +2885,7 @@
'<br><br>Click <span class="onboard-hl">Settings</span> &rarr; <span class="onboard-hl">My Credentials</span> to fill in your values. They are stored securely in Vault and applied automatically when you connect.'
},
{ title: 'Connect vs Login', minRole: 2, body:
'Address book entries have two connection modes:' +
'Connections entries have two connection modes:' +
'<br><br>&bull; <span class="onboard-hl">Connect</span> &mdash; launches the session directly using the saved settings.' +
'<br>&bull; <span class="onboard-hl">Login</span> &mdash; runs an automated login script after connecting (e.g. filling in a web login form). If an entry shows Login, the admin has configured an auto-login workflow for it.'
},
@@ -2625,10 +2899,10 @@
'<br><br>Toggle the file panel with <span class="onboard-key">Ctrl</span>+<span class="onboard-key">Alt</span>+<span class="onboard-key">F</span>.'
},
{ title: 'Ad-hoc Sessions', minRole: 3, body:
'As a power user, you can create one-off sessions from the <span class="onboard-hl">Sessions</span> page &mdash; useful for quick connections that aren\'t in the address book.' +
'As a power user, you can create one-off sessions from the <span class="onboard-hl">Sessions</span> page &mdash; useful for quick connections that aren\'t in the connections.' +
'<br><br>Choose SSH, RDP, VNC, or Web and enter connection details directly.'
},
{ title: 'Managing the Address Book', minRole: 4, body:
{ title: 'Managing the Connections', minRole: 4, body:
'As an admin, you can:' +
'<br>&bull; Create and edit <span class="onboard-hl">folders</span> with OIDC group permissions' +
'<br>&bull; Add, edit, clone, and delete <span class="onboard-hl">entries</span>' +
+1 -1
View File
@@ -94,7 +94,7 @@
<h1>rustguac</h1>
</div>
<nav>
<a href="/addressbook.html">Address Book</a>
<a href="/connections.html">Connections</a>
<a href="/sessions.html" id="sessions-link">Sessions</a>
<a href="/recordings.html">Recordings</a>
<a href="/reports.html" id="reports-link" style="display:none">Reports</a>
+3 -3
View File
@@ -89,13 +89,13 @@
// If already logged in via API key, redirect
if (sessionStorage.getItem('rustguac_api_key')) {
window.location.href = '/addressbook.html';
window.location.href = '/connections.html';
}
// Check if already authenticated via cookie
fetch('/api/me', { credentials: 'same-origin' })
.then(function(res) {
if (res.ok) window.location.href = '/addressbook.html';
if (res.ok) window.location.href = '/connections.html';
});
function applyThemeColors(colors){var r=document.documentElement.style;for(var k in colors)r.setProperty('--'+k.replace(/_/g,'-'),colors[k]);var s=document.getElementById('bg-pattern-style');if(!s){s=document.createElement('style');s.id='bg-pattern-style';document.head.appendChild(s)}s.textContent=colors.bg_pattern&&colors.bg_pattern!=='none'?'body{background-image:'+colors.bg_pattern+';background-attachment:fixed}':'';localStorage.setItem('rustguac_theme_colors',JSON.stringify(colors))}
@@ -150,7 +150,7 @@
.then(function(res) {
if (!res.ok) throw new Error('Invalid API key');
sessionStorage.setItem('rustguac_api_key', key);
window.location.href = '/addressbook.html';
window.location.href = '/connections.html';
})
.catch(function(err) {
errorEl.textContent = err.message;
+1 -1
View File
@@ -146,7 +146,7 @@
<h1>rustguac</h1>
</div>
<nav>
<a href="/addressbook.html">Address Book</a>
<a href="/connections.html">Connections</a>
<a href="/sessions.html" id="sessions-link">Sessions</a>
<a href="/recordings.html" class="active">Recordings</a>
<a href="/reports.html" id="reports-link" style="display:none">Reports</a>
+1 -1
View File
@@ -57,7 +57,7 @@
<h1>rustguac</h1>
</div>
<nav>
<a href="/addressbook.html">Address Book</a>
<a href="/connections.html">Connections</a>
<a href="/sessions.html" id="sessions-link">Sessions</a>
<a href="/recordings.html">Recordings</a>
<a href="/reports.html" class="active">Reports</a>
+5 -4
View File
@@ -55,7 +55,7 @@
<h1>rustguac</h1>
</div>
<nav>
<a href="/addressbook.html">Address Book</a>
<a href="/connections.html">Connections</a>
<a href="/sessions.html" class="active" id="sessions-link">Sessions</a>
<a href="/recordings.html">Recordings</a>
<a href="/reports.html" id="reports-link" style="display:none">Reports</a>
@@ -66,7 +66,7 @@
</nav>
<div id="adhoc-notice" style="display:none;background:var(--surface);padding:1em 1.5em;border-radius:6px;max-width:400px;margin:1.5em 0;color:var(--text-muted);">
Use the <a href="/addressbook.html">Address Book</a> to connect to saved entries.
Use the <a href="/connections.html">Connections</a> to connect to saved entries.
</div>
<form id="session-form" style="display:none">
@@ -211,7 +211,7 @@
if (!data) return;
var level = roleLevel[data.role] || 0;
if (level < 3) {
window.location.href = '/addressbook.html';
window.location.href = '/connections.html';
return;
}
if (data.role === 'admin') {
@@ -573,7 +573,8 @@
}
function refreshSessions() {
fetch('/api/sessions', { headers: apiHeaders(), credentials: 'same-origin' })
// Admins get everyone's sessions; non-admins see only their own (see #102).
fetch('/api/sessions?all=true', { headers: apiHeaders(), credentials: 'same-origin' })
.then(function(res) {
if (!res.ok) throw new Error('auth');
return res.json();
+1 -1
View File
@@ -23,7 +23,7 @@
<h1>rustguac</h1>
</div>
<nav>
<a href="/addressbook.html">Address Book</a>
<a href="/connections.html">Connections</a>
<a href="/sessions.html" id="sessions-link">Sessions</a>
<a href="/recordings.html">Recordings</a>
<a href="/reports.html" id="reports-link" style="display:none">Reports</a>