From 3cf21933eaf499d8d8380bfd70ffafeb9bfe48e2 Mon Sep 17 00:00:00 2001 From: Dave Kempe Date: Wed, 11 Feb 2026 19:14:53 +1100 Subject: [PATCH] Add credential prompting for /api/connect deep-links, fix NetBox webhook docs When an address book entry has prompt_credentials: true or no stored credentials, /api/connect now returns an inline credential form instead of failing or connecting without auth. The form POSTs to the existing connect endpoint and redirects to the client page. Fix NetBox webhook body template docs: use "type" not "session_type" (matches Vault storage format), replace regex_replace/cut filters with standard Jinja2 equivalents (lower, split) since NetBox's Jinja2 environment doesn't include Ansible or Django template filters. Co-Authored-By: Claude Opus 4.6 --- docs/api.md | 6 +- docs/netbox.md | 12 ++-- src/api.rs | 135 +++++++++++++++++++++++++++++++++++++++++++ static/sessions.html | 3 + 4 files changed, 149 insertions(+), 7 deletions(-) diff --git a/docs/api.md b/docs/api.md index f3768ee..d237d57 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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 address book (credentials from Vault). Otherwise it creates an ad-hoc session. No credentials are passed in the URL for ad-hoc mode — if the target requires authentication, the user will see guacd's login prompt. +If the address book entry has `prompt_credentials: true` or has no stored password/key, the endpoint returns an inline credential form instead of creating the session immediately. The user enters credentials, which are POSTed to the connect endpoint and used for that session only (never stored). + See [NetBox Integration](netbox.md) for usage with NetBox Custom Links. ## Sessions @@ -383,7 +385,7 @@ Create a connection entry. The body includes a `name` field plus all entry field ```json { "name": "prod-db", - "session_type": "ssh", + "type": "ssh", "hostname": "db.internal.example.com", "port": 22, "username": "admin", @@ -403,7 +405,7 @@ Create a connection entry. The body includes a `name` field plus all entry field | Field | Type | Used by | Description | |-------|------|---------|-------------| -| `session_type` | string | All | `ssh`, `rdp`, `vnc`, or `web` | +| `type` | string | All | `ssh`, `rdp`, `vnc`, or `web` | | `hostname` | string | SSH, RDP, VNC | Target hostname or IP | | `port` | integer | SSH, RDP, VNC | Target port | | `username` | string | SSH, RDP | Username | diff --git a/docs/netbox.md b/docs/netbox.md index 79e4abf..8e9e056 100644 --- a/docs/netbox.md +++ b/docs/netbox.md @@ -2,7 +2,7 @@ rustguac integrates with [NetBox](https://netbox.dev/) to provide one-click remote console access from device pages. No NetBox plugin is required — the integration uses NetBox's built-in Custom Links, Custom Fields, and Event Rules. -**Note:** NetBox Custom Links use **Jinja2** template syntax. Filter arguments use parentheses — `default('ssh')` — not Django's colon syntax (`default:'ssh'`). +**Note:** NetBox Custom Links and Webhook body templates use **Jinja2** template syntax. Filter arguments use parentheses — `default('ssh')` — not Django's colon syntax (`default:'ssh'`). Only standard Jinja2 filters are available (e.g. `lower`, `default`, `split`). Ansible filters like `regex_replace` and Django filters like `cut` are **not** available. Custom Links use `object.cf.field_name` for custom fields; Webhook body templates use `data.custom_fields.field_name` (the REST API serialization). ## Custom Fields @@ -150,15 +150,17 @@ Use Event Rule **conditions** to sync only the devices you want. You can filter - Body template: ```json { - "name": "{{ data.name | lower | regex_replace('[^a-z0-9_.\\-]', '-') }}", - "session_type": "{{ data.custom_fields.remote_protocol | default('ssh') }}", - "hostname": "{{ data.primary_ip4.address | cut('/') }}", + "name": "{{ data.name | lower }}", + "type": "{{ data.custom_fields.remote_protocol | default('ssh') }}", + "hostname": "{{ data.primary_ip4.address.split('/')[0] }}", "port": {{ data.custom_fields.remote_port | default(22) }}, "display_name": "{{ data.name }} ({{ data.site.name }})", "prompt_credentials": true } ``` + **Important:** The entry field is `type`, not `session_type` (it matches the Vault storage format). The hostname uses `.split('/')[0]` to strip the CIDR prefix from NetBox IP addresses (e.g. `10.0.0.1/24` → `10.0.0.1`). Avoid `regex_replace` and `cut` filters — they are not available in NetBox's Jinja2 environment. + ### Create webhook: device deleted 1. **Create an Event Rule**: @@ -169,7 +171,7 @@ Use Event Rule **conditions** to sync only the devices you want. You can filter 2. **Create the Webhook**: - Name: `rustguac-sync-delete` - - URL: `https://console.example.com/api/addressbook/folders/shared/netbox-sync/entries/{{ data.name | lower | regex_replace("[^a-z0-9_.-]", "-") }}` + - URL: `https://console.example.com/api/addressbook/folders/shared/netbox-sync/entries/{{ data.name | lower }}` - HTTP method: DELETE - Additional headers: ``` diff --git a/src/api.rs b/src/api.rs index e178c4e..ae12573 100644 --- a/src/api.rs +++ b/src/api.rs @@ -2170,6 +2170,24 @@ pub async fn quick_connect( } }; + // Check if we need to prompt for credentials before connecting + let needs_prompt = ab_entry.session_type != "web" + && (ab_entry.prompt_credentials == Some(true) + || (ab_entry.password.as_ref().is_none_or(|p| p.is_empty()) + && ab_entry.private_key.as_ref().is_none_or(|k| k.is_empty()))); + + if needs_prompt { + return quick_connect_credential_form( + scope, + folder, + entry, + &ab_entry.session_type, + ab_entry.username.as_deref(), + ab_entry.domain.as_deref(), + ab_entry.display_name.as_deref(), + ); + } + let session_type = match ab_entry.session_type.as_str() { "ssh" => SessionType::Ssh, "rdp" => SessionType::Rdp, @@ -2293,6 +2311,123 @@ pub async fn quick_connect( } } +/// Return an inline HTML credential form for quick-connect when prompting is needed. +fn quick_connect_credential_form( + scope: &str, + folder: &str, + entry: &str, + session_type: &str, + username: Option<&str>, + domain: Option<&str>, + display_name: Option<&str>, +) -> Response { + let title = display_name.unwrap_or(entry); + let user_val = html_escape(username.unwrap_or("")); + let domain_val = html_escape(domain.unwrap_or("")); + let domain_display = if session_type == "rdp" { + "block" + } else { + "none" + }; + let html = format!( + r##" +Connect — {title} + + +
+

{title}

+
{session_type_upper} connection
+
+ + + + +
+ + +
+ +
+
+
+ +"##, + title = html_escape(title), + session_type_upper = session_type.to_uppercase(), + domain_display = domain_display, + scope = html_escape(scope), + folder = html_escape(folder), + entry = html_escape(entry), + user_val = user_val, + domain_val = domain_val, + ); + (StatusCode::OK, axum::response::Html(html)).into_response() +} + /// Return an HTML error page for quick-connect failures (browser redirect flow). fn quick_connect_error(status: StatusCode, message: &str) -> Response { let html = format!( diff --git a/static/sessions.html b/static/sessions.html index 2dc36f6..d41b193 100644 --- a/static/sessions.html +++ b/static/sessions.html @@ -406,6 +406,9 @@ jumpSection.style.display = ''; }); + // 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');