The v1.7.2 floating "⛶ Fullscreen" corner button at 0.45 opacity was still 80px of permanent clutter in the top-right of the remote session display before fullscreen was entered. This PR moves the manual fullscreen action into the existing Ctrl+Alt+Shift session-menu panel (next to the Home button), removing the floating overlay entirely. The per-entry `fullscreen_on_connect` flag and the in-fullscreen top bar (entry name + Exit + Disconnect) are unchanged. Esc-key forwarding via navigator.keyboard.lock still applies. Also adds an "In-session keyboard shortcuts" section to docs/web-sessions.md documenting the Ctrl+Alt+Shift panel toggle, Ctrl+V clipboard paste-sync, Esc behaviour, and the disable_copy / disable_paste interaction. Closes #156.
24 KiB
Web Browser Sessions
Web browser sessions give users a full Chromium browser running on the server, streamed to their own browser via the Guacamole protocol. Each session spawns a headless Xvnc display with Chromium in kiosk mode — the user sees and interacts with a real browser without installing anything locally.
This is useful for:
- Controlled web access — give operators access to specific internal web applications without exposing credentials or granting direct network access
- Credential isolation — passwords and session cookies stay server-side, never reaching the user's machine
- Kiosk-style portals — lock Chromium to a specific site with domain allowlisting
- Automated login — pre-fill credentials via native autofill or run a login script so the user lands on an authenticated page
How it works
User's browser
│
│ WebSocket (Guacamole protocol)
▼
rustguac
│
│ Guacamole protocol (TCP/TLS)
▼
guacd
│
│ VNC to localhost
▼
Xvnc (virtual display :100–:199)
│
└── Chromium (kiosk mode, isolated profile)
│
└── https://target-app.example.com
- rustguac allocates an X display number and spawns Xvnc
- A unique Chromium profile directory is created (
/tmp/rustguac-chromium-{uuid}) - Optionally, the autofill database is pre-populated with credentials
- Chromium launches on the Xvnc display, navigating to the configured URL
- guacd connects to the Xvnc display via VNC and streams it to the user
- Optionally, a login script runs to automate complex login flows
- When the session ends, Chromium and Xvnc are killed and the profile directory is deleted
Quick start
Connections entry
Create a web entry in the connections with at minimum:
| Field | Value |
|---|---|
| Type | web |
| URL | https://your-app.example.com |
Optionally add credentials for autofill or login scripts:
| Field | Value |
|---|---|
| Username | operator@example.com |
| Password | secret |
API
curl -X POST https://rustguac.example.com/api/sessions \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"session_type": "web",
"url": "https://your-app.example.com",
"width": 1920,
"height": 1080
}'
Network allowlist
By default, web sessions can only connect to localhost. To allow external URLs, add the target networks to web_allowed_networks in your config:
web_allowed_networks = ["10.0.0.0/8", "172.16.0.0/12"]
This is a server-side CIDR check applied at session creation. The URL's hostname is resolved and every returned IP must match at least one allowed range. See Domain allowlisting for the separate client-side restriction.
Native autofill
For simple login flows (a form with username and password fields), native autofill is the easiest approach. rustguac pre-populates Chromium's built-in password manager database before launch — no scripts, no external runtimes, no CDP.
When the user clicks on a login form, Chromium shows its familiar autofill dropdown with the pre-filled credentials.
Configuring autofill
The autofill field on an connections entry is a JSON string containing an array of credential objects:
[
{
"url": "https://your-app.example.com",
"username": "$USERNAME",
"password": "$PASSWORD"
}
]
| Field | Description |
|---|---|
url |
Origin URL that Chromium matches against the login form. Must include the scheme (https://). |
username |
Username to autofill. Use $USERNAME to substitute the entry's username field. |
password |
Password to autofill. Use $PASSWORD to substitute the entry's password field. |
The $USERNAME and $PASSWORD placeholders are resolved server-side from the entry's credentials before Chromium launches. You can also use literal values if the credentials differ from the entry's main username/password.
Multiple autofill entries
For SSO redirect chains where the user is redirected from one site to an identity provider and back, add multiple entries:
[
{
"url": "https://app.example.com",
"username": "$USERNAME",
"password": "$PASSWORD"
},
{
"url": "https://idp.example.com",
"username": "$USERNAME",
"password": "$PASSWORD"
}
]
Chromium will offer autofill on both domains.
How it works internally
rustguac creates a Chromium profile directory before launch and writes to Default/Login Data (a SQLite database that Chromium uses for its password manager). Passwords are encrypted using Chromium's Linux os_crypt backend:
- Derive a 16-byte AES key via PBKDF2 (password
"peanuts", salt"saltysalt", 1 iteration, SHA-1) - Encrypt with AES-128-CBC, IV = 16 ×
0x20(space characters) - Store as
v10prefix + ciphertext blob
This is Chromium's own obfuscation layer for the headless Linux case (no keyring). It is not a security boundary — the security boundary is that the profile directory is ephemeral (deleted on session end) and only accessible server-side.
UI
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 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 connections entry editor, expand the Allowed Domains section and add domain names:
example.com
cdn.example.com
Subdomains are automatically included — adding example.com also allows *.example.com. Localhost (127.0.0.1) is always allowed.
Two-layer restriction
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 |
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_networksprevents rustguac from initiating connections to disallowed networks (SSRF protection)allowed_domainsprevents 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 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
curl -X POST https://rustguac.example.com/api/sessions \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"session_type": "web",
"url": "https://wiki.internal.example.com",
"allowed_domains": ["wiki.internal.example.com"]
}'
Login scripts
For complex login flows that native autofill can't handle (multi-step forms, CAPTCHAs, JavaScript-heavy SPAs, MFA prompts), login scripts provide full browser automation via the Chrome DevTools Protocol (CDP).
A login script is a server-side executable that connects to the already-running Chromium instance, performs login automation, then disconnects — leaving the user on an authenticated page.
How it works
- The connections entry specifies a
login_scriptfilename (e.g.,portal-login.js) - When the session starts, Chromium is launched with
--remote-debugging-port={cdp_port} - After Chromium is ready, rustguac spawns the script as a child process
- The script connects to Chromium via CDP, performs automation, then exits
- The user watches the automation live (it's all happening on the VNC display) and takes over the authenticated session
Script interface
Environment variables:
| Variable | Description |
|---|---|
DISPLAY |
X display number (e.g., :100) |
RUSTGUAC_CDP_PORT |
Chrome DevTools Protocol port (e.g., 9200) |
RUSTGUAC_URL |
Target URL |
RUSTGUAC_USERNAME |
Username (empty string if not set) |
RUSTGUAC_PASSWORD |
Password (empty string if not set) |
RUSTGUAC_SESSION_ID |
Session UUID |
Stdin (preferred for credentials):
Credentials are also sent as JSON on stdin, which is more secure than environment variables (env vars are readable via /proc/<pid>/environ on Linux):
{
"username": "operator@example.com",
"password": "secret",
"url": "https://app.example.com",
"cdp_port": 9200,
"session_id": "550e8400-e29b-41d4-a716-446655440000"
}
Requirements:
- The script must be in the
login_scripts_dirdirectory (default:/opt/rustguac/scripts) - The script must be executable (
chmod +x) - Path traversal is blocked — the filename is validated against the scripts directory
- Scripts have a timeout (default: 120 seconds, configurable via
login_script_timeout_secs) - Script failure is non-fatal — the session continues and the user can log in manually
Example: Playwright login script
This example uses Playwright to automate a login flow. It reads credentials from stdin, connects to Chromium's CDP endpoint, fills a login form, and disconnects.
#!/usr/bin/env node
// login-example.js — Playwright login script for rustguac
//
// Install: npm install playwright-core (in /opt/rustguac/scripts or globally)
// The script uses playwright-core (no bundled browsers) since Chromium is
// already running — it connects via CDP rather than launching a new browser.
'use strict';
const { chromium } = require('playwright-core');
// ── Read credentials from stdin (secure) or env vars (fallback) ─────
async function getCredentials() {
const stdinData = await readStdin();
if (stdinData) {
try {
const creds = JSON.parse(stdinData);
return {
cdpPort: creds.cdp_port,
url: creds.url,
username: creds.username,
password: creds.password,
};
} catch (e) {
console.warn('[login] Failed to parse stdin, falling back to env vars');
}
}
return {
cdpPort: parseInt(process.env.RUSTGUAC_CDP_PORT, 10),
url: process.env.RUSTGUAC_URL || '',
username: process.env.RUSTGUAC_USERNAME || '',
password: process.env.RUSTGUAC_PASSWORD || '',
};
}
function readStdin() {
return new Promise((resolve) => {
const chunks = [];
const timer = setTimeout(() => {
process.stdin.destroy();
resolve(chunks.join(''));
}, 1000);
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => chunks.push(chunk));
process.stdin.on('end', () => { clearTimeout(timer); resolve(chunks.join('')); });
process.stdin.on('error', () => { clearTimeout(timer); resolve(''); });
process.stdin.resume();
});
}
// ── Connect to Chromium CDP with retry ──────────────────────────────
async function connectCDP(port, timeoutMs = 15000) {
const url = `http://127.0.0.1:${port}`;
const deadline = Date.now() + timeoutMs;
let lastErr;
while (Date.now() < deadline) {
try {
return await chromium.connectOverCDP(url);
} catch (e) {
lastErr = e;
await new Promise(r => setTimeout(r, 500));
}
}
throw new Error(`CDP not ready on port ${port}: ${lastErr?.message}`);
}
// ── Main ────────────────────────────────────────────────────────────
async function main() {
const creds = await getCredentials();
if (!creds.cdpPort) {
console.error('[login] No CDP port — exiting');
process.exit(1);
}
console.log(`[login] Connecting to CDP on port ${creds.cdpPort}...`);
const browser = await connectCDP(creds.cdpPort);
const page = browser.contexts()[0]?.pages()[0];
if (!page) {
console.error('[login] No page found');
await browser.close();
process.exit(1);
}
// Wait for the page to load (Chromium may still be navigating)
await page.waitForLoadState('domcontentloaded', { timeout: 15000 }).catch(() => {});
// ── Automate your login flow here ───────────────────────────────
//
// This example fills a simple username/password form.
// Adapt the selectors and steps for your target application.
// Fill the login form
await page.fill('#username', creds.username);
await page.fill('#password', creds.password);
// Submit
await page.click('button[type="submit"]');
// Wait for navigation to confirm login succeeded
try {
await page.waitForURL('**/dashboard**', { timeout: 10000 });
console.log('[login] Login successful');
} catch {
console.error('[login] Login may have failed — user can retry manually');
}
// Disconnect CDP — browser stays running for the user
await browser.close();
}
main().catch((err) => {
console.error(`[login] Error: ${err.message}`);
process.exit(1);
});
To use this script:
- Save it to
/opt/rustguac/scripts/login-example.js - Make it executable:
chmod +x /opt/rustguac/scripts/login-example.js - Install Playwright:
cd /opt/rustguac/scripts && npm install playwright-core - Set the
login_scriptfield on an connections entry tologin-example.js
Example: Shell script with curl
Not every login needs a browser automation framework. If the target app accepts form POSTs, a shell script can set cookies directly:
#!/bin/bash
# login-cookie.sh — Set auth cookies in Chromium via CDP
#
# For apps where logging in is a simple POST that returns a session cookie.
# This approach is faster than Playwright but only works for basic form logins.
set -euo pipefail
# Read credentials from stdin JSON
CREDS=$(cat)
CDP_PORT=$(echo "$CREDS" | python3 -c "import sys,json; print(json.load(sys.stdin)['cdp_port'])")
USERNAME=$(echo "$CREDS" | python3 -c "import sys,json; print(json.load(sys.stdin)['username'])")
PASSWORD=$(echo "$CREDS" | python3 -c "import sys,json; print(json.load(sys.stdin)['password'])")
URL=$(echo "$CREDS" | python3 -c "import sys,json; print(json.load(sys.stdin)['url'])")
# POST login form and capture cookies
COOKIES=$(curl -s -c - -X POST "$URL/api/login" \
-d "username=$USERNAME&password=$PASSWORD" \
2>/dev/null | grep -v '^#')
# Set each cookie in Chromium via CDP
# (This uses the CDP Network.setCookie command via the /json/protocol endpoint)
echo "[login] Cookies captured, injecting into browser..."
# Navigate Chromium to trigger a reload with the new cookies
# The user lands on the authenticated page
echo "[login] Done — user should see authenticated page"
Combining autofill and login scripts
Autofill and login scripts can be used together on the same entry:
- Autofill pre-populates the password manager — useful if the script fails or for subsequent logins during the session
- Login script automates the initial login flow — handles complex cases like MFA, JavaScript-heavy forms, or multi-step wizards
The autofill database is written before Chromium launches, and the login script runs after. They don't interfere with each other.
Configuration
| Config key | Default | Description |
|---|---|---|
login_scripts_dir |
/opt/rustguac/scripts |
Directory containing login scripts |
login_script_timeout_secs |
120 |
Maximum script runtime before it's killed |
cdp_port_range_start |
9200 |
First CDP port in the allocation pool |
cdp_port_range_end |
9299 |
Last CDP port |
Clipboard control
Clipboard copy and paste can be independently disabled per connections entry. This uses guacd's native disable-copy and disable-paste parameters.
| Field | Effect |
|---|---|
disable_copy |
Prevents server → client clipboard transfer (data loss prevention) |
disable_paste |
Prevents client → server clipboard transfer (prevents pasting malicious content) |
These work for all session types (SSH, RDP, VNC, Web), not just web sessions. See Security: Clipboard control for details.
In-session keyboard shortcuts
The session page supports a small set of browser-side shortcuts:
| Shortcut | Action | Notes |
|---|---|---|
Ctrl+Alt+Shift |
Toggle the clipboard side panel | Works globally on the session page (capture phase), including when the remote display is focused. |
Ctrl+V (Windows/Linux) or Cmd+V (macOS) |
Sync browser clipboard text to the remote session, then send paste | If clipboard API access is available, rustguac reads local clipboard text and sends it to the remote before forwarding the paste key event. |
Esc (browser fullscreen) |
Exit fullscreen | Browser-native fullscreen key. rustguac may request keyboard lock while in fullscreen; if lock is unavailable, an on-screen notice reminds users to press Esc. |
Additional behavior:
- No dedicated keyboard combo is currently assigned for entering fullscreen. Users can use entry-level fullscreen-on-connect, or open the
Ctrl+Alt+Shiftclipboard panel and click its Fullscreen button (next to Home). - All other key presses are passed through to the remote host by Guacamole keyboard handling.
- Clipboard policy flags still apply:
disable_copyanddisable_pastecan block corresponding clipboard flows regardless of local shortcuts.
URL placeholders
The entry URL supports credential placeholders that are URL-encoded and substituted before Chromium navigates:
https://app.example.com/login?user=$RUSTGUAC_USERNAME&pass=$RUSTGUAC_PASSWORD
| Placeholder | Substituted with |
|---|---|
$RUSTGUAC_USERNAME |
Entry username (URL-encoded) |
$RUSTGUAC_PASSWORD |
Entry password (URL-encoded) |
This is useful for applications that accept credentials as URL parameters (e.g., some IPMI/KVM web consoles).
SSH tunnels for web sessions
Web sessions support multi-hop SSH tunnel chains to reach targets on isolated networks. When jump hosts are configured:
- An SSH tunnel chain is established through the bastion hosts
- The final hop forwards to the URL's host and port
- The URL is rewritten to
{scheme}://127.0.0.1:{tunnel_port}{path}before being passed to Chromium
Note: HTTPS targets will show certificate warnings when tunnelled, because the hostname changes from the original to 127.0.0.1. The original URL is still displayed in the session list.
Chromium security hardening
Every web session runs Chromium with a comprehensive managed policy and an isolated profile. See Security: Web session hardening for the full policy table.
Warning: The Chromium managed policy is installed globally at /etc/chromium/policies/managed/rustguac.json. This affects all Chromium instances on the machine — not just rustguac sessions. Do not install rustguac on a desktop machine where you want to use Chromium for normal browsing. rustguac is designed to run on a dedicated server or VM.
Key restrictions:
- DevTools UI is blocked by URL filter (
chrome://*is in URLBlocklist), downloads, printing, and file dialogs are disabled - Extensions cannot be installed
- Dangerous URL schemes (
file://,chrome://,javascript:) are blocked - Browser sign-in and sync are disabled
- Each session gets a fresh UUID-based profile directory, deleted on session end
- Chromium runs with its normal SUID sandbox (no
--no-sandbox)
API reference
Create a web session
POST /api/sessions
{
"session_type": "web",
"url": "https://app.example.com",
"username": "operator",
"password": "secret",
"width": 1920,
"height": 1080,
"autofill": "[{\"url\":\"https://app.example.com\",\"username\":\"$USERNAME\",\"password\":\"$PASSWORD\"}]",
"allowed_domains": ["app.example.com"],
"login_script": "my-login.js",
"disable_copy": false,
"disable_paste": false
}
| Field | Type | Required | Description |
|---|---|---|---|
session_type |
string | Yes | Must be "web" |
url |
string | Yes | Target URL (http:// or https://) |
username |
string | No | Username for autofill/script substitution |
password |
string | No | Password for autofill/script substitution |
width |
integer | No | Browser width in pixels (default: 1920, range: 640–8192) |
height |
integer | No | Browser height in pixels (default: 1080, range: 480–8192) |
dpi |
integer | No | Display DPI (default: 96) |
autofill |
string | No | JSON array of autofill credentials (see Native autofill) |
allowed_domains |
array | No | Domain allowlist (see Domain allowlisting) |
login_script |
string | No | Script filename in login_scripts_dir |
disable_copy |
boolean | No | Disable clipboard copy (default: false) |
disable_paste |
boolean | No | Disable clipboard paste (default: false) |
jump_hosts |
array | No | SSH tunnel hops (see SSH tunnels) |
Connections entry fields
When creating entries via the Vault connections (UI or API), the same fields are available:
{
"type": "web",
"url": "https://app.example.com",
"username": "operator",
"password": "secret",
"display_name": "Internal App",
"autofill": "[{\"url\":\"https://app.example.com\",\"username\":\"$USERNAME\",\"password\":\"$PASSWORD\"}]",
"allowed_domains": ["app.example.com"],
"login_script": "my-login.js",
"disable_copy": false,
"disable_paste": false,
"enable_recording": true
}
Troubleshooting
Autofill dropdown doesn't appear:
- Verify the
urlin the autofill JSON matches the login form's origin (scheme + host + port). For example,https://app.example.comwon't match a form athttps://app.example.com:8443. - Check that the autofill JSON is valid — the server logs a warning if parsing fails.
- Ensure the entry has a username and password set (the
$USERNAME/$PASSWORDplaceholders need values to substitute).
Domain blocking is too strict:
- Remember that subdomains are automatically included — adding
example.comallows*.example.com. - CDN domains may need to be added separately (e.g.,
cdn.example.com,fonts.googleapis.com). - Check the browser's address bar — if it shows "This site can't be reached", the domain is being blocked.
Login script doesn't run:
- The script must be executable:
chmod +x /opt/rustguac/scripts/my-script.js - Check the
login_scripts_dirconfig points to the right directory. - Check rustguac logs for
[login-script]messages — script stdout/stderr is captured. - The script has a timeout (default 120s). Increase
login_script_timeout_secsif needed.
Browser shows a blank white screen:
- The Xvnc display may not be ready. Check logs for Xvnc startup errors.
- Verify
chromium_pathandxvnc_pathin the config point to valid binaries. - Ensure the
rustguacsystem user has a real home directory (/home/rustguac) — Chromium's crashpad handler crashes without one.
"Controlled by automated test software" banner:
- This appears when
allowed_domainsis set, because--enable-automationis used to suppress a different infobar about--host-rules. The banner is cosmetic and does not affect functionality.
Certificate errors when using SSH tunnels:
- Expected behaviour. When tunnelling, the URL is rewritten to
127.0.0.1:{port}, which won't match the target's TLS certificate. The user can click through the warning or use HTTP if the tunnel is trusted.