From ab82c382cb0ced72c77bcc81a6816dcc8765f802 Mon Sep 17 00:00:00 2001 From: Jad Seifeddine Date: Mon, 19 Oct 2026 11:47:16 +0700 Subject: [PATCH 1/5] entries: one entry, several protocols A machine reached over more than one protocol needed one entry per protocol, with the hostname, display name and credentials copied between them. An entry can now hold a list of protocols. The entry's own fields are the host's defaults. Each item in `protocols` inherits them and sets only what differs; an empty value clears an inherited one, and `port` is never inherited. The first protocol is the primary. An optional `name` tells two protocols of the same type apart. - Existing flat entries are read as one protocol and connect exactly as before. An entry is stored in the flat form whenever that loses nothing, so older instances keep reading it, and the write API still accepts the flat body, including the NetBox webhook's. - Connecting resolves host and protocol to one flat entry, so folder defaults, credential variables, recording and the rest apply per protocol unchanged. Credentials come from the protocol, then the host, then the folder's defaults for that type. - The connect endpoint and `/api/connect` take an optional `protocol` (a name, or a type), defaulting to the primary. Sessions and recordings record the protocol they used. - Updates keep stored secrets per protocol, matched by name. - Connections: the editor has a Host section and a tab per protocol. The list shows a badge per protocol, the primary first, folding the rest into a +N chip. Connect is a split button whose menu lists every protocol, the primary marked as default. - Docs: entries in api.md, a multi-protocol NetBox webhook in netbox.md, and an upgrade note in migration.md. Refs sol1/rustguac#296 --- CLAUDE.md | 9 + docs/api.md | 44 +- docs/migration.md | 16 + docs/netbox.md | 71 +- src/api.rs | 392 ++++- src/entry_protocols.rs | 694 +++++++++ src/import.rs | 1 + src/main.rs | 1 + src/recording.rs | 3 + src/session.rs | 20 + src/vault.rs | 69 +- src/websocket.rs | 3 + static/client.html | 5 +- static/connections.html | 1380 ++++++++++++----- static/recordings.html | 2 + static/search.js | 38 +- static/sessions.html | 5 + tests/e2e/README.md | 1 + .../e2e/specs/multi-protocol-entries.spec.mjs | 289 ++++ 19 files changed, 2622 insertions(+), 421 deletions(-) create mode 100644 src/entry_protocols.rs create mode 100644 tests/e2e/specs/multi-protocol-entries.spec.mjs diff --git a/CLAUDE.md b/CLAUDE.md index 53f5bc05..adae960c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,6 +62,15 @@ Vault KV v2 path structure: - `/.defaults` — default credentials per session type, lent to entries without their own; subfolders inherit them only while the subfolder has `inherit_from_parent: true` and no `allowed_groups`/`allowed_users` of its own - `/users/` — per-user credential variables +Entries (#296, `src/entry_protocols.rs`): either the old flat form (top-level +`type`) or host fields + `protocols: [{type, name?, overrides...}]`, first = primary. A protocol inherits host fields it does not +set (`""`/`[]` clears; `port` is never inherited). `pick_protocol` / +`effective_of` resolve host+protocol to a flat single-type entry, which the +connect path uses unchanged. A flat entry is read as one protocol (`split`; the +host keeps `HOST_KEYS`), and `compact()` stores the flat form whenever that is +lossless, so older instances keep reading it. Updates keep secrets per protocol, +matched by name. + #### Multiple Vault backends (DR) Optional `[vault_shared]` / `[vault_local]` blocks (same keys as `[vault]`) give diff --git a/docs/api.md b/docs/api.md index 0e89406e..c203e36b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -24,7 +24,7 @@ Quick-connect endpoint for external integrations (e.g., NetBox Custom Links). Cr | Parameter | Type | Description | |-----------|------|-------------| -| `protocol` | string | `ssh`, `rdp`, `vnc`, or `web` (default: ssh) | +| `protocol` | string | Ad-hoc mode: `ssh`, `rdp`, `vnc`, or `web` (default: ssh). Connections mode: which of the entry's [protocols](#multi-protocol-entries) to open, by name (falling back to type); default the primary | | `hostname` | string | Target hostname or IP | | `port` | integer | Target port (uses protocol default if omitted) | | `username` | string | Username (optional) | @@ -38,6 +38,8 @@ Quick-connect endpoint for external integrations (e.g., NetBox Custom Links). Cr When `scope`, `folder`, and `entry` are all provided, the endpoint connects via the connections (credentials from Vault). Otherwise it creates an ad-hoc session. No credentials are passed in the URL for ad-hoc mode — if the target requires authentication, the user will see guacd's login prompt. +In connections mode, `&protocol=ssh` opens the entry's SSH protocol; links without it open the primary, so links made before multi-protocol entries keep working. A protocol the user may not use is answered like one that does not exist (`404`). + If the connections entry has `prompt_credentials: true` or has no stored password/key, the endpoint returns an inline credential form instead of creating the session immediately. The user enters credentials, which are POSTed to the connect endpoint and used for that session only (never stored). An entry without its own secret that is covered by [folder default credentials](credential-variables.md#folder-default-credentials) connects with those, exactly as it does from the Connections page. See [NetBox Integration](netbox.md) for usage with NetBox Custom Links. @@ -525,6 +527,14 @@ List visible folders. Filtered by OIDC group membership (admins see all). List entries in a folder. Scope is `shared` or `instance`. Requires folder group access. +Each entry lists its protocols ([multi-protocol entries](#multi-protocol-entries)); `GET /api/addressbook` and `GET /api/addressbook/search-index` do the same. The top-level fields (`session_type`, `hostname`, `port`, `username`, `has_credentials`, ...) describe the primary, so clients that know nothing of protocols see what they always did. In addition: + +| Field | Description | +|-------|-------------| +| `protocol` | Name of the protocol the top-level fields describe | +| `protocols` | Every protocol, in order, each with the same fields as the entry (its effective values: host fields with the protocol's on top) and its `protocol` name | +| `settings` | Admins only: the fields set at that level as stored, for the editor (the host's own at the top level, the protocol's overrides in `protocols`). Secrets appear only as `has_password`, `has_private_key`, ... flags | + ### `POST /api/addressbook/folders/:scope/:folder/entries/:entry/connect` Create a session from an connections entry. Reads credentials (including jump host credentials) from Vault server-side and creates a session. Requires **operator** role and folder group access. @@ -533,6 +543,7 @@ Optional body to override or supply credentials at connect time: ```json { + "protocol": "ssh", "username": "jdoe@CORP.EXAMPLE.COM", "password": "user-password", "domain": "CORP.EXAMPLE.COM", @@ -546,6 +557,8 @@ Optional body to override or supply credentials at connect time: `keyboard_layout` is the connecting user's own keyboard (**Settings > My keyboard layout** in the UI). It applies to RDP entries with no `server_layout` of their own, and to VDI desktops. A name guacd doesn't know is ignored. +`protocol` picks one of the entry's protocols by name (or by type, when no protocol has that name); without it the primary is used. A protocol the entry does not have answers `404`. The session records the protocol's name as `address_book_protocol`, and so does its recording (`protocol`). + 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` @@ -667,12 +680,41 @@ Create a connection entry. The body includes a `name` field plus all entry field | `jump_hosts` | array | All | Multi-hop SSH tunnel chain (same format as session creation) | | `external_id` | string | All | Stable id of the record the entry is synced from (the [NetBox webhook](netbox.md#create-webhook-device-createdupdated) sends `netbox:device:`). What `PUT ...?move=true` matches on; never used to connect. Preserved on update when omitted. | +| `protocols` | array | All | The ways to connect to the host, primary first, in place of `type` (see [Multi-protocol entries](#multi-protocol-entries)) | + For `spice` and `proxmox` entries, the `spice_*` and `proxmox_*` fields listed under [`POST /api/sessions`](#post-apisessions) apply here too. The `proxmox_token_secret` is write-only: it is never returned by the read endpoints (a `has_proxmox_token_secret` boolean indicates whether one is stored), and it is preserved on update when omitted. See [SPICE and Proxmox VE Sessions](spice-and-proxmox.md) for the editor fields and how the token is stored. +#### Multi-protocol entries + +An entry can list several protocols for one host instead of a single `type`. The top-level fields are the host's defaults; each protocol has a `type`, an optional `name` and any entry field, which overrides the host's. An empty value (`""` or `[]`) clears the inherited one. The first protocol is the primary. + +```json +{ + "name": "server01", + "hostname": "10.0.0.10", + "display_name": "server01.example.com", + "username": "Administrator", + "protocols": [ + { "type": "rdp", "port": 3389 }, + { "type": "ssh", "port": 22, "username": "admin" }, + { "type": "ssh", "name": "ssh-2222", "port": 2222 }, + { "type": "web", "url": "https://10.0.0.10/", "hostname": "" } + ] +} +``` + +- `name` defaults to the type and must be unique in the entry (letters, digits, `-`, `_`, `.`). The connect endpoints, updates and sessions refer to a protocol by it. +- `port` is set per protocol and never inherited: a protocol without one connects on its type's standard port (SSH 22, RDP 3389, VNC 5900). A top-level `port` next to `protocols` is refused. +- Credentials resolve in this order: the protocol's own, the host's, the folder's [default credentials](credential-variables.md#folder-default-credentials) for the protocol's type, then `$variable` references are filled in. +- The single-protocol form (a top-level `type`) is still accepted everywhere and is the same as a one-item `protocols` list. A body with both `type` and `protocols` is refused with `400`, as is an unknown protocol type or two protocols with the same name. +- An entry that loses nothing by it is stored in the single-protocol form, so instances that predate this keep reading it. An entry with more than one protocol, a protocol name, or a host default the single form would put on the protocol (for example a host `username`) is stored with `protocols`; see [Migration](migration.md#multi-protocol-entries) before you create one. + ### `PUT /api/addressbook/folders/:scope/:folder/entries/:entry` (admin) Update a connection entry, or create it if there is none of that name in the folder. Uses read-modify-write: reads existing entry from Vault, merges incoming fields on top. Credentials (`password`, `private_key`, `container_password`, `proxmox_token_secret`) that are omitted from the request are preserved from the existing entry, and so is `external_id`. Jump host credentials are merged per-hop by index. If the existing entry cannot be read for any reason other than not existing, the response is `502` and nothing is written, so a request without secrets never replaces an entry that has them. +With [protocols](#multi-protocol-entries), the request's list is the entry's list of protocols, and secrets are kept per protocol: each protocol keeps the ones it omits from the stored protocol of the same name, and the host keeps its own. When the request and the stored entry each have one protocol, the two are matched whatever their names, so an old single-protocol body that changes an entry's `type` keeps its secrets as before. A single-protocol body (such as the original NetBox webhook) sent for an entry that has several protocols replaces them with that one. + **`?move=true`** is for sync sources such as the [NetBox webhook](netbox.md#create-webhook-device-createdupdated), where the folder or the name is computed from the device and can change. It needs `external_id` in the body and matches the device on that, never on its name: 1. If `:folder/:entry` exists with the same `external_id`, or with none (an entry made by hand before syncing began), it is updated in place. diff --git a/docs/migration.md b/docs/migration.md index d844f3e3..ebe522ee 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -162,6 +162,22 @@ Once imported, connections appear in the connections UI. You can: - Guacamole user/group permissions are not imported. Use rustguac's OIDC group mappings and folder `allowed_groups` instead. - Credentials (passwords, private keys) are imported into Vault where they are stored encrypted at rest and never touch disk. +## Multi-protocol entries + +Connection entries can list several [protocols](api.md#multi-protocol-entries) for one host (RDP, SSH and a web UI on the same server), with host-level defaults that each protocol can override. + +Nothing needs to be done on upgrade: + +- Existing entries are read as they are, as entries with one protocol, and connect exactly as before. Nothing in Vault is rewritten. +- Existing links (`/api/connect?scope=…&folder=…&entry=…`), API clients, scripts, imports and the original single-protocol NetBox webhook keep working, since the single-protocol form (a top-level `type`) is still accepted everywhere. +- An entry that can be stored in the old form without losing anything still is, whether it was saved from the editor, the API or a webhook, so older instances keep reading it. + +An entry is stored in the new form only when it needs it: more than one protocol, a protocol with a name of its own, or a host-level default such as a shared username. **Older rustguac versions cannot read an entry in the new form**: they leave it out of their lists and cannot connect to it. An update from them (an edit, or the NetBox webhook's `PUT`) is refused rather than written over it, but creating a new entry of the same name there replaces it. It is missing for everyone on that instance until it is upgraded. + +So, when several rustguac instances share a Vault (including a `[vault_shared]` scope), upgrade every one of them before anyone adds a second protocol or a protocol name to an entry, or switches the NetBox webhook to the [multi-protocol template](netbox.md#multi-protocol-entries). + +There is no bulk conversion command, and none is needed: old entries keep working in the old form, and each entry moves to the new one only when it is edited to use something that needs it. + ## Splitting to multiple Vaults (disaster recovery) If you already run a single Vault serving both the `shared` and `instance` diff --git a/docs/netbox.md b/docs/netbox.md index ff6efddc..888a8521 100644 --- a/docs/netbox.md +++ b/docs/netbox.md @@ -12,8 +12,10 @@ Go to **Customization > Custom Fields** and create the following fields. Assign |------|------|---------|-------------| | `console_enabled` | Boolean | false | Opt-in: enables remote console links on the device page | | `console_mode` | Selection: `addressbook`, `adhoc` | — | How to connect: via connections entry (Vault credentials) or ad-hoc (direct to IP) | -| `remote_protocol` | Selection: `ssh`, `rdp`, `vnc`, `web` | — | Protocol for ad-hoc connections (connections entries have their own) | -| `remote_port` | Integer | — | Port override for ad-hoc connections (leave blank for protocol default) | +| `remote_protocol` | Selection: `ssh`, `rdp`, `vnc`, `web` | — | Protocol for ad-hoc connections, and the primary protocol of a synced entry | +| `remote_port` | Integer | — | Port override for `remote_protocol` (leave blank for protocol default) | +| `remote_protocols` | Multiple selection: `ssh`, `rdp`, `vnc`, `web` | — | Optional. More protocols for a synced entry, besides `remote_protocol` (see [Multi-protocol entries](#multi-protocol-entries)) | +| `remote_ports` | JSON | — | Optional. Ports for protocols that do not use the standard one, e.g. `{"ssh": 2222, "web": 8443}` | The `console_enabled` field is the master switch — no links appear until it's checked. The `console_mode` field controls which link is shown: @@ -45,6 +47,16 @@ https://console.example.com/api/connect?scope=shared&folder=production&entry={{ Replace `production` with your connections folder name. The entry name must match the lowercase device name in Vault. +The link opens the entry's primary protocol. To offer each protocol of a [multi-protocol entry](#multi-protocol-entries), add `&protocol=` (the protocol's name, which is its type unless it was given another, e.g. `ssh`). Create one Custom Link per protocol with the same **Group name** (e.g. `Console`), and NetBox shows them as one Console dropdown: + +| Name | Group name | Link text | Link URL ends in | +|------|-----------|-----------|------------------| +| Console SSH | Console | `{% if object.cf.console_enabled and object.cf.console_mode == 'addressbook' and ('ssh' == (object.cf.remote_protocol or 'ssh') or 'ssh' in (object.cf.remote_protocols or [])) %}SSH{% endif %}` | `&protocol=ssh` | +| Console RDP | Console | same, with `'rdp'` | `&protocol=rdp` | +| Console Web | Console | same, with `'web'` | `&protocol=web` | + +A link naming a protocol the entry does not have says so. + ### Link 2: Ad-hoc SSH (blue outline) | Setting | Value | @@ -147,7 +159,7 @@ Use Event Rule **conditions** to sync only the devices you want. You can filter ``` Authorization: Bearer ``` - - Body template: + - Body template (one protocol per device; see [Multi-protocol entries](#multi-protocol-entries) for the template that syncs several): ```json { "type": "{{ data.custom_fields.remote_protocol | default('ssh') }}", @@ -171,6 +183,57 @@ Use Event Rule **conditions** to sync only the devices you want. You can filter The first time a device is seen, rustguac reads every entry in the scope to look for its `external_id`, so a first sync of thousands of devices at once is slow. Updates to a device already in place need no scan. +### Multi-protocol entries + +An entry can offer several ways into one device, for example RDP, SSH and its web UI, each with its own port and, if needed, credentials. `remote_protocol` stays the primary protocol (what the plain Console link opens) and `remote_port` its port. Add the optional `remote_protocols` and `remote_ports` custom fields from the table above, and use this body template instead of the one above: + +```jinja +{%- set ip = data.primary_ip4.address.split('/')[0] -%} +{%- set primary = data.custom_fields.remote_protocol or 'ssh' -%} +{%- set ports = data.custom_fields.remote_ports or {} -%} +{%- set extra = data.custom_fields.remote_protocols or [] -%} +{%- macro proto(p, port) -%} +{"type": "{{ p }}" +{%- if p == 'web' %}, "url": "https://{{ ip }}{% if port %}:{{ port }}{% endif %}/" +{%- elif port %}, "port": {{ port }}{% endif %}} +{%- endmacro -%} +{ + "hostname": "{{ ip }}", + "display_name": "{{ data.name }} ({{ data.site.name }})", + "prompt_credentials": true, + "external_id": "netbox:device:{{ data.id }}", + "protocols": [ + {{ proto(primary, data.custom_fields.remote_port or ports.get(primary)) }} + {%- for p in extra if p != primary %}, + {{ proto(p, ports.get(p)) }} + {%- endfor %} + ] +} +``` + +For a device with `remote_protocol: rdp`, `remote_protocols: [ssh, web]` and `remote_ports: {"ssh": 2222, "web": 8443}` it renders: + +```json +{ + "hostname": "10.0.0.9", + "display_name": "fw01 (DC1)", + "prompt_credentials": true, + "external_id": "netbox:device:42", + "protocols": [ + {"type": "rdp"}, + {"type": "ssh", "port": 2222}, + {"type": "web", "url": "https://10.0.0.9:8443/"} + ] +} +``` + +- The host fields (`hostname`, `display_name`, `prompt_credentials`, `external_id`) apply to every protocol. A protocol without a port uses its type's standard one (SSH 22, RDP 3389, VNC 5900); a web protocol gets a URL instead. +- A device without the new fields renders a single `{"type": "ssh"}` (or its `remote_protocol`), which is stored exactly as the single-protocol template stores it, so you can switch templates without touching existing entries. +- Passwords and keys stored on an entry are kept per protocol on every sync, matched by protocol name, and so are any host-level ones. +- The body is the whole list of protocols: a protocol added to a synced entry by hand in rustguac is removed on the device's next sync. Add it in NetBox instead (`remote_protocols`). +- The single-protocol template above keeps working unchanged. It is the same as a one-item `protocols` list. +- Every rustguac instance that shares the Vault must be on a version that knows multi-protocol entries before any device gets a second protocol: older versions cannot read such an entry and leave it out (see [Migration](migration.md)). + ### Create webhook: device deleted 1. **Create an Event Rule**: @@ -214,7 +277,7 @@ Both NetBox and rustguac support OIDC authentication. When configured with the s 1. Configure rustguac with OIDC (see [Integrations > OIDC](integrations.md)) 2. Configure NetBox with the same OIDC provider -3. Create the four custom fields (`console_enabled`, `console_mode`, `remote_protocol`, `remote_port`) +3. Create the four custom fields (`console_enabled`, `console_mode`, `remote_protocol`, `remote_port`), plus `remote_protocols` and `remote_ports` for multi-protocol entries 4. Create the two Custom Links (Console green, Quick SSH blue) 5. On devices you want to enable: check `console_enabled`, set `console_mode` 6. For connections mode: ensure matching entries exist in Vault (manually or via webhook sync) diff --git a/src/api.rs b/src/api.rs index 4a387af8..dcb6d8fa 100644 --- a/src/api.rs +++ b/src/api.rs @@ -987,6 +987,9 @@ pub async fn list_recordings( if let Some(ref t) = sidecar.session_type { rec["session_type"] = json!(t); } + if let Some(ref p) = sidecar.protocol { + rec["protocol"] = json!(p); + } } if let Some(ref conv) = mp4 { rec["mp4"] = mp4_state_json(&conv.state(&name))["state"].clone(); @@ -3594,8 +3597,14 @@ pub async fn ab_list_all( let mut entries = Vec::new(); for name in &entry_names { if let Ok(entry) = vault.get_entry(&folder.scope, &folder.name, name).await { - let mut info = crate::vault::EntryInfo::from((name.as_str(), &entry)); - info.apply_inherited(&entry, defaults.as_ref()); + let Some(info) = crate::vault::EntryInfo::for_viewer( + name, + &entry, + defaults.as_ref(), + id.has_role("admin"), + ) else { + continue; + }; entries.push(info); } } @@ -3700,8 +3709,14 @@ pub async fn ab_search_index( .filter(|s| !s.is_empty()); for name in &names { if let Ok(entry) = vault.get_entry(&scope, &path, name).await { - let mut info = crate::vault::EntryInfo::from((name.as_str(), &entry)); - info.apply_inherited(&entry, defaults.as_ref()); + let Some(info) = crate::vault::EntryInfo::for_viewer( + name, + &entry, + defaults.as_ref(), + id.has_role("admin"), + ) else { + continue; + }; emitted.push(json!({ "scope": scope, "folder_path": path, @@ -3757,8 +3772,14 @@ pub async fn ab_list_entries( let mut entries = Vec::new(); for name in &entry_names { if let Ok(entry) = vault.get_entry(&scope, &folder, name).await { - let mut info = crate::vault::EntryInfo::from((name.as_str(), &entry)); - info.apply_inherited(&entry, defaults.as_ref()); + let Some(info) = crate::vault::EntryInfo::for_viewer( + name, + &entry, + defaults.as_ref(), + id.has_role("admin"), + ) else { + continue; + }; entries.push(info); } } @@ -3794,6 +3815,10 @@ pub struct ConnectRequest { pub password: Option, #[serde(default)] pub domain: Option, + /// Which of the entry's protocols to connect with, by name (#296). + /// Default: the primary. + #[serde(default)] + pub protocol: Option, } /// POST /api/ssh/probe-host-key — Probe an SSH server to retrieve its host key. @@ -4029,6 +4054,7 @@ pub fn session_request_from_entry( record_typescript: entry.record_typescript, address_book_entry, address_book_folder, + address_book_protocol: None, entry_display_name, max_recordings: entry.max_recordings, login_script: entry.login_script, @@ -4162,6 +4188,20 @@ pub async fn ab_connect_entry( } }; + // The protocol to connect with, resolved to a plain single-type entry. + let Some((protocol_name, ab_entry)) = ab_entry.pick_protocol(req.protocol.as_deref()) else { + let msg = if req + .protocol + .as_deref() + .is_some_and(|p| !p.trim().is_empty()) + { + "protocol not found" + } else { + "entry not found" + }; + return (StatusCode::NOT_FOUND, Json(json!({"error": msg}))).into_response(); + }; + let ab_entry = lend_folder_defaults(vault.as_ref(), &scope, &folder, &entry, ab_entry).await; // Resolve credential variable references ($domain_user, $domain_password, etc.) @@ -4201,7 +4241,10 @@ pub async fn ab_connect_entry( let ab_entry_key = format!("{}/{}/{}", scope, folder, entry); let create_req = match entry_connect_request(ab_entry, req, &ab_entry_key, &folder) { - Ok(r) => r, + Ok(mut r) => { + r.address_book_protocol = Some(protocol_name.clone()); + r + } Err(msg) => { return (StatusCode::BAD_REQUEST, Json(json!({ "error": msg }))).into_response() } @@ -4216,6 +4259,7 @@ pub async fn ab_connect_entry( client_ip = %client_ip_addr, folder = %folder, entry = %entry, + protocol = %protocol_name, scope = %scope, "Address book connect requested" ); @@ -4834,6 +4878,35 @@ pub async fn ab_delete_folder( } } +/// Check an entry body from the API and tidy it for storage: either form +/// (a top-level `type`, or `protocols`), written in the old flat form when +/// that loses nothing. +fn prepare_entry_body(mut e: AddressBookEntry) -> Result { + e.check_shape().map_err(|e| e.to_string())?; + if let Some(protocols) = e.protocols.as_mut() { + for p in protocols.iter_mut() { + if p.name.as_deref() == Some(p.session_type.as_str()) { + p.name = None; + } + } + } + Ok(e.compact()) +} + +/// Audit details of an entry write: headline only, the protocol types. +/// `moved_from` is `Some` for an update (`Some(None)`: not moved). +fn entry_audit_details(e: &AddressBookEntry, moved_from: Option>) -> String { + let types = e.protocol_types(); + let mut d = json!({ "type": types.first().cloned().unwrap_or_default() }); + if types.len() > 1 { + d["protocols"] = json!(types); + } + if let Some(from) = moved_from { + d["moved_from"] = json!(from); + } + d.to_string() +} + #[derive(Deserialize)] pub struct CreateEntryRequest { pub name: String, @@ -4864,14 +4937,14 @@ pub async fn ab_create_entry( } }; - let session_type = req.entry.session_type.clone(); - match vault - .put_entry(&scope, &folder, &req.name, &req.entry) - .await - { + let entry = match prepare_entry_body(req.entry) { + Ok(e) => e, + Err(msg) => return ab_error(StatusCode::BAD_REQUEST, msg), + }; + let details = entry_audit_details(&entry, None); + match vault.put_entry(&scope, &folder, &req.name, &entry).await { Ok(()) => { let ip = audit_client_ip(&headers, &addr, trusted.as_ref()); - let details = json!({ "type": session_type }).to_string(); log_ab_event( &database, &admin_email, @@ -5014,7 +5087,40 @@ impl EntryStore for VaultBackends { /// webhook (which has none) can send them back. Jump host credentials are /// kept per hop by index; `external_id` is kept the same way so an edit /// from the form does not cut a synced entry loose from its source. +/// +/// Both sides are taken as a host and its protocols (#296), so an old flat +/// body and an old flat stored entry each count as one protocol. The host's +/// secrets are kept as above, and each protocol's are kept from the stored +/// protocol of the same name; when both sides have exactly one protocol the +/// two are paired whatever their names, so an old flat body that changes +/// an entry's type keeps its secrets as it always did. The request's list +/// of protocols is the entry's list. The result is written in the old flat +/// form whenever that loses nothing (`AddressBookEntry::compact`). fn merge_entry_update(data: AddressBookEntry, existing: AddressBookEntry) -> AddressBookEntry { + let (data_host, mut data_protocols) = data.split(); + let (existing_host, existing_protocols) = existing.split(); + let mut host = merge_host_update(data_host, existing_host); + let pair_singles = data_protocols.len() == 1 && existing_protocols.len() == 1; + for p in &mut data_protocols { + let old = existing_protocols + .iter() + .find(|o| o.name() == p.name()) + .or(if pair_singles { + existing_protocols.first() + } else { + None + }); + if let Some(old) = old { + crate::entry_protocols::keep_secrets(&mut p.settings, &old.settings); + } + } + host.protocols = Some(data_protocols); + host.session_type = String::new(); + host.compact() +} + +/// `merge_entry_update` for the host's own fields. +fn merge_host_update(data: AddressBookEntry, existing: AddressBookEntry) -> AddressBookEntry { let merged_jump_hosts = if let Some(ref new_hops) = data.jump_hosts { let old_hops = existing.jump_hosts.as_deref().unwrap_or(&[]); let merged: Vec<_> = new_hops @@ -5270,6 +5376,10 @@ pub async fn ab_update_entry( } }; let ip = audit_client_ip(&headers, &addr, trusted.as_ref()); + let data = match prepare_entry_body(data) { + Ok(e) => e, + Err(msg) => return ab_error(StatusCode::BAD_REQUEST, msg), + }; let external_id = data.external_id.clone(); let result = upsert_entry(vault.as_ref(), &scope, &folder, &entry, data, query.move_).await; @@ -5315,11 +5425,7 @@ pub async fn ab_update_entry( let response = match result { Ok(outcome) => { - let details = json!({ - "type": outcome.entry.session_type, - "moved_from": outcome.moved_from, - }) - .to_string(); + let details = entry_audit_details(&outcome.entry, Some(outcome.moved_from.as_ref())); log_ab_event( &database, &admin_email, @@ -6752,7 +6858,15 @@ impl FolderSource for VaultFolderSource<'_> { .unwrap_or_default(); for entry_name in &entries { if let Ok(entry) = self.vault.get_entry(scope, path, entry_name).await { - out.extend(crate::vault::entry_credential_variables(&entry)); + // Every protocol (#296), each resolved against the host it + // inherits from. + for (_, eff) in entry.visible_protocols() { + for var in crate::vault::entry_credential_variables(&eff) { + if !out.contains(&var) { + out.push(var); + } + } + } } } out @@ -7246,6 +7360,20 @@ pub async fn quick_connect( } }; + // In entry mode `protocol` picks one of the entry's protocols by + // name (or type); without it the primary is used, as links made + // before multi-protocol entries expect. + let requested = query.protocol.as_deref().filter(|p| !p.trim().is_empty()); + let Some((protocol_name, ab_entry)) = ab_entry.pick_protocol(requested) else { + return quick_connect_error( + StatusCode::NOT_FOUND, + &match requested { + Some(p) => format!("Entry '{}' has no protocol '{}'.", entry, p), + None => format!("Entry '{}' not found in {}/{}.", entry, scope, folder), + }, + ); + }; + // Same folder defaults as the Connections connect endpoint, so an // entry that works from the page works from a link too. let ab_entry = lend_folder_defaults(vault.as_ref(), scope, folder, entry, ab_entry).await; @@ -7295,6 +7423,7 @@ pub async fn quick_connect( scope, folder, entry, + &protocol_name, &ab_entry.session_type, ab_entry.username.as_deref(), ab_entry.domain.as_deref(), @@ -7314,7 +7443,10 @@ pub async fn quick_connect( } let create_req = match quick_connect_entry_request(ab_entry, &query, scope, folder, entry) { - Ok(r) => r, + Ok(mut r) => { + r.address_book_protocol = Some(protocol_name.clone()); + r + } Err(msg) => return quick_connect_error(StatusCode::BAD_REQUEST, &msg), }; @@ -7324,6 +7456,7 @@ pub async fn quick_connect( scope = %scope, folder = %folder, entry = %entry, + protocol = %protocol_name, "Quick connect (address book)" ); @@ -7399,6 +7532,7 @@ pub async fn quick_connect( record_typescript: None, address_book_entry: None, address_book_folder: None, + address_book_protocol: None, entry_display_name: None, max_recordings: None, login_script: None, @@ -7477,10 +7611,12 @@ fn quick_connect_entry_request( } /// Return an inline HTML credential form for quick-connect when prompting is needed. +#[allow(clippy::too_many_arguments)] fn quick_connect_credential_form( scope: &str, folder: &str, entry: &str, + protocol: &str, session_type: &str, username: Option<&str>, domain: Option<&str>, @@ -7521,7 +7657,7 @@ button:disabled{{opacity:.6;cursor:wait}}

{title}

{session_type_upper} connection
+ data-scope="{scope}" data-folder="{folder}" data-entry="{entry}" data-protocol="{protocol}"> @@ -7551,6 +7687,7 @@ document.getElementById('cred-form').addEventListener('submit', async function(e username: document.getElementById('username').value || undefined, password: document.getElementById('password').value || undefined, domain: document.getElementById('domain').value || undefined, + protocol: form.dataset.protocol || undefined, width: window.innerWidth, height: window.innerHeight, dpi: Math.round(window.devicePixelRatio * 96) || 96, @@ -7587,6 +7724,7 @@ document.getElementById('cred-form').addEventListener('submit', async function(e scope = html_escape(scope), folder = html_escape(folder), entry = html_escape(entry), + protocol = html_escape(protocol), user_val = user_val, domain_val = domain_val, ); @@ -7676,6 +7814,7 @@ mod tests { record_typescript: ab_entry.record_typescript, address_book_entry: Some(ab_entry_key.clone()), address_book_folder: Some(folder.to_string()), + address_book_protocol: None, entry_display_name: ab_entry.display_name.clone(), max_recordings: ab_entry.max_recordings, login_script: ab_entry.login_script, @@ -7772,6 +7911,7 @@ mod tests { record_typescript: ab_entry.record_typescript, address_book_entry: Some(ab_entry_key), address_book_folder: Some(folder.to_string()), + address_book_protocol: None, entry_display_name: ab_entry.display_name.clone(), max_recordings: ab_entry.max_recordings, login_script: ab_entry.login_script, @@ -9997,4 +10137,214 @@ mod tests { .collect(); assert!(super::normalize_allowed_users(many).is_err()); } + + // ── Multi-protocol entries (#296): the old flat body still works. ── + + /// Exactly what the NetBox webhook template in docs/netbox.md (before + /// #296) renders for a device with no custom fields set. + fn netbox_flat_body() -> serde_json::Value { + json!({ + "type": "ssh", + "hostname": "10.0.0.9", + "port": 22, + "display_name": "fw01 (DC1)", + "prompt_credentials": true, + "external_id": "netbox:device:1" + }) + } + + #[tokio::test] + async fn old_netbox_body_upserts_moves_keeps_secrets_and_connects_as_before() { + let store = FakeStore::with(&[("tenant-a", "fw01", fw01_stored())]); + let body = super::prepare_entry_body(entry(netbox_flat_body())).unwrap(); + assert!( + body.protocols.is_none(), + "an old body stays in the old form" + ); + let out = upsert_entry(&store, "shared", "tenant-b", "fw01", body, true) + .await + .unwrap(); + assert_eq!(out.moved_from, Some(from("tenant-a", "fw01"))); + assert_eq!(store.keys(), ["tenant-b/fw01"]); + let stored = store.stored("tenant-b", "fw01").unwrap(); + // Stored exactly as before #296: flat, no protocols list. + let v = serde_json::to_value(&stored).unwrap(); + assert_eq!(v["type"], json!("ssh")); + assert!(v.get("protocols").is_none()); + assert_eq!(stored.hostname.as_deref(), Some("10.0.0.9")); + assert_eq!(stored.prompt_credentials, Some(true)); + assert_eq!(stored.password.as_deref(), Some("$tenant_a_password")); + assert_eq!(stored.private_key.as_deref(), Some("-----BEGIN KEY-----")); + assert_eq!(stored.external_id.as_deref(), Some("netbox:device:1")); + // The body has no username or jump hosts, so, as before, they go. + assert_eq!(stored.username, None); + // And it connects as the flat entry always did. + let (name, eff) = stored.pick_protocol(None).unwrap(); + assert_eq!(name, "ssh"); + assert_eq!( + serde_json::to_value(&eff).unwrap(), + serde_json::to_value(&stored).unwrap() + ); + // The steady state: a second sync is an update in place. + let again = upsert_entry( + &store, + "shared", + "tenant-b", + "fw01", + super::prepare_entry_body(entry(netbox_flat_body())).unwrap(), + true, + ) + .await + .unwrap(); + assert_eq!(again.moved_from, None); + assert_eq!( + store + .stored("tenant-b", "fw01") + .unwrap() + .password + .as_deref(), + Some("$tenant_a_password") + ); + } + + #[test] + fn new_netbox_template_without_the_new_fields_stores_what_it_did() { + // The multi-protocol template in docs/netbox.md, rendered for a + // device whose remote_protocols / remote_ports are unset. + let e = super::prepare_entry_body(entry(json!({ + "hostname": "10.0.0.9", + "display_name": "fw01 (DC1)", + "prompt_credentials": true, + "external_id": "netbox:device:1", + "protocols": [{"type": "ssh"}] + }))) + .unwrap(); + let v = serde_json::to_value(&e).unwrap(); + assert!(v.get("protocols").is_none(), "old form: {v}"); + assert_eq!(v["type"], json!("ssh")); + assert_eq!(v["prompt_credentials"], json!(true)); + // The old template stored port 22; this one leaves it to the + // session default, which is 22. + assert_eq!(e.port, None); + // And with extra protocols it lists them, primary first. + let e = super::prepare_entry_body(entry(json!({ + "hostname": "10.0.0.9", "display_name": "fw01 (DC1)", "prompt_credentials": true, + "external_id": "netbox:device:1", + "protocols": [{"type": "rdp"}, {"type": "ssh", "port": 2222}, + {"type": "web", "url": "https://10.0.0.9:8443/"}] + }))) + .unwrap(); + assert_eq!(e.protocol_types(), ["rdp", "ssh", "web"]); + let (_, ssh) = e.pick_protocol(Some("ssh")).unwrap(); + assert_eq!((ssh.port, ssh.prompt_credentials), (Some(2222), Some(true))); + } + + #[test] + fn an_old_body_that_changes_the_type_keeps_the_secrets_as_it_did() { + let merged = merge_entry_update( + entry(json!({"type": "rdp", "hostname": "h", "port": 3389})), + entry(json!({"type": "ssh", "hostname": "h", "password": "pw"})), + ); + assert_eq!(merged.session_type, "rdp"); + assert_eq!(merged.password.as_deref(), Some("pw")); + assert!(merged.protocols.is_none()); + } + + #[test] + fn multi_protocol_update_keeps_each_protocols_secrets_by_name() { + let existing = entry(json!({ + "hostname": "10.0.0.10", "password": "host-pw", "external_id": "netbox:device:4", + "protocols": [ + {"type": "rdp", "password": "rdp-pw"}, + {"type": "ssh", "name": "ssh-2222", "port": 2222, "private_key": "KEY"} + ] + })); + // The editor sends no secrets, and has reordered the protocols and + // added one. + let data = super::prepare_entry_body(entry(json!({ + "hostname": "10.0.0.11", + "protocols": [ + {"type": "ssh", "name": "ssh-2222", "port": 2222, "username": "admin"}, + {"type": "rdp"}, + {"type": "vnc"} + ] + }))) + .unwrap(); + let merged = merge_entry_update(data, existing); + assert_eq!(merged.password.as_deref(), Some("host-pw")); + assert_eq!(merged.external_id.as_deref(), Some("netbox:device:4")); + let ps = merged.protocols.as_ref().unwrap(); + assert_eq!(ps[0].name(), "ssh-2222"); + assert_eq!(ps[0].settings["private_key"], json!("KEY")); + assert_eq!(ps[1].settings["password"], json!("rdp-pw")); + assert!(ps[2].settings.get("password").is_none()); + // Effective credentials: own, then the host's. + assert_eq!( + merged.effective(Some("vnc")).unwrap().password.as_deref(), + Some("host-pw") + ); + assert_eq!( + merged.effective(None).unwrap().hostname.as_deref(), + Some("10.0.0.11") + ); + } + + #[test] + fn a_new_single_protocol_body_is_stored_in_the_old_form() { + let e = super::prepare_entry_body(entry(json!({ + "hostname": "h", "display_name": "d", + "protocols": [{"type": "ssh", "name": "ssh", "port": 22, "username": "u"}] + }))) + .unwrap(); + assert!(e.protocols.is_none()); + assert_eq!(e.session_type, "ssh"); + assert!(super::prepare_entry_body(entry(json!({"hostname": "h"}))).is_err()); + } + + #[test] + fn listing_shows_every_protocol_and_no_secrets() { + let e = entry(json!({ + "hostname": "10.0.0.10", "username": "Administrator", "password": "host-pw", + "protocols": [ + {"type": "rdp", "password": "rdp-pw"}, + {"type": "ssh", "username": "admin"} + ] + })); + let info = crate::vault::EntryInfo::for_viewer("srv", &e, None, false).unwrap(); + assert_eq!(info.session_type, "rdp"); + assert_eq!(info.protocol.as_deref(), Some("rdp")); + assert_eq!(info.protocols.len(), 2); + assert_eq!(info.protocols[1].username.as_deref(), Some("admin")); + let text = serde_json::to_string(&info).unwrap(); + assert!(!text.contains("pw"), "{text}"); + assert!(!text.contains("settings"), "settings are for admins"); + // An admin also gets each level's own fields, secrets as flags. + let admin = crate::vault::EntryInfo::for_viewer("srv", &e, None, true).unwrap(); + assert_eq!(admin.protocols.len(), 2); + let text = serde_json::to_string(&admin).unwrap(); + assert!(!text.contains("pw\""), "{text}"); + assert_eq!( + admin.settings.as_ref().unwrap()["has_password"], + json!(true) + ); + assert_eq!( + admin.protocols[0].settings.as_ref().unwrap()["has_password"], + json!(true) + ); + } + + #[test] + fn a_flat_entry_lists_as_before_plus_its_one_protocol() { + let e = fw01_stored(); + let before = serde_json::to_value(crate::vault::EntryInfo::from(("fw01", &e))).unwrap(); + let now = serde_json::to_value( + crate::vault::EntryInfo::for_viewer("fw01", &e, None, false).unwrap(), + ) + .unwrap(); + for (k, v) in before.as_object().unwrap() { + assert_eq!(&now[k], v, "{k} unchanged"); + } + assert_eq!(now["protocol"], json!("ssh")); + assert_eq!(now["protocols"].as_array().unwrap().len(), 1); + } } diff --git a/src/entry_protocols.rs b/src/entry_protocols.rs new file mode 100644 index 00000000..349e7536 --- /dev/null +++ b/src/entry_protocols.rs @@ -0,0 +1,694 @@ +//! Multi-protocol connection entries (#296). +//! +//! An entry describes one host. Its top-level fields are the host's +//! defaults, and `protocols` lists the ways to connect to it, each a session +//! type plus the fields it overrides: +//! +//! ```json +//! { +//! "hostname": "10.0.0.10", +//! "display_name": "server01.example.com", +//! "username": "Administrator", +//! "protocols": [ +//! { "type": "rdp", "port": 3389 }, +//! { "type": "ssh", "port": 22, "username": "admin" }, +//! { "type": "web", "url": "https://10.0.0.10/", "hostname": "" } +//! ] +//! } +//! ``` +//! +//! A protocol inherits every host field it does not set, and clears one by +//! setting it empty (`""` or `[]`). `port` is the exception: it is never +//! inherited, so a protocol without one connects on its type's standard +//! port. The first protocol is the primary, the one Connect uses. +//! +//! `AddressBookEntry::effective` resolves the host and one protocol into a +//! plain single-type entry, which is what the connect path, folder defaults +//! and credential variables have always worked on, so they are unchanged. +//! +//! An entry stored before this existed has a top-level `type` and no +//! `protocols`. It is read as a one-protocol entry ([`split`]): the host +//! keeps the fields that describe the machine (see [`HOST_KEYS`]) and the +//! protocol gets everything else, credentials included, so a protocol added +//! later does not silently inherit them. Its effective entry is the stored +//! entry exactly. Nothing is rewritten on upgrade, and an entry that can be +//! written in the old form without losing anything is still written that +//! way ([`compact`]), so instances that do not know about `protocols` keep +//! reading it. +//! +//! [`split`]: AddressBookEntry::split +//! [`compact`]: AddressBookEntry::compact + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +use crate::vault::AddressBookEntry; + +/// Session types a protocol may have. +pub const PROTOCOL_TYPES: &[&str] = &["ssh", "rdp", "vnc", "spice", "proxmox", "web", "vdi"]; + +/// Fields an entry in the old single-protocol form keeps on the host when it +/// is read as a one-protocol entry: what describes the machine rather than +/// how to connect to it, and whether to prompt for credentials (a policy +/// for the host, which the NetBox webhook sets there). Everything else, +/// credentials included, moves to the protocol. +pub const HOST_KEYS: &[&str] = &[ + "hostname", + "display_name", + "external_id", + "banner", + "prompt_credentials", + "wol_send_packet", + "wol_mac_addr", + "wol_broadcast_addr", + "wol_udp_port", + "wol_wait_time", +]; + +/// Fields that hold a secret. They are kept on update when the request +/// omits them, and never sent to a browser. +pub const SECRET_KEYS: &[&str] = &[ + "password", + "private_key", + "container_password", + "proxmox_token_secret", +]; + +/// Most protocols one entry may have. +pub const MAX_PROTOCOLS: usize = 16; + +/// One way to connect to an entry's host: a session type and the host +/// fields it overrides. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct ProtocolEntry { + #[serde(rename = "type")] + pub session_type: String, + /// Tells two protocols of the same type apart (`ssh` and `ssh-2222`). + /// Defaults to the type; unique within the entry. The connect API, + /// updates and sessions refer to a protocol by it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Every other field, as stored: any entry field, overriding the host's. + #[serde(flatten)] + pub settings: Map, +} + +impl ProtocolEntry { + /// The protocol's name: `name` when set, else its type. + pub fn name(&self) -> &str { + self.name + .as_deref() + .filter(|n| !n.is_empty()) + .unwrap_or(&self.session_type) + } +} + +/// Drop JSON nulls: an absent field and a null one mean the same (inherit). +fn non_null(map: Map) -> Map { + map.into_iter().filter(|(_, v)| !v.is_null()).collect() +} + +fn to_map(e: &AddressBookEntry) -> Map { + match serde_json::to_value(e) { + Ok(Value::Object(m)) => non_null(m), + _ => Map::new(), + } +} + +/// Why an entry body was refused. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EntryShapeError(pub String); + +impl std::fmt::Display for EntryShapeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// A protocol name: what an entry name may be (it appears in links). +fn valid_protocol_name(n: &str) -> bool { + !n.is_empty() + && n.len() <= 64 + && n.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') +} + +impl AddressBookEntry { + /// Whether the entry lists its protocols (the multi-protocol form) rather + /// than having a single top-level `type`. + pub fn has_protocol_list(&self) -> bool { + self.protocols.as_ref().is_some_and(|p| !p.is_empty()) + } + + /// The host and its protocols. An entry in the old single-protocol form + /// becomes one protocol: the host keeps [`HOST_KEYS`], the protocol gets + /// every other field it has. + pub fn split(&self) -> (AddressBookEntry, Vec) { + if self.has_protocol_list() { + let mut host = self.clone(); + let protocols = host.protocols.take().unwrap_or_default(); + host.session_type = String::new(); + return (host, protocols); + } + let mut flat = self.clone(); + flat.protocols = None; + flat.normalize_jump_hosts(); + let mut host = Map::new(); + let mut settings = Map::new(); + for (k, v) in to_map(&flat) { + if k == "type" { + continue; + } + if HOST_KEYS.contains(&k.as_str()) { + host.insert(k, v); + } else { + settings.insert(k, v); + } + } + let host: AddressBookEntry = + serde_json::from_value(Value::Object(host)).unwrap_or_default(); + let protocol = ProtocolEntry { + session_type: self.session_type.clone(), + settings, + ..Default::default() + }; + (host, vec![protocol]) + } + + /// The protocols in order, the primary first. + pub fn protocol_list(&self) -> Vec { + self.split().1 + } + + /// The single-type entry for one protocol: the host's fields with the + /// protocol's on top. `None` picks the primary. An unknown name, or a + /// protocol whose fields do not form a valid entry, is `None`. + #[cfg(test)] + pub fn effective(&self, protocol: Option<&str>) -> Option { + let (host, protocols) = self.split(); + let p = match protocol { + None => protocols.first(), + Some(name) => protocols.iter().find(|p| p.name() == name), + }?; + effective_of(&host, p).ok() + } + + /// Write in the old single-protocol form when nothing would be lost by + /// it: one protocol, without a name of its own, whose + /// fields and the host's fall exactly where [`split`] would put them back. + /// Such an entry stays readable by instances that predate `protocols`. + /// Anything else is returned as it is. + /// + /// [`split`]: AddressBookEntry::split + pub fn compact(self) -> AddressBookEntry { + let Some(protocols) = self.protocols.as_ref() else { + return self; + }; + let [p] = protocols.as_slice() else { + return self; + }; + if p.name + .as_deref() + .is_some_and(|n| !n.is_empty() && n != p.session_type) + || p.session_type.is_empty() + { + return self; + } + let mut host = self.clone(); + host.protocols = None; + host.session_type = String::new(); + let mut flat = to_map(&host); + if flat.keys().any(|k| !HOST_KEYS.contains(&k.as_str())) { + return self; + } + for (k, v) in non_null(p.settings.clone()) { + if HOST_KEYS.contains(&k.as_str()) || k == "type" || k == "protocols" { + return self; + } + flat.insert(k, v); + } + flat.insert("type".into(), Value::String(p.session_type.clone())); + match serde_json::from_value::(Value::Object(flat)) { + Ok(mut e) => { + e.normalize_jump_hosts(); + e + } + Err(_) => self, + } + } + + /// Check an entry body. The old single-protocol form needs a `type`; the + /// multi-protocol form needs `protocols` and no top-level `type`, each + /// protocol of a known type, with a unique name, and fields that make a + /// valid entry. `port` is set per protocol, never on the host. + pub fn check_shape(&self) -> Result<(), EntryShapeError> { + let err = |m: String| Err(EntryShapeError(m)); + let Some(protocols) = self.protocols.as_ref() else { + if self.session_type.trim().is_empty() { + return err("type is required (or a protocols list)".into()); + } + return Ok(()); + }; + if protocols.is_empty() { + return err("protocols must list at least one protocol".into()); + } + if protocols.len() > MAX_PROTOCOLS { + return err(format!("at most {MAX_PROTOCOLS} protocols per entry")); + } + if !self.session_type.is_empty() { + return err( + "an entry with protocols has no top-level type: set type on each protocol".into(), + ); + } + if self.port.is_some() { + return err("port is set per protocol, not on the host".into()); + } + let (host, _) = self.split(); + let mut seen: Vec<&str> = Vec::new(); + for (i, p) in protocols.iter().enumerate() { + if !PROTOCOL_TYPES.contains(&p.session_type.as_str()) { + return err(format!( + "protocols[{i}]: unknown type {:?} (use one of {})", + p.session_type, + PROTOCOL_TYPES.join(", ") + )); + } + if let Some(n) = p.name.as_deref() { + if !valid_protocol_name(n) { + return err(format!( + "protocols[{i}]: name must be 1-64 letters, digits, '-', '_' or '.'" + )); + } + } + if seen.contains(&p.name()) { + return err(format!( + "protocols[{i}]: two protocols are named {:?}; give one a name", + p.name() + )); + } + seen.push(p.name()); + if p.settings.contains_key("protocols") { + return err(format!("protocols[{i}]: a protocol cannot list protocols")); + } + if let Err(e) = effective_of(&host, p) { + return err(format!("protocols[{i}]: {e}")); + } + } + Ok(()) + } + + /// The protocol for `requested` (`None` for the primary): its name and + /// its effective entry. A name that matches no protocol is tried as a + /// type, so a link that names the type of a single-protocol entry + /// (`&protocol=rdp`) keeps working. + pub fn pick_protocol(&self, requested: Option<&str>) -> Option<(String, AddressBookEntry)> { + let (host, protocols) = self.split(); + let p = match requested.map(str::trim).filter(|r| !r.is_empty()) { + None => protocols.first(), + Some(r) => protocols + .iter() + .find(|p| p.name() == r) + .or_else(|| protocols.iter().find(|p| p.session_type == r)), + }?; + let eff = effective_of(&host, p).ok()?; + Some((p.name().to_string(), eff)) + } + + /// Every protocol with its effective entry, in order. + pub fn visible_protocols(&self) -> Vec<(ProtocolEntry, AddressBookEntry)> { + let (host, protocols) = self.split(); + protocols + .into_iter() + .filter_map(|p| effective_of(&host, &p).ok().map(|e| (p, e))) + .collect() + } + + /// The session types of the entry's protocols, in order (for audit). + pub fn protocol_types(&self) -> Vec { + self.protocol_list() + .into_iter() + .map(|p| p.session_type) + .collect() + } +} + +/// The effective single-type entry of `protocol` on `host`: the host's +/// fields, less `port` (never inherited), with every field the protocol sets +/// on top, an empty one included (that is how a protocol clears one). +pub fn effective_of( + host: &AddressBookEntry, + protocol: &ProtocolEntry, +) -> Result { + let mut m = to_map(host); + for k in ["protocols", "type", "port"] { + m.remove(k); + } + for (k, v) in &protocol.settings { + if v.is_null() || k == "protocols" { + continue; + } + m.insert(k.clone(), v.clone()); + } + m.insert("type".into(), Value::String(protocol.session_type.clone())); + let mut e: AddressBookEntry = + serde_json::from_value(Value::Object(m)).map_err(|e| e.to_string())?; + e.normalize_jump_hosts(); + e.protocols = None; + Ok(e) +} + +/// Keep the stored secrets an update omits, in one protocol's or the host's +/// fields: a secret the request leaves out is kept, one it sends (even +/// empty, to clear it) wins. Jump host credentials and host keys are kept +/// per hop by position, as for the host. +pub fn keep_secrets(new: &mut Map, old: &Map) { + for k in SECRET_KEYS { + let absent = new.get(*k).is_none_or(Value::is_null); + if absent { + if let Some(v) = old.get(*k).filter(|v| !v.is_null()) { + new.insert((*k).to_string(), v.clone()); + } + } + } + if let (Some(Value::Array(new_hops)), Some(Value::Array(old_hops))) = + (new.get_mut("jump_hosts"), old.get("jump_hosts")) + { + for (i, hop) in new_hops.iter_mut().enumerate() { + let (Some(hop), Some(Value::Object(old_hop))) = (hop.as_object_mut(), old_hops.get(i)) + else { + continue; + }; + for k in ["password", "private_key", "host_key"] { + if hop.get(k).is_none_or(Value::is_null) { + if let Some(v) = old_hop.get(k).filter(|v| !v.is_null()) { + hop.insert(k.to_string(), v.clone()); + } + } + } + } + } +} + +/// One level's own fields (the host's, or a protocol's overrides) as an +/// admin's editor may see them: every secret replaced by a `has_` +/// flag (an empty one, which clears an inherited secret, is kept as it is), +/// jump hosts without their credentials and with the host key as its +/// fingerprint. For admins only. +pub fn editor_settings(settings: &Map) -> Value { + let mut out = Map::new(); + for (k, v) in settings { + if v.is_null() { + continue; + } + if SECRET_KEYS.contains(&k.as_str()) { + match v.as_str() { + Some("") => { + out.insert(k.clone(), Value::String(String::new())); + } + _ => { + out.insert(format!("has_{k}"), Value::Bool(true)); + } + } + continue; + } + if k == "jump_hosts" { + let hops: Vec = v + .as_array() + .map(|hops| { + hops.iter() + .map(|h| { + let mut o = Map::new(); + for f in ["hostname", "port", "username"] { + if let Some(x) = h.get(f) { + o.insert(f.into(), x.clone()); + } + } + if let Some(fp) = h + .get("host_key") + .and_then(Value::as_str) + .and_then(|k| crate::tunnel::fingerprint_openssh_key(k).ok()) + { + o.insert("host_key_fingerprint".into(), Value::String(fp)); + } + for f in ["password", "private_key"] { + if h.get(f) + .and_then(Value::as_str) + .is_some_and(|s| !s.is_empty()) + { + o.insert(format!("has_{f}"), Value::Bool(true)); + } + } + Value::Object(o) + }) + .collect() + }) + .unwrap_or_default(); + out.insert(k.clone(), Value::Array(hops)); + continue; + } + // Legacy single jump host fields never reach here (normalised), but + // their secrets must not either. + if k == "jump_password" || k == "jump_private_key" { + continue; + } + out.insert(k.clone(), v.clone()); + } + Value::Object(out) +} + +/// The host's own fields, for [`editor_settings`]. +pub fn host_settings(host: &AddressBookEntry) -> Map { + let mut m = to_map(host); + for k in ["protocols", "type"] { + m.remove(k); + } + m +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn entry(v: Value) -> AddressBookEntry { + serde_json::from_value(v).unwrap() + } + + /// An entry in the old single-protocol form with every kind of field. + fn legacy_rdp() -> Value { + json!({ + "type": "rdp", "hostname": "10.0.0.10", "port": 3390, + "username": "Administrator", "password": "pw", "domain": "CORP", + "security": "nla", "ignore_cert": true, "display_name": "server01", + "prompt_credentials": false, "banner": "Authorised use only", + "enable_recording": true, "max_recordings": 5, + "jump_hosts": [{"hostname": "bastion", "port": 22, "username": "j", "password": "jp"}], + "wol_send_packet": true, "wol_mac_addr": "00:11:22:33:44:55", + "external_id": "netbox:device:7", "allow_sharing": true, + "fullscreen_on_connect": true, "disable_copy": true + }) + } + + #[test] + fn a_legacy_entry_reads_as_one_protocol_with_its_credentials() { + let e = entry(legacy_rdp()); + let (host, protocols) = e.split(); + assert_eq!(protocols.len(), 1); + let p = &protocols[0]; + assert_eq!(p.session_type, "rdp"); + assert_eq!(p.name(), "rdp"); + assert_eq!(p.settings["port"], json!(3390)); + assert_eq!(p.settings["password"], json!("pw")); + assert_eq!(p.settings["username"], json!("Administrator")); + assert!(p.settings.get("hostname").is_none()); + assert_eq!(host.hostname.as_deref(), Some("10.0.0.10")); + assert_eq!(host.display_name.as_deref(), Some("server01")); + assert_eq!(host.external_id.as_deref(), Some("netbox:device:7")); + assert_eq!(host.banner.as_deref(), Some("Authorised use only")); + assert_eq!(host.wol_send_packet, Some(true)); + assert_eq!(host.password, None, "credentials stay with the protocol"); + assert_eq!(host.username, None); + } + + #[test] + fn a_legacy_entry_resolves_to_exactly_itself() { + // Back-compat: the effective entry of item 0 of a stored flat entry + // is the stored entry, field for field. + for v in [ + legacy_rdp(), + json!({"type": "ssh", "hostname": "h", "port": 22}), + json!({"type": "web", "url": "https://x/", "allowed_domains": []}), + json!({"type": "ssh", "hostname": "h", "jump_host": "b", "jump_password": "x"}), + json!({"type": "vdi", "container_image": "img", "container_env": {"A": "1"}}), + ] { + let mut stored = entry(v.clone()); + stored.normalize_jump_hosts(); + let eff = stored.effective(None).unwrap(); + assert_eq!( + serde_json::to_value(&eff).unwrap(), + serde_json::to_value(&stored).unwrap(), + "{v}" + ); + assert!(stored.effective(Some(&stored.session_type)).is_some()); + } + } + + #[test] + fn a_legacy_entry_round_trips_through_compact_unchanged() { + let stored = entry(legacy_rdp()); + let (host, protocols) = stored.split(); + let mut rebuilt = host; + rebuilt.protocols = Some(protocols); + let compacted = rebuilt.compact(); + assert!(compacted.protocols.is_none(), "written in the old form"); + let mut expect = stored.clone(); + expect.normalize_jump_hosts(); + assert_eq!( + serde_json::to_value(&compacted).unwrap(), + serde_json::to_value(&expect).unwrap() + ); + } + + #[test] + fn compact_keeps_the_new_form_when_the_old_one_would_lose_something() { + // Two protocols. + let two = entry(json!({"hostname": "h", "protocols": [{"type": "ssh"}, {"type": "rdp"}]})); + assert!(two.compact().has_protocol_list()); + // A host default the old form would move into the protocol. + let host_user = + entry(json!({"hostname": "h", "username": "u", "protocols": [{"type": "ssh"}]})); + assert!(host_user.compact().has_protocol_list()); + // A protocol override of a host field. + let cleared = + entry(json!({"hostname": "h", "protocols": [{"type": "web", "hostname": ""}]})); + assert!(cleared.compact().has_protocol_list()); + // A name of its own. + let named = + entry(json!({"hostname": "h", "protocols": [{"type": "ssh", "name": "ssh-alt"}]})); + assert!(named.compact().has_protocol_list()); + // But a plain single protocol is written the old way. + let plain = entry(json!({"hostname": "h", "display_name": "d", + "protocols": [{"type": "ssh", "port": 2222, "username": "u", "name": "ssh"}]})) + .compact(); + assert!(!plain.has_protocol_list()); + assert_eq!(plain.session_type, "ssh"); + assert_eq!(plain.port, Some(2222)); + assert_eq!(plain.username.as_deref(), Some("u")); + assert_eq!(plain.hostname.as_deref(), Some("h")); + } + + #[test] + fn protocols_inherit_override_and_clear_host_fields() { + let e = entry(json!({ + "hostname": "10.0.0.10", "username": "Administrator", "display_name": "server01", + "password": "host-pw", + "protocols": [ + {"type": "rdp", "port": 3389}, + {"type": "ssh", "port": 22, "username": "admin"}, + {"type": "web", "url": "https://10.0.0.10/", "hostname": "", "password": ""} + ] + })); + let rdp = e.effective(None).unwrap(); + assert_eq!(rdp.session_type, "rdp"); + assert_eq!(rdp.hostname.as_deref(), Some("10.0.0.10")); + assert_eq!(rdp.username.as_deref(), Some("Administrator")); + assert_eq!(rdp.password.as_deref(), Some("host-pw")); + assert_eq!(rdp.port, Some(3389)); + let ssh = e.effective(Some("ssh")).unwrap(); + assert_eq!(ssh.username.as_deref(), Some("admin")); + assert_eq!(ssh.password.as_deref(), Some("host-pw")); + assert_eq!(ssh.display_name.as_deref(), Some("server01")); + let web = e.effective(Some("web")).unwrap(); + assert_eq!(web.hostname.as_deref(), Some("")); + assert_eq!(web.password.as_deref(), Some("")); + assert!(!crate::vault::entry_has_own_credentials(&web)); + assert_eq!(web.url.as_deref(), Some("https://10.0.0.10/")); + assert!(e.effective(Some("vnc")).is_none()); + } + + #[test] + fn port_is_never_inherited_so_a_protocol_gets_its_standard_port() { + // A host-level port is refused on write, but even if one is stored + // it never reaches a protocol: an SSH protocol without a port must + // not dial the RDP port. + let e = entry(json!({"hostname": "h", "port": 3389, + "protocols": [{"type": "rdp", "port": 3390}, {"type": "ssh"}]})); + assert_eq!(e.effective(Some("rdp")).unwrap().port, Some(3390)); + assert_eq!( + e.effective(Some("ssh")).unwrap().port, + None, + "session default: 22" + ); + assert!(e.check_shape().is_err()); + } + + #[test] + fn check_shape_accepts_both_forms_and_refuses_a_muddle() { + assert!(entry(legacy_rdp()).check_shape().is_ok()); + assert!(entry(json!({"hostname": "h", "protocols": [{"type": "ssh"}, {"type": "ssh", "name": "ssh-2222", "port": 2222}]})) + .check_shape() + .is_ok()); + for bad in [ + json!({"hostname": "h"}), + json!({"hostname": "h", "protocols": []}), + json!({"type": "ssh", "protocols": [{"type": "ssh"}]}), + json!({"protocols": [{"type": "telnet"}]}), + json!({"protocols": [{"type": "ssh"}, {"type": "ssh"}]}), + json!({"protocols": [{"type": "ssh", "name": "has space"}]}), + json!({"protocols": [{"type": "ssh", "port": "twenty-two"}]}), + json!({"protocols": [{"type": "ssh", "protocols": []}]}), + ] { + assert!(entry(bad.clone()).check_shape().is_err(), "{bad}"); + } + } + + #[test] + fn pick_protocol_falls_back_from_name_to_type() { + let e = entry(json!({"hostname": "h", "protocols": [ + {"type": "ssh", "name": "ssh-2222", "port": 2222}, {"type": "rdp"}]})); + assert_eq!(e.pick_protocol(Some("ssh-2222")).unwrap().0, "ssh-2222"); + assert_eq!(e.pick_protocol(Some("ssh")).unwrap().0, "ssh-2222"); + assert_eq!(e.pick_protocol(Some("rdp")).unwrap().0, "rdp"); + assert_eq!(e.pick_protocol(Some("")).unwrap().0, "ssh-2222"); + assert!(e.pick_protocol(Some("vnc")).is_none()); + // An old quick link naming the type of a single-protocol entry. + assert_eq!( + entry(legacy_rdp()).pick_protocol(Some("rdp")).unwrap().0, + "rdp" + ); + } + + #[test] + fn keep_secrets_keeps_what_the_update_omits() { + let old = json!({"password": "p", "private_key": "k", "proxmox_token_secret": "t", + "jump_hosts": [{"hostname": "b", "username": "j", "password": "jp", "host_key": "hk"}]}); + let mut new = json!({"username": "u", "private_key": "", + "jump_hosts": [{"hostname": "b", "username": "j"}]}); + keep_secrets(new.as_object_mut().unwrap(), old.as_object().unwrap()); + assert_eq!(new["password"], json!("p")); + assert_eq!(new["private_key"], json!(""), "an empty one clears"); + assert_eq!(new["proxmox_token_secret"], json!("t")); + assert_eq!(new["jump_hosts"][0]["password"], json!("jp")); + assert_eq!(new["jump_hosts"][0]["host_key"], json!("hk")); + } + + #[test] + fn editor_settings_never_carry_a_secret() { + let s = json!({"username": "u", "password": "p", "private_key": "", "container_password": "c", + "jump_hosts": [{"hostname": "b", "port": 22, "username": "j", "password": "jp", "private_key": "k"}]}); + let out = editor_settings(s.as_object().unwrap()); + let text = out.to_string(); + for secret in ["\"p\"", "\"c\"", "jp", "\"k\""] { + assert!(!text.contains(secret), "{secret} leaked: {text}"); + } + assert_eq!(out["has_password"], json!(true)); + assert_eq!(out["has_container_password"], json!(true)); + assert_eq!( + out["private_key"], + json!(""), + "a cleared secret shows as cleared" + ); + assert_eq!(out["jump_hosts"][0]["has_password"], json!(true)); + assert_eq!(out["username"], json!("u")); + } +} diff --git a/src/import.rs b/src/import.rs index e30bd66a..211393be 100644 --- a/src/import.rs +++ b/src/import.rs @@ -200,6 +200,7 @@ pub async fn cmd_import_guacamole( wol_wait_time: param_map.get("wol-wait-time").and_then(|s| s.parse().ok()), // A Guacamole dump is a one-off import, not a sync source. external_id: None, + protocols: None, }; if !cred_maps.is_empty() diff --git a/src/main.rs b/src/main.rs index b7d43f47..a6d6512c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod browser; mod config; mod db; mod drive; +mod entry_protocols; mod guacd; mod import; mod migrate; diff --git a/src/recording.rs b/src/recording.rs index 3f56b0ac..07c86e82 100644 --- a/src/recording.rs +++ b/src/recording.rs @@ -33,6 +33,9 @@ pub struct RecordingMeta { /// Session type (ssh, rdp, vnc, web). #[serde(default, skip_serializing_if = "Option::is_none")] pub session_type: Option, + /// Name of the entry protocol the session used (#296). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub protocol: Option, } /// Get the disk usage percentage for the filesystem containing `path`. diff --git a/src/session.rs b/src/session.rs index 4d392d4a..61d4aefd 100644 --- a/src/session.rs +++ b/src/session.rs @@ -92,6 +92,9 @@ pub struct CreateSessionRequest { pub address_book_entry: Option, /// Address book folder name (for reporting). pub address_book_folder: Option, + /// Name of the entry protocol (#296); set by the API for an entry. + #[serde(default)] + pub address_book_protocol: Option, /// Display name of the address book entry (for reporting). pub entry_display_name: Option, /// Per-entry max recordings to keep. @@ -225,6 +228,7 @@ impl CreateSessionRequest { /// session belongs to and carry its admin's policy: /// /// - `address_book_entry` / `address_book_folder` / `entry_display_name` + /// / `address_book_protocol` /// attribute the session and its recording to an entry. Claimed by an /// ad-hoc caller together with `max_recordings`, per-entry rotation /// would delete that entry's older recordings when the session ended. @@ -243,6 +247,7 @@ impl CreateSessionRequest { pub fn clear_entry_only_fields(&mut self) { self.address_book_entry = None; self.address_book_folder = None; + self.address_book_protocol = None; self.entry_display_name = None; self.max_recordings = None; self.record_typescript = None; @@ -404,6 +409,10 @@ pub struct SessionInfo { pub address_book_entry: Option, #[serde(skip_serializing_if = "Option::is_none")] pub address_book_folder: Option, + /// Name of the entry protocol the session connects with (#296), for + /// entries with more than one way in (`ssh`, `rdp`, `ssh-2222`). + #[serde(skip_serializing_if = "Option::is_none")] + pub address_book_protocol: Option, #[serde(skip_serializing_if = "Option::is_none")] pub entry_display_name: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -458,6 +467,8 @@ pub struct Session { pub address_book_entry: Option, /// Address book folder name (for reporting). pub address_book_folder: Option, + /// Name of the entry protocol the session uses (#296). + pub address_book_protocol: Option, /// Display name of the address book entry (for reporting). pub entry_display_name: Option, /// Per-entry max recordings to keep (from address book entry). @@ -850,6 +861,7 @@ impl Session { url: self.url.clone(), address_book_entry: self.address_book_entry.clone(), address_book_folder: self.address_book_folder.clone(), + address_book_protocol: self.address_book_protocol.clone(), entry_display_name: self.entry_display_name.clone(), thumbnail_url: Some(format!("/api/sessions/{}/thumbnail", self.id)), fullscreen_on_connect: self.fullscreen_on_connect, @@ -2135,6 +2147,7 @@ impl SessionManager { recording_enabled, address_book_entry: req.address_book_entry, address_book_folder: req.address_book_folder, + address_book_protocol: req.address_book_protocol, entry_display_name: req.entry_display_name, max_recordings: req.max_recordings, login_script_handle, @@ -2966,6 +2979,7 @@ mod tests { "hostname": "10.0.0.1", "address_book_entry": "shared/prod/db01", "address_book_folder": "prod", + "address_book_protocol": "ssh", "entry_display_name": "db01", "max_recordings": 1, "record_typescript": true, @@ -2975,6 +2989,7 @@ mod tests { req.clear_entry_only_fields(); assert_eq!(req.address_book_entry, None); assert_eq!(req.address_book_folder, None); + assert_eq!(req.address_book_protocol, None); assert_eq!(req.entry_display_name, None); assert_eq!(req.max_recordings, None); assert_eq!(req.record_typescript, None); @@ -3014,6 +3029,7 @@ mod tests { "enable_recording": false, "enable_drive": true, "address_book_entry": "shared/prod/db01", + "address_book_protocol": "ssh-2222", "max_recordings": 1, "record_typescript": true, "wol_send_packet": true, @@ -3035,6 +3051,7 @@ mod tests { assert_eq!(req.kerberos_cache, None); assert_eq!(req.allow_sharing, None); assert_eq!(req.address_book_entry, None); + assert_eq!(req.address_book_protocol, None); assert_eq!(req.max_recordings, None); assert_eq!(req.record_typescript, None); assert_eq!(req.wol_send_packet, None); @@ -3487,6 +3504,7 @@ mod tests { recording_enabled: false, address_book_entry: None, address_book_folder: None, + address_book_protocol: None, entry_display_name: None, max_recordings: None, login_script_handle: None, @@ -4123,6 +4141,8 @@ mod tests { ("disable_copy", serde_json::json!(true)), ("allow_sharing", serde_json::json!(true)), ("container_env", serde_json::json!({"A": "1"})), + // Another protocol of the same entry is another session (#296). + ("address_book_protocol", serde_json::json!("ssh-2222")), ] { let other = entry_request(serde_json::json!({ key: value })); assert_ne!( diff --git a/src/vault.rs b/src/vault.rs index 4a9616eb..cb51f1e9 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -273,8 +273,11 @@ impl FolderConfig { /// A connection entry stored in Vault. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct AddressBookEntry { - #[serde(rename = "type")] - pub session_type: String, // "ssh", "rdp", "vnc", "web" + /// Session type of an entry in the single-protocol form ("ssh", "rdp", + /// "vnc", "web", ...). Empty, and not stored, for an entry that lists + /// its `protocols` instead (see `crate::entry_protocols`). + #[serde(rename = "type", default, skip_serializing_if = "String::is_empty")] + pub session_type: String, pub hostname: Option, pub port: Option, pub username: Option, @@ -509,6 +512,11 @@ pub struct AddressBookEntry { /// the edit form does. #[serde(default, skip_serializing_if = "Option::is_none")] pub external_id: Option, + /// The ways to connect to this host (#296), the primary first. Each + /// inherits the fields above and overrides any of them. Absent for an + /// entry in the single-protocol form, which has a top-level `type`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub protocols: Option>, } impl AddressBookEntry { @@ -722,6 +730,18 @@ pub struct EntryInfo { /// `AddressBookEntry::external_id`). #[serde(skip_serializing_if = "Option::is_none")] pub external_id: Option, + /// Name of the protocol the fields above describe: the primary. + #[serde(skip_serializing_if = "Option::is_none")] + pub protocol: Option, + /// Every protocol, in order, each described as the fields above + /// describe the first (#296). Empty inside a protocol. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub protocols: Vec, + /// Admins only: the fields set at this level, as stored, for the editor: + /// the host's own on the entry, a protocol's overrides on a protocol. + /// Secrets appear only as `has_` flags. + #[serde(skip_serializing_if = "Option::is_none")] + pub settings: Option, } impl EntryInfo { @@ -831,7 +851,52 @@ impl From<(&str, &AddressBookEntry)> for EntryInfo { wol_udp_port: e.wol_udp_port, wol_wait_time: e.wol_wait_time, external_id: e.external_id.clone(), + protocol: None, + protocols: Vec::new(), + settings: None, + } + } +} + +impl EntryInfo { + /// An entry as a list shows it: the primary's fields, and every + /// protocol under `protocols`. `None` when no protocol forms a valid + /// entry. An admin also gets each level's own fields for the editor + /// (secrets as flags only). + pub fn for_viewer( + name: &str, + e: &AddressBookEntry, + defaults: Option<&FolderDefaults>, + admin: bool, + ) -> Option { + use crate::entry_protocols::{editor_settings, host_settings}; + let mut protocols: Vec = e + .visible_protocols() + .into_iter() + .map(|(p, eff)| { + let mut info = EntryInfo::from((name, &eff)); + info.apply_inherited(&eff, defaults); + info.protocol = Some(p.name().to_string()); + if admin { + info.settings = Some(editor_settings(&p.settings)); + } + info + }) + .collect(); + if protocols.is_empty() { + return None; + } + let mut top = protocols[0].clone(); + top.settings = None; + if admin { + let (host, _) = e.split(); + top.settings = Some(editor_settings(&host_settings(&host))); + } + for p in &mut protocols { + p.protocols = Vec::new(); } + top.protocols = protocols; + Some(top) } } diff --git a/src/websocket.rs b/src/websocket.rs index 7b9c8544..b955e011 100644 --- a/src/websocket.rs +++ b/src/websocket.rs @@ -283,6 +283,9 @@ async fn handle_ws( session_type: session_info .as_ref() .map(|s| format!("{:?}", s.session_type).to_lowercase()), + protocol: session_info + .as_ref() + .and_then(|s| s.address_book_protocol.clone()), }; if let Err(e) = crate::recording::write_meta(&recording_path, &meta) { tracing::warn!(session_id = %session_id, error = %e, "Failed to write recording .meta"); diff --git a/static/client.html b/static/client.html index 199db4b3..9307aa1f 100644 --- a/static/client.html +++ b/static/client.html @@ -503,7 +503,8 @@

Session Ended

relaunchInfo = { entry_key: session.address_book_entry, folder: session.address_book_folder || '', - display_name: session.entry_display_name || '' + display_name: session.entry_display_name || '', + protocol: session.address_book_protocol || '' }; } if (session && session.entry_display_name) entryDisplayName = session.entry_display_name; @@ -2057,6 +2058,8 @@

Session Ended

try { return localStorage.getItem('rustguac_keyboard_layout') || undefined; } catch (e) { return undefined; } })() }; + // Reconnect with the protocol the session used (#296). + if (relaunchInfo.protocol) body.protocol = relaunchInfo.protocol; var url = '/api/addressbook/folders/' + encodeURIComponent(scope) + '/' + encodeURIComponent(folder) diff --git a/static/connections.html b/static/connections.html index 34413427..00e85142 100644 --- a/static/connections.html +++ b/static/connections.html @@ -645,6 +645,73 @@ /* A non-admin's ad-hoc request can't turn on a drive the site has off. */ #entry-modal.adhoc.adhoc-restricted.drive-off #em-drive-section { display: none !important; } .var-pick { margin-top: 0.3em; font-size: 0.85em; width: auto; max-width: 100%; } + /* Multi-protocol entry editor and list (#296) */ + .em-host-section { margin: 0.8em 0; padding: 0.6em 0.8em; border: 1px solid var(--border); border-radius: 6px; } + .em-section-title { font-weight: 600; color: var(--accent); margin-bottom: 0.3em; } + .em-section-title span { font-weight: normal; color: var(--text-muted); font-size: 0.85em; } + .em-proto-tabs { display: flex; flex-wrap: wrap; gap: 0.3em; margin: 0.8em 0 0.4em; align-items: center; } + .em-proto-tab { padding: 0.25em 0.7em; font-size: 0.85em; background: var(--input); color: var(--text); border: 1px solid var(--border); border-radius: 4px; cursor: pointer; } + .em-proto-tab.active { border-color: var(--accent); color: var(--accent); font-weight: 600; } + .em-proto-head { padding: 0.5em 0.8em; border-left: 3px solid var(--accent); margin-bottom: 0.6em; } + .em-proto-actions { display: flex; gap: 0.4em; margin-top: 0.5em; } + .em-proto-actions button { padding: 0.2em 0.7em; font-size: 0.8em; background: var(--input); color: var(--accent); border: 1px solid var(--border); border-radius: 4px; cursor: pointer; } + .em-inherit-row { font-size: 0.85em; color: var(--text-muted); margin-top: 0.4em; } + .em-inherit-row label { display: inline; margin-right: 0.8em; text-transform: none; letter-spacing: 0; } + .em-inherit-row input { display: inline; width: auto; margin-right: 0.3em; } + .type-spice { background: #3d6b8f; color: #fff; } + /* Type column of an entry with several protocols: compact badges, + the primary first and outlined, wrapping to at most two lines. What + does not fit goes behind a +N chip (fitProtoBadges); the max-height + keeps it to two lines even before that runs. */ + .proto-badges { display: flex; flex-wrap: wrap; align-items: center; gap: 3px; min-width: 104px; max-width: 176px; max-height: 43px; overflow: hidden; } + .proto-badges .type-badge, .proto-badges .proto-more { + font-size: 10.5px; line-height: 16px; padding: 1px 5px; min-width: 0; + letter-spacing: 0.06em; white-space: nowrap; border: 1px solid transparent; box-sizing: border-box; + } + .proto-badges .proto-primary { box-shadow: inset 0 0 0 1.5px currentColor; } + .proto-badges [hidden] { display: none !important; } + .proto-badges > * { flex: none; } + .proto-badges .proto-extra { opacity: 0.8; } + .proto-badges .proto-more { border: 1px dashed var(--border); border-radius: var(--radius); color: var(--text-muted); cursor: default; } + /* Connect split button: Connect and the protocol menu toggle are one + control, one border, one height, rounded only on the outer ends. */ + .split-btn { display: inline-flex; vertical-align: middle; } + .split-btn .btn-connect { margin: 0; } + .split-btn .split-main { border-top-right-radius: 0; border-bottom-right-radius: 0; } + .split-btn .split-toggle { + min-width: 0; width: 26px; padding: 0; margin-left: 0; + border-top-left-radius: 0; border-bottom-left-radius: 0; + border-left: 1px solid color-mix(in srgb, var(--bg) 35%, transparent); + font-size: var(--fz-sm); line-height: 1; + } + .split-btn .split-toggle[aria-expanded="true"] { background: var(--accent-hover); } + /* The protocol menu: fixed, so the table never clips it, and placed + inside the viewport when it opens (openProtoMenu). */ + .proto-menu-list { + position: fixed; z-index: 150; min-width: 200px; max-width: calc(100vw - 16px); + max-height: calc(100vh - 16px); overflow-y: auto; + background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); + padding: var(--s-1); box-shadow: 0 8px 28px rgba(0,0,0,.45); text-align: left; + } + .proto-menu-list[hidden] { display: none; } + .proto-item { + display: flex; align-items: center; justify-content: flex-start; gap: var(--s-2); width: 100%; + padding: 5px var(--s-2); margin: 0; min-width: 0; height: auto; + background: transparent; border: 0; border-radius: var(--radius); + color: var(--text); font-size: var(--fz-sm); text-align: left; cursor: pointer; white-space: nowrap; + } + .proto-item:hover, .proto-item:focus { background: var(--input); outline: none; } + .proto-item:focus-visible { box-shadow: inset 0 0 0 1px var(--accent); } + .proto-item .type-badge { font-size: 10.5px; line-height: 16px; padding: 1px 5px; min-width: 44px; letter-spacing: 0.06em; } + .proto-item .pi-name { overflow: hidden; text-overflow: ellipsis; } + .proto-item .pi-detail { color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; max-width: 16em; } + .proto-item .pi-port { color: var(--text-muted); margin-left: auto; padding-left: var(--s-2); font-variant-numeric: tabular-nums; } + .proto-item .pi-default { + font-size: 10px; line-height: 14px; padding: 0 5px; border-radius: 7px; + border: 1px solid var(--accent); color: var(--accent); text-transform: uppercase; letter-spacing: 0.06em; + } + .session-card-info .card-meta > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .session-card-info .card-meta .type-badge { flex: none; } @@ -831,6 +898,35 @@

New Entry

+
+
Host defaults every protocol below inherits
+ + + +
Used by every protocol that leaves its own field blank. Credentials set on a protocol win over these, and folder defaults apply only when neither has any.
+
+
+
+ +
Tells two protocols of the same type apart, e.g. ssh-2222. Links pick a protocol with &protocol= and this name. Fixed once saved.
+
Don't inherit from the host: + + + +
+
+ + +
+
Used by every protocol that leaves its own field blank. Credentials set on a protocol win over these, and folder defaults apply only when neither has any.
+ + +
Narrows who can see and use this entry inside the folder. A protocol can set its own rule instead. Admins always can.
@@ -919,6 +926,18 @@

New Entry

+ +
@@ -3004,7 +3023,7 @@

Enter Credentials

// yet. Empty until the cred status has loaded (setCredVars !== null). function entryUnsetVars(e) { if (!setCredVars || !e) return []; - // Across every protocol (#296). + // Across every protocol the user may use (#296). var vars = []; ((e.protocols && e.protocols.length) ? e.protocols : [e]).forEach(function(p) { (p.credential_variables || []).forEach(function(v) { if (vars.indexOf(v) < 0) vars.push(v); }); @@ -3088,7 +3107,7 @@

Enter Credentials

// sessionStorage for this tab; the Folder facet only applies while // searching. // Folder-view filter (Type), shown in the folder header. - // An entry's protocols, primary first; an entry + // An entry's protocols the caller may use, primary first; an entry // from an older server is its own single protocol. function entryProtocols(e) { return (e && e.protocols && e.protocols.length) ? e.protocols : [e]; } function entryProtocol(e, name) { @@ -3291,7 +3310,7 @@

Enter Credentials

html += ''; if (protos.length > 1) { // Split button: Connect opens the primary, the toggle a - // menu of every protocol. + // menu of every protocol the user may use. var menuId = 'proto-menu-' + idx; html += '' + connectBtn + '' @@ -4690,9 +4709,9 @@

Enter Credentials

// ── Multi-protocol entries (#296) ── // // The editor holds the host's fields and a list of protocols. The - // Host box edits the host's hostname, username and password; the form - // below it edits the selected protocol, which inherits any field it - // leaves blank from the host. Every other field the host + // Host box edits the host's hostname, username, password and access + // rule; the form below it edits the selected protocol, which inherits + // any field it leaves blank from the host. Every other field the host // holds (banner, Wake-on-LAN, prompting, ...) is shown in a protocol's // form and stays on the host unless that protocol changes it. The // first protocol is the primary, the one Connect uses. @@ -4717,6 +4736,9 @@

Enter Credentials

}; function protoName(it) { return it.name || it.type; } + function splitList(v) { + return (v || '').split(',').map(function(x) { return x.trim(); }).filter(function(x) { return x; }); + } // Drop the editor-only flags the server adds in place of secrets. function stripFlags(obj) { var out = {}; @@ -4746,8 +4768,8 @@

Enter Credentials

} function protoEdNew(type) { - return { host: {}, hostHasPassword: false, active: 0, - items: [{ type: type, name: '', settings: {}, isNew: true }] }; + return { host: {}, hostHasPassword: false, groups: [], users: [], active: 0, + items: [{ type: type, name: '', settings: {}, rule: null, isNew: true }] }; } // From an entry as the list endpoints give it to an admin. function protoEdFromInfo(info) { @@ -4756,6 +4778,8 @@

Enter Credentials

return { host: stripFlags(hostSettings), hostHasPassword: !!hostSettings.has_password, + groups: info.allowed_groups || [], + users: info.allowed_users || [], active: 0, items: ps.map(function(p) { var nm = p.protocol && p.protocol !== p.session_type ? p.protocol : ''; @@ -4764,6 +4788,7 @@

Enter Credentials

// Without settings (an older server) the protocol's // own fields are the entry's. settings: p.settings || (info.settings ? {} : p), + rule: (p.allowed_groups || p.allowed_users) ? { groups: p.allowed_groups || [], users: p.allowed_users || [] } : null, isNew: false }; }) @@ -4777,6 +4802,8 @@

Enter Credentials

var hpw = document.getElementById('em-host-password'); hpw.value = ''; hpw.placeholder = ed.hostHasPassword ? '(stored, leave blank to keep)' : ''; + document.getElementById('em-host-groups').value = (ed.groups || []).join(', '); + document.getElementById('em-host-users').value = (ed.users || []).join(', '); protoLoad(0); } @@ -4840,6 +4867,11 @@

Enter Credentials

['hostname', 'username', 'password'].forEach(function(k) { document.getElementById('em-proto-clear-' + k).checked = (it.settings || {})[k] === ''; }); + var own = !!it.rule; + document.getElementById('em-proto-own-rule').checked = own; + document.getElementById('em-proto-rule').style.display = own ? '' : 'none'; + document.getElementById('em-proto-groups').value = own ? (it.rule.groups || []).join(', ') : ''; + document.getElementById('em-proto-users').value = own ? (it.rule.users || []).join(', ') : ''; renderProtoTabs(); } @@ -4875,6 +4907,12 @@

Enter Credentials

}); it.settings = flat; if (it.isNew) it.name = document.getElementById('em-proto-name').value.trim(); + if (document.getElementById('em-proto-own-rule').checked) { + it.rule = { groups: splitList(document.getElementById('em-proto-groups').value), + users: splitList(document.getElementById('em-proto-users').value) }; + } else { + it.rule = null; + } } // The body to save: the host's fields, then the protocols in order. @@ -4889,6 +4927,10 @@

Enter Credentials

var hp = document.getElementById('em-host-password').value; if (hp) body.password = hp; else delete body.password; delete body.port; // set per protocol + var g = splitList(document.getElementById('em-host-groups').value); + var u = splitList(document.getElementById('em-host-users').value); + if (g.length) body.allowed_groups = g; + if (u.length) body.allowed_users = u; var seen = {}; for (var i = 0; i < protoEd.items.length; i++) { var it = protoEd.items[i]; @@ -4902,6 +4944,7 @@

Enter Credentials

delete p.display_name; delete p.external_id; p.type = it.type; if (it.name) p.name = it.name; + if (it.rule) { p.allowed_groups = it.rule.groups; p.allowed_users = it.rule.users; } return p; }); // One protocol: a hostname typed into its form is the host's, so @@ -4923,7 +4966,7 @@

Enter Credentials

protoStore(); var used = protoEd.items.map(function(x) { return x.type; }); var next = ['ssh', 'rdp', 'web', 'vnc', 'spice', 'proxmox', 'vdi'].filter(function(x) { return used.indexOf(x) < 0; })[0] || 'ssh'; - protoEd.items.push({ type: next, name: '', settings: {}, isNew: true }); + protoEd.items.push({ type: next, name: '', settings: {}, rule: null, isNew: true }); protoLoad(protoEd.items.length - 1); return; } @@ -4944,6 +4987,9 @@

Enter Credentials

protoEd.items.splice(protoEd.active, 1); protoLoad(0); }); + document.getElementById('em-proto-own-rule').addEventListener('change', function() { + document.getElementById('em-proto-rule').style.display = this.checked ? '' : 'none'; + }); document.getElementById('em-proto-name').addEventListener('input', function() { if (!protoEd) return; var it = protoEd.items[protoEd.active]; diff --git a/tests/e2e/specs/multi-protocol-entries.spec.mjs b/tests/e2e/specs/multi-protocol-entries.spec.mjs index cfde8435..0bffd001 100644 --- a/tests/e2e/specs/multi-protocol-entries.spec.mjs +++ b/tests/e2e/specs/multi-protocol-entries.spec.mjs @@ -19,7 +19,7 @@ function multi() { settings: { hostname: '10.0.0.10', display_name: 'srv01 (DC1)', username: 'Administrator', has_password: true }, protocols: [ { ...rdp, protocol: 'rdp', settings: { port: 3389 } }, - { ...ssh, protocol: 'ssh', settings: { port: 2222, username: 'admin' } } + { ...ssh, protocol: 'ssh', settings: { port: 2222, username: 'admin' }, allowed_groups: ['netops'] } ] }; } @@ -236,7 +236,7 @@ test('editing an old single-protocol entry saves it as one protocol, host fields expect(JSON.stringify(body)).not.toContain('has_password'); }); -test('adding a protocol and making it primary', async ({ page }) => { +test('adding a protocol, with its own access rule, and making it primary', async ({ page }) => { const api = await installApi(page, { folders: folders() }); await openAcme(page); await page.locator('button[data-edit-entry="web01"]').click(); @@ -244,13 +244,16 @@ test('adding a protocol and making it primary', async ({ page }) => { await expect(page.locator('#em-type')).toHaveValue('rdp'); await expect(page.locator('#em-type')).toBeEnabled(); await page.locator('#em-rdp-port').fill('3390'); + await page.locator('#em-proto-own-rule').check(); + await page.locator('#em-proto-groups').fill('windows-admins, ops'); await page.locator('#em-proto-primary').click(); await expect(page.locator('#em-proto-tabs .em-proto-tab')).toHaveText(['★ rdp', 'ssh', '+ Add protocol']); await page.locator('#em-save').click(); await expect.poll(() => api.calls.find(c => c.method === 'PUT' && /entries\/web01$/.test(c.path))).toBeTruthy(); const body = api.calls.find(c => c.method === 'PUT' && /entries\/web01$/.test(c.path)).body; expect(body.protocols.map(p => p.type)).toEqual(['rdp', 'ssh']); - expect(body.protocols[0]).toMatchObject({ type: 'rdp', port: 3390 }); + expect(body.protocols[0]).toMatchObject({ port: 3390, allowed_groups: ['windows-admins', 'ops'], allowed_users: [] }); + expect(body.protocols[1].allowed_groups).toBeUndefined(); }); test('the multi-protocol editor loads each protocol with the host as its defaults', async ({ page }) => { @@ -265,6 +268,8 @@ test('the multi-protocol editor loads each protocol with the host as its default await expect(page.locator('#em-type')).toHaveValue('ssh'); await expect(page.locator('#em-username')).toHaveValue('admin'); await expect(page.locator('#em-port')).toHaveValue('2222'); + await expect(page.locator('#em-proto-own-rule')).toBeChecked(); + await expect(page.locator('#em-proto-groups')).toHaveValue('netops'); }); test('the Type filter matches any of an entry\'s protocols', async ({ page }) => {