connections: open ad-hoc connections in the entry editor

Ad-hoc sessions had their own form on the Sessions page with a subset
of the options a connection entry has. "Connect to a host" now opens
the Connections entry editor in an ad-hoc mode instead, so an ad-hoc
connection has every option an entry has, now and as entries gain more.

The button sits beside the global search box on every page that has
one. It is shown to poweruser and above under the same rules as the
search box, and opens the editor in place on Connections or goes to
/connections.html#adhoc elsewhere. Ad-hoc mode has no name or folder,
Connect instead of Save, and for admins a "Save as entry" action.

POST /api/addressbook/adhoc/connect takes the entry as the editor
builds it. It resolves only the caller's own credential variables,
never lends folder defaults, and goes through apply_adhoc_policy and
the session rate limit like every other ad-hoc route.
POST /api/sessions is unchanged.

session_request_from_entry() replaces the two copies of the entry to
session request conversion, used by the Connections connect endpoint
and /api/connect, so an option added to entries works in all three.
can_connect_adhoc() is the one place that decides who may connect
ad-hoc.

The Sessions ad-hoc form is removed. loadFolders() now runs its
callback, so #open= links work again.

Refs sol1/rustguac#295

(cherry picked from commit dd40bf08ec)
This commit is contained in:
Jad Seifeddine
2026-10-19 10:33:56 +07:00
committed by Dave Kempe
parent 7d6ca5c5d3
commit d75446f040
21 changed files with 1902 additions and 785 deletions
+35 -1
View File
@@ -237,7 +237,9 @@ The `jump_hosts` array defines an ordered chain of SSH bastion hops. Each hop co
| `wol_wait_time` | integer | SSH, RDP, VNC | Seconds to wait for the host to boot before connecting (at most 600) |
| `owner` | string | All | Create the session on behalf of this user (their login email). Admin callers only, see [Creating a session for someone else](#creating-a-session-for-someone-else) |
**Sessions created by non-admin users.** A request from a poweruser is held to a stricter policy than a connection entry or an admin; the same policy applies to the ad-hoc mode of `GET /api/connect`:
`POST /api/sessions` takes literal values only: it does not resolve [credential variables](credential-variables.md). To connect with them, use [`POST /api/addressbook/adhoc/connect`](#post-apiaddressbookadhocconnect).
**Sessions created by non-admin users.** A request from a poweruser is held to a stricter policy than a connection entry or an admin; the same policy applies to the ad-hoc mode of `GET /api/connect` and to `POST /api/addressbook/adhoc/connect`:
- Every target is checked against the network allowlists, including the first jump host and the Proxmox VE API and SPICE proxy (see [Network allowlists](security.md#network-allowlists-ssrf-protection)).
- Fields that only a connection entry may set are ignored: `address_book_entry`, `address_book_folder`, `entry_display_name`, `max_recordings`, `record_typescript`, `container_username`, `container_password`, `kerberos_cache`, `allow_sharing` and the Wake-on-LAN fields (`wol_*`). A VDI session always uses the caller's own container.
@@ -540,6 +542,38 @@ Optional body to override or supply credentials at connect time:
Prompted credentials are used for the current session only and are never stored. Jump host credentials always come from the Vault entry and cannot be overridden at connect time.
### `POST /api/addressbook/adhoc/connect`
Connect to a host that is not a saved entry (an ad-hoc connection), described with every option an entry has. This is what **Connect to a host** (beside the search box on every page) sends. Requires **poweruser** role or higher. Works without Vault, and shares the session-creation rate limit with `POST /api/sessions`.
```json
{
"entry": {
"type": "ssh",
"hostname": "10.0.0.5",
"username": "deploy",
"private_key": "$my_ssh_key",
"ssh_font_size": 14,
"jump_hosts": [{ "hostname": "bastion.example.com", "port": 22, "username": "jump" }]
},
"generate_keypair": false,
"width": 1920,
"height": 1080,
"screen_width": 2560,
"screen_height": 1440,
"dpi": 96
}
```
- `entry` takes the same fields as [creating an entry](#post-apiaddressbookfoldersscopefolderentries-admin), in the same form. Its name, display name and folder are not used. Legacy single jump host fields are accepted, as on entries.
- `generate_keypair` (SSH): generate an ephemeral Ed25519 keypair, as with `POST /api/sessions`.
- `width`, `height`, `screen_width`, `screen_height`, `dpi`: as for the entry connect endpoint above.
- [Credential variables](credential-variables.md) (`$name` in `username`, `password`, `domain`, `private_key`) are resolved from the **caller's own** My Credentials, server side; values are never returned. A variable the caller has not set returns `412` with `{"error": "missing credential variables", "missing_variables": ["name"]}`. An API key has no credential variables, so a request from one that uses them returns `400`. Without Vault, references are not resolved.
- [Folder default credentials](credential-variables.md#folder-default-credentials) never apply: an ad-hoc connection belongs to no folder.
- Requests from non-admins get the [ad-hoc policy](#post-apisessions) (network allowlists on every target, including SPICE, Proxmox and jump hosts; entry-only options ignored; site recording, drive and VDI limits).
The response is the same as `POST /api/sessions` (`201`).
### `POST /api/addressbook/folders` (admin)
Create a folder.
+2 -2
View File
@@ -297,8 +297,8 @@ things are required: `typescript_path` must be set here (the global "where"),
and the individual connection entry must have **Enable typescript recording
for this session** ticked in its Recording Settings (Connections page, SSH
entries only). A connection with the box unticked, or any session that is not
SSH, writes no typescript. Ad-hoc SSH sessions from the Sessions page have no
entry and so never record a typescript.
SSH, writes no typescript. Ad-hoc SSH connections (Connect to a host) have no
entry; for a non-admin they never record a typescript.
The typescript is produced by guacd, so `typescript_path` must be writable by
the guacd process. On a standard install guacd runs as the same `rustguac`
+6
View File
@@ -55,6 +55,12 @@ Credential variables are expanded at connect time in the following entry fields:
VDI entries that auto-derive credentials (the default for images that honour `VDI_USERNAME`/`VDI_PASSWORD`) do not need credential variables. They only apply when an admin has set explicit `container_username` / `container_password` overrides for an image with a baked-in account.
### Ad-hoc connections
**Connect to a host** (beside the search box on every page) (poweruser and up) accepts your own variables in its credential fields, so an SSH key you use everywhere is one pick rather than a paste: choose `$my_ssh_key` from the list under the private key field, or type a name the username field suggests. Only the variable names reach the browser; the server resolves them from your My Credentials when connecting, exactly as for a saved entry, and a name you have not set is reported before anything connects.
Only your own variables are used, and folder default credentials never apply (an ad-hoc connection has no folder). Variables in jump host credentials are not resolved. An admin who saves an ad-hoc connection as an entry (**Save as entry**) saves the `$name` references, not the values. `POST /api/sessions` does not resolve variables; see [`POST /api/addressbook/adhoc/connect`](api.md#post-apiaddressbookadhocconnect).
## My Credentials dialog
Open it from the **Credentials** link in the top navigation bar, or from the
+1 -1
View File
@@ -41,7 +41,7 @@ This starts both `rustguac-guacd` (the protocol daemon) and `rustguac` (the web
4. **(Required for connections) Set up Vault or OpenBao:**
The connections page is rustguac's primary user-facing feature. It stores SSH, RDP, VNC, web session, and VDI entries in [HashiCorp Vault](https://www.vaultproject.io/) or [OpenBao](https://openbao.org/) KV v2 — credentials never reach the browser. **Without one of these, the Connections UI is unavailable** and users can only run ad-hoc sessions via the Sessions page or the API.
The connections page is rustguac's primary user-facing feature. It stores SSH, RDP, VNC, web session, and VDI entries in [HashiCorp Vault](https://www.vaultproject.io/) or [OpenBao](https://openbao.org/) KV v2 — credentials never reach the browser. **Without one of these, the Connections UI is unavailable** and users can only open ad-hoc connections (**Connect to a host**, beside the search box on every page) or use the API.
For a single-host install the fastest path is the bundled quickstart helper, which auto-detects vault or bao and provisions everything:
+3 -3
View File
@@ -262,7 +262,7 @@ Two things to watch:
## Vault / OpenBao Connections
The connections feature 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. **Either Vault or OpenBao is required for the connections feature** (entries, folders, the Connections page); without it rustguac falls back to ad-hoc-only sessions via the Sessions page.
The connections feature 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. **Either Vault or OpenBao is required for the connections feature** (entries, folders, the Connections page); without it rustguac falls back to ad-hoc connections only, through **Connect to a host** (beside the search box on every page).
### Quickstart script
@@ -617,9 +617,9 @@ Admins configure jump hosts per entry in the connections editor. Click "Add Jump
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).
#### Ad-hoc sessions
#### Ad-hoc connections
Powerusers can add jump hosts when creating sessions from the Sessions page. The same multi-hop card UI is available — click "Add Jump Host" in the SSH Tunnel section.
Powerusers can add jump hosts to an ad-hoc connection (**Connect to a host** (beside the search box on every page)), which uses the same entry editor: click "Add Jump Host" in the SSH Tunnel section. For non-admins the first jump host must be inside `ssh_allowed_networks`.
### Per-hop credentials
+2 -2
View File
@@ -6,7 +6,7 @@ rustguac is a lightweight Rust replacement for the Apache Guacamole Java webapp.
rustguac sits between web browsers and guacd, proxying the Guacamole protocol over WebSockets. It manages session lifecycle, authentication, session recording, VDI container orchestration, and Vault-backed connection storage.
The connections feature (the main user-facing UI) requires HashiCorp Vault or OpenBao for credential storage. Without one, rustguac falls back to ad-hoc-only sessions created via the API. See [Vault / OpenBao Connections](integrations.md#vault--openbao-connections) for setup, including the `contrib/vault-quickstart.sh` helper.
The connections feature (the main user-facing UI) requires HashiCorp Vault or OpenBao for credential storage. Without one, rustguac falls back to ad-hoc connections only (**Connect to a host** beside the search box, or the API). See [Vault / OpenBao Connections](integrations.md#vault--openbao-connections) for setup, including the `contrib/vault-quickstart.sh` helper.
## Why not Apache Guacamole?
@@ -135,7 +135,7 @@ You -> bastion-1:22 -> bastion-2:22 -> target:3389 RDP
Jump hosts can be configured:
- **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
- **Per ad-hoc connection** — powerusers add jump hosts in **Connect to a host** (beside the search box on every page)
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.
+5 -4
View File
@@ -7,11 +7,11 @@ rustguac implements a 4-tier role hierarchy:
| Role | Level | Description |
|------|-------|-------------|
| **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 |
| **poweruser** | 3 | Ad-hoc connections (Connect to a host) + connections connect + self-service API tokens |
| **operator** | 2 | Connections connect only (no ad-hoc connections); 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.
Roles are hierarchical: each role includes all permissions of lower roles. For example, a poweruser can do everything an operator can, plus open ad-hoc connections.
## Authentication paths
@@ -83,7 +83,7 @@ Their role, group mappings, tokens and credentials are kept. This is CLI-only on
| Endpoint | Required role | Notes |
|----------|--------------|-------|
| `POST /api/sessions` | poweruser | Create ad-hoc sessions |
| `POST /api/sessions` | poweruser | Create ad-hoc sessions. Non-admins get the [ad-hoc policy](security.md#ad-hoc-sessions-from-non-admins) |
| `GET /api/sessions` | operator | List all sessions |
| `GET /api/sessions/:id` | operator | View session details |
| `DELETE /api/sessions/:id` | operator | Non-admins can only delete their own sessions |
@@ -95,6 +95,7 @@ Their role, group mappings, tokens and credentials are kept. This is CLI-only on
| `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 connections entry |
| `POST /api/addressbook/adhoc/connect` | poweruser | Ad-hoc connection described as an entry (Connect to a host); the caller's own credential variables only, no folder defaults. Works without Vault |
| `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 |
+4 -2
View File
@@ -76,7 +76,7 @@ SSH and VNC connections are made to the address that was checked rather than to
### Ad-hoc sessions from non-admins
A session a poweruser creates from their own request (`POST /api/sessions`, or `GET /api/connect` without an entry) is not a connection entry, so it gets none of an entry's trust. Besides the allowlist checks above:
A session a poweruser creates from their own request (**Connect to a host** (beside the search box on every page), `POST /api/addressbook/adhoc/connect`, `POST /api/sessions`, or `GET /api/connect` without an entry) is not a connection entry, so it gets none of an entry's trust. Besides the allowlist checks above, which cover every target the entry editor can name, SPICE, Proxmox VE and jump hosts included:
- Settings an admin makes on an entry are ignored when a poweruser sends them: recording attribution and rotation (`address_book_entry`, `max_recordings`), typescript recording, sharing, Wake-on-LAN, a Kerberos credential cache path on the rustguac host, and a fixed VDI account (`container_username` / `container_password`). A VDI session always runs in the caller's own container, with the caller's own home directory.
- Site-wide recording cannot be switched off for the session, and a drive disabled in `[drive]` cannot be switched on.
@@ -84,6 +84,8 @@ A session a poweruser creates from their own request (`POST /api/sessions`, or `
Admins (an admin API key included) and connection entries are trusted to set all of these.
An ad-hoc connection may use [credential variables](credential-variables.md), but only the caller's own: they are resolved server side from the caller's My Credentials, and the values are never sent to the browser. [Folder default credentials](credential-variables.md#folder-default-credentials) never apply to one, since they belong to a folder and an ad-hoc connection has none.
## Authentication
rustguac supports two parallel authentication paths. See [Roles and Access Control](roles-and-access-control.md) for the full role system.
@@ -319,7 +321,7 @@ trusted_proxies = ["127.0.0.1/32"]
## Credential handling
- **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.
- **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 connections, jump host credentials are provided in the 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.
+1087 -240
View File
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -1309,9 +1309,15 @@ async fn run_server(config: Config, database: Db) {
// WebSocket ticket store (single-use tokens to keep API keys out of WS URLs)
let ws_ticket_store = auth::WsTicketStore::new();
// Session creation route (rate-limited only when enabled)
// Session creation routes (rate-limited only when enabled). Ad-hoc
// connections from the Connections page create sessions too, so they
// share the limit.
let mut session_create_route = Router::new()
.route("/api/sessions", post(api::create_session))
.route(
"/api/addressbook/adhoc/connect",
post(api::ab_adhoc_connect),
)
.with_state(manager.clone());
if rate_limit_enabled {
let conf = GovernorConfigBuilder::default()
+328 -20
View File
@@ -635,6 +635,14 @@
.folder-row-main { gap: 2px; }
.folder-desc { font-size: 12px; }
.tree-children { margin-left: var(--s-3) !important; }
/* Ad-hoc mode of the entry editor (Connect to a host): no name or
folder, Connect instead of Save, and for non-admins none of the
options only a saved entry may set. */
#entry-modal:not(.adhoc) .adhoc-only,
#entry-modal.adhoc .adhoc-hide,
#entry-modal.adhoc.adhoc-restricted .entry-only,
#entry-modal:not(.adhoc-restricted) .adhoc-restricted-only { display: none !important; }
.var-pick { margin-top: 0.3em; font-size: 0.85em; width: auto; max-width: 100%; }
</style>
<script>(function(){var c=localStorage.getItem('rustguac_theme_colors');if(c){try{var o=JSON.parse(c),r=document.documentElement.style;for(var k in o)r.setProperty('--'+k.replace(/_/g,'-'),o[k]);if(o.bg_pattern&&o.bg_pattern!=='none'){var s=document.createElement('style');s.id='bg-pattern-style';s.textContent='body{background-image:'+o.bg_pattern+';background-attachment:fixed}';document.head.appendChild(s)}}catch(e){}}})();</script>
</head>
@@ -805,6 +813,9 @@
<div class="modal-overlay" id="entry-modal">
<div class="modal">
<h3 id="entry-modal-title">New Entry</h3>
<p class="modal-hint adhoc-only" id="em-adhoc-hint">A one-off connection to any host. Nothing is saved. Credential fields accept your own <a href="/docs.html#credential-variables">credential variables</a>, such as <code>$my_ssh_key</code>.</p>
<datalist id="em-cred-vars"></datalist>
<div class="adhoc-hide">
<label>Name
<input type="text" id="em-name" placeholder="e.g. web-server-01" pattern="[a-zA-Z0-9_.-]+" maxlength="64">
</label>
@@ -812,6 +823,7 @@
<label>Display name
<input type="text" id="em-display-name" placeholder="e.g. Web Server 01">
</label>
</div>
<label>Type
<select id="em-type">
<option value="ssh">SSH</option>
@@ -839,14 +851,22 @@
<label>Private key (PEM)
<textarea id="em-private-key" rows="3" style="resize:vertical;font-size:0.85em" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"></textarea>
</label>
<div class="adhoc-only">
<label style="margin-top:0.5em">
<input type="checkbox" id="em-ssh-generate-keypair" style="display:inline;width:auto;margin-right:0.4em"> Generate ephemeral SSH keypair
</label>
<div class="field-hint">A fresh Ed25519 key for this session only. Its public key is shown before connecting, to add to the host's <code>authorized_keys</code>.</div>
</div>
<label>Terminal font size <span style="color:var(--text-muted);font-size:0.85em">(optional)</span>
<input type="number" id="em-ssh-font-size" min="6" max="72" placeholder="12">
</label>
<div class="field-hint">Points. Leave blank for the default (12). Increase or decrease to tune readability — the terminal size no longer scales with display DPI.</div>
<div class="entry-only">
<label style="margin-top:0.8em">
<input type="checkbox" id="em-ssh-prompt-creds" style="display:inline;width:auto;margin-right:0.4em"> Prompt for credentials at connect time
</label>
<div class="field-hint">When enabled, users will be asked for username/password even if credentials are stored.</div>
</div>
</div>
<div id="em-rdp-fields" style="display:none">
<label>Hostname
@@ -911,10 +931,12 @@
<label>Monitors <span style="color:var(--text-muted);font-size:0.85em">(RDP Display Control; the host must allow multiple monitors)</span>
<input type="number" id="em-rdp-monitors" min="1" max="4" value="1">
</label>
<div class="entry-only">
<label style="margin-top:0.8em">
<input type="checkbox" id="em-rdp-prompt-creds" style="display:inline;width:auto;margin-right:0.4em"> Prompt for credentials at connect time
</label>
<div class="field-hint">When enabled, users will be asked for username/password even if credentials are stored.</div>
</div>
</div>
<div id="em-vnc-fields" style="display:none">
<label>Hostname
@@ -938,10 +960,12 @@
<option value="32">32-bit</option>
</select>
</label>
<div class="entry-only">
<label style="margin-top:0.8em">
<input type="checkbox" id="em-vnc-prompt-creds" style="display:inline;width:auto;margin-right:0.4em"> Prompt for credentials at connect time
</label>
<div class="field-hint">When enabled, users will be asked for credentials even if they are stored.</div>
</div>
</div>
<div id="em-spice-fields" style="display:none">
<label>Hostname
@@ -980,7 +1004,7 @@
<label>Proxy <span style="color:var(--text-muted);font-size:0.85em">(optional)</span>
<input type="text" id="em-spice-proxy" placeholder="http://proxy.example.com:3128">
</label>
<label style="margin-top:0.8em">
<label style="margin-top:0.8em" class="entry-only">
<input type="checkbox" id="em-spice-prompt-creds" style="display:inline;width:auto;margin-right:0.4em"> Prompt for credentials at connect time
</label>
<div class="field-hint">Direct SPICE displays (e.g. libvirt/QEMU consoles). The TLS, CA, certificate-subject and proxy fields support connecting through a SPICE proxy. For Proxmox VE VMs, use the "Proxmox VE console" type instead (it brokers the one-time ticket automatically).</div>
@@ -1081,6 +1105,7 @@
<label>Environment Variables <span style="color:var(--text-muted);font-size:0.85em">(optional, KEY=VALUE per line)</span>
<textarea id="em-vdi-env" rows="3" style="resize:vertical;font-size:0.85em" placeholder="TZ=Australia/Brisbane&#10;LANG=en_US.UTF-8"></textarea>
</label>
<div class="entry-only">
<label>Container username <span style="color:var(--text-muted);font-size:0.85em">(optional, overrides auto-derive)</span>
<input type="text" id="em-vdi-container-username" placeholder="Leave blank to derive from operator identity">
</label>
@@ -1088,6 +1113,7 @@
<input type="password" id="em-vdi-container-password" placeholder="Leave blank to generate per-connect" autocomplete="new-password">
</label>
<div class="field-hint">Use these only when the container image has a fixed account (Pattern B in the <a href="/docs.html#vdi" style="color:var(--accent)">VDI docs</a>). When set, rustguac uses these creds for the RDP login into the container, and the same container is shared by everyone connecting through this entry (deterministic name from the username). When left blank, rustguac derives a per-operator username and generates a fresh password every connect. Both fields support <a href="/docs.html#credential-variables" style="color:var(--accent)">credential variables</a> (e.g. <code>$corp_username</code>).</div>
</div>
<label>Idle Timeout <span style="color:var(--text-muted);font-size:0.85em">(optional, minutes)</span>
<input type="number" id="em-vdi-idle-timeout" min="0" placeholder="e.g. 60 (0 = use global default)">
</label>
@@ -1133,12 +1159,15 @@
<label>
<input type="checkbox" id="em-enable-recording" style="display:inline;width:auto;margin-right:0.4em" checked> Enable recording
</label>
<div class="entry-only">
<label style="margin-top:0.5em">Max recordings to keep
<input type="number" id="em-max-recordings" value="0" min="0" style="width:100px">
</label>
<div class="field-hint">0 = unlimited. Oldest recordings are deleted when the limit is exceeded.</div>
</div>
<div class="field-hint adhoc-restricted-only">Recording that is on for the whole site stays on.</div>
</div>
<div id="em-typescript-row" style="display:none;margin-top:0.6em;padding-top:0.5em;border-top:1px dashed var(--border)">
<div id="em-typescript-row" class="entry-only" style="display:none;margin-top:0.6em;padding-top:0.5em;border-top:1px dashed var(--border)">
<label>
<input type="checkbox" id="em-record-typescript" style="display:inline;width:auto;margin-right:0.4em"> Enable typescript recording for this session
</label>
@@ -1204,7 +1233,7 @@
<button type="button" class="btn-add" id="em-add-hop" style="margin-top:8px">+ Add Jump Host</button>
</div>
</div>
<div id="em-wol-section" style="display:none;margin-top:1em;padding-top:0.8em;border-top:1px solid var(--border)">
<div id="em-wol-section" class="entry-only" style="display:none;margin-top:1em;padding-top:0.8em;border-top:1px solid var(--border)">
<label style="cursor:pointer;color:var(--accent);font-size:0.95em" id="em-wol-toggle">
<span id="em-wol-arrow">&#9654;</span> Wake-on-LAN
</label>
@@ -1230,6 +1259,7 @@
</div>
</div>
<div id="em-sharing-section" style="margin-top:1em;padding-top:0.8em;border-top:1px solid var(--border)">
<div class="entry-only">
<label style="text-transform:none;letter-spacing:0;cursor:pointer">
<input type="checkbox" id="em-allow-sharing" style="display:inline;width:auto;margin-right:0.5em">
Allow session sharing
@@ -1247,6 +1277,7 @@
this box is ticked, connect automatically (replaces the current tab — browsers
block new-tab popups without a click). Kiosk-style single-purpose accounts.
</div>
</div>
<label style="text-transform:none;letter-spacing:0;cursor:pointer;margin-top:0.8em">
<input type="checkbox" id="em-fullscreen-on-connect" style="display:inline;width:auto;margin-right:0.5em">
Open in fullscreen on connect
@@ -1267,16 +1298,28 @@
on-screen clutter over the remote display.
</div>
</div>
<div id="em-move-section" style="display:none;margin-top:1em;padding-top:0.8em;border-top:1px solid var(--border)">
<div id="em-move-section" class="adhoc-hide" style="display:none;margin-top:1em;padding-top:0.8em;border-top:1px solid var(--border)">
<label>Move to folder
<select id="em-move-target">
<option value="">(keep in current folder)</option>
</select>
</label>
</div>
<div id="em-saveas-section" class="adhoc-only" style="display:none;margin-top:1em;padding-top:0.8em;border-top:1px solid var(--border)">
<div class="field-hint" style="margin-bottom:0.5em">Save this connection as an entry. Credential variables are saved as references, not values.</div>
<label>Folder
<select id="em-saveas-folder"></select>
</label>
<label>Name
<input type="text" id="em-saveas-name" placeholder="e.g. web-server-01" pattern="[a-zA-Z0-9_.-]+" maxlength="64">
</label>
<div class="field-hint">Alphanumeric, hyphens, underscores, dots. Used as the Vault key.</div>
</div>
<div id="em-error" style="color:var(--primary);margin-top:0.5em"></div>
<div class="modal-actions">
<button id="em-save">Save</button>
<button id="em-save" class="adhoc-hide">Save</button>
<button id="em-connect" class="adhoc-only">Connect</button>
<button id="em-saveas" class="btn-add adhoc-only" style="display:none">Save as entry</button>
<button class="btn-cancel" id="em-cancel">Cancel</button>
</div>
</div>
@@ -1806,6 +1849,11 @@
if (level >= 2) {
document.getElementById('tokens-link').style.display = '';
}
// Ad-hoc connections need no Vault, so this is settled before
// the no-Vault return below. Poweruser and up; an API key is
// always admin. The button itself is search.js's.
canAdhoc = level >= 3 || !!apiKey;
vaultReady = !!data.vault_enabled;
if (apiKey) {
// A key is never on the page GET, so the server served the anonymous variant.
document.documentElement.setAttribute('data-role-level', '4');
@@ -1825,12 +1873,13 @@
// Vault not configured at all
document.getElementById('no-vault').style.display = '';
}
maybeOpenAdhocFromHash();
return;
}
// The first poll records the version this page is about to
// load; issued before the load so nothing in between is missed.
startLiveRefresh();
loadFolders(function() { rgHashOpen(); });
loadFolders(function() { rgHashOpen(); maybeOpenAdhocFromHash(); });
loadCredStatus();
loadActiveSessions();
setInterval(loadActiveSessions, 10000);
@@ -2034,14 +2083,16 @@
});
}
function loadFolders() {
// `done` runs once the folder list (and any restored selection) is
// in place, for the URL hash actions that need it.
function loadFolders(done) {
fetch('/api/addressbook', { headers: apiHeaders(), credentials: 'same-origin' })
.then(function(res) {
if (res.status === 503) {
document.getElementById('vault-unavailable').style.display = '';
setTimeout(function() {
document.getElementById('vault-unavailable').style.display = 'none';
loadFolders();
loadFolders(done);
}, 15000);
return null;
}
@@ -2103,6 +2154,7 @@
renderFolders();
loadEntries(f.scope, folderPath(f));
maybeAutoOpenSingleton();
if (done) done();
return;
}
}
@@ -2113,6 +2165,7 @@
loadEntries(folders[0].scope, folderPath(folders[0]));
}
maybeAutoOpenSingleton();
if (done) done();
});
return;
}
@@ -4310,6 +4363,7 @@
function openNewEntry() {
if (!selectedFolder) return;
setAdhocMode(false);
entryEditMode = null;
cloneSource = null;
document.getElementById('entry-modal-title').textContent = 'New Entry in ' + folderPath(selectedFolder);
@@ -4439,6 +4493,7 @@
}
function openEditEntry(name, scope, folder, type) {
setAdhocMode(false);
entryEditMode = { scope: scope, folder: folder, name: name };
cloneSource = null;
document.getElementById('entry-modal-title').textContent = 'Edit: ' + name;
@@ -4678,17 +4733,12 @@
entryTypeSelect.disabled = false;
});
document.getElementById('em-save').addEventListener('click', function() {
var name = document.getElementById('em-name').value.trim();
// The entry the editor describes, in the form the entries API takes
// and the ad-hoc connect endpoint reads. Shared by Save, Connect and
// Save as entry, so every option works in all three.
function buildEntryFromForm() {
var displayName = document.getElementById('em-display-name').value.trim();
var type = entryTypeSelect.value;
var errEl = document.getElementById('em-error');
if (!name || !/^[a-zA-Z0-9_.-]+$/.test(name)) {
errEl.textContent = 'Name must be alphanumeric, hyphens, underscores, dots only.';
return;
}
var entry = { type: type };
if (displayName) entry.display_name = displayName;
@@ -4904,6 +4954,19 @@
});
}
}
return entry;
}
document.getElementById('em-save').addEventListener('click', function() {
var name = document.getElementById('em-name').value.trim();
var errEl = document.getElementById('em-error');
if (!name || !/^[a-zA-Z0-9_.-]+$/.test(name)) {
errEl.textContent = 'Name must be alphanumeric, hyphens, underscores, dots only.';
return;
}
var entry = buildEntryFromForm();
errEl.textContent = '';
var scope = selectedFolder.scope;
@@ -5012,6 +5075,251 @@
}
});
// ── Ad-hoc connections ("Connect to a host") ──
// The entry editor in ad-hoc mode: the same form, so every option an
// entry has works ad-hoc too, with Connect in place of Save and no
// name or folder. Poweruser and up. The server applies the ad-hoc
// policy (network fences, site recording and drive settings, VDI
// limits) whatever the form sends; for non-admins the options only a
// saved entry may set are hidden here as well.
var canAdhoc = false; // set by init() from the caller's role
var vaultReady = false; // set by init(): Save as entry and variables need Vault
// Credential fields that offer the caller's own variables: text
// inputs get a datalist, the rest (passwords, keys) a picker.
var ADHOC_VAR_LIST_FIELDS = ['em-username', 'em-rdp-username', 'em-rdp-domain', 'em-vnc-username', 'em-web-username'];
var ADHOC_VAR_PICK_FIELDS = ['em-password', 'em-private-key', 'em-rdp-password', 'em-vnc-password', 'em-spice-password', 'em-web-password'];
function setAdhocMode(on) {
var modal = document.getElementById('entry-modal');
modal.classList.toggle('adhoc', !!on);
modal.classList.toggle('adhoc-restricted', !!on && !isAdmin);
ADHOC_VAR_LIST_FIELDS.forEach(function(id) {
var el = document.getElementById(id);
if (on) el.setAttribute('list', 'em-cred-vars'); else el.removeAttribute('list');
});
}
function renderAdhocVars(names) {
var dl = document.getElementById('em-cred-vars');
dl.innerHTML = '';
names.forEach(function(n) {
var o = document.createElement('option');
o.value = '$' + n;
dl.appendChild(o);
});
ADHOC_VAR_PICK_FIELDS.forEach(function(id) {
var field = document.getElementById(id);
var host = field.closest('label') || field;
var pick = host.nextElementSibling;
if (!pick || !pick.classList.contains('var-pick')) {
pick = document.createElement('select');
pick.className = 'var-pick adhoc-only';
pick.setAttribute('data-for', id);
pick.setAttribute('aria-label', 'Use one of your credential variables');
pick.addEventListener('change', function() {
if (!pick.value) return;
field.value = '$' + pick.value;
field.dispatchEvent(new Event('input', { bubbles: true }));
pick.value = '';
});
host.parentNode.insertBefore(pick, host.nextSibling);
}
pick.innerHTML = '<option value="">Use a credential variable&hellip;</option>';
names.forEach(function(n) {
var o = document.createElement('option');
o.value = n;
o.textContent = '$' + n;
pick.appendChild(o);
});
pick.style.display = names.length ? '' : 'none';
});
}
// Only the names: the values never reach the browser and are
// resolved by the server when connecting.
function loadAdhocVars() {
renderAdhocVars([]);
if (!vaultReady || apiKey) return;
fetch('/api/me/credentials', { headers: apiHeaders(), credentials: 'same-origin' })
.then(function(r) { return r.ok ? r.json() : null; })
.then(function(d) {
if (d && d.credentials) renderAdhocVars(Object.keys(d.credentials).sort());
})
.catch(function() {});
}
function syncAdhocKeypair() {
var gen = document.getElementById('em-ssh-generate-keypair').checked;
['em-password', 'em-private-key'].forEach(function(id) {
var el = document.getElementById(id);
el.disabled = gen;
if (gen) el.value = '';
});
}
document.getElementById('em-ssh-generate-keypair').addEventListener('change', syncAdhocKeypair);
function adhocSaveAsTargets() {
var out = [];
(folders || []).forEach(function(f) { out.push(f); });
for (var ck in subfolderCache) {
subfolderCache[ck].forEach(function(f) { out.push(f); });
}
return out;
}
function openAdhocEntry() {
if (!canAdhoc) return;
entryEditMode = null;
cloneSource = null;
setAdhocMode(true);
document.getElementById('entry-modal-title').textContent = 'Connect to a host';
document.getElementById('em-name').value = '';
document.getElementById('em-display-name').value = '';
entryTypeSelect.value = 'ssh';
entryTypeSelect.disabled = false;
entryTypeSelect.dispatchEvent(new Event('change'));
clearEntryFields();
document.getElementById('em-ssh-generate-keypair').checked = false;
syncAdhocKeypair();
document.getElementById('em-error').textContent = '';
// Admins can keep what they filled in as a saved entry.
var targets = isAdmin && vaultReady ? adhocSaveAsTargets() : [];
var sel = document.getElementById('em-saveas-folder');
sel.innerHTML = '';
targets.forEach(function(f) {
var p = folderPath(f);
var opt = document.createElement('option');
opt.value = f.scope + '|' + p;
opt.textContent = p + ' (' + f.scope + ')';
if (selectedFolder && selectedFolder.scope === f.scope && folderPath(selectedFolder) === p) opt.selected = true;
sel.appendChild(opt);
});
document.getElementById('em-saveas-name').value = '';
var canSave = targets.length > 0;
document.getElementById('em-saveas-section').style.display = canSave ? '' : 'none';
document.getElementById('em-saveas').style.display = canSave ? '' : 'none';
loadAdhocVars();
document.getElementById('entry-modal').classList.add('active');
try { document.getElementById('em-hostname').focus(); } catch (e) {}
}
// The global "Connect to a host" button (search.js) opens the editor
// in place here; every other page links to /connections.html#adhoc.
// A click before this page knows the caller's role waits for it.
var adhocRequested = false;
window.rgOpenAdhoc = function() {
if (canAdhoc) openAdhocEntry(); else adhocRequested = true;
};
function maybeOpenAdhocFromHash() {
var fromHash = location.hash === '#adhoc';
if (!(fromHash || adhocRequested) || !canAdhoc) return;
adhocRequested = false;
if (fromHash) {
try { history.replaceState(null, '', location.pathname + location.search); } catch (e) {}
}
openAdhocEntry();
}
function apiErrorText(t) {
try { var j = JSON.parse(t); if (j && j.error) return j.error; } catch (e) {}
return t;
}
function doAdhocConnect() {
var btn = document.getElementById('em-connect');
var errEl = document.getElementById('em-error');
var entry = buildEntryFromForm();
delete entry.display_name;
var body = { entry: entry };
if (entry.type === 'ssh' && document.getElementById('em-ssh-generate-keypair').checked) {
body.generate_keypair = true;
delete entry.password;
delete entry.private_key;
}
var bw = window.innerWidth, bh = window.innerHeight;
if (bw > 0 && bh > 0) { body.width = bw; body.height = bh; }
if (window.screen && screen.width > 0 && screen.height > 0) {
body.screen_width = screen.width;
body.screen_height = screen.height;
}
body.dpi = Math.round((window.devicePixelRatio || 1) * 96);
// Opened inside the click so it is not popup-blocked (see doConnect).
var targetWin = window.open('', '_blank');
errEl.textContent = '';
btn.disabled = true;
btn.textContent = 'Connecting...';
fetch('/api/addressbook/adhoc/connect', {
method: 'POST',
headers: apiHeaders({ 'Content-Type': 'application/json' }),
credentials: 'same-origin',
body: JSON.stringify(body)
})
.then(function(res) {
if (res.status === 412) {
return res.json().then(function(j) {
var names = (j.missing_variables || []).map(function(n) { return '$' + n; });
throw new Error('You have no credential variable ' + names.join(', ')
+ '. Pick one of yours from the list, or set it in My Credentials.');
});
}
if (!res.ok) return res.text().then(function(t) { throw new Error(apiErrorText(t)); });
return res.json();
})
.then(function(data) {
var url = data.client_url + (entry.fullscreen_on_connect ? '?fs=1' : '');
if (targetWin && !targetWin.closed) targetWin.location.href = url;
else window.open(url, '_blank');
document.getElementById('entry-modal').classList.remove('active');
if (vaultReady) loadActiveSessions();
})
.catch(function(err) {
if (targetWin && !targetWin.closed) targetWin.close();
errEl.textContent = err.message;
})
.finally(function() {
btn.disabled = false;
btn.textContent = 'Connect';
});
}
document.getElementById('em-connect').addEventListener('click', doAdhocConnect);
// Admin: keep the ad-hoc connection as an entry. Credential variables
// are stored as the references typed, never as values.
document.getElementById('em-saveas').addEventListener('click', function() {
var btn = this;
var errEl = document.getElementById('em-error');
var name = document.getElementById('em-saveas-name').value.trim();
var target = document.getElementById('em-saveas-folder').value;
if (!target) { errEl.textContent = 'Choose a folder to save the entry in.'; return; }
if (!name || !/^[a-zA-Z0-9_.-]+$/.test(name)) {
errEl.textContent = 'Name must be alphanumeric, hyphens, underscores, dots only.';
return;
}
var sep = target.indexOf('|');
var scope = target.slice(0, sep), folder = target.slice(sep + 1);
var entry = buildEntryFromForm();
delete entry.display_name;
var body = { name: name };
for (var k in entry) body[k] = entry[k];
errEl.textContent = '';
btn.disabled = true;
fetch('/api/addressbook/folders/' + encodeURIComponent(scope) + '/' + encodeURIComponent(folder) + '/entries', {
method: 'POST',
headers: apiHeaders({ 'Content-Type': 'application/json' }),
credentials: 'same-origin',
body: JSON.stringify(body)
})
.then(function(res) {
if (!res.ok) return res.text().then(function(t) { throw new Error(apiErrorText(t)); });
document.getElementById('entry-modal').classList.remove('active');
showInfo('Saved "' + name + '" in ' + folder + '.');
window.rgSelectFolder(scope, folder);
})
.catch(function(err) { errEl.textContent = err.message; })
.finally(function() { btn.disabled = false; });
});
// Close modals on overlay click
document.querySelectorAll('.modal-overlay').forEach(function(el) {
el.addEventListener('click', function(e) {
@@ -5365,9 +5673,9 @@
'<br><br>Use it to upload and download files via a shared virtual drive. SSH sessions support SFTP file transfer instead.' +
'<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 connections.' +
'<br><br>Choose SSH, RDP, VNC, or Web and enter connection details directly.'
{ title: 'Connect to a host', minRole: 3, body:
'As a power user, you can connect to a host that isn\'t a saved entry with <span class="onboard-hl">Connect to a host</span>, next to the search box on every page.' +
'<br><br>It opens the entry editor with every option an entry has. Nothing is saved, and credential fields accept your own credential variables, such as <code>$my_ssh_key</code>.'
},
{ title: 'Managing the Connections', minRole: 4, body:
'As an admin, you can:' +
+20 -2
View File
@@ -1002,7 +1002,6 @@ html:not([data-role-level="4"]) .admin-only { display: none !important; }
/* ── Modal extras ─────────────────────────────────────── */
.modal .modal-hint { color: var(--text-muted); font-size: var(--fz-sm); margin: 0 0 var(--s-3); }
.modal.adhoc-modal { min-width: 520px; max-width: 640px; }
/* ── Global connection search (search.js) and the results overlay ──
Shared by every page and by the connections folder view, so the markup
@@ -1039,8 +1038,27 @@ html:not([data-role-level="4"]) .admin-only { display: none !important; }
pointer-events: none;
}
.global-search:focus-within .global-search-key, .global-search.has-value .global-search-key { display: none; }
/* "Connect to a host" (search.js, poweruser and up): beside the centred box
on wide screens, after it in the row on narrow ones. */
.global-adhoc {
position: absolute; top: 50%; transform: translateY(-50%);
left: calc(50% + max(min(640px, 42vw), 320px) / 2 + var(--s-3));
height: 48px; white-space: nowrap;
}
@media (max-width: 1180px) {
.global-search { position: static; transform: none; margin-left: auto; width: 360px; min-width: 0; }
/* Relative, so the icon and key hint stay on the box rather than on
whatever follows it in the row. */
.global-search { position: relative; left: auto; top: auto; transform: none; margin-left: auto; width: 360px; min-width: 0; }
.global-adhoc { position: static; transform: none; height: var(--ctl-sm); }
}
/* Phones: a 16px gutter, and the box takes a row of its own under the logo
and the button. */
@media (max-width: 640px) {
body { padding-left: 16px; padding-right: 16px; }
.brand-row:has(.global-search), .brand-row:has(.global-adhoc) { flex-wrap: wrap; row-gap: var(--s-2); }
.global-search { order: 3; width: 100%; margin-left: 0; }
.global-search input[type="search"] { height: 40px; }
.global-adhoc { margin-left: auto; }
}
#search-overlay {
display: none;
+33 -2
View File
@@ -13,7 +13,11 @@
* Only an authenticated operator or above gets the box: the server writes the
* caller's role on <html data-role-level> (0 anonymous ... 4 admin), and an
* API-key user, who is served the anonymous variant, is confirmed through
* /api/me first. The cached index is keyed to that identity and dropped the
* /api/me first. A poweruser or above also gets "Connect to a host" beside
* it, for an ad-hoc connection: it opens the editor in place on the
* connections page (window.rgOpenAdhoc) and /connections.html#adhoc
* elsewhere. It needs no Vault, so it shows even when the box has nothing to
* search, and it goes with the box when the identity is dropped. The cached index is keyed to that identity and dropped the
* moment the server stops recognising it (logout, expiry, a different user in
* the same tab), so nothing of one user's address book is ever shown to the
* next, nor on the login page.
@@ -24,6 +28,7 @@
window.__rgGlobalSearch = true;
var searchIndex = null, searchEngaged = false, searchFilters = null;
var confirmedLevel = null; // role level /api/me answered with
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
@@ -74,6 +79,23 @@
document.body.appendChild(ov);
}
// "Connect to a host": poweruser and above, the server's can_connect_adhoc.
var ADHOC_LEVEL = 3;
function injectAdhoc(level) {
var brand = document.querySelector('.brand-row');
if (!brand || level == null || level < ADHOC_LEVEL || document.getElementById('rg-adhoc')) return;
var b = document.createElement('button');
b.type = 'button'; b.id = 'rg-adhoc'; b.className = 'btn-add global-adhoc';
b.title = 'Connect to a host that is not a saved entry, with every option an entry has. Nothing is saved.';
b.innerHTML = '&#9889; Connect to a host';
b.addEventListener('click', function () {
if (typeof window.rgOpenAdhoc === 'function') window.rgOpenAdhoc();
else window.location.href = '/connections.html#adhoc';
});
brand.appendChild(b);
}
function removeAdhoc() { var b = document.getElementById('rg-adhoc'); if (b) b.parentNode.removeChild(b); }
function input() { return document.getElementById('rg-search'); }
function overlay() { return document.getElementById('search-overlay'); }
@@ -101,6 +123,7 @@
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
var gs = document.getElementById('global-search'); if (gs) gs.parentNode.removeChild(gs);
var ov = overlay(); if (ov) ov.parentNode.removeChild(ov);
removeAdhoc();
document.body.classList.remove('search-engaged');
searchEngaged = false;
}
@@ -579,6 +602,7 @@
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (me) {
if (!me || !me.role || (ROLE_LEVEL[me.role] || 0) < MIN_LEVEL) return null;
confirmedLevel = ROLE_LEVEL[me.role];
return (me.name || '') + '|' + me.role + '|' + (me.groups || []).join(',');
})
.catch(function () { return null; });
@@ -604,11 +628,18 @@
// it) is confirmed first and gets the box only on a yes.
if (level != null && level >= MIN_LEVEL) {
injectUI();
whoAmI().then(function (who) { if (who) boot(who); else signedOut(); });
injectAdhoc(level);
whoAmI().then(function (who) {
if (!who) { signedOut(); return; }
// The server's answer wins over the page's.
if (confirmedLevel < ADHOC_LEVEL) removeAdhoc(); else injectAdhoc(confirmedLevel);
boot(who);
});
} else {
whoAmI().then(function (who) {
if (!who) { dropCache(); return; }
injectUI();
injectAdhoc(confirmedLevel);
boot(who);
});
}
+4 -468
View File
@@ -5,24 +5,9 @@
<title>rustguac - Sessions</title>
<link rel="stylesheet" href="/rustguac.css">
<style>
form {
background: var(--surface);
border: 1px solid var(--border);
padding: var(--s-4) var(--s-5);
border-radius: var(--radius);
max-width: 480px;
margin: var(--s-4) 0;
}
form input {
display: block;
width: 100%;
box-sizing: border-box;
}
form button {
margin-top: var(--s-4);
min-width: 140px;
}
#sessions { margin-top: var(--s-5); }
#error { color: var(--primary); margin: var(--s-2) 0; }
#error:empty { display: none; }
#sessions a { text-decoration: none; font-size: var(--fz-sm); }
#sessions a:hover { text-decoration: underline; }
#sessions a.shadow-link {
@@ -35,19 +20,6 @@
/* Owner column cues so admins can scan own vs others' sessions */
#sessions td.owner-self { color: var(--text-muted); }
#sessions td.owner-other { color: var(--accent); font-weight: bold; }
.btn-add-hop {
background: transparent;
color: var(--accent);
border: 1px solid var(--border);
font-size: var(--fz-sm);
height: var(--ctl-sm);
padding: 0 var(--s-3);
margin-top: var(--s-2);
font-weight: normal;
}
.btn-add-hop:hover { background: var(--surface); border-color: var(--accent); }
.adhoc-modal form { background: transparent; border: 0; padding: 0; margin: 0; max-width: none; width: auto; }
.adhoc-modal #new-session-fields { margin-top: 0; }
</style>
<script>(function(){var c=localStorage.getItem('rustguac_theme_colors');if(c){try{var o=JSON.parse(c),r=document.documentElement.style;for(var k in o)r.setProperty('--'+k.replace(/_/g,'-'),o[k]);if(o.bg_pattern&&o.bg_pattern!=='none'){var s=document.createElement('style');s.id='bg-pattern-style';s.textContent='body{background-image:'+o.bg_pattern+';background-attachment:fixed}';document.head.appendChild(s)}}catch(e){}}})();</script>
</head>
@@ -72,127 +44,12 @@
Use the <a href="/connections.html">Connections</a> to connect to saved entries.
</div>
<div id="adhoc-modal" class="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="adhoc-modal-title">
<div class="modal adhoc-modal">
<h3 id="adhoc-modal-title">New Ad-Hoc Session</h3>
<p class="modal-hint">A one-off session to any host. Nothing is saved; use Connections for entries you come back to.</p>
<form id="session-form">
<div id="new-session-fields">
<label>Type
<select id="session-type" style="display:block;width:100%;padding:0.4em;margin-top:0.2em;background:var(--input);border:1px solid var(--border);color:var(--text);font-family:monospace;font-size:1em;border-radius:3px;">
<option value="ssh" selected>SSH</option>
<option value="rdp">RDP</option>
<option value="vnc">VNC</option>
<option value="web">Web</option>
<option value="vdi">VDI (Docker)</option>
</select>
</label>
<div id="ssh-fields">
<label>Host
<input type="text" id="hostname" placeholder="192.168.1.1">
</label>
<label>Port
<input type="number" id="port" value="22">
</label>
<label>Username (optional — SSH will prompt if empty)
<input type="text" id="username">
</label>
<label>Password (optional — SSH will prompt if empty)
<input type="password" id="password">
</label>
<label style="margin-top:0.8em">
<input type="checkbox" id="generate-keypair" style="display:inline;width:auto;margin-right:0.4em"> Generate ephemeral SSH keypair
</label>
<div id="private-key-field">
<label>Private key (optional — paste OpenSSH PEM)
<textarea id="private-key" rows="4" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" style="display:block;width:100%;padding:0.4em;margin-top:0.2em;background:var(--input);border:1px solid var(--border);color:var(--text);font-family:monospace;font-size:0.85em;border-radius:3px;box-sizing:border-box;resize:vertical;"></textarea>
</label>
<div style="color:var(--primary);font-size:0.8em;margin-top:0.3em;">Warning: Do not paste important production keys here. Use ephemeral or test keys only.</div>
</div>
</div>
<div id="rdp-fields" style="display:none">
<label>Host
<input type="text" id="rdp-hostname" placeholder="192.168.1.1">
</label>
<label>Port
<input type="number" id="rdp-port" value="3389">
</label>
<label>Username
<input type="text" id="rdp-username">
</label>
<label>Password
<input type="password" id="rdp-password">
</label>
<label>Domain (optional)
<input type="text" id="rdp-domain">
</label>
<label>Security
<select id="rdp-security" style="display:block;width:100%;padding:0.4em;margin-top:0.2em;background:var(--input);border:1px solid var(--border);color:var(--text);font-family:monospace;font-size:1em;border-radius:3px;">
<option value="" selected>Default</option>
<option value="tls">TLS</option>
<option value="nla">NLA</option>
<option value="rdp">RDP</option>
</select>
</label>
<label style="margin-top:0.8em">
<input type="checkbox" id="rdp-ignore-cert" style="display:inline;width:auto;margin-right:0.4em"> Ignore certificate errors
</label>
</div>
<div id="vnc-fields" style="display:none">
<label>Host
<input type="text" id="vnc-hostname" placeholder="192.168.1.1">
</label>
<label>Port
<input type="number" id="vnc-port" value="5900">
</label>
<label>Username (optional)
<input type="text" id="vnc-username">
</label>
<label>Password (optional)
<input type="password" id="vnc-password">
</label>
</div>
<div id="web-fields" style="display:none">
<label>URL
<input type="text" id="url" placeholder="https://example.com">
</label>
</div>
<div id="vdi-fields" style="display:none">
<label>Container Image
<input type="text" id="vdi-image" placeholder="e.g. myregistry/xrdp-desktop:latest">
</label>
<div style="color:var(--text-muted);font-size:0.8em;margin-top:0.3em">Docker image with xrdp on port 3389. Container is ephemeral.</div>
</div>
<div id="jump-section" style="display:none;margin-top:1em;padding-top:0.8em;border-top:1px solid var(--border)">
<label style="cursor:pointer;color:var(--accent);font-size:0.95em" id="jump-toggle">
<span id="jump-arrow">&#9654;</span> SSH Tunnel / Jump Hosts
</label>
<div id="jump-fields" style="display:none">
<div style="color:#555;font-size:0.8em;margin-bottom:0.5em">Route through one or more SSH bastion hosts (multi-hop).</div>
<div id="flow-diagram" class="flow-diagram"></div>
<div id="hops-list"></div>
<button type="button" class="btn-add-hop" id="add-hop-btn">+ Add Jump Host</button>
</div>
</div>
<label>Banner message (shown to user before session starts)
<input type="text" id="banner" placeholder="e.g. Please enter your credentials when prompted">
</label>
<div class="modal-actions">
<button type="submit" id="connect-btn">Connect</button>
<button type="button" class="btn-cancel" id="adhoc-cancel">Cancel</button>
</div>
<div id="error"></div>
</div>
</form>
</div>
</div>
<div id="sessions">
<div class="list-tools">
<input type="text" id="session-search" class="list-search" placeholder="Search sessions (user, host, owner, ID&#8230;)">
<div id="session-filters"></div>
<button type="button" class="btn-add list-action" id="new-session-btn" style="display:none" title="Start a one-off session to any host. Nothing is saved.">&#9889; New ad-hoc session</button>
</div>
<div id="error" role="alert"></div>
<span class="section-count" id="session-counts" style="display:none"></span>
<table>
<thead><tr>
@@ -246,8 +103,6 @@
document.getElementById('tokens-link').style.display = '';
}
if (level >= 3) {
// poweruser+ can create ad-hoc sessions
document.getElementById('new-session-btn').style.display = '';
document.getElementById('reports-link').style.display = '';
}
})
@@ -258,7 +113,6 @@
document.documentElement.setAttribute('data-role-level', '4');
// API key users are always admin
myRole = 'admin';
document.getElementById('new-session-btn').style.display = '';
document.getElementById('admin-link').style.display = '';
document.getElementById('tokens-link').style.display = '';
document.getElementById('reports-link').style.display = '';
@@ -285,324 +139,6 @@
return h;
}
var form = document.getElementById('session-form');
var errorEl = document.getElementById('error');
var btn = document.getElementById('connect-btn');
var sessionType = document.getElementById('session-type');
var sshFields = document.getElementById('ssh-fields');
var rdpFields = document.getElementById('rdp-fields');
var vncFields = document.getElementById('vnc-fields');
var webFields = document.getElementById('web-fields');
var jumpSection = document.getElementById('jump-section');
var generateKeypair = document.getElementById('generate-keypair');
var privateKeyField = document.getElementById('private-key-field');
var passwordField = document.getElementById('password');
generateKeypair.addEventListener('change', function() {
if (generateKeypair.checked) {
privateKeyField.style.display = 'none';
passwordField.disabled = true;
passwordField.value = '';
} else {
privateKeyField.style.display = '';
passwordField.disabled = false;
}
});
var vdiFields = document.getElementById('vdi-fields');
sessionType.addEventListener('change', function() {
sshFields.style.display = 'none';
rdpFields.style.display = 'none';
vncFields.style.display = 'none';
webFields.style.display = 'none';
vdiFields.style.display = 'none';
var v = sessionType.value;
if (v === 'ssh') sshFields.style.display = '';
else if (v === 'rdp') rdpFields.style.display = '';
else if (v === 'vnc') vncFields.style.display = '';
else if (v === 'web') webFields.style.display = '';
else if (v === 'vdi') vdiFields.style.display = '';
// Hide jump hosts for VDI (local containers)
jumpSection.style.display = v === 'vdi' ? 'none' : '';
});
// "New ad-hoc session" opens the form in a modal; Cancel, Escape or
// a click on the backdrop closes it. The form itself is unchanged.
var adhocModal = document.getElementById('adhoc-modal');
function openAdhoc() {
adhocModal.classList.add('active');
document.getElementById('error').textContent = '';
var first = document.getElementById('session-type');
if (first) { try { first.focus(); } catch (e) {} }
}
function closeAdhoc() { adhocModal.classList.remove('active'); }
document.getElementById('new-session-btn').addEventListener('click', openAdhoc);
document.getElementById('adhoc-cancel').addEventListener('click', closeAdhoc);
adhocModal.addEventListener('mousedown', function(e) { if (e.target === adhocModal) closeAdhoc(); });
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && adhocModal.classList.contains('active')) closeAdhoc();
});
// Show jump section on initial load (default is SSH)
jumpSection.style.display = '';
document.getElementById('jump-toggle').addEventListener('click', function() {
var fields = document.getElementById('jump-fields');
var arrow = document.getElementById('jump-arrow');
if (fields.style.display === 'none') {
fields.style.display = '';
arrow.innerHTML = '&#9660;';
} else {
fields.style.display = 'none';
arrow.innerHTML = '&#9654;';
}
});
// ── Escape helpers ──
function escapeHtml(s) {
var d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function escapeAttr(s) {
return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&#39;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// ── Multi-hop management ──
var hopList = []; // Array of {hostname, port, username, password, private_key, expanded}
function renderHops() {
var container = document.getElementById('hops-list');
container.innerHTML = '';
hopList.forEach(function(hop, i) {
var card = document.createElement('div');
card.className = 'hop-card';
// Header
var header = document.createElement('div');
header.className = 'hop-card-header';
var num = document.createElement('span');
num.className = 'hop-num';
num.textContent = '#' + (i + 1);
header.appendChild(num);
var hostWrap = document.createElement('span');
hostWrap.className = 'hop-hostname';
var hostInput = document.createElement('input');
hostInput.type = 'text';
hostInput.placeholder = 'bastion.example.com';
hostInput.value = hop.hostname || '';
hostInput.setAttribute('data-hop', i);
hostInput.setAttribute('data-field', 'hostname');
hostInput.addEventListener('input', onHopFieldChange);
hostInput.addEventListener('click', function(e) { e.stopPropagation(); });
hostWrap.appendChild(hostInput);
header.appendChild(hostWrap);
var toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = 'hop-remove';
toggle.textContent = hop.expanded ? 'collapse' : 'expand';
toggle.setAttribute('data-hop', i);
toggle.addEventListener('click', function(e) {
e.stopPropagation();
hopList[i].expanded = !hopList[i].expanded;
renderHops();
});
header.appendChild(toggle);
var removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'hop-remove';
removeBtn.textContent = 'remove';
removeBtn.setAttribute('data-hop', i);
removeBtn.addEventListener('click', function(e) {
e.stopPropagation();
hopList.splice(i, 1);
renderHops();
updateFlowDiagram();
});
header.appendChild(removeBtn);
card.appendChild(header);
// Body (collapsible)
var body = document.createElement('div');
body.className = 'hop-card-body' + (hop.expanded ? ' expanded' : '');
body.innerHTML =
'<label>SSH Port<input type="number" data-hop="' + i + '" data-field="port" value="' + (hop.port || 22) + '"></label>' +
'<label>Username<input type="text" data-hop="' + i + '" data-field="username" value="' + escapeAttr(hop.username || '') + '"></label>' +
'<label>Password<input type="password" data-hop="' + i + '" data-field="password" value="' + escapeAttr(hop.password || '') + '"></label>' +
'<label>Private key (PEM)<textarea data-hop="' + i + '" data-field="private_key" rows="2" style="resize:vertical;font-size:0.85em" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----">' + escapeHtml(hop.private_key || '') + '</textarea></label>';
var inputs = body.querySelectorAll('input, textarea');
for (var j = 0; j < inputs.length; j++) {
inputs[j].addEventListener('input', onHopFieldChange);
}
card.appendChild(body);
container.appendChild(card);
});
updateFlowDiagram();
}
function onHopFieldChange(e) {
var idx = parseInt(e.target.getAttribute('data-hop'));
var field = e.target.getAttribute('data-field');
if (field === 'port') {
hopList[idx][field] = parseInt(e.target.value) || 22;
} else {
hopList[idx][field] = e.target.value;
}
if (field === 'hostname') updateFlowDiagram();
}
function updateFlowDiagram() {
var diag = document.getElementById('flow-diagram');
if (!diag) return;
var html = '<span class="flow-node flow-node-you">You</span>';
hopList.forEach(function(hop) {
var label = hop.hostname || '???';
html += '<span class="flow-arrow">&rarr;</span><span class="flow-node flow-node-hop">' + escapeHtml(label) + '</span>';
});
// Add target
var type = sessionType.value;
var targetHost = '';
var targetPort = '';
var typeLabel = type.toUpperCase();
if (type === 'ssh') {
targetHost = document.getElementById('hostname').value || '???';
targetPort = document.getElementById('port').value || '22';
} else if (type === 'rdp') {
targetHost = document.getElementById('rdp-hostname').value || '???';
targetPort = document.getElementById('rdp-port').value || '3389';
} else if (type === 'vnc') {
targetHost = document.getElementById('vnc-hostname').value || '???';
targetPort = document.getElementById('vnc-port').value || '5900';
} else if (type === 'web') {
// Parse host:port from URL
try {
var u = new URL(document.getElementById('url').value);
targetHost = u.hostname || '???';
targetPort = u.port || (u.protocol === 'https:' ? '443' : '80');
} catch(e) {
targetHost = '???';
targetPort = '80';
}
}
if (targetHost) {
html += '<span class="flow-arrow">&rarr;</span><span class="flow-node flow-node-target">' + escapeHtml(targetHost) + ':' + escapeHtml(targetPort) + ' ' + typeLabel + '</span>';
}
// Show URL rewrite warning for web sessions with hops
var warn = '';
if (type === 'web' && hopList.length > 0) {
warn = '<div style="color:var(--primary);font-size:0.8em;margin-top:0.4em">Note: The URL will be rewritten to 127.0.0.1:{tunnel_port} inside the headless browser. TLS certificate errors are expected if the target uses HTTPS.</div>';
}
diag.innerHTML = hopList.length > 0 ? html + warn : '';
}
document.getElementById('add-hop-btn').addEventListener('click', function() {
hopList.push({ hostname: '', port: 22, username: '', password: '', private_key: '', expanded: true });
renderHops();
});
form.addEventListener('submit', function(e) {
e.preventDefault();
errorEl.textContent = '';
btn.disabled = true;
btn.textContent = 'Connecting...';
var body = {};
if (sessionType.value === 'vdi') {
body.session_type = 'vdi';
body.container_image = document.getElementById('vdi-image').value;
} else if (sessionType.value === 'web') {
body.session_type = 'web';
body.url = document.getElementById('url').value;
} else if (sessionType.value === 'vnc') {
body.session_type = 'vnc';
body.hostname = document.getElementById('vnc-hostname').value;
body.port = parseInt(document.getElementById('vnc-port').value) || 5900;
var un = document.getElementById('vnc-username').value;
if (un) body.username = un;
var pw = document.getElementById('vnc-password').value;
if (pw) body.password = pw;
} else if (sessionType.value === 'rdp') {
body.session_type = 'rdp';
body.hostname = document.getElementById('rdp-hostname').value;
body.port = parseInt(document.getElementById('rdp-port').value) || 3389;
var un = document.getElementById('rdp-username').value;
if (un) body.username = un;
var pw = document.getElementById('rdp-password').value;
if (pw) body.password = pw;
var dom = document.getElementById('rdp-domain').value;
if (dom) body.domain = dom;
var sec = document.getElementById('rdp-security').value;
if (sec) body.security = sec;
if (document.getElementById('rdp-ignore-cert').checked) body.ignore_cert = true;
} else {
body.session_type = 'ssh';
body.hostname = document.getElementById('hostname').value;
body.port = parseInt(document.getElementById('port').value) || 22;
var un = document.getElementById('username').value;
if (un) body.username = un;
if (document.getElementById('generate-keypair').checked) {
body.generate_keypair = true;
} else {
var pw = document.getElementById('password').value;
if (pw) body.password = pw;
var pk = document.getElementById('private-key').value.trim();
if (pk) body.private_key = pk;
}
}
// Jump hosts (multi-hop, all session types)
var hops = hopList.filter(function(h) { return h.hostname && h.hostname.trim(); })
.map(function(h) {
var hop = { hostname: h.hostname.trim(), port: h.port || 22, username: h.username || '' };
if (h.password) hop.password = h.password;
if (h.private_key) hop.private_key = h.private_key;
return hop;
});
if (hops.length > 0) body.jump_hosts = hops;
var banner = document.getElementById('banner').value;
if (banner) body.banner = banner;
// Send browser dimensions so RDP sessions start at correct resolution
var bw = window.innerWidth, bh = window.innerHeight;
if (bw > 0 && bh > 0) {
body.width = bw;
body.height = bh;
body.dpi = Math.round((window.devicePixelRatio || 1) * 96);
}
fetch('/api/sessions', {
method: 'POST',
headers: apiHeaders({ 'Content-Type': 'application/json' }),
credentials: 'same-origin',
body: JSON.stringify(body),
})
.then(function(res) {
if (!res.ok) return res.text().then(function(t) { throw new Error(t); });
return res.json();
})
.then(function(data) {
window.open(data.client_url, '_blank');
hopList = [];
renderHops();
refreshSessions();
})
.catch(function(err) {
errorEl.textContent = err.message;
})
.finally(function() {
btn.disabled = false;
btn.textContent = 'Connect';
});
});
function h(tag, text, attrs) {
var el = document.createElement(tag);
if (text != null) el.textContent = text;
@@ -735,7 +271,7 @@
empty.style.display = '';
empty.textContent = myRole === 'admin'
? 'No active sessions.'
: 'No active sessions. Use Connections to connect to a saved entry, or the form above to start an ad-hoc session.';
: 'No active sessions. Use Connections to connect to a saved entry, or Connect to a host for an ad-hoc connection.';
} else {
empty.style.display = 'none';
}
+4 -2
View File
@@ -28,13 +28,15 @@ One spec per change, named after the branch that introduced it:
| `connections-folder-filter` | #254: live filter box, ARIA tree, arrow keys, `f` shortcut |
| `connections-app-shell` | #254: clipped body, two scroll panes, folder change scrolls the pane not the page |
| `role-nav` | one nav on every page, gated by `data-role-level` |
| `sessions-adhoc-modal` | ad-hoc form opens as a modal |
| `sessions-adhoc-modal` | #295: Sessions has no ad-hoc form or button of its own; the global button there opens the editor on Connections |
| `adhoc-button` | #295: search.js's Connect to a host beside the box on every search page, for poweruser and admin, in place on Connections and via `#adhoc` elsewhere; never for operator, viewer, signed out or on the login page; gone on expiry and logout; shown without Vault; API key confirmed first |
| `facet-filters` | shared facets narrow lists, persist, combine with search |
| `folder-display-name` | display name over the key, modal field round-trips |
| `themed-dialogs` | `rgConfirm`/`rgAlert` replace native dialogs |
| `global-search` | `/` opens the overlay on every signed-in page, folder view untouched; nothing on the login page, for anonymous or viewer callers, or from a previous login's cache |
| `search-under-modals` | a page modal covers the search box |
| `search-under-modals` | a page modal (the ad-hoc editor included) covers the search box |
| `zero-click-fullscreen` | #257: same-tab iframe sized from the measured viewport |
| `connections-adhoc` | #295: Connect to a host opens the entry editor in ad-hoc mode (no name, Connect for Save, entry-only options hidden for non-admins), offers the caller's credential variable names, posts to `/api/addressbook/adhoc/connect` and never `/api/sessions`; ephemeral keypair; missing variable named; admin Save as entry keeps `$var` references; works without Vault and from `#adhoc`; no button for operators |
| `recordings-mp4` | #241: the mp4 button converts, shows progress and downloads; refused while live; hidden without server support |
The fixture suppresses the first-visit onboarding tour (`options.onboarding`
+7
View File
@@ -182,6 +182,13 @@ export async function installApi(page, options = {}) {
session_id: id, client_url: `/client.html?session=${id}`, ws_url: `/ws/${id}`, session_type: 'rdp'
}));
}
if (path === '/api/addressbook/adhoc/connect' && method === 'POST') {
const id = '44444444-4444-4444-8444-444444444444';
return route.fulfill(json({
session_id: id, client_url: `/client.html?session=${id}`, ws_url: `/ws/${id}`,
session_type: body?.entry?.type ?? 'ssh'
}, 201));
}
if (path.startsWith('/api/addressbook/') && method !== 'GET') {
state.version += 1;
return route.fulfill(json({ ok: true }));
+113
View File
@@ -0,0 +1,113 @@
// #295: "Connect to a host" is search.js's, beside the global search box on
// every page that has one, with the box's own rules plus the server's
// can_connect_adhoc: poweruser and above. On Connections it opens the ad-hoc
// editor in place; anywhere else it goes to /connections.html#adhoc.
import { test, expect } from '@playwright/test';
import { installApi, settle } from '../lib/api.mjs';
const GONE = { status: 401, contentType: 'application/json', body: '{"error":"not authenticated"}' };
for (const role of ['poweruser', 'admin']) {
for (const path of ['/sessions.html', '/recordings.html', '/reports.html']) {
test(`${role} on ${path}: the button sits beside the box and opens the ad-hoc editor`, async ({ page }) => {
await installApi(page, { role });
await page.goto(path);
await settle(page);
const btn = page.locator('.brand-row #rg-adhoc');
await expect(btn).toBeVisible();
await expect(page.locator('.brand-row #rg-search')).toBeVisible();
// Beside the box, on its right, vertically centred with it.
const [b, s] = await Promise.all([btn.boundingBox(), page.locator('#rg-search').boundingBox()]);
expect(b.x).toBeGreaterThan(s.x + s.width);
expect(Math.abs((b.y + b.height / 2) - (s.y + s.height / 2))).toBeLessThanOrEqual(2);
await btn.click();
await page.waitForURL(/\/connections\.html/);
await settle(page);
await expect(page.locator('#entry-modal')).toHaveClass(/adhoc/);
await expect(page.locator('#entry-modal')).toHaveClass(/active/);
});
}
}
test('on Connections the button opens the editor in place', async ({ page }) => {
await installApi(page, { role: 'poweruser' });
await page.goto('/connections.html');
await settle(page);
const before = page.url();
await page.locator('#rg-adhoc').click();
await expect(page.locator('#entry-modal')).toHaveClass(/adhoc/);
expect(page.url()).toBe(before);
// The page itself has no button of its own: there is one.
await expect(page.locator('#rg-adhoc')).toHaveCount(1);
await expect(page.locator('#btn-adhoc')).toHaveCount(0);
});
for (const role of ['operator', 'viewer']) {
test(`no button for ${role}`, async ({ page }) => {
await installApi(page, { role });
for (const path of ['/connections.html', '/recordings.html']) {
await page.goto(path);
await settle(page);
await expect(page.locator('#rg-adhoc')).toHaveCount(0);
}
});
}
test('no button signed out, on the login page or on a page', async ({ page }) => {
await installApi(page, { routes: { 'GET /api/me': GONE } });
await page.goto('/');
await settle(page);
await expect(page.locator('#rg-adhoc')).toHaveCount(0);
await page.goto('/recordings.html');
await settle(page);
await expect(page.locator('#rg-adhoc')).toHaveCount(0);
});
test('without Vault a poweruser still gets the button', async ({ page }) => {
await installApi(page, { role: 'poweruser', routes: {
'GET /api/me': { name: 'pu@example.com', role: 'poweruser', groups: [], vault_enabled: false, vault_configured: false },
'GET /api/addressbook/search-index': { status: 404, contentType: 'application/json', body: '{"error":"vault not configured"}' }
} });
await page.goto('/recordings.html');
await settle(page);
await expect(page.locator('#rg-adhoc')).toBeVisible();
});
test('the button goes when the session expires, and after logout', async ({ page }) => {
let expired = false;
await installApi(page, { role: 'poweruser', routes: {
'GET /api/addressbook/version': (req, url, state) => expired ? GONE : { version: state.version, changed_at: 0 }
} });
await page.clock.install();
await page.goto('/sessions.html');
await settle(page);
await expect(page.locator('#rg-adhoc')).toBeVisible();
expired = true;
await page.clock.runFor(31_000);
await expect(page.locator('#rg-adhoc')).toHaveCount(0);
await expect(page.locator('#rg-search')).toHaveCount(0);
});
test('logging out leaves no button on the login page', async ({ page }) => {
let signedIn = true;
await installApi(page, { routes: { 'GET /api/me': () => signedIn
? { name: 'pu@example.com', role: 'poweruser', groups: [], vault_enabled: true, vault_configured: true }
: GONE } });
await page.goto('/recordings.html');
await settle(page);
await expect(page.locator('#rg-adhoc')).toBeVisible();
signedIn = false;
await page.locator('#logout-item').click();
await page.waitForURL(/\/(index\.html)?$/);
await settle(page);
await expect(page.locator('#rg-adhoc')).toHaveCount(0);
});
test('an API key is confirmed through /api/me before the button shows', async ({ page }) => {
await page.addInitScript(() => { try { sessionStorage.setItem('rustguac_api_key', 'k'); } catch (e) {} });
const api = await installApi(page, { role: 'admin' });
await page.goto('/recordings.html');
await settle(page);
await expect(page.locator('#rg-adhoc')).toBeVisible();
expect(api.calls.some(c => c.path === '/api/me')).toBe(true);
});
+215
View File
@@ -0,0 +1,215 @@
// #295: ad-hoc connections use the Connections entry editor. "Connect to a
// host" (search.js, beside the search box) opens it here with no name or
// folder and Connect in place of Save;
// it sends the entry to POST /api/addressbook/adhoc/connect, never to
// /api/sessions. Non-admins do not see the options only a saved entry may
// set; admins can save what they filled in as an entry.
import { test, expect } from '@playwright/test';
import { installApi, settle } from '../lib/api.mjs';
const MY_CREDS = {
credentials: {
my_ssh_key: { masked: '****', scope: 'local' },
my_user: { masked: 'a***', scope: 'local' }
},
creds_split: false,
default_scope: 'local'
};
function adhocCalls(api) {
return api.calls.filter(c => c.method === 'POST' && c.path === '/api/addressbook/adhoc/connect');
}
async function openEditor(page) {
await page.locator('#rg-adhoc').click();
const modal = page.locator('#entry-modal');
await expect(modal).toHaveClass(/active/);
await expect(modal).toHaveClass(/adhoc/);
return modal;
}
test('a poweruser connects to a host with one of their own credential variables', async ({ page }) => {
const api = await installApi(page, { role: 'poweruser', routes: { 'GET /api/me/credentials': MY_CREDS } });
await page.goto('/connections.html');
await settle(page);
await expect(page.locator('#rg-adhoc')).toBeVisible();
const modal = await openEditor(page);
await expect(page.locator('#entry-modal-title')).toHaveText('Connect to a host');
// No name, display name or Save; Connect instead.
await expect(page.locator('#em-name')).toBeHidden();
await expect(page.locator('#em-display-name')).toBeHidden();
await expect(page.locator('#em-save')).toBeHidden();
await expect(page.locator('#em-connect')).toBeVisible();
await expect(page.locator('#em-saveas')).toBeHidden();
// Entry-only options are not offered to a poweruser.
await expect(page.locator('#em-ssh-prompt-creds')).toBeHidden();
await expect(page.locator('#em-allow-sharing')).toBeHidden();
await expect(page.locator('#em-auto-open-singleton')).toBeHidden();
await expect(page.locator('#em-wol-section')).toBeHidden();
await page.locator('#em-recording-toggle').click();
await page.locator('#em-override-recording').check();
await expect(page.locator('#em-enable-recording')).toBeVisible();
await expect(page.locator('#em-max-recordings')).toBeHidden();
await expect(page.locator('#em-typescript-row')).toBeHidden();
// What a caller may choose stays, including options the old form lacked.
await expect(page.locator('#em-ssh-font-size')).toBeVisible();
await expect(page.locator('#em-disable-copy')).toBeVisible();
await expect(page.locator('#em-ssh-generate-keypair')).toBeVisible();
await page.locator('#em-type').selectOption('vdi');
await expect(page.locator('#em-vdi-cpu')).toBeVisible();
await expect(page.locator('#em-vdi-container-username')).toBeHidden();
await page.locator('#em-type').selectOption('spice');
await expect(page.locator('#em-spice-hostname')).toBeVisible();
await expect(page.locator('#em-spice-prompt-creds')).toBeHidden();
await page.locator('#em-type').selectOption('ssh');
// The caller's variable names are offered: a picker for the key, a
// datalist for the username. Values never reach the page.
await expect(page.locator('#em-cred-vars option')).toHaveCount(2);
await expect(page.locator('#em-username')).toHaveAttribute('list', 'em-cred-vars');
const keyPick = page.locator('select.var-pick[data-for="em-private-key"]');
await expect(keyPick).toBeVisible();
await keyPick.selectOption('my_ssh_key');
await expect(page.locator('#em-private-key')).toHaveValue('$my_ssh_key');
await page.locator('#em-hostname').fill('10.0.0.5');
await page.locator('#em-username').fill('$my_user');
await page.locator('#em-ssh-font-size').fill('16');
const [popup] = await Promise.all([
page.waitForEvent('popup'),
page.locator('#em-connect').click()
]);
await expect.poll(() => adhocCalls(api).length).toBe(1);
const sent = adhocCalls(api)[0].body;
expect(sent.entry).toMatchObject({
type: 'ssh', hostname: '10.0.0.5', port: 22, username: '$my_user',
private_key: '$my_ssh_key', ssh_font_size: 16
});
expect(sent.entry.name).toBeUndefined();
expect(sent.width).toBe(page.viewportSize().width);
expect(sent.dpi).toBeGreaterThan(0);
expect(api.calls.some(c => c.method === 'POST' && c.path === '/api/sessions')).toBe(false);
await expect.poll(() => popup.url()).toContain('/client.html?session=4444');
await expect(modal).not.toHaveClass(/active/);
await popup.close();
});
test('the ephemeral keypair replaces the password and key', async ({ page }) => {
const api = await installApi(page, { role: 'poweruser' });
await page.goto('/connections.html');
await settle(page);
await openEditor(page);
await page.locator('#em-hostname').fill('10.0.0.6');
await page.locator('#em-password').fill('typed');
await page.locator('#em-ssh-generate-keypair').check();
await expect(page.locator('#em-password')).toBeDisabled();
await expect(page.locator('#em-private-key')).toBeDisabled();
const [popup] = await Promise.all([
page.waitForEvent('popup'),
page.locator('#em-connect').click()
]);
await expect.poll(() => adhocCalls(api).length).toBe(1);
const sent = adhocCalls(api)[0].body;
expect(sent.generate_keypair).toBe(true);
expect(sent.entry.password).toBeUndefined();
expect(sent.entry.private_key).toBeUndefined();
await popup.close();
});
test('a missing credential variable is named and nothing opens', async ({ page }) => {
await installApi(page, {
role: 'poweruser',
routes: {
'POST /api/addressbook/adhoc/connect': {
status: 412, contentType: 'application/json',
body: JSON.stringify({ error: 'missing credential variables', missing_variables: ['nope'] })
}
}
});
await page.goto('/connections.html');
await settle(page);
await openEditor(page);
await page.locator('#em-hostname').fill('10.0.0.7');
await page.locator('#em-password').fill('$nope');
await page.locator('#em-connect').click();
await expect(page.locator('#em-error')).toContainText('$nope');
await expect(page.locator('#entry-modal')).toHaveClass(/active/);
});
test('an admin sees every option and can save the connection as an entry', async ({ page }) => {
const api = await installApi(page, { role: 'admin', routes: { 'GET /api/me/credentials': MY_CREDS } });
await page.goto('/connections.html');
await settle(page);
await openEditor(page);
await expect(page.locator('#em-ssh-prompt-creds')).toBeVisible();
await expect(page.locator('#em-allow-sharing')).toBeVisible();
await expect(page.locator('#em-wol-section')).toBeVisible();
await expect(page.locator('#em-name')).toBeHidden();
await expect(page.locator('#em-saveas')).toBeVisible();
await page.locator('#em-hostname').fill('10.0.0.8');
await page.locator('select.var-pick[data-for="em-private-key"]').selectOption('my_ssh_key');
await page.locator('#em-saveas-folder').selectOption('shared|beta');
await page.locator('#em-saveas-name').fill('bad name');
await page.locator('#em-saveas').click();
await expect(page.locator('#em-error')).toContainText('Name must be');
await page.locator('#em-saveas-name').fill('db02');
await page.locator('#em-saveas').click();
const create = () => api.calls.find(c => c.method === 'POST' && c.path === '/api/addressbook/folders/shared/beta/entries');
await expect.poll(create).toBeTruthy();
// The reference is saved, not a value.
expect(create().body).toMatchObject({ name: 'db02', type: 'ssh', hostname: '10.0.0.8', private_key: '$my_ssh_key' });
await expect(page.locator('#entry-modal')).not.toHaveClass(/active/);
expect(adhocCalls(api)).toHaveLength(0);
});
test('the normal entry editor is unchanged after an ad-hoc connection', async ({ page }) => {
await installApi(page, { role: 'admin' });
await page.goto('/connections.html');
await settle(page);
await openEditor(page);
await page.locator('#em-cancel').click();
await page.locator('.folder-row').filter({ hasText: /acme/ }).first().click();
await page.locator('#btn-new-entry').click();
const modal = page.locator('#entry-modal');
await expect(modal).toHaveClass(/active/);
await expect(modal).not.toHaveClass(/adhoc/);
await expect(page.locator('#em-name')).toBeVisible();
await expect(page.locator('#em-save')).toBeVisible();
await expect(page.locator('#em-connect')).toBeHidden();
await expect(page.locator('#em-ssh-generate-keypair')).toBeHidden();
await expect(page.locator('#em-username')).not.toHaveAttribute('list', /.+/);
});
test('without Vault the button and #adhoc still work', async ({ page }) => {
const api = await installApi(page, {
role: 'poweruser',
routes: {
'GET /api/me': { name: 'pu@example.com', role: 'poweruser', groups: [], vault_enabled: false, vault_configured: false }
}
});
await page.goto('/connections.html#adhoc');
await settle(page);
await expect(page.locator('#no-vault')).toBeVisible();
await expect(page.locator('#rg-adhoc')).toBeVisible();
await expect(page.locator('#entry-modal')).toHaveClass(/adhoc/);
await expect(page.locator('#entry-modal')).toHaveClass(/active/);
await expect(page.locator('#em-saveas')).toBeHidden();
// No variables to suggest, so the pickers stay away.
await expect(page.locator('select.var-pick[data-for="em-private-key"]')).toBeHidden();
expect(api.calls.some(c => c.path === '/api/me/credentials')).toBe(false);
expect(new URL(page.url()).hash).toBe('');
});
test('an operator gets no button and #adhoc opens nothing', async ({ page }) => {
await installApi(page, { role: 'operator' });
await page.goto('/connections.html#adhoc');
await settle(page);
await expect(page.locator('#rg-adhoc')).toBeHidden();
await expect(page.locator('#entry-modal')).not.toHaveClass(/active/);
});
+2 -2
View File
@@ -19,9 +19,9 @@ test('sessions: the Type facet narrows the list and the count follows', async ({
await settle(page);
const rows = page.locator('#session-list tr');
await expect(rows).toHaveCount(2);
// Every list page shares the toolbar: search box, facets, action on the right.
// Every list page shares the toolbar: search box and facets. Sessions has
// no action of its own since ad-hoc moved to the global button (#295).
await expect(page.locator('#sessions .list-tools .list-search')).toBeVisible();
await expect(page.locator('#sessions .list-tools #new-session-btn')).toBeVisible();
const menu = await openFacet(page, '#session-filters', 'Type');
const ssh = menu.locator('.rg-facet-opt').filter({ hasText: /ssh/i }).first();
+9 -7
View File
@@ -1,5 +1,5 @@
// The global search lifts the brand row above its own results overlay. A
// page modal (the ad-hoc session dialog, the folder and entry dialogs) must
// page modal (the ad-hoc connection editor, the folder and entry dialogs) must
// still cover the search box: nothing from the search chrome may sit on top
// of an open modal.
import { test, expect } from '@playwright/test';
@@ -13,16 +13,18 @@ async function topElementAt(page, locator) {
}, [box.x + box.width / 2, box.y + box.height / 2]);
}
test('the ad-hoc session modal covers the global search box', async ({ page }) => {
test('the ad-hoc connection editor covers the global search box', async ({ page }) => {
await installApi(page, { role: 'poweruser' });
await page.goto('/sessions.html');
await page.goto('/connections.html');
await settle(page);
const search = page.locator('#rg-search');
await expect(search).toBeVisible();
await page.locator('#new-session-btn').click();
await expect(page.locator('#adhoc-modal')).toHaveClass(/active/);
const top = await topElementAt(page, search);
expect(top.closestModal, `the modal overlay must be on top, found ${top.tag}#${top.id}`).toBe(true);
await page.locator('#rg-adhoc').click();
await expect(page.locator('#entry-modal')).toHaveClass(/active/);
for (const el of [search, page.locator('#rg-adhoc')]) {
const top = await topElementAt(page, el);
expect(top.closestModal, `the modal overlay must be on top, found ${top.tag}#${top.id}`).toBe(true);
}
// And the search box cannot be typed into while the modal is open.
await page.keyboard.press('/');
await expect(search).not.toBeFocused();
+15 -26
View File
@@ -1,35 +1,24 @@
// The ad-hoc session form lives in a modal opened from the button on the
// Sessions heading; Cancel and Connect share a height.
// Ad-hoc connections moved out of Sessions (#295): the old form and its
// button are gone, and the global "Connect to a host" button beside the
// search box takes a poweruser from here to the ad-hoc editor on
// Connections.
import { test, expect } from '@playwright/test';
import { installApi, settle } from '../lib/api.mjs';
test('the ad-hoc form opens as a modal and Cancel closes it', async ({ page }) => {
test('Sessions has no ad-hoc form; the global button opens the editor on Connections', async ({ page }) => {
await installApi(page, { role: 'poweruser' });
await page.goto('/sessions.html');
await settle(page);
const modal = page.locator('#adhoc-modal');
await expect(modal).not.toHaveClass(/active/);
const btn = page.locator('#new-session-btn');
await expect(btn).toBeVisible();
await btn.click();
await expect(modal).toHaveClass(/active/);
await expect(modal).toHaveAttribute('role', 'dialog');
await expect(page.locator('#adhoc-modal-title')).toBeVisible();
await expect(page.locator('#session-form')).toBeVisible();
await expect(page.locator('#adhoc-modal')).toHaveCount(0);
await expect(page.locator('#session-form')).toHaveCount(0);
await expect(page.locator('#new-session-btn')).toHaveCount(0);
const [ok, cancel] = await Promise.all([
page.locator('#connect-btn').boundingBox(),
page.locator('#adhoc-cancel').boundingBox()
]);
expect(Math.abs(ok.height - cancel.height), 'Connect and Cancel must be the same height').toBeLessThanOrEqual(1);
await page.locator('#adhoc-cancel').click();
await expect(modal).not.toHaveClass(/active/);
});
test('a viewer gets no ad-hoc button', async ({ page }) => {
await installApi(page, { role: 'viewer' });
await page.goto('/sessions.html');
await page.locator('#rg-adhoc').click();
await page.waitForURL(/\/connections\.html/);
await settle(page);
await expect(page.locator('#new-session-btn')).toBeHidden();
const modal = page.locator('#entry-modal');
await expect(modal).toHaveClass(/active/);
await expect(modal).toHaveClass(/adhoc/);
await expect(page.locator('#em-connect')).toBeVisible();
expect(new URL(page.url()).hash).toBe('');
});