From d75446f040ea16cc9fec8b153ea9ca91f472bc3e Mon Sep 17 00:00:00 2001 From: Jad Seifeddine Date: Mon, 19 Oct 2026 10:33:56 +0700 Subject: [PATCH] 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 dd40bf08ec88dd0a8384d12903a890a2b162c703) --- docs/api.md | 36 +- docs/configuration.md | 4 +- docs/credential-variables.md | 6 + docs/installation.md | 2 +- docs/integrations.md | 6 +- docs/overview.md | 4 +- docs/roles-and-access-control.md | 9 +- docs/security.md | 6 +- src/api.rs | 1327 ++++++++++++++--- src/main.rs | 8 +- static/connections.html | 348 ++++- static/rustguac.css | 22 +- static/search.js | 35 +- static/sessions.html | 472 +----- tests/e2e/README.md | 6 +- tests/e2e/lib/api.mjs | 7 + tests/e2e/specs/adhoc-button.spec.mjs | 113 ++ tests/e2e/specs/connections-adhoc.spec.mjs | 215 +++ tests/e2e/specs/facet-filters.spec.mjs | 4 +- tests/e2e/specs/search-under-modals.spec.mjs | 16 +- tests/e2e/specs/sessions-adhoc-modal.spec.mjs | 41 +- 21 files changed, 1902 insertions(+), 785 deletions(-) create mode 100644 tests/e2e/specs/adhoc-button.spec.mjs create mode 100644 tests/e2e/specs/connections-adhoc.spec.mjs diff --git a/docs/api.md b/docs/api.md index 2603564..545dee0 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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. diff --git a/docs/configuration.md b/docs/configuration.md index d833813..f326014 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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` diff --git a/docs/credential-variables.md b/docs/credential-variables.md index 7ebca1b..f8d0ff2 100644 --- a/docs/credential-variables.md +++ b/docs/credential-variables.md @@ -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 diff --git a/docs/installation.md b/docs/installation.md index 38179f9..234977a 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -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: diff --git a/docs/integrations.md b/docs/integrations.md index 8c111b2..49cdae4 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -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 diff --git a/docs/overview.md b/docs/overview.md index 3be7052..86e2712 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -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. diff --git a/docs/roles-and-access-control.md b/docs/roles-and-access-control.md index cf83925..b0c817d 100644 --- a/docs/roles-and-access-control.md +++ b/docs/roles-and-access-control.md @@ -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 | diff --git a/docs/security.md b/docs/security.md index 4d25ed2..d0f4563 100644 --- a/docs/security.md +++ b/docs/security.md @@ -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. diff --git a/src/api.rs b/src/api.rs index cc10ebc..94a11f4 100644 --- a/src/api.rs +++ b/src/api.rs @@ -42,7 +42,7 @@ pub async fn create_session( // Require poweruser+ role for ad-hoc session creation if let Some(ref id) = identity { - if !id.has_role("poweruser") { + if !can_connect_adhoc(id) { return ( StatusCode::FORBIDDEN, Json(json!({"error": "insufficient permissions — poweruser role required for ad-hoc sessions"})), @@ -77,7 +77,50 @@ pub async fn create_session( let proxies = trusted.map(|Extension(t)| t.0).unwrap_or_default(); let client_ip = client_ip(&headers, addr.ip(), &proxies); - let target = match req.session_type { + let target = session_target(&req); + + tracing::info!( + admin = %admin_name, + caller = %caller_name, + client_ip = %client_ip, + session_type = ?req.session_type, + target = %target, + "Session creation requested" + ); + + match manager.create_session(req, admin_name.clone()).await { + Ok(info) => { + tracing::info!( + admin = %admin_name, + client_ip = %client_ip, + session_id = %info.session_id, + session_type = ?info.session_type, + target = %target, + "Session created successfully" + ); + (StatusCode::CREATED, Json(json!(info))).into_response() + } + Err(e) => { + let msg = e.to_string(); + tracing::error!( + admin = %admin_name, + client_ip = %client_ip, + target = %target, + error = %msg, + "Session creation failed" + ); + let status = match &e { + crate::session::SessionError::ValidationError(_) => StatusCode::BAD_REQUEST, + _ => StatusCode::BAD_GATEWAY, + }; + (status, Json(json!({ "error": msg }))).into_response() + } + } +} + +/// A short description of what a session request connects to, for the log. +fn session_target(req: &CreateSessionRequest) -> String { + match req.session_type { crate::session::SessionType::Ssh => { format!( "{}:{}", @@ -119,44 +162,6 @@ pub async fn create_session( crate::session::SessionType::Vdi => { req.container_image.as_deref().unwrap_or("?").to_string() } - }; - - tracing::info!( - admin = %admin_name, - caller = %caller_name, - client_ip = %client_ip, - session_type = ?req.session_type, - target = %target, - "Session creation requested" - ); - - match manager.create_session(req, admin_name.clone()).await { - Ok(info) => { - tracing::info!( - admin = %admin_name, - client_ip = %client_ip, - session_id = %info.session_id, - session_type = ?info.session_type, - target = %target, - "Session created successfully" - ); - (StatusCode::CREATED, Json(json!(info))).into_response() - } - Err(e) => { - let msg = e.to_string(); - tracing::error!( - admin = %admin_name, - client_ip = %client_ip, - target = %target, - error = %msg, - "Session creation failed" - ); - let status = match &e { - crate::session::SessionError::ValidationError(_) => StatusCode::BAD_REQUEST, - _ => StatusCode::BAD_GATEWAY, - }; - (status, Json(json!({ "error": msg }))).into_response() - } } } @@ -3784,9 +3789,9 @@ pub async fn ssh_probe_host_key( identity: Option>, axum::Json(body): axum::Json, ) -> impl IntoResponse { - // Require admin or poweruser + // Used while filling in an entry or an ad-hoc connection. match &identity { - Some(Extension(id)) if id.has_role("poweruser") => {} + Some(Extension(id)) if id.has_role("admin") || can_connect_adhoc(id) => {} _ => { return ( StatusCode::FORBIDDEN, @@ -3912,6 +3917,173 @@ fn round_down_mod16(v: u32) -> u32 { } } +/// The session type an entry's `type` names, or `None` for an unknown one. +fn entry_session_type(entry_type: &str) -> Option { + Some(match entry_type { + "ssh" => SessionType::Ssh, + "rdp" => SessionType::Rdp, + "vnc" => SessionType::Vnc, + "spice" => SessionType::Spice, + "proxmox" => SessionType::Proxmox, + "web" => SessionType::Web, + "vdi" => SessionType::Vdi, + _ => return None, + }) +} + +/// How the browser that asked will draw the session: never part of an entry. +pub struct EntryDisplay { + pub width: Option, + pub height: Option, + pub dpi: Option, +} + +/// The saved entry a session comes from, for its recording and reports. +pub struct EntryRef { + /// `scope/folder/entry`. + pub key: String, + pub folder: String, +} + +/// The session request a connection entry describes. The one conversion +/// used by the Connections connect endpoint, `/api/connect` links and +/// ad-hoc connections, so an option added to entries works in all three. +/// +/// `attribution` names the saved entry the session belongs to; an ad-hoc +/// connection has none, and its entry's display name is then not used +/// either. The request is returned unfenced and unfiltered: an ad-hoc +/// caller's request still has to go through +/// `crate::session::apply_adhoc_policy`. +pub fn session_request_from_entry( + entry: AddressBookEntry, + display: EntryDisplay, + attribution: Option, +) -> Result { + let session_type = entry_session_type(&entry.session_type) + .ok_or_else(|| format!("unknown session type: {}", entry.session_type))?; + let (address_book_entry, address_book_folder, entry_display_name) = match attribution { + Some(r) => (Some(r.key), Some(r.folder), entry.display_name), + None => (None, None, None), + }; + Ok(CreateSessionRequest { + session_type, + hostname: entry.hostname, + port: entry.port, + username: entry.username, + password: entry.password, + private_key: entry.private_key, + generate_keypair: None, + url: entry.url, + domain: entry.domain, + security: entry.security, + server_layout: entry.server_layout, + ignore_cert: entry.ignore_cert, + auth_pkg: entry.auth_pkg, + kdc_url: entry.kdc_url, + kerberos_cache: None, + color_depth: entry.color_depth, + jump_hosts: entry.jump_hosts, + jump_host: None, + jump_port: None, + jump_username: None, + jump_password: None, + jump_private_key: None, + width: display.width, + height: display.height, + dpi: display.dpi, + banner: entry.banner, + enable_drive: entry.enable_drive, + remote_app: entry.remote_app, + remote_app_dir: entry.remote_app_dir, + remote_app_args: entry.remote_app_args, + enable_recording: entry.enable_recording, + record_typescript: entry.record_typescript, + address_book_entry, + address_book_folder, + entry_display_name, + max_recordings: entry.max_recordings, + login_script: entry.login_script, + autofill: entry.autofill, + allowed_domains: entry.allowed_domains, + disable_copy: entry.disable_copy, + disable_paste: entry.disable_paste, + enable_gfx: entry.enable_gfx, + enable_desktop_composition: entry.enable_desktop_composition, + enable_wallpaper: entry.enable_wallpaper, + enable_theming: entry.enable_theming, + enable_full_window_drag: entry.enable_full_window_drag, + force_lossless: entry.force_lossless, + enable_h264: entry.enable_h264, + container_image: entry.container_image, + container_cpu_limit: entry.container_cpu_limit, + container_memory_limit: entry.container_memory_limit, + container_env: entry.container_env, + container_idle_timeout_mins: entry.container_idle_timeout_mins, + container_username: entry.container_username, + container_password: entry.container_password, + allow_sharing: entry.allow_sharing, + fullscreen_on_connect: entry.fullscreen_on_connect, + autohide_side_tabs: entry.autohide_side_tabs, + spice_tls: entry.spice_tls, + spice_tls_port: entry.spice_tls_port, + spice_ca_cert: entry.spice_ca_cert, + spice_cert_subject: entry.spice_cert_subject, + spice_proxy: entry.spice_proxy, + proxmox_url: entry.proxmox_url, + proxmox_node: entry.proxmox_node, + proxmox_vmid: entry.proxmox_vmid, + proxmox_token_id: entry.proxmox_token_id, + proxmox_token_secret: entry.proxmox_token_secret, + proxmox_verify_tls: entry.proxmox_verify_tls, + max_monitors: entry.max_monitors, + owner: None, + fence_all_targets: false, + ssh_font_size: entry.ssh_font_size, + wol_send_packet: entry.wol_send_packet, + wol_mac_addr: entry.wol_mac_addr, + wol_broadcast_addr: entry.wol_broadcast_addr, + wol_udp_port: entry.wol_udp_port, + wol_wait_time: entry.wol_wait_time, + }) +} + +/// The session request for connecting a saved entry from the Connections +/// page: the entry, with the credentials and banner the user was prompted +/// for (never stored) in place of its own, at the size the page asked for. +fn entry_connect_request( + mut ab_entry: AddressBookEntry, + req: ConnectRequest, + entry_key: &str, + folder: &str, +) -> Result { + ab_entry.username = req.username.or(ab_entry.username); + ab_entry.password = req.password.or(ab_entry.password); + ab_entry.domain = req.domain.or(ab_entry.domain); + ab_entry.banner = req.banner.or(ab_entry.banner); + let (width, height) = initial_session_size( + &ab_entry.session_type, + ab_entry.fullscreen_on_connect.unwrap_or(false), + (req.width, req.height), + (req.screen_width, req.screen_height), + ); + let display = EntryDisplay { + width, + height, + dpi: req.dpi, + }; + let attribution = EntryRef { + key: entry_key.to_string(), + folder: folder.to_string(), + }; + session_request_from_entry(ab_entry, display, Some(attribution)) +} + +/// Whether `id` may open ad-hoc connections: sessions to a target of their +/// own choosing rather than a saved entry. The one place that decides it. +pub fn can_connect_adhoc(id: &AuthIdentity) -> bool { + id.has_role("poweruser") +} + /// POST /api/addressbook/folders/:scope/:folder/entries/:entry/connect — Create session from entry. #[allow(clippy::too_many_arguments)] pub async fn ab_connect_entry( @@ -3995,112 +4167,12 @@ pub async fn ab_connect_entry( ab_entry }; - // Map address book entry type to SessionType - let session_type = match ab_entry.session_type.as_str() { - "ssh" => SessionType::Ssh, - "rdp" => SessionType::Rdp, - "vnc" => SessionType::Vnc, - "spice" => SessionType::Spice, - "proxmox" => SessionType::Proxmox, - "web" => SessionType::Web, - "vdi" => SessionType::Vdi, - other => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": format!("unknown session type: {}", other)})), - ) - .into_response() - } - }; - - // Build CreateSessionRequest from the Vault entry + connect request display params. - // ConnectRequest credentials override address book values (for prompted credentials). let ab_entry_key = format!("{}/{}/{}", scope, folder, entry); - let (initial_width, initial_height) = initial_session_size( - &ab_entry.session_type, - ab_entry.fullscreen_on_connect.unwrap_or(false), - (req.width, req.height), - (req.screen_width, req.screen_height), - ); - let create_req = CreateSessionRequest { - session_type, - hostname: ab_entry.hostname, - port: ab_entry.port, - username: req.username.or(ab_entry.username), - password: req.password.or(ab_entry.password), - private_key: ab_entry.private_key, - generate_keypair: None, - url: ab_entry.url, - domain: req.domain.or(ab_entry.domain), - security: ab_entry.security, - server_layout: ab_entry.server_layout, - ignore_cert: ab_entry.ignore_cert, - auth_pkg: ab_entry.auth_pkg, - kdc_url: ab_entry.kdc_url, - kerberos_cache: None, - color_depth: ab_entry.color_depth, - jump_hosts: ab_entry.jump_hosts, - jump_host: None, - jump_port: None, - jump_username: None, - jump_password: None, - jump_private_key: None, - width: initial_width, - height: initial_height, - dpi: req.dpi, - banner: req.banner.or(ab_entry.banner), - enable_drive: ab_entry.enable_drive, - remote_app: ab_entry.remote_app, - remote_app_dir: ab_entry.remote_app_dir, - remote_app_args: ab_entry.remote_app_args, - enable_recording: ab_entry.enable_recording, - record_typescript: ab_entry.record_typescript, - address_book_entry: Some(ab_entry_key.clone()), - address_book_folder: Some(folder.to_string()), - entry_display_name: ab_entry.display_name.clone(), - max_recordings: ab_entry.max_recordings, - login_script: ab_entry.login_script, - autofill: ab_entry.autofill, - allowed_domains: ab_entry.allowed_domains, - disable_copy: ab_entry.disable_copy, - disable_paste: ab_entry.disable_paste, - enable_gfx: ab_entry.enable_gfx, - enable_desktop_composition: ab_entry.enable_desktop_composition, - enable_wallpaper: ab_entry.enable_wallpaper, - enable_theming: ab_entry.enable_theming, - enable_full_window_drag: ab_entry.enable_full_window_drag, - force_lossless: ab_entry.force_lossless, - enable_h264: ab_entry.enable_h264, - container_image: ab_entry.container_image, - container_cpu_limit: ab_entry.container_cpu_limit, - container_memory_limit: ab_entry.container_memory_limit, - container_env: ab_entry.container_env, - container_idle_timeout_mins: ab_entry.container_idle_timeout_mins, - container_username: ab_entry.container_username, - container_password: ab_entry.container_password, - allow_sharing: ab_entry.allow_sharing, - fullscreen_on_connect: ab_entry.fullscreen_on_connect, - autohide_side_tabs: ab_entry.autohide_side_tabs, - spice_tls: ab_entry.spice_tls, - spice_tls_port: ab_entry.spice_tls_port, - spice_ca_cert: ab_entry.spice_ca_cert, - spice_cert_subject: ab_entry.spice_cert_subject, - spice_proxy: ab_entry.spice_proxy, - proxmox_url: ab_entry.proxmox_url, - proxmox_node: ab_entry.proxmox_node, - proxmox_vmid: ab_entry.proxmox_vmid, - proxmox_token_id: ab_entry.proxmox_token_id, - proxmox_token_secret: ab_entry.proxmox_token_secret, - proxmox_verify_tls: ab_entry.proxmox_verify_tls, - max_monitors: ab_entry.max_monitors, - owner: None, - fence_all_targets: false, - ssh_font_size: ab_entry.ssh_font_size, - wol_send_packet: ab_entry.wol_send_packet, - wol_mac_addr: ab_entry.wol_mac_addr, - wol_broadcast_addr: ab_entry.wol_broadcast_addr, - wol_udp_port: ab_entry.wol_udp_port, - wol_wait_time: ab_entry.wol_wait_time, + let create_req = match entry_connect_request(ab_entry, req, &ab_entry_key, &folder) { + Ok(r) => r, + Err(msg) => { + return (StatusCode::BAD_REQUEST, Json(json!({ "error": msg }))).into_response() + } }; let proxies = trusted.map(|Extension(t)| t.0).unwrap_or_default(); @@ -4155,6 +4227,199 @@ pub async fn ab_connect_entry( } } +/// Where an ad-hoc connection's credential variables are read from: the +/// caller's own My Credentials and nothing else. A trait so the resolution +/// is testable without a Vault. +#[allow(async_fn_in_trait)] +pub trait UserCredentialSource { + async fn user_credentials( + &self, + email: &str, + ) -> Result, VaultError>; +} + +impl UserCredentialSource for VaultBackends { + async fn user_credentials( + &self, + email: &str, + ) -> Result, VaultError> { + self.get_user_credentials(email).await + } +} + +/// Fill in the `$variable` references of an ad-hoc connection from the +/// caller's own credential variables, as a saved entry's are at connect. +/// `creds` is `None` when no Vault is configured: then nothing is resolved. +/// +/// Unlike a saved entry, nothing else is filled in. An ad-hoc connection +/// belongs to no folder, so folder default credentials never apply. +async fn resolve_adhoc_entry( + mut entry: AddressBookEntry, + id: &AuthIdentity, + creds: Option<&S>, +) -> Result { + entry.normalize_jump_hosts(); + if crate::vault::entry_credential_variables(&entry).is_empty() { + return Ok(entry); + } + let AuthIdentity::User { email, .. } = id else { + return Err(AdhocEntryError::ApiKeyVariables); + }; + let Some(creds) = creds else { + return Ok(entry); + }; + let user_creds = creds.user_credentials(email).await.unwrap_or_else(|e| { + tracing::warn!("Failed to read user credentials from Vault: {}", e); + std::collections::HashMap::new() + }); + crate::vault::resolve_credential_variables(&entry, &user_creds) + .map_err(AdhocEntryError::MissingVariables) +} + +/// Why an ad-hoc connection's credential variables could not be resolved. +#[derive(Debug, PartialEq)] +enum AdhocEntryError { + /// The caller is an API key, which has no credential variables. + ApiKeyVariables, + /// The caller has no variable of these names. + MissingVariables(Vec), +} + +impl IntoResponse for AdhocEntryError { + fn into_response(self) -> Response { + match self { + Self::ApiKeyVariables => ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "credential variables belong to a signed-in user; an API key has none" + })), + ) + .into_response(), + Self::MissingVariables(missing) => ( + StatusCode::PRECONDITION_FAILED, + Json(json!({ + "error": "missing credential variables", + "missing_variables": missing, + })), + ) + .into_response(), + } + } +} + +/// The session request for an ad-hoc connection, under the ad-hoc policy. +fn adhoc_session_request( + entry: AddressBookEntry, + req: &AdhocConnectRequest, + is_admin: bool, + config: &crate::config::Config, +) -> Result { + let (width, height) = initial_session_size( + &entry.session_type, + entry.fullscreen_on_connect.unwrap_or(false), + (req.width, req.height), + (req.screen_width, req.screen_height), + ); + let display = EntryDisplay { + width, + height, + dpi: req.dpi, + }; + let mut create_req = session_request_from_entry(entry, display, None)?; + create_req.generate_keypair = req.generate_keypair.filter(|&g| g); + crate::session::apply_adhoc_policy(&mut create_req, is_admin, config); + Ok(create_req) +} + +/// Body of `POST /api/addressbook/adhoc/connect`. +#[derive(Deserialize)] +pub struct AdhocConnectRequest { + /// The connection, in the form a saved entry takes. Its name, folder + /// and the like are not used. + pub entry: AddressBookEntry, + /// SSH: generate an ephemeral keypair (ad-hoc only; shown in a banner). + #[serde(default)] + pub generate_keypair: Option, + #[serde(default)] + pub width: Option, + #[serde(default)] + pub height: Option, + #[serde(default)] + pub screen_width: Option, + #[serde(default)] + pub screen_height: Option, + #[serde(default)] + pub dpi: Option, +} + +/// POST /api/addressbook/adhoc/connect — Connect to a host that is not a +/// saved entry, described with every option an entry has. Poweruser+. +/// Credential variables resolve from the caller's own; folder defaults +/// never apply. Non-admins get the ad-hoc policy (`apply_adhoc_policy`). +#[allow(clippy::too_many_arguments)] +pub async fn ab_adhoc_connect( + State(manager): State, + ConnectInfo(addr): ConnectInfo, + headers: axum::http::HeaderMap, + identity: Option>, + trusted: Option>, + Extension(vault): Extension, + Extension(vault_configured): Extension, + Json(mut req): Json, +) -> Response { + let id = match identity { + Some(Extension(id)) if can_connect_adhoc(&id) => id, + _ => { + return ( + StatusCode::FORBIDDEN, + Json(json!({"error": "insufficient permissions — poweruser role required for ad-hoc connections"})), + ) + .into_response() + } + }; + + let creds = vault_configured.0.then_some(vault.as_ref()); + let entry = match resolve_adhoc_entry(std::mem::take(&mut req.entry), &id, creds).await { + Ok(e) => e, + Err(e) => return e.into_response(), + }; + let create_req = + match adhoc_session_request(entry, &req, id.has_role("admin"), manager.config()) { + Ok(r) => r, + Err(msg) => { + return (StatusCode::BAD_REQUEST, Json(json!({ "error": msg }))).into_response() + } + }; + + let proxies = trusted.map(|Extension(t)| t.0).unwrap_or_default(); + let client_ip_addr = client_ip(&headers, addr.ip(), &proxies); + let user = id.display_name().to_string(); + let target = session_target(&create_req); + tracing::info!( + user = %user, + client_ip = %client_ip_addr, + session_type = ?create_req.session_type, + target = %target, + "Ad-hoc connect requested" + ); + + match manager.create_session(create_req, user.clone()).await { + Ok(info) => { + tracing::info!(user = %user, session_id = %info.session_id, "Ad-hoc session created"); + (StatusCode::CREATED, Json(json!(info))).into_response() + } + Err(e) => { + let msg = e.to_string(); + tracing::error!(user = %user, target = %target, error = %msg, "Ad-hoc session creation failed"); + let status = match &e { + crate::session::SessionError::ValidationError(_) => StatusCode::BAD_REQUEST, + _ => StatusCode::BAD_GATEWAY, + }; + (status, Json(json!({ "error": msg }))).into_response() + } + } +} + // ── Admin-only address book management ── #[derive(Deserialize)] @@ -7002,99 +7267,20 @@ pub async fn quick_connect( ); } - let session_type = match ab_entry.session_type.as_str() { - "ssh" => SessionType::Ssh, - "rdp" => SessionType::Rdp, - "vnc" => SessionType::Vnc, - "web" => SessionType::Web, - other => { - return quick_connect_error( - StatusCode::BAD_REQUEST, - &format!("Unknown session type: {}", other), - ); - } - }; + // Links open the four protocols a browser tab can carry on its own. + if !matches!( + ab_entry.session_type.as_str(), + "ssh" | "rdp" | "vnc" | "web" + ) { + return quick_connect_error( + StatusCode::BAD_REQUEST, + &format!("Unknown session type: {}", ab_entry.session_type), + ); + } - let ab_entry_key = format!("{}/{}/{}", scope, folder, entry); - let create_req = CreateSessionRequest { - session_type, - hostname: ab_entry.hostname, - port: ab_entry.port, - username: ab_entry.username, - password: ab_entry.password, - private_key: ab_entry.private_key, - generate_keypair: None, - url: ab_entry.url, - domain: ab_entry.domain, - security: ab_entry.security, - server_layout: ab_entry.server_layout, - ignore_cert: ab_entry.ignore_cert, - auth_pkg: ab_entry.auth_pkg, - kdc_url: ab_entry.kdc_url, - kerberos_cache: None, - color_depth: ab_entry.color_depth, - jump_hosts: ab_entry.jump_hosts, - jump_host: None, - jump_port: None, - jump_username: None, - jump_password: None, - jump_private_key: None, - width: query.width, - height: query.height, - dpi: query.dpi, - banner: ab_entry.banner, - enable_drive: ab_entry.enable_drive, - remote_app: ab_entry.remote_app, - remote_app_dir: ab_entry.remote_app_dir, - remote_app_args: ab_entry.remote_app_args, - enable_recording: ab_entry.enable_recording, - record_typescript: ab_entry.record_typescript, - address_book_entry: Some(ab_entry_key), - address_book_folder: Some(folder.to_string()), - entry_display_name: ab_entry.display_name.clone(), - max_recordings: ab_entry.max_recordings, - login_script: ab_entry.login_script, - autofill: ab_entry.autofill, - allowed_domains: ab_entry.allowed_domains, - disable_copy: ab_entry.disable_copy, - disable_paste: ab_entry.disable_paste, - enable_gfx: ab_entry.enable_gfx, - enable_desktop_composition: ab_entry.enable_desktop_composition, - enable_wallpaper: ab_entry.enable_wallpaper, - enable_theming: ab_entry.enable_theming, - enable_full_window_drag: ab_entry.enable_full_window_drag, - force_lossless: ab_entry.force_lossless, - enable_h264: ab_entry.enable_h264, - container_image: ab_entry.container_image, - container_cpu_limit: ab_entry.container_cpu_limit, - container_memory_limit: ab_entry.container_memory_limit, - container_env: ab_entry.container_env, - container_idle_timeout_mins: ab_entry.container_idle_timeout_mins, - container_username: ab_entry.container_username, - container_password: ab_entry.container_password, - allow_sharing: ab_entry.allow_sharing, - fullscreen_on_connect: ab_entry.fullscreen_on_connect, - autohide_side_tabs: ab_entry.autohide_side_tabs, - spice_tls: ab_entry.spice_tls, - spice_tls_port: ab_entry.spice_tls_port, - spice_ca_cert: ab_entry.spice_ca_cert, - spice_cert_subject: ab_entry.spice_cert_subject, - spice_proxy: ab_entry.spice_proxy, - proxmox_url: None, - proxmox_node: None, - proxmox_vmid: None, - proxmox_token_id: None, - proxmox_token_secret: None, - proxmox_verify_tls: None, - max_monitors: None, - owner: None, - fence_all_targets: false, - ssh_font_size: ab_entry.ssh_font_size, - wol_send_packet: ab_entry.wol_send_packet, - wol_mac_addr: ab_entry.wol_mac_addr, - wol_broadcast_addr: ab_entry.wol_broadcast_addr, - wol_udp_port: ab_entry.wol_udp_port, - wol_wait_time: ab_entry.wol_wait_time, + let create_req = match quick_connect_entry_request(ab_entry, &query, scope, folder, entry) { + Ok(r) => r, + Err(msg) => return quick_connect_error(StatusCode::BAD_REQUEST, &msg), }; tracing::info!( @@ -7115,7 +7301,7 @@ pub async fn quick_connect( } // Ad-hoc mode: requires poweruser+ - if !id.has_role("poweruser") { + if !can_connect_adhoc(&id) { return quick_connect_error( StatusCode::FORBIDDEN, "Poweruser role or higher required for ad-hoc connections.", @@ -7231,6 +7417,27 @@ pub async fn quick_connect( } } +/// The session request for an entry opened from a `/api/connect` link, at +/// the size the link asks for. +fn quick_connect_entry_request( + ab_entry: AddressBookEntry, + query: &QuickConnectQuery, + scope: &str, + folder: &str, + entry: &str, +) -> Result { + let display = EntryDisplay { + width: query.width, + height: query.height, + dpi: query.dpi, + }; + let attribution = EntryRef { + key: format!("{}/{}/{}", scope, folder, entry), + folder: folder.to_string(), + }; + session_request_from_entry(ab_entry, display, Some(attribution)) +} + /// Return an inline HTML credential form for quick-connect when prompting is needed. fn quick_connect_credential_form( scope: &str, @@ -7374,6 +7581,646 @@ fn html_escape(s: &str) -> String { #[cfg(test)] mod tests { use super::*; + use crate::session::SessionInfo; + + // ── The entry-to-request conversion, against the code it replaced ── + + /// `ab_connect_entry`'s inline conversion before it moved into + /// `session_request_from_entry`, verbatim. + #[allow(clippy::let_and_return)] // kept verbatim + fn legacy_ab_connect_request( + ab_entry: AddressBookEntry, + session_type: SessionType, + req: ConnectRequest, + scope: &str, + folder: &str, + entry: &str, + ) -> CreateSessionRequest { + let ab_entry_key = format!("{}/{}/{}", scope, folder, entry); + let (initial_width, initial_height) = initial_session_size( + &ab_entry.session_type, + ab_entry.fullscreen_on_connect.unwrap_or(false), + (req.width, req.height), + (req.screen_width, req.screen_height), + ); + let create_req = CreateSessionRequest { + session_type, + hostname: ab_entry.hostname, + port: ab_entry.port, + username: req.username.or(ab_entry.username), + password: req.password.or(ab_entry.password), + private_key: ab_entry.private_key, + generate_keypair: None, + url: ab_entry.url, + domain: req.domain.or(ab_entry.domain), + security: ab_entry.security, + server_layout: ab_entry.server_layout, + ignore_cert: ab_entry.ignore_cert, + auth_pkg: ab_entry.auth_pkg, + kdc_url: ab_entry.kdc_url, + kerberos_cache: None, + color_depth: ab_entry.color_depth, + jump_hosts: ab_entry.jump_hosts, + jump_host: None, + jump_port: None, + jump_username: None, + jump_password: None, + jump_private_key: None, + width: initial_width, + height: initial_height, + dpi: req.dpi, + banner: req.banner.or(ab_entry.banner), + enable_drive: ab_entry.enable_drive, + remote_app: ab_entry.remote_app, + remote_app_dir: ab_entry.remote_app_dir, + remote_app_args: ab_entry.remote_app_args, + enable_recording: ab_entry.enable_recording, + record_typescript: ab_entry.record_typescript, + address_book_entry: Some(ab_entry_key.clone()), + address_book_folder: Some(folder.to_string()), + entry_display_name: ab_entry.display_name.clone(), + max_recordings: ab_entry.max_recordings, + login_script: ab_entry.login_script, + autofill: ab_entry.autofill, + allowed_domains: ab_entry.allowed_domains, + disable_copy: ab_entry.disable_copy, + disable_paste: ab_entry.disable_paste, + enable_gfx: ab_entry.enable_gfx, + enable_desktop_composition: ab_entry.enable_desktop_composition, + enable_wallpaper: ab_entry.enable_wallpaper, + enable_theming: ab_entry.enable_theming, + enable_full_window_drag: ab_entry.enable_full_window_drag, + force_lossless: ab_entry.force_lossless, + enable_h264: ab_entry.enable_h264, + container_image: ab_entry.container_image, + container_cpu_limit: ab_entry.container_cpu_limit, + container_memory_limit: ab_entry.container_memory_limit, + container_env: ab_entry.container_env, + container_idle_timeout_mins: ab_entry.container_idle_timeout_mins, + container_username: ab_entry.container_username, + container_password: ab_entry.container_password, + allow_sharing: ab_entry.allow_sharing, + fullscreen_on_connect: ab_entry.fullscreen_on_connect, + autohide_side_tabs: ab_entry.autohide_side_tabs, + spice_tls: ab_entry.spice_tls, + spice_tls_port: ab_entry.spice_tls_port, + spice_ca_cert: ab_entry.spice_ca_cert, + spice_cert_subject: ab_entry.spice_cert_subject, + spice_proxy: ab_entry.spice_proxy, + proxmox_url: ab_entry.proxmox_url, + proxmox_node: ab_entry.proxmox_node, + proxmox_vmid: ab_entry.proxmox_vmid, + proxmox_token_id: ab_entry.proxmox_token_id, + proxmox_token_secret: ab_entry.proxmox_token_secret, + proxmox_verify_tls: ab_entry.proxmox_verify_tls, + max_monitors: ab_entry.max_monitors, + owner: None, + fence_all_targets: false, + ssh_font_size: ab_entry.ssh_font_size, + wol_send_packet: ab_entry.wol_send_packet, + wol_mac_addr: ab_entry.wol_mac_addr, + wol_broadcast_addr: ab_entry.wol_broadcast_addr, + wol_udp_port: ab_entry.wol_udp_port, + wol_wait_time: ab_entry.wol_wait_time, + }; + create_req + } + + /// `quick_connect`'s inline conversion (entry mode), verbatim. + #[allow(clippy::let_and_return)] // kept verbatim + fn legacy_quick_connect_request( + ab_entry: AddressBookEntry, + session_type: SessionType, + query: QuickConnectQuery, + scope: &str, + folder: &str, + entry: &str, + ) -> CreateSessionRequest { + let ab_entry_key = format!("{}/{}/{}", scope, folder, entry); + let create_req = CreateSessionRequest { + session_type, + hostname: ab_entry.hostname, + port: ab_entry.port, + username: ab_entry.username, + password: ab_entry.password, + private_key: ab_entry.private_key, + generate_keypair: None, + url: ab_entry.url, + domain: ab_entry.domain, + security: ab_entry.security, + server_layout: ab_entry.server_layout, + ignore_cert: ab_entry.ignore_cert, + auth_pkg: ab_entry.auth_pkg, + kdc_url: ab_entry.kdc_url, + kerberos_cache: None, + color_depth: ab_entry.color_depth, + jump_hosts: ab_entry.jump_hosts, + jump_host: None, + jump_port: None, + jump_username: None, + jump_password: None, + jump_private_key: None, + width: query.width, + height: query.height, + dpi: query.dpi, + banner: ab_entry.banner, + enable_drive: ab_entry.enable_drive, + remote_app: ab_entry.remote_app, + remote_app_dir: ab_entry.remote_app_dir, + remote_app_args: ab_entry.remote_app_args, + enable_recording: ab_entry.enable_recording, + record_typescript: ab_entry.record_typescript, + address_book_entry: Some(ab_entry_key), + address_book_folder: Some(folder.to_string()), + entry_display_name: ab_entry.display_name.clone(), + max_recordings: ab_entry.max_recordings, + login_script: ab_entry.login_script, + autofill: ab_entry.autofill, + allowed_domains: ab_entry.allowed_domains, + disable_copy: ab_entry.disable_copy, + disable_paste: ab_entry.disable_paste, + enable_gfx: ab_entry.enable_gfx, + enable_desktop_composition: ab_entry.enable_desktop_composition, + enable_wallpaper: ab_entry.enable_wallpaper, + enable_theming: ab_entry.enable_theming, + enable_full_window_drag: ab_entry.enable_full_window_drag, + force_lossless: ab_entry.force_lossless, + enable_h264: ab_entry.enable_h264, + container_image: ab_entry.container_image, + container_cpu_limit: ab_entry.container_cpu_limit, + container_memory_limit: ab_entry.container_memory_limit, + container_env: ab_entry.container_env, + container_idle_timeout_mins: ab_entry.container_idle_timeout_mins, + container_username: ab_entry.container_username, + container_password: ab_entry.container_password, + allow_sharing: ab_entry.allow_sharing, + fullscreen_on_connect: ab_entry.fullscreen_on_connect, + autohide_side_tabs: ab_entry.autohide_side_tabs, + spice_tls: ab_entry.spice_tls, + spice_tls_port: ab_entry.spice_tls_port, + spice_ca_cert: ab_entry.spice_ca_cert, + spice_cert_subject: ab_entry.spice_cert_subject, + spice_proxy: ab_entry.spice_proxy, + proxmox_url: None, + proxmox_node: None, + proxmox_vmid: None, + proxmox_token_id: None, + proxmox_token_secret: None, + proxmox_verify_tls: None, + max_monitors: None, + owner: None, + fence_all_targets: false, + ssh_font_size: ab_entry.ssh_font_size, + wol_send_packet: ab_entry.wol_send_packet, + wol_mac_addr: ab_entry.wol_mac_addr, + wol_broadcast_addr: ab_entry.wol_broadcast_addr, + wol_udp_port: ab_entry.wol_udp_port, + wol_wait_time: ab_entry.wol_wait_time, + }; + create_req + } + + /// An entry with every field the conversion reads set to something + /// distinct, so a field dropped or swapped shows up. + fn full_entry(session_type: &str) -> AddressBookEntry { + // In parts: one json! literal this size exceeds the macro recursion limit. + let mut body = serde_json::Map::new(); + for part in [ + json!({ + "type": session_type, + "hostname": "host.example.test", "port": 2222, + "username": "alice", "password": "pw", "private_key": "KEY", + "url": "https://app.example.test", "domain": "CORP", + "security": "nla", "server_layout": "en-gb-qwerty", "ignore_cert": true, + "display_name": "Host One", "enable_drive": true, "auth_pkg": "kerberos", + "kdc_url": "https://kdc.example.test", "prompt_credentials": false, + "color_depth": 16 + }), + json!({ + "jump_hosts": [{"hostname": "bastion", "port": 22, "username": "j", + "password": "jp", "private_key": null, "host_key": null}], + "remote_app": "||notepad", "remote_app_dir": "C:\\", "remote_app_args": "-x", + "enable_recording": false, "max_recordings": 3, "record_typescript": true, + "login_script": "login.sh", "autofill": "[]", + "allowed_domains": ["example.test"], "disable_copy": true, "disable_paste": true, + "banner": "be careful", "enable_gfx": true, "enable_desktop_composition": true, + "enable_wallpaper": true, "enable_theming": true, "enable_full_window_drag": true + }), + json!({ + "force_lossless": true, "enable_h264": false, + "container_image": "desktop:1", "container_cpu_limit": 3.0, + "container_memory_limit": 2048, "container_env": {"A": "1"}, + "container_idle_timeout_mins": 5, "container_username": "svc", + "container_password": "svcpw", "allow_sharing": true, + "auto_open_if_singleton": true, "fullscreen_on_connect": true, + "autohide_side_tabs": true, "spice_tls": true, "spice_tls_port": 5901, + "spice_ca_cert": "CA", "spice_cert_subject": "CN=x" + }), + json!({ + "spice_proxy": "http://proxy:3128", "proxmox_url": "https://pve:8006", + "proxmox_node": "pve1", "proxmox_vmid": 101, "proxmox_token_id": "u@pam!t", + "proxmox_token_secret": "secret", "proxmox_verify_tls": true, + "max_monitors": 2, "ssh_font_size": 14, "wol_send_packet": true, + "wol_mac_addr": "00:11:22:33:44:55", "wol_broadcast_addr": "192.0.2.255", + "wol_udp_port": 9, "wol_wait_time": 30, "external_id": "netbox:device:1" + }), + ] { + body.extend(part.as_object().unwrap().clone()); + } + serde_json::from_value(serde_json::Value::Object(body)).unwrap() + } + + fn as_json(req: &CreateSessionRequest) -> serde_json::Value { + let mut v = serde_json::to_value(req).unwrap(); + v["fence_all_targets"] = json!(req.fence_all_targets); + v + } + + #[test] + fn entry_connect_request_matches_the_inline_conversion() { + for session_type in ["ssh", "rdp", "vnc", "spice", "proxmox", "web", "vdi"] { + for prompt in [ + json!({ "width": 1700, "height": 900, "screen_width": 1920, + "screen_height": 1080, "dpi": 144 }), + json!({ "width": 800, "height": 600, "username": "bob", + "password": "typed", "domain": "OTHER", "banner": "hi" }), + ] { + let req = || serde_json::from_value::(prompt.clone()).unwrap(); + let new = entry_connect_request( + full_entry(session_type), + req(), + "shared/ops/host-1", + "ops", + ) + .unwrap(); + let old = legacy_ab_connect_request( + full_entry(session_type), + entry_session_type(session_type).unwrap(), + req(), + "shared", + "ops", + "host-1", + ); + assert_eq!(as_json(&new), as_json(&old), "{session_type} {prompt}"); + assert_eq!(new.reuse_fingerprint(), old.reuse_fingerprint()); + } + } + } + + #[test] + fn quick_connect_request_matches_the_inline_conversion() { + let query = || -> QuickConnectQuery { + serde_urlencoded::from_str("width=1280&height=720&dpi=96").unwrap() + }; + for session_type in ["ssh", "rdp", "vnc", "web"] { + let new = quick_connect_entry_request( + full_entry(session_type), + &query(), + "shared", + "ops", + "host-1", + ) + .unwrap(); + let old = legacy_quick_connect_request( + full_entry(session_type), + entry_session_type(session_type).unwrap(), + query(), + "shared", + "ops", + "host-1", + ); + let (mut new, mut old) = (as_json(&new), as_json(&old)); + // The one difference: links dropped the entry's Proxmox fields + // and monitor count. Proxmox fields mean nothing to these four + // types; the monitor count now reaches an RDP session from a + // link as it does from the page. + assert_eq!(new["max_monitors"], json!(2)); + for k in [ + "proxmox_url", + "proxmox_node", + "proxmox_vmid", + "proxmox_token_id", + "proxmox_token_secret", + "proxmox_verify_tls", + "max_monitors", + ] { + assert_eq!(old[k], serde_json::Value::Null, "{k}"); + new.as_object_mut().unwrap().remove(k); + old.as_object_mut().unwrap().remove(k); + } + assert_eq!(new, old, "{session_type}"); + } + } + + #[test] + fn an_adhoc_request_names_no_entry() { + let display = EntryDisplay { + width: Some(1024), + height: Some(768), + dpi: Some(96), + }; + let req = session_request_from_entry(full_entry("ssh"), display, None).unwrap(); + assert_eq!(req.address_book_entry, None); + assert_eq!(req.address_book_folder, None); + assert_eq!(req.entry_display_name, None); + assert_eq!(req.hostname.as_deref(), Some("host.example.test")); + assert!(session_request_from_entry( + AddressBookEntry { + session_type: "telnet".into(), + ..Default::default() + }, + EntryDisplay { + width: None, + height: None, + dpi: None + }, + None, + ) + .is_err()); + } + + #[test] + fn saved_entries_keep_their_admin_settings() { + // A saved entry's fixed VDI account, recording opt-out, drive and + // resources reach the session; only ad-hoc requests are filtered. + let req = entry_connect_request( + full_entry("vdi"), + serde_json::from_value(json!({})).unwrap(), + "shared/ops/host-1", + "ops", + ) + .unwrap(); + assert!(!req.fence_all_targets); + assert_eq!(req.container_username.as_deref(), Some("svc")); + assert_eq!(req.enable_recording, Some(false)); + assert_eq!(req.enable_drive, Some(true)); + assert_eq!(req.container_cpu_limit, Some(3.0)); + assert_eq!(req.max_recordings, Some(3)); + } + + // ── Ad-hoc connections ── + + /// Credentials and folder defaults behind one fake, counting what is + /// read. Folder defaults are set for every type, so an ad-hoc entry + /// would pick them up if anything lent them. + struct FakeVault { + creds: std::collections::HashMap, + cred_reads: std::sync::Mutex>, + defaults_reads: std::sync::atomic::AtomicUsize, + } + + impl FakeVault { + fn with(creds: &[(&str, &str)]) -> Self { + Self { + creds: creds + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + cred_reads: Default::default(), + defaults_reads: Default::default(), + } + } + } + + impl UserCredentialSource for FakeVault { + async fn user_credentials( + &self, + email: &str, + ) -> Result, VaultError> { + self.cred_reads.lock().unwrap().push(email.to_string()); + Ok(self.creds.clone()) + } + } + + impl DefaultsSource for FakeVault { + async fn folder_defaults( + &self, + _scope: &str, + _folder: &str, + ) -> Result, VaultError> { + self.defaults_reads + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(Some( + serde_json::from_value(json!({ + "ssh": { "username": "folder-user", "password": "folder-pw" } + })) + .unwrap(), + )) + } + async fn takes_parent_defaults(&self, _s: &str, _f: &str) -> Result { + self.defaults_reads + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(true) + } + } + + fn user(email: &str, role: &str) -> AuthIdentity { + AuthIdentity::User { + email: email.into(), + role: role.into(), + groups: vec![], + } + } + + fn adhoc_entry(body: serde_json::Value) -> AddressBookEntry { + serde_json::from_value(body).unwrap() + } + + #[tokio::test] + async fn adhoc_resolves_only_the_callers_own_variables() { + let vault = FakeVault::with(&[("my_ssh_key", "-----KEY-----"), ("me", "alice")]); + let entry = adhoc_entry(json!({ + "type": "ssh", "hostname": "10.0.0.1", + "username": "$me", "private_key": "$my_ssh_key", + })); + let caller = user("Alice@example.com", "poweruser"); + let out = resolve_adhoc_entry(entry, &caller, Some(&vault)) + .await + .unwrap(); + assert_eq!(out.username.as_deref(), Some("alice")); + assert_eq!(out.private_key.as_deref(), Some("-----KEY-----")); + assert_eq!(*vault.cred_reads.lock().unwrap(), vec!["Alice@example.com"]); + } + + #[tokio::test] + async fn adhoc_missing_variables_are_named() { + let vault = FakeVault::with(&[("me", "alice")]); + let entry = adhoc_entry(json!({ + "type": "ssh", "hostname": "10.0.0.1", "username": "$me", "password": "$nope", + })); + let err = resolve_adhoc_entry(entry, &user("a@example.com", "poweruser"), Some(&vault)) + .await + .unwrap_err(); + assert_eq!(err, AdhocEntryError::MissingVariables(vec!["nope".into()])); + assert_eq!( + err.into_response().status(), + StatusCode::PRECONDITION_FAILED + ); + } + + #[tokio::test] + async fn adhoc_variables_need_a_signed_in_user() { + let vault = FakeVault::with(&[("me", "alice")]); + let entry = adhoc_entry(json!({ "type": "ssh", "hostname": "h", "username": "$me" })); + let err = resolve_adhoc_entry(entry, &AuthIdentity::ApiKey("ci".into()), Some(&vault)) + .await + .unwrap_err(); + assert_eq!(err, AdhocEntryError::ApiKeyVariables); + assert_eq!(err.into_response().status(), StatusCode::BAD_REQUEST); + assert!(vault.cred_reads.lock().unwrap().is_empty()); + // Without variables an API key connects as before. + let plain = adhoc_entry(json!({ "type": "ssh", "hostname": "h", "username": "root" })); + let ci = AuthIdentity::ApiKey("ci".into()); + assert!(resolve_adhoc_entry(plain, &ci, Some(&vault)).await.is_ok()); + } + + #[tokio::test] + async fn adhoc_without_vault_leaves_references_alone() { + let entry = adhoc_entry(json!({ "type": "ssh", "hostname": "h", "username": "$me" })); + let out = + resolve_adhoc_entry::(entry, &user("a@example.com", "poweruser"), None) + .await + .unwrap(); + assert_eq!(out.username.as_deref(), Some("$me")); + } + + #[tokio::test] + async fn adhoc_never_takes_folder_defaults() { + // A bare SSH entry is exactly what folder defaults fill in on a saved + // entry; an ad-hoc one has no folder, so it stays bare. + let vault = FakeVault::with(&[]); + let caller = user("a@example.com", "poweruser"); + let out = resolve_adhoc_entry( + adhoc_entry(json!({ "type": "ssh", "hostname": "10.0.0.1" })), + &caller, + Some(&vault), + ) + .await + .unwrap(); + assert_eq!(out.username, None); + assert_eq!(out.password, None); + assert_eq!( + vault + .defaults_reads + .load(std::sync::atomic::Ordering::SeqCst), + 0, + "no folder defaults may be read for an ad-hoc connection" + ); + } + + #[tokio::test] + async fn adhoc_legacy_jump_host_fields_become_a_chain() { + let entry = adhoc_entry(json!({ + "type": "ssh", "hostname": "h", "jump_host": "bastion", "jump_username": "j", + })); + let out = + resolve_adhoc_entry::(entry, &user("a@example.com", "poweruser"), None) + .await + .unwrap(); + let hops = out.jump_hosts.unwrap(); + assert_eq!((hops[0].hostname.as_str(), hops[0].port), ("bastion", 22)); + assert_eq!(out.jump_host, None); + } + + fn adhoc_test_manager() -> SessionManager { + let config = crate::config::Config { + recording_path: std::env::temp_dir() + .join(format!("rustguac-adhoc-test-{}", Uuid::new_v4())), + xvnc_path: "/bin/true".into(), + chromium_path: "/bin/true".into(), + login_scripts_dir: "/tmp".into(), + ..Default::default() + }; + SessionManager::new(config, None) + } + + fn adhoc_body(extra: serde_json::Value) -> AdhocConnectRequest { + let mut body = json!({ "width": 1280, "height": 720 }); + body.as_object_mut() + .unwrap() + .extend(extra.as_object().unwrap().clone()); + serde_json::from_value(body).unwrap() + } + + /// What a poweruser's ad-hoc connection would make of `entry`, through + /// to `create_session` on a manager with the default (localhost-only) + /// allowlists. + async fn poweruser_adhoc(entry: serde_json::Value) -> Result { + let mgr = adhoc_test_manager(); + let mut body = adhoc_body(json!({ "entry": entry })); + let entry = std::mem::take(&mut body.entry); + let req = adhoc_session_request(entry, &body, false, mgr.config())?; + assert!(req.fence_all_targets); + mgr.create_session(req, "pu@example.com".into()) + .await + .map_err(|e| e.to_string()) + } + + #[tokio::test] + async fn adhoc_fences_spice_proxmox_and_jump_hosts() { + let spice = poweruser_adhoc(json!({ "type": "spice", "hostname": "192.0.2.30" })).await; + assert!( + spice + .as_ref() + .unwrap_err() + .contains("not in the allowed network list"), + "{:?}", + spice.err() + ); + let pve = poweruser_adhoc(json!({ + "type": "proxmox", "proxmox_url": "https://192.0.2.20:8006", "proxmox_vmid": 100, + })) + .await; + assert!( + pve.as_ref().unwrap_err().contains("Proxmox API"), + "{:?}", + pve.err() + ); + let jump = poweruser_adhoc(json!({ + "type": "ssh", "hostname": "127.0.0.1", + "jump_hosts": [{ "hostname": "192.0.2.10", "port": 22, "username": "j" }], + })) + .await; + assert!( + jump.as_ref().unwrap_err().contains("jump host"), + "{:?}", + jump.err() + ); + } + + #[test] + fn adhoc_drops_wake_on_lan_and_entry_only_settings() { + let mgr = adhoc_test_manager(); + let body = adhoc_body(json!({ "entry": { "type": "vdi" } })); + let req = adhoc_session_request(full_entry("ssh"), &body, false, mgr.config()).unwrap(); + assert!(req.fence_all_targets); + assert_eq!(req.wol_send_packet, None); + assert_eq!(req.wol_mac_addr, None); + assert_eq!(req.container_username, None); + assert_eq!(req.max_recordings, None); + assert_eq!(req.record_typescript, None); + assert_eq!(req.allow_sharing, None); + // Recording is on site-wide by default: the entry's opt-out is ignored. + assert_eq!(req.enable_recording, None); + // No [drive] section: the drive stays off. + assert_eq!(req.enable_drive, None); + // What an ad-hoc caller may choose is kept. + assert_eq!(req.login_script.as_deref(), Some("login.sh")); + assert_eq!(req.ssh_font_size, Some(14)); + assert_eq!(req.disable_copy, Some(true)); + } + + #[test] + fn adhoc_from_an_admin_is_used_as_sent() { + let mgr = adhoc_test_manager(); + let body = adhoc_body(json!({ "entry": { "type": "ssh" }, "generate_keypair": true })); + let req = adhoc_session_request(full_entry("ssh"), &body, true, mgr.config()).unwrap(); + assert!(!req.fence_all_targets); + assert_eq!(req.wol_send_packet, Some(true)); + assert_eq!(req.enable_recording, Some(false)); + assert_eq!(req.generate_keypair, Some(true)); + assert_eq!(req.address_book_entry, None); + } #[test] fn folder_update_leaves_an_omitted_display_name_alone() { diff --git a/src/main.rs b/src/main.rs index 5b1b7d0..6822e53 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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() diff --git a/static/connections.html b/static/connections.html index 26bf83d..d059e56 100644 --- a/static/connections.html +++ b/static/connections.html @@ -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%; } @@ -805,6 +813,9 @@
- -
+
+
- @@ -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 = ''; + 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 @@ '

Use it to upload and download files via a shared virtual drive. SSH sessions support SFTP file transfer instead.' + '

Toggle the file panel with Ctrl+Alt+F.' }, - { title: 'Ad-hoc Sessions', minRole: 3, body: - 'As a power user, you can create one-off sessions from the Sessions page — useful for quick connections that aren\'t in the connections.' + - '

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 Connect to a host, next to the search box on every page.' + + '

It opens the entry editor with every option an entry has. Nothing is saved, and credential fields accept your own credential variables, such as $my_ssh_key.' }, { title: 'Managing the Connections', minRole: 4, body: 'As an admin, you can:' + diff --git a/static/rustguac.css b/static/rustguac.css index 087b2f4..1a2cd0c 100644 --- a/static/rustguac.css +++ b/static/rustguac.css @@ -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; diff --git a/static/search.js b/static/search.js index a65cf30..20a18b8 100644 --- a/static/search.js +++ b/static/search.js @@ -13,7 +13,11 @@ * Only an authenticated operator or above gets the box: the server writes the * caller's role on (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 = '⚡ 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); }); } diff --git a/static/sessions.html b/static/sessions.html index d73aa7a..05b2bf3 100644 --- a/static/sessions.html +++ b/static/sessions.html @@ -5,24 +5,9 @@ rustguac - Sessions @@ -72,127 +44,12 @@ Use the Connections to connect to saved entries. - -
-
+ @@ -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 = '▼'; - } else { - fields.style.display = 'none'; - arrow.innerHTML = '▶'; - } - }); - - // ── Escape helpers ── - function escapeHtml(s) { - var d = document.createElement('div'); - d.textContent = s; - return d.innerHTML; - } - function escapeAttr(s) { - return s.replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''').replace(//g, '>'); - } - - // ── 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 = - '' + - '' + - '' + - ''; - - 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 = 'You'; - hopList.forEach(function(hop) { - var label = hop.hostname || '???'; - html += '→' + escapeHtml(label) + ''; - }); - // 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 += '→' + escapeHtml(targetHost) + ':' + escapeHtml(targetPort) + ' ' + typeLabel + ''; - } - // Show URL rewrite warning for web sessions with hops - var warn = ''; - if (type === 'web' && hopList.length > 0) { - warn = '
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.
'; - } - 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'; } diff --git a/tests/e2e/README.md b/tests/e2e/README.md index a6fc68c..1404e08 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -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` diff --git a/tests/e2e/lib/api.mjs b/tests/e2e/lib/api.mjs index 2a6cfb6..e3b73bd 100644 --- a/tests/e2e/lib/api.mjs +++ b/tests/e2e/lib/api.mjs @@ -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 })); diff --git a/tests/e2e/specs/adhoc-button.spec.mjs b/tests/e2e/specs/adhoc-button.spec.mjs new file mode 100644 index 0000000..d2525f5 --- /dev/null +++ b/tests/e2e/specs/adhoc-button.spec.mjs @@ -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); +}); diff --git a/tests/e2e/specs/connections-adhoc.spec.mjs b/tests/e2e/specs/connections-adhoc.spec.mjs new file mode 100644 index 0000000..c205bd8 --- /dev/null +++ b/tests/e2e/specs/connections-adhoc.spec.mjs @@ -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/); +}); diff --git a/tests/e2e/specs/facet-filters.spec.mjs b/tests/e2e/specs/facet-filters.spec.mjs index 1dcf15e..f656f29 100644 --- a/tests/e2e/specs/facet-filters.spec.mjs +++ b/tests/e2e/specs/facet-filters.spec.mjs @@ -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(); diff --git a/tests/e2e/specs/search-under-modals.spec.mjs b/tests/e2e/specs/search-under-modals.spec.mjs index 08e56b0..0c62478 100644 --- a/tests/e2e/specs/search-under-modals.spec.mjs +++ b/tests/e2e/specs/search-under-modals.spec.mjs @@ -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(); diff --git a/tests/e2e/specs/sessions-adhoc-modal.spec.mjs b/tests/e2e/specs/sessions-adhoc-modal.spec.mjs index 5b34d9b..31a8c8d 100644 --- a/tests/e2e/specs/sessions-adhoc-modal.spec.mjs +++ b/tests/e2e/specs/sessions-adhoc-modal.spec.mjs @@ -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(''); });