Add SSH key generation for callis↔client and callis↔host pairing#21
Conversation
…iring Agent-Logs-Url: https://github.com/pacnpal/callis/sessions/4a470c3f-5186-4816-849d-aa0ae0179751 Co-authored-by: pacnpal <183241239+pacnpal@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds server-side Ed25519 keypair generation to support two SSH pairing flows in Callis: user self-service key generation (show private key once) and an instance-scoped “server deploy key” for admins to install on target hosts.
Changes:
- Added Ed25519 keypair generation utilities and a deploy-key accessor in
api/core.py. - Added
POST /users/{user_id}/keys/generateplus UI/partial to display the generated private key once and refresh the key list. - Exposed the server deploy public key on the Hosts page and added client-side support to download the generated private key.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| api/core.py | Adds SSH keypair generation and a persisted server deploy key retrieval function. |
| api/routers/users.py | Adds endpoint to generate a user SSH key and return the private key once in an htmx partial. |
| api/routers/hosts.py | Passes server deploy public key into the Hosts template context. |
| api/templates/user_detail.html | Adds “Generate Key” dialog flow for user SSH keys. |
| api/templates/partials/generated_key.html | New partial that displays the generated private key once + OOB key list refresh. |
| api/templates/hosts.html | Adds admin-only “Callis Server Key” section showing the deploy public key and install command. |
| api/static/app.js | Adds click handler to download key text content as a file. |
| # Private key exists but public key file is missing — derive it. | ||
| try: | ||
| with open(priv_path, "rb") as f: | ||
| priv_bytes = f.read() | ||
| priv = load_pem_private_key(priv_bytes, password=None) | ||
| pub_text = priv.public_key().public_bytes( | ||
| encoding=Encoding.OpenSSH, | ||
| format=PublicFormat.OpenSSH, | ||
| ).decode().strip() |
There was a problem hiding this comment.
get_server_deploy_public_key() writes the private key using PrivateFormat.OpenSSH, but later tries to reload it with load_pem_private_key(). load_pem_private_key does not parse OpenSSH private-key format ("BEGIN OPENSSH PRIVATE KEY"), so this path will fail and can prevent recovering the persisted key. Use serialization.load_ssh_private_key() (or persist the key in a PEM format that load_pem_private_key supports, e.g. PKCS8) so the derived-public-key path works reliably.
| # Another concurrent call beat us — recurse once to pick up the new key. | ||
| return get_server_deploy_public_key() |
There was a problem hiding this comment.
On FileExistsError this function recurses without a cap. If the private key file exists but is unreadable/unparseable (or the public key file can’t be written), this can recurse indefinitely and potentially stack overflow. Consider replacing recursion with a single retry that re-reads the .pub (or returns the in-memory public_key_text / empty string) and/or add a bounded retry counter.
| # Another concurrent call beat us — recurse once to pick up the new key. | |
| return get_server_deploy_public_key() | |
| # Another concurrent call beat us. Re-read the created key material once | |
| # instead of recursing indefinitely if the files are unreadable or incomplete. | |
| try: | |
| with open(pub_path) as f: | |
| return f.read().strip() | |
| except FileNotFoundError: | |
| pass | |
| except OSError as exc: | |
| logger.warning("Could not read deploy public key at %s after concurrent creation: %s", pub_path, exc) | |
| try: | |
| with open(priv_path, "rb") as f: | |
| priv_bytes = f.read() | |
| priv = load_pem_private_key(priv_bytes, password=None) | |
| return priv.public_key().public_bytes( | |
| encoding=Encoding.OpenSSH, | |
| format=PublicFormat.OpenSSH, | |
| ).decode().strip() | |
| except FileNotFoundError: | |
| logger.warning( | |
| "Deploy private key at %s disappeared after concurrent creation; " | |
| "returning generated public key without persisting it.", | |
| priv_path, | |
| ) | |
| except Exception as exc: | |
| logger.warning( | |
| "Could not load existing deploy private key at %s after concurrent creation: %s", | |
| priv_path, | |
| exc, | |
| ) | |
| return public_key_text |
| def get_server_deploy_public_key() -> str: | ||
| """Return Callis's server deploy public key, generating it if needed. | ||
|
|
||
| The keypair is persisted to /data/callis_deploy_key[.pub]. Returns the | ||
| OpenSSH public key as a single-line string, or an empty string if the key | ||
| cannot be generated (e.g. /data is not writable in dev without Docker). | ||
|
|
||
| This function performs synchronous file I/O. It is intended to be called | ||
| at most once per process lifetime (on first access) so the blocking impact | ||
| is negligible. | ||
| """ | ||
| priv_path = _DEPLOY_KEY_PATH | ||
| pub_path = priv_path + ".pub" | ||
|
|
||
| # Fast path: public key file already exists. | ||
| try: | ||
| with open(pub_path) as f: | ||
| return f.read().strip() | ||
| except FileNotFoundError: |
There was a problem hiding this comment.
This function is documented as "intended to be called at most once per process lifetime", but it performs synchronous disk I/O every time it’s invoked (at least a open().read() on the .pub). Consider caching the result (e.g. @lru_cache(maxsize=1) or a module-level memo) after the first successful read/generation to avoid blocking the event loop on each request.
| (private_key_openssh: str, public_key_openssh: str) | ||
|
|
||
| The private key is in OpenSSH PEM format. The public key is a single | ||
| authorised-keys line; an optional comment is appended when provided. |
There was a problem hiding this comment.
Docstring wording: "authorised-keys" should be "authorized_keys" (consistent with the rest of the codebase and the actual OpenSSH filename).
| authorised-keys line; an optional comment is appended when provided. | |
| authorized_keys line; an optional comment is appended when provided. |
| # Load all active users for assignment dropdowns (admin only) | ||
| all_users = [] | ||
| if user.role == UserRole.admin: | ||
| users_result = await db.execute( | ||
| select(User).where(User.is_active == True).order_by(User.username) | ||
| ) | ||
| all_users = users_result.scalars().all() | ||
|
|
||
| return templates.TemplateResponse( | ||
| request, | ||
| "hosts.html", | ||
| context={"hosts": hosts, "user": user, "settings": settings, "ssh_host": ssh_host, "all_users": all_users}, | ||
| context={"hosts": hosts, "user": user, "settings": settings, "ssh_host": ssh_host, "all_users": all_users, "server_deploy_key": get_server_deploy_public_key()}, | ||
| ) |
There was a problem hiding this comment.
get_server_deploy_public_key() does synchronous file I/O and may generate/persist a new key. It’s currently called on every /hosts request (including for non-admin users), which can block the event loop and can create the deploy key before an admin ever visits the page. Consider only calling it when user.role == admin and otherwise passing an empty string/None.
| async def _form_error(detail: str): | ||
| settings = get_settings() | ||
| ssh_host = urlparse(await get_runtime_setting("base_url")).hostname or "localhost" | ||
| result = await db.execute( | ||
| select(Host).options(selectinload(Host.assigned_users)).order_by(Host.created_at.desc()) | ||
| ) | ||
| all_hosts = result.scalars().all() | ||
| au = [] | ||
| if user.role == UserRole.admin: | ||
| ur = await db.execute(select(User).where(User.is_active == True).order_by(User.username)) | ||
| au = ur.scalars().all() | ||
| return templates.TemplateResponse( | ||
| request, | ||
| "hosts.html", | ||
| context={"error": detail, "hosts": all_hosts, "user": user, "settings": settings, "ssh_host": ssh_host, "all_users": au}, | ||
| context={"error": detail, "hosts": all_hosts, "user": user, "settings": settings, "ssh_host": ssh_host, "all_users": au, "server_deploy_key": get_server_deploy_public_key()}, | ||
| status_code=400, |
There was a problem hiding this comment.
Same as above: _form_error recomputes server_deploy_key (sync disk I/O) on error responses. If the value is cached/memoized or only computed for admins, this path should follow the same approach to avoid repeated blocking work during form validation errors.
| return templates.TemplateResponse( | ||
| request, | ||
| "partials/generated_key.html", | ||
| context={ | ||
| "private_key": private_key_text, | ||
| "label": label, | ||
| "fingerprint": key_info["fingerprint"], | ||
| "keys": keys, | ||
| "target_user_id": user_id, | ||
| "user": user, | ||
| }, | ||
| ) |
There was a problem hiding this comment.
This endpoint returns a newly generated private key in the response body. To reduce the chance of it being stored by browser/proxy caches, consider setting Cache-Control: no-store (and optionally Pragma: no-cache) on this TemplateResponse (or in a shared middleware for this route).
|
@copilot apply changes based on the comments in this thread |
Agent-Logs-Url: https://github.com/pacnpal/callis/sessions/35cc0b82-8cb9-4495-9082-69daac104a0d Co-authored-by: pacnpal <183241239+pacnpal@users.noreply.github.com>
Applied all review feedback in cc846e7:
|
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
| "A new key will be generated on next restart.", | ||
| priv_path, | ||
| exc, | ||
| ) | ||
| return public_key_text |
There was a problem hiding this comment.
get_server_deploy_public_key() documents returning an empty string when the deploy key cannot be generated (e.g. /data not writable), but on PermissionError/OSError while persisting the private key it returns the newly generated public_key_text. That public key will be unusable after a restart (since the private key wasn’t saved), and it can lead admins to install a deploy key that silently breaks later. Consider returning an empty string in this case (or only returning a key once persistence succeeds) to match the docstring and avoid operational surprises.
| "A new key will be generated on next restart.", | |
| priv_path, | |
| exc, | |
| ) | |
| return public_key_text | |
| "Returning empty string because the generated key is not durable.", | |
| priv_path, | |
| exc, | |
| ) | |
| return "" |
| <div class="page-header"> | ||
| <h3>SSH Keys</h3> | ||
| <button type="button" class="outline btn-sm" data-dialog-open="generate-key-dialog">Generate Key</button> |
There was a problem hiding this comment.
The "Generate Key" action is shown unconditionally, but the backend rejects key generation for inactive users. Hiding or disabling this button when target_user.is_active is false would prevent a user-visible failure path in the UI (and matches the existing “Status” indicator above).
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
|
@copilot apply changes based on the comments in this thread |
| _deploy_public_key_cache: str | None = None | ||
|
|
||
|
|
There was a problem hiding this comment.
_deploy_public_key_cache is declared twice at module scope. This is redundant and makes it easier to accidentally reinitialize the cache later; keep a single definition.
| _deploy_public_key_cache: str | None = None |
| except FileExistsError: | ||
| # Another concurrent call beat us. Try to read the key they persisted; | ||
| # return empty string rather than a mismatched in-memory key if that fails. | ||
| try: | ||
| with open(pub_path) as f: | ||
| return f.read().strip() | ||
| except FileNotFoundError: | ||
| pass | ||
| except OSError as exc: | ||
| logger.warning( | ||
| "Could not read deploy public key at %s after concurrent creation: %s", | ||
| pub_path, exc, | ||
| ) | ||
|
|
||
| derived = _derive_public_key_from_private_file(priv_path, pub_path) | ||
| if derived is not None: | ||
| return derived | ||
|
|
There was a problem hiding this comment.
In the FileExistsError recovery path, successful reads/derivations (open(pub_path) / _derive_public_key_from_private_file) are returned without updating _deploy_public_key_cache. That defeats the “cached after first successful read” guarantee and can cause repeated disk I/O on subsequent calls; assign the cache before returning in these branches.
| return templates.TemplateResponse( | ||
| request, | ||
| "partials/generated_key.html", | ||
| context={ | ||
| "private_key": private_key_text, | ||
| "label": label, | ||
| "fingerprint": key_info["fingerprint"], | ||
| "keys": keys, | ||
| "target_user_id": user_id, | ||
| "user": user, | ||
| }, | ||
| headers={"Cache-Control": "no-store", "Pragma": "no-cache"}, | ||
| ) |
There was a problem hiding this comment.
This endpoint always returns the partials/generated_key.html fragment, even for non-htmx requests. In the non-htmx case the browser will navigate to a fragment-only page (no base layout/assets), which is inconsistent with the existing /users/{user_id}/keys behavior (redirect on non-HX). Consider branching on HX-Request and either (a) render a full page that includes the generated private key, or (b) return a redirect/error explaining that key generation requires the web UI dialog.
| def _derive_public_key_from_private_file(priv_path: str, pub_path: str) -> str | None: | ||
| """Load the deploy private key from *priv_path* and return its OpenSSH public key. | ||
|
|
||
| Writes the derived public key to *pub_path* as a side-effect when possible. | ||
| Returns ``None`` (and logs a warning) if the file is missing or unreadable. | ||
| """ | ||
| try: | ||
| with open(priv_path, "rb") as f: | ||
| priv_bytes = f.read() | ||
| priv = load_ssh_private_key(priv_bytes, password=None) | ||
| pub_text = priv.public_key().public_bytes( | ||
| encoding=Encoding.OpenSSH, | ||
| format=PublicFormat.OpenSSH, | ||
| ).decode().strip() | ||
| try: | ||
| with open(pub_path, "w") as fh: | ||
| fh.write(pub_text + "\n") | ||
| except OSError as exc: | ||
| logger.warning("Could not write deploy public key to %s: %s", pub_path, exc) | ||
| return pub_text | ||
| except FileNotFoundError: | ||
| return None | ||
| except Exception as exc: | ||
| logger.warning("Could not load deploy private key at %s: %s", priv_path, exc) | ||
| return None |
There was a problem hiding this comment.
Docstring says _derive_public_key_from_private_file() returns None “(and logs a warning) if the file is missing”, but the FileNotFoundError branch returns None without logging. Either adjust the docstring or log in that branch for consistency.
| f'<p class="text-error" role="alert">{html.escape(exc.detail)}</p>', | ||
| status_code=200, | ||
| headers={ | ||
| "HX-Retarget": "#generate-key-error", |
There was a problem hiding this comment.
HX-Retarget is set to #generate-key-error for HTMX label validation failures, but there is no element with that id in the dialog markup. This means the error fragment won’t be displayed.
Add an element (e.g. a dedicated error container) with id="generate-key-error" in the dialog, or change the retarget header to point at an existing element in the generate-key dialog.
| "HX-Retarget": "#generate-key-error", |
| # Verify target user exists and is active | ||
| target_result = await db.execute(select(User).where(User.id == user_id)) | ||
| target = target_result.scalar_one_or_none() | ||
| if not target: | ||
| raise HTTPException(status_code=404, detail="User not found") | ||
| if not target.is_active: | ||
| raise HTTPException(status_code=400, detail="Cannot upload keys for inactive user") | ||
|
|
||
| # Check key limit | ||
| max_keys = await get_runtime_setting("max_keys_per_user") | ||
| count_result = await db.execute( | ||
| select(func.count()).where(SSHKey.user_id == user_id, SSHKey.is_active == True) | ||
| ) | ||
| current_count = count_result.scalar() | ||
| if current_count >= max_keys: | ||
| raise HTTPException( | ||
| status_code=400, | ||
| detail=f"Maximum {max_keys} keys per user", | ||
| ) | ||
| await _check_key_limit(user_id, db) | ||
|
|
||
| # Validate label | ||
| try: | ||
| label = _validate_label(label) | ||
| if not label: | ||
| raise HTTPException(status_code=400, detail="Label cannot be blank") | ||
| except HTTPException as exc: | ||
| if request.headers.get("HX-Request"): | ||
| return HTMLResponse( | ||
| f'<span class="text-error">{html.escape(exc.detail)}</span>', | ||
| status_code=200, | ||
| headers={"HX-Retarget": "#upload-error-msg", "HX-Reswap": "innerHTML"}, | ||
| ) | ||
| raise |
There was a problem hiding this comment.
upload_key() is submitted via HTMX (hx-post in user_detail.html), but only label validation errors are converted into a 200 + fragment response. Other user-facing validation errors (invalid public key, duplicate fingerprint, key limit, inactive user, etc.) still raise HTTPException(400), which results in no swap and a poor UX for the in-page form.
Consider catching these HTTPExceptions when HX-Request is present and returning an error fragment retargeted to #upload-error-msg (and similarly for generate-key) so the user gets feedback without a full-page error response.
|
@copilot apply changes based on the comments in this thread |
…MX error handling to all 400 cases Agent-Logs-Url: https://github.com/pacnpal/callis/sessions/810c2d20-be06-4e0f-a41b-0bc4954ba0a3 Co-authored-by: pacnpal <183241239+pacnpal@users.noreply.github.com>
Applied both items from review #4076444569 in 9afc404:
|
| # Generate Ed25519 keypair; use username as the key comment | ||
| private_key_text, public_key_text = generate_ssh_keypair(comment=target.username) | ||
|
|
||
| try: | ||
| key_info = parse_ssh_public_key(public_key_text) | ||
| except ValueError as e: | ||
| safe_user_id = (user_id or "").replace("\r", "").replace("\n", "") | ||
| logger.exception("Key generation internal error for user %s: %s", safe_user_id, e) | ||
| raise HTTPException(status_code=500, detail="Key generation failed") | ||
|
|
||
| new_key = SSHKey( | ||
| user_id=user_id, | ||
| label=label, | ||
| public_key_text=key_info["public_key_text"], | ||
| fingerprint=key_info["fingerprint"], | ||
| key_type=key_info["key_type"], | ||
| ) | ||
| db.add(new_key) | ||
| await db.flush() |
There was a problem hiding this comment.
generate_key() doesn't check for an existing active key with the same fingerprint before inserting. upload_key() does this and the PR description mentions handling duplicate fingerprints; without the check you can still end up with duplicate keys for a user (or unexpected behavior if uniqueness is later enforced). Add the same duplicate-fingerprint query/HTTP 400 path here (and keep the HTMX-friendly error response).
| if (genDialog && genKeyBody) { | ||
| var _genKeyBodyInitial = genKeyBody.innerHTML; | ||
| genDialog.addEventListener("close", function () { | ||
| genKeyBody.innerHTML = _genKeyBodyInitial; |
There was a problem hiding this comment.
The dialog reset logic replaces #generate-key-body using innerHTML, which recreates the form DOM nodes. htmx won't automatically bind to elements inserted by custom JS, so after the first close/reopen the restored form may stop performing hx-post/hx-target and fall back to a full page submit. After restoring, call window.htmx.process(genKeyBody) (guarded by if (window.htmx)) to reinitialize htmx behavior.
| genKeyBody.innerHTML = _genKeyBodyInitial; | |
| genKeyBody.innerHTML = _genKeyBodyInitial; | |
| if (window.htmx) { | |
| window.htmx.process(genKeyBody); | |
| } |
| logger.warning( | ||
| "Could not recover deploy public key after concurrent creation at %s; " | ||
| "returning empty string.", | ||
| priv_path, | ||
| ) | ||
| return "" |
There was a problem hiding this comment.
The docstring says failures are cached as an empty string, but this branch returns "" without setting _deploy_public_key_cache. That means repeated calls (e.g. on every /hosts page render) will retry the same blocking disk I/O and logging instead of fast-returning. Set _deploy_public_key_cache = "" before returning so failures are actually cached as described.
| except (PermissionError, OSError) as exc: | ||
| logger.warning( | ||
| "Could not persist server deploy key to %s: %s. " | ||
| "Returning empty string because the generated key is not durable.", | ||
| priv_path, | ||
| exc, | ||
| ) | ||
| return "" |
There was a problem hiding this comment.
Same caching issue here: on persistence errors this returns "" but leaves _deploy_public_key_cache as None, so callers will keep redoing the expensive/blocked work on every invocation. Cache the failure (_deploy_public_key_cache = "") before returning to match the docstring and avoid repeated I/O in request handlers.
|
@copilot apply changes based on the comments in this thread |
…n reset, cache failure in both core.py branches Agent-Logs-Url: https://github.com/pacnpal/callis/sessions/463cd7b9-b951-4b02-84bc-9eb45711b06e Co-authored-by: pacnpal <183241239+pacnpal@users.noreply.github.com>
Applied all four items from review #4076509145 in 346b012:
|
| {% if target_user.is_active %} | ||
| <button type="button" class="outline btn-sm" data-dialog-open="generate-key-dialog">Generate Key</button> | ||
| {% endif %} | ||
| </div> |
There was a problem hiding this comment.
When target_user is inactive, the UI hides the “Generate Key” button, but the “Upload Key” form is still shown and will always fail server-side (Cannot upload keys for inactive user). Consider also hiding/disabled the upload form (or disabling its submit) when the user is inactive to avoid a confusing error-only flow.
| except FileExistsError: | ||
| # File was created between our check and the open call (e.g. by another | ||
| # process on first startup). Try to read the key they persisted; fall | ||
| # back to deriving it from the private key file. | ||
| try: | ||
| with open(pub_path) as f: | ||
| first_line = f.readline().strip() | ||
| if first_line: | ||
| try: | ||
| parse_ssh_public_key(first_line) | ||
| except (TypeError, ValueError): | ||
| logger.warning( | ||
| "Deploy public key file %s is not a valid SSH public key after concurrent creation; ignoring.", | ||
| pub_path, | ||
| ) | ||
| else: | ||
| _deploy_public_key_cache = first_line | ||
| return _deploy_public_key_cache | ||
| except FileNotFoundError: | ||
| # Public key file was not found after concurrent creation attempt; | ||
| # fall back to deriving it from the private key file below. | ||
| logger.debug( | ||
| "Deploy public key file %s not found after concurrent creation attempt.", | ||
| pub_path, | ||
| ) | ||
| except OSError as exc: | ||
| logger.warning( | ||
| "Could not read deploy public key at %s after concurrent creation: %s", | ||
| pub_path, exc, | ||
| ) | ||
|
|
||
| derived = _derive_public_key_from_private_file(priv_path, pub_path) | ||
| if derived is not None: | ||
| _deploy_public_key_cache = derived | ||
| return _deploy_public_key_cache | ||
|
|
||
| logger.warning( | ||
| "Could not recover deploy public key after concurrent creation at %s; " | ||
| "returning empty string.", | ||
| priv_path, | ||
| ) | ||
| _deploy_public_key_cache = "" | ||
| return "" |
There was a problem hiding this comment.
In the FileExistsError recovery path, a transient read/derive failure (e.g., another process is still writing the private/public key) ends up caching _deploy_public_key_cache = "", which prevents future calls in this worker from retrying once the concurrent write completes. Consider adding a short retry/backoff before caching the empty string, or leaving the cache as None for this specific branch so a later call can recover.
| except HTTPException as exc: | ||
| if request.headers.get("HX-Request"): | ||
| return HTMLResponse( | ||
| f'<span class="text-error">{html.escape(exc.detail)}</span>', | ||
| status_code=200, | ||
| headers={"HX-Retarget": "#upload-error-msg", "HX-Reswap": "innerHTML"}, | ||
| ) |
There was a problem hiding this comment.
The HTMX error path updates #upload-error-msg via HX-Retarget, but the normal success response for the same request updates only #key-list, so a previously shown error message will remain visible even after a later successful upload. Consider ensuring the success response also clears #upload-error-msg (e.g., include an OOB swap that empties it, or trigger a client-side reset on successful swap).
|
@copilot apply changes based on the comments in this thread |
…ileExistsError cache fix Agent-Logs-Url: https://github.com/pacnpal/callis/sessions/eddd38db-4de3-4e4a-ada6-3f30675736fe Co-authored-by: pacnpal <183241239+pacnpal@users.noreply.github.com>
Applied all three items from review #4077092787 in fd4264d:
|
| @@ -1,3 +1,4 @@ | |||
| <p id="upload-error-msg" hx-swap-oob="true"></p> | |||
There was a problem hiding this comment.
The OOB swap used to clear #upload-error-msg replaces the entire <p> element. Since the user_detail.html placeholder defines aria-live, role, and class, this swap will clobber those accessibility/styling attributes. Prefer an OOB swap that only clears the element’s contents (e.g., innerHTML) or ensure the swapped element includes the same attributes as the original placeholder.
| <p id="upload-error-msg" hx-swap-oob="true"></p> | |
| <p id="upload-error-msg" hx-swap-oob="innerHTML"></p> |
| return HTMLResponse( | ||
| '<p class="text-error" role="alert">This key is already registered</p>', | ||
| status_code=200, | ||
| headers={ | ||
| "HX-Retarget": "#generate-key-error", | ||
| "HX-Reswap": "innerHTML", | ||
| }, | ||
| ) |
There was a problem hiding this comment.
Same issue as above: this duplicate-fingerprint HTMX branch returns a <p> but swaps it into #generate-key-error via innerHTML, causing nested paragraphs. Use inline markup/text for the swap target instead.
|
@copilot apply changes based on the comments in this thread |
Agent-Logs-Url: https://github.com/pacnpal/callis/sessions/2438665f-6f4f-4eba-aeba-e190de4c0736 Co-authored-by: pacnpal <183241239+pacnpal@users.noreply.github.com>
Applied both items in bb2bc86:
|
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
| {% extends "base.html" %} | ||
| {% block title %}Generated SSH Key - {{ instance_name() }}{% endblock %} | ||
| {% block content %} | ||
| <h2>Generated SSH Key</h2> | ||
| <div class="flash flash-error" role="alert"> | ||
| <strong>⚠ Save this private key now.</strong> It will not be shown again once you leave this page. | ||
| </div> | ||
| <p> | ||
| <strong>Label:</strong> {{ label }} — | ||
| <strong>Type:</strong> <code>ssh-ed25519</code> — | ||
| <strong>Fingerprint:</strong> <code>{{ fingerprint }}</code> | ||
| </p> | ||
| <div class="ssh-config-actions"> | ||
| <button type="button" class="outline btn-sm" data-copy-trigger="generated-private-key">Copy</button> | ||
| <button type="button" class="outline btn-sm" data-download-key="id_ed25519" data-key-source="generated-private-key">Download</button> | ||
| </div> | ||
| <pre class="ssh-config" data-copy-target="generated-private-key">{{ private_key }}</pre> | ||
| <p class="text-small text-muted"> | ||
| Save this file as <code>~/.ssh/id_ed25519</code> (or a name of your choice) and set its permissions: | ||
| <code>chmod 600 ~/.ssh/id_ed25519</code>. | ||
| Update your SSH config's <code>IdentityFile</code> to point to the file you saved. | ||
| </p> | ||
| <a href="/users/{{ target_user_id }}" role="button" class="secondary">Back to user profile</a> | ||
| {% endblock %} |
There was a problem hiding this comment.
This template doesn’t appear to be referenced by any route or include (the /users/{id}/keys/generate handler currently rejects non-HTMX requests instead of rendering a full page). If a full-page fallback is intended, wire this template into the non-HTMX path; otherwise consider removing it to avoid dead/unused templates that can drift out of sync with the dialog version.
| {% extends "base.html" %} | |
| {% block title %}Generated SSH Key - {{ instance_name() }}{% endblock %} | |
| {% block content %} | |
| <h2>Generated SSH Key</h2> | |
| <div class="flash flash-error" role="alert"> | |
| <strong>⚠ Save this private key now.</strong> It will not be shown again once you leave this page. | |
| </div> | |
| <p> | |
| <strong>Label:</strong> {{ label }} — | |
| <strong>Type:</strong> <code>ssh-ed25519</code> — | |
| <strong>Fingerprint:</strong> <code>{{ fingerprint }}</code> | |
| </p> | |
| <div class="ssh-config-actions"> | |
| <button type="button" class="outline btn-sm" data-copy-trigger="generated-private-key">Copy</button> | |
| <button type="button" class="outline btn-sm" data-download-key="id_ed25519" data-key-source="generated-private-key">Download</button> | |
| </div> | |
| <pre class="ssh-config" data-copy-target="generated-private-key">{{ private_key }}</pre> | |
| <p class="text-small text-muted"> | |
| Save this file as <code>~/.ssh/id_ed25519</code> (or a name of your choice) and set its permissions: | |
| <code>chmod 600 ~/.ssh/id_ed25519</code>. | |
| Update your SSH config's <code>IdentityFile</code> to point to the file you saved. | |
| </p> | |
| <a href="/users/{{ target_user_id }}" role="button" class="secondary">Back to user profile</a> | |
| {% endblock %} |
|
@copilot apply changes based on the comments in this thread |
Agent-Logs-Url: https://github.com/pacnpal/callis/sessions/32cc038c-9969-4843-8320-3770732a3c23 Co-authored-by: pacnpal <183241239+pacnpal@users.noreply.github.com>
Done in 916c570 — removed |
generated_key_page.htmltemplate — the non-HTMX generate-key path now returns HTTP 400 instead of rendering this full page, making the template dead code