From c5ef1325ab77c9ef2d60c0632d78fed40a920e49 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 7 Apr 2026 14:30:34 +0000
Subject: [PATCH 01/38] feat: add SSH key generation for callis<->client and
callis<->host pairing
Agent-Logs-Url: https://github.com/pacnpal/callis/sessions/4a470c3f-5186-4816-849d-aa0ae0179751
Co-authored-by: pacnpal <183241239+pacnpal@users.noreply.github.com>
---
api/core.py | 114 ++++++++++++++++++++++
api/routers/hosts.py | 6 +-
api/routers/users.py | 89 ++++++++++++++++-
api/static/app.js | 22 +++++
api/templates/hosts.html | 13 +++
api/templates/partials/generated_key.html | 25 +++++
api/templates/user_detail.html | 22 +++++
7 files changed, 287 insertions(+), 4 deletions(-)
create mode 100644 api/templates/partials/generated_key.html
diff --git a/api/core.py b/api/core.py
index 3315476..0bc8ba9 100644
--- a/api/core.py
+++ b/api/core.py
@@ -16,6 +16,14 @@
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from cryptography.hazmat.primitives.serialization import (
+ Encoding,
+ NoEncryption,
+ PrivateFormat,
+ PublicFormat,
+ load_pem_private_key,
+)
import jwt
from jwt.exceptions import PyJWTError as JWTError
import bcrypt
@@ -487,6 +495,112 @@ def _check_rsa_key_size(key_data: bytes, min_bits: int) -> None:
raise ValueError(f"RSA key is {key_bits} bits, minimum {min_bits} required")
+# ---------------------------------------------------------------------------
+# SSH Keypair Generation
+# ---------------------------------------------------------------------------
+
+def generate_ssh_keypair(comment: str = "") -> tuple[str, str]:
+ """Generate an Ed25519 SSH keypair.
+
+ Returns:
+ (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.
+ """
+ private_key = Ed25519PrivateKey.generate()
+ private_key_text = private_key.private_bytes(
+ encoding=Encoding.PEM,
+ format=PrivateFormat.OpenSSH,
+ encryption_algorithm=NoEncryption(),
+ ).decode()
+ public_key_text = private_key.public_key().public_bytes(
+ encoding=Encoding.OpenSSH,
+ format=PublicFormat.OpenSSH,
+ ).decode().rstrip()
+ if comment:
+ public_key_text = f"{public_key_text} {comment}"
+ return private_key_text, public_key_text
+
+
+_DEPLOY_KEY_PATH = "/data/callis_deploy_key"
+
+
+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:
+ pass
+ except OSError as exc:
+ logger.warning("Could not read deploy public key at %s: %s", pub_path, exc)
+ return ""
+
+ # 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()
+ try:
+ with open(pub_path, "w") as f:
+ f.write(pub_text + "\n")
+ except OSError:
+ pass
+ return pub_text
+ except FileNotFoundError:
+ pass
+ except Exception as exc:
+ logger.warning("Could not load existing deploy private key at %s: %s", priv_path, exc)
+
+ # Generate a fresh keypair and persist it.
+ private_key_text, public_key_text = generate_ssh_keypair(comment="callis@deploy")
+ public_key_text = public_key_text.strip()
+ try:
+ os.makedirs(os.path.dirname(priv_path), exist_ok=True)
+ except (OSError, ValueError) as exc:
+ logger.warning("Could not create directory for deploy key at %s: %s", priv_path, exc)
+ try:
+ fd = os.open(priv_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
+ with os.fdopen(fd, "w") as f:
+ f.write(private_key_text)
+ except FileExistsError:
+ # Another concurrent call beat us — recurse once to pick up the new key.
+ return get_server_deploy_public_key()
+ except (PermissionError, OSError) as exc:
+ logger.warning(
+ "Could not persist server deploy key to %s: %s. "
+ "A new key will be generated on next restart.",
+ priv_path,
+ exc,
+ )
+ return public_key_text
+ try:
+ with open(pub_path, "w") as f:
+ f.write(public_key_text + "\n")
+ except (PermissionError, OSError) as exc:
+ logger.warning("Could not write deploy public key to %s: %s", pub_path, exc)
+ logger.info("Generated Callis server deploy key and saved to %s", priv_path)
+ return public_key_text
+
+
# ---------------------------------------------------------------------------
# Audit logging (append-only)
# ---------------------------------------------------------------------------
diff --git a/api/routers/hosts.py b/api/routers/hosts.py
index 38e7383..d86f232 100644
--- a/api/routers/hosts.py
+++ b/api/routers/hosts.py
@@ -8,7 +8,7 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
-from core import get_db, get_runtime_setting, get_settings, register_template_filters, slugify, write_audit_log
+from core import get_db, get_runtime_setting, get_settings, get_server_deploy_public_key, register_template_filters, slugify, write_audit_log
from dependencies import require_role, require_totp_complete
from models import AuditAction, Host, User, UserRole
@@ -44,7 +44,7 @@ async def host_list(
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()},
)
@@ -72,7 +72,7 @@ async def _form_error(detail: str):
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,
)
diff --git a/api/routers/users.py b/api/routers/users.py
index 0c7af8d..2ddde32 100644
--- a/api/routers/users.py
+++ b/api/routers/users.py
@@ -1,4 +1,5 @@
from urllib.parse import urlparse
+from datetime import datetime, timezone
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
@@ -7,7 +8,7 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
-from core import RESERVED_USERNAMES, USERNAME_RE, get_db, get_runtime_setting, get_settings, hash_password, parse_ssh_public_key, register_template_filters, write_audit_log
+from core import RESERVED_USERNAMES, USERNAME_RE, generate_ssh_keypair, get_db, get_runtime_setting, get_settings, hash_password, parse_ssh_public_key, register_template_filters, write_audit_log
from dependencies import require_admin_or_self, require_role
from models import AuditAction, SSHKey, User, UserRole
@@ -391,3 +392,89 @@ async def revoke_key(
context={"keys": keys, "target_user_id": user_id, "user": user},
)
return RedirectResponse(url=request.url_for("user_detail", user_id=user_id), status_code=303)
+
+
+@router.post("/users/{user_id}/keys/generate")
+async def generate_key(
+ request: Request,
+ user_id: str,
+ label: str = Form(""),
+ db: AsyncSession = Depends(get_db),
+ user: User = Depends(require_admin_or_self),
+):
+ # 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 generate 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",
+ )
+
+ # Default label when blank
+ label = label.strip()
+ if not label:
+ label = f"Generated {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M')}"
+
+ # 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:
+ raise HTTPException(status_code=500, detail=f"Key generation error: {e}")
+
+ 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()
+
+ await write_audit_log(
+ db,
+ actor_id=user.id,
+ action=AuditAction.KEY_ADDED,
+ target_type="key",
+ target_id=new_key.id,
+ source_ip=request.client.host if request.client else None,
+ detail={
+ "fingerprint": key_info["fingerprint"],
+ "key_type": key_info["key_type"],
+ "label": label,
+ "generated": True,
+ },
+ )
+
+ # Fetch updated key list for the out-of-band swap
+ keys_result = await db.execute(
+ select(SSHKey).where(SSHKey.user_id == user_id, SSHKey.is_active == True)
+ )
+ keys = keys_result.scalars().all()
+
+ 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,
+ },
+ )
diff --git a/api/static/app.js b/api/static/app.js
index ebcc878..38b2e5f 100644
--- a/api/static/app.js
+++ b/api/static/app.js
@@ -115,3 +115,25 @@ document.addEventListener("change", function (e) {
}
}
});
+
+// Download private key: reads the key from the element referenced by
+// data-key-source (a data-copy-target id) and triggers a browser download
+// using the filename in data-download-key.
+document.addEventListener("click", function (e) {
+ var btn = e.target.closest("[data-download-key]");
+ if (!btn) return;
+ var filename = btn.getAttribute("data-download-key") || "id_ed25519";
+ var sourceId = btn.getAttribute("data-key-source") || "generated-private-key";
+ var copyTarget = document.querySelector('[data-copy-target="' + sourceId + '"]');
+ if (!copyTarget) return;
+ var text = copyTarget.textContent;
+ var blob = new Blob([text], { type: "text/plain" });
+ var url = URL.createObjectURL(blob);
+ var a = document.createElement("a");
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+});
diff --git a/api/templates/hosts.html b/api/templates/hosts.html
index 5f8fad3..b4c8793 100644
--- a/api/templates/hosts.html
+++ b/api/templates/hosts.html
@@ -127,4 +127,17 @@
Add Host
{% if not hosts %}
No hosts configured yet. {% if user.role.value == "admin" %}Click Add Host above to register your first internal server. Once added, assign users so they can connect through the bastion.{% else %}Ask an administrator to add hosts and assign you to them.{% endif %}
{% endif %}
+
+{% if user.role.value == "admin" and server_deploy_key %}
+
+
+Add this public key to each target host's ~/.ssh/authorized_keys (or the relevant system user's authorized_keys) to allow Callis to authenticate when connecting directly to that host. This key is unique to this Callis instance.
+
+ Copy
+
+{{ server_deploy_key }}
+On each target host run: echo '{{ server_deploy_key }}' >> ~/.ssh/authorized_keys
+{% endif %}
{% endblock %}
diff --git a/api/templates/partials/generated_key.html b/api/templates/partials/generated_key.html
new file mode 100644
index 0000000..d64a6d3
--- /dev/null
+++ b/api/templates/partials/generated_key.html
@@ -0,0 +1,25 @@
+
+
+ ⚠ Save this private key now. It will not be shown again once you close this dialog.
+
+
+ Label: {{ label }} —
+ Type: ssh-ed25519 —
+ Fingerprint: {{ fingerprint }}
+
+
+ Copy
+ Download
+
+
{{ private_key }}
+
+ Save this file as ~/.ssh/id_ed25519 (or a name of your choice) and set its permissions:
+ chmod 600 ~/.ssh/id_ed25519.
+ Update your SSH config's IdentityFile to point to the file you saved.
+
+
I've saved my key — Close
+
+
+
+ {% include "partials/key_list.html" %}
+
diff --git a/api/templates/user_detail.html b/api/templates/user_detail.html
index 32df5ad..141e37d 100644
--- a/api/templates/user_detail.html
+++ b/api/templates/user_detail.html
@@ -38,6 +38,7 @@ {{ target_user.display_name }}
Date: Tue, 7 Apr 2026 23:00:38 +0000
Subject: [PATCH 10/38] fix: log key-gen errors server-side; check/fix deploy
key file permissions
Agent-Logs-Url: https://github.com/pacnpal/callis/sessions/ff581fb4-674c-405e-a78d-c9f46caed022
Co-authored-by: pacnpal <183241239+pacnpal@users.noreply.github.com>
---
api/core.py | 16 +++++++++++++++-
api/routers/users.py | 5 ++++-
2 files changed, 19 insertions(+), 2 deletions(-)
diff --git a/api/core.py b/api/core.py
index 6b55a69..6f9c525 100644
--- a/api/core.py
+++ b/api/core.py
@@ -534,9 +534,23 @@ def _derive_public_key_from_private_file(priv_path: str, pub_path: str) -> str |
"""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.
+ Returns ``None`` if the file is missing; returns ``None`` and logs a warning
+ if the file is unreadable or has insecure permissions that cannot be tightened.
"""
try:
+ st = os.stat(priv_path)
+ mode = _stat.S_IMODE(st.st_mode)
+ if mode & 0o077:
+ try:
+ os.chmod(priv_path, 0o600)
+ except OSError as exc:
+ logger.warning(
+ "Deploy private key at %s has insecure permissions %s and could not be tightened: %s",
+ priv_path,
+ oct(mode),
+ exc,
+ )
+ return None
with open(priv_path, "rb") as f:
priv_bytes = f.read()
priv = load_ssh_private_key(priv_bytes, password=None)
diff --git a/api/routers/users.py b/api/routers/users.py
index 4181559..a84eae7 100644
--- a/api/routers/users.py
+++ b/api/routers/users.py
@@ -1,3 +1,4 @@
+import logging
from urllib.parse import urlparse
from datetime import datetime, timezone
@@ -13,6 +14,7 @@
from models import AuditAction, SSHKey, User, UserRole
router = APIRouter()
+logger = logging.getLogger("callis")
templates = Jinja2Templates(directory="templates")
register_template_filters(templates)
@@ -433,7 +435,8 @@ async def generate_key(
try:
key_info = parse_ssh_public_key(public_key_text)
except ValueError as e:
- raise HTTPException(status_code=500, detail=f"Key generation error: {e}")
+ logger.error("Key generation internal error for user %s: %s", user_id, e)
+ raise HTTPException(status_code=500, detail="Key generation failed")
new_key = SSHKey(
user_id=user_id,
From b48c1b26e88454386b7ae0728b6999ee9315dfa5 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 7 Apr 2026 23:38:41 +0000
Subject: [PATCH 11/38] fix: exception logging, HX-Request guard on
generate_key, anyio threadpool for deploy key I/O
Agent-Logs-Url: https://github.com/pacnpal/callis/sessions/6f9ae47e-b0de-4273-aaf9-b78d6ea7fbe3
Co-authored-by: pacnpal <183241239+pacnpal@users.noreply.github.com>
---
api/core.py | 10 +++++++++-
api/routers/hosts.py | 7 +++++--
api/routers/users.py | 32 +++++++++++++++++---------------
3 files changed, 31 insertions(+), 18 deletions(-)
diff --git a/api/core.py b/api/core.py
index 6f9c525..bc972c7 100644
--- a/api/core.py
+++ b/api/core.py
@@ -578,8 +578,16 @@ def get_server_deploy_public_key() -> str:
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).
+ **Blocking:** this function performs synchronous disk I/O (stat, open, read,
+ chmod, and possibly key generation and file writes). It must not be called
+ directly from async request handlers. Call it at application startup (e.g.
+ in the FastAPI ``lifespan`` function) or offload it to a thread pool::
+
+ import anyio
+ key = await anyio.to_thread.run_sync(get_server_deploy_public_key)
+
The result is cached in memory after the first successful read so that
- subsequent calls do not block the event loop with disk I/O.
+ subsequent calls return immediately without any I/O.
"""
global _deploy_public_key_cache
if _deploy_public_key_cache is not None:
diff --git a/api/routers/hosts.py b/api/routers/hosts.py
index 0993ce9..c4c0b13 100644
--- a/api/routers/hosts.py
+++ b/api/routers/hosts.py
@@ -1,6 +1,7 @@
import re
from urllib.parse import urlparse
+import anyio
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
@@ -41,7 +42,7 @@ async def host_list(
select(User).where(User.is_active == True).order_by(User.username)
)
all_users = users_result.scalars().all()
- server_deploy_key = get_server_deploy_public_key()
+ server_deploy_key = await anyio.to_thread.run_sync(get_server_deploy_public_key)
return templates.TemplateResponse(
request,
@@ -68,13 +69,15 @@ async def _form_error(detail: str):
)
all_hosts = result.scalars().all()
au = []
+ server_deploy_key = ""
if user.role == UserRole.admin:
ur = await db.execute(select(User).where(User.is_active == True).order_by(User.username))
au = ur.scalars().all()
+ server_deploy_key = await anyio.to_thread.run_sync(get_server_deploy_public_key)
return templates.TemplateResponse(
request,
"hosts.html",
- 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()},
+ context={"error": detail, "hosts": all_hosts, "user": user, "settings": settings, "ssh_host": ssh_host, "all_users": au, "server_deploy_key": server_deploy_key},
status_code=400,
)
diff --git a/api/routers/users.py b/api/routers/users.py
index a84eae7..6c1e142 100644
--- a/api/routers/users.py
+++ b/api/routers/users.py
@@ -434,8 +434,8 @@ async def generate_key(
try:
key_info = parse_ssh_public_key(public_key_text)
- except ValueError as e:
- logger.error("Key generation internal error for user %s: %s", user_id, e)
+ except ValueError:
+ logger.exception("Key generation internal error for user %s", user_id)
raise HTTPException(status_code=500, detail="Key generation failed")
new_key = SSHKey(
@@ -469,16 +469,18 @@ async def generate_key(
)
keys = keys_result.scalars().all()
- 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"},
- )
+ if request.headers.get("HX-Request"):
+ 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"},
+ )
+ return RedirectResponse(url=request.url_for("user_detail", user_id=user_id), status_code=303)
From 19ecbb8f2003f2261e6bcd3594160015fc90ac57 Mon Sep 17 00:00:00 2001
From: pacnpal <183241239+pacnpal@users.noreply.github.com>
Date: Tue, 7 Apr 2026 19:43:26 -0400
Subject: [PATCH 12/38] Potential fix for pull request finding 'CodeQL / Log
Injection'
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
---
api/routers/users.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/api/routers/users.py b/api/routers/users.py
index 6c1e142..9381106 100644
--- a/api/routers/users.py
+++ b/api/routers/users.py
@@ -435,7 +435,8 @@ async def generate_key(
try:
key_info = parse_ssh_public_key(public_key_text)
except ValueError:
- logger.exception("Key generation internal error for user %s", user_id)
+ safe_user_id = (user_id or "").replace("\r", "").replace("\n", "")
+ logger.error("Key generation internal error for user %s: %s", safe_user_id, e)
raise HTTPException(status_code=500, detail="Key generation failed")
new_key = SSHKey(
From 7838983647bfa64bf547252d1bd3c3d8085ccf80 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 8 Apr 2026 00:10:10 +0000
Subject: [PATCH 13/38] fix: bind ValueError as e, logger.exception(),
thread-safe deploy key cache with Lock
Agent-Logs-Url: https://github.com/pacnpal/callis/sessions/578faae5-c70a-4fb3-bda3-03c46fc30e0a
Co-authored-by: pacnpal <183241239+pacnpal@users.noreply.github.com>
---
api/core.py | 139 +++++++++++++++++++++++--------------------
api/routers/users.py | 4 +-
2 files changed, 76 insertions(+), 67 deletions(-)
diff --git a/api/core.py b/api/core.py
index bc972c7..379c775 100644
--- a/api/core.py
+++ b/api/core.py
@@ -6,6 +6,7 @@
import secrets
import stat as _stat
import struct
+import threading
from collections import OrderedDict
from datetime import datetime, timedelta, timezone
from functools import lru_cache
@@ -525,9 +526,10 @@ def generate_ssh_keypair(comment: str = "") -> tuple[str, str]:
_DEPLOY_KEY_PATH = "/data/callis_deploy_key"
_deploy_public_key_cache: str | None = None
-# Note: Callis runs as a single-worker asyncio process (uvicorn --workers 1),
-# so _deploy_public_key_cache is only accessed from one thread. Multi-worker
-# deployments use separate OS processes, each with their own cache.
+# This cache is process-local. In multi-worker deployments, each worker process
+# maintains its own copy. The lock below protects concurrent initialisation when
+# get_server_deploy_public_key() is offloaded to a thread pool.
+_deploy_key_lock = threading.Lock()
def _derive_public_key_from_private_file(priv_path: str, pub_path: str) -> str | None:
@@ -590,43 +592,19 @@ def get_server_deploy_public_key() -> str:
subsequent calls return immediately without any I/O.
"""
global _deploy_public_key_cache
+ # Lock-free fast path — avoids acquiring the lock on every call once cached.
if _deploy_public_key_cache is not None:
return _deploy_public_key_cache
- priv_path = _DEPLOY_KEY_PATH
- pub_path = priv_path + ".pub"
-
- # Fast path: public key file already exists.
- try:
- with open(pub_path) as f:
- _deploy_public_key_cache = f.read().strip()
+ with _deploy_key_lock:
+ # Re-check inside the lock to handle concurrent first-call racing.
+ if _deploy_public_key_cache is not None:
return _deploy_public_key_cache
- except FileNotFoundError:
- pass
- except OSError as exc:
- logger.warning("Could not read deploy public key at %s: %s", pub_path, exc)
- return ""
-
- # Private key exists but public key file is missing — derive it.
- pub_text = _derive_public_key_from_private_file(priv_path, pub_path)
- if pub_text is not None:
- _deploy_public_key_cache = pub_text
- return pub_text
- # Generate a fresh keypair and persist it.
- private_key_text, public_key_text = generate_ssh_keypair(comment="callis@deploy")
- public_key_text = public_key_text.strip()
- try:
- os.makedirs(os.path.dirname(priv_path), exist_ok=True)
- except (OSError, ValueError) as exc:
- logger.warning("Could not create directory for deploy key at %s: %s", priv_path, exc)
- try:
- fd = os.open(priv_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
- with os.fdopen(fd, "w") as f:
- f.write(private_key_text)
- 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.
+ priv_path = _DEPLOY_KEY_PATH
+ pub_path = priv_path + ".pub"
+
+ # Fast path: public key file already exists.
try:
with open(pub_path) as f:
_deploy_public_key_cache = f.read().strip()
@@ -634,38 +612,69 @@ def get_server_deploy_public_key() -> str:
except FileNotFoundError:
pass
except OSError as exc:
- logger.warning(
- "Could not read deploy public key at %s after concurrent creation: %s",
- pub_path, exc,
- )
+ logger.warning("Could not read deploy public key at %s: %s", pub_path, exc)
+ return ""
+
+ # Private key exists but public key file is missing — derive it.
+ pub_text = _derive_public_key_from_private_file(priv_path, pub_path)
+ if pub_text is not None:
+ _deploy_public_key_cache = pub_text
+ return pub_text
+
+ # Generate a fresh keypair and persist it.
+ private_key_text, public_key_text = generate_ssh_keypair(comment="callis@deploy")
+ public_key_text = public_key_text.strip()
+ try:
+ os.makedirs(os.path.dirname(priv_path), exist_ok=True)
+ except (OSError, ValueError) as exc:
+ logger.warning("Could not create directory for deploy key at %s: %s", priv_path, exc)
+ try:
+ fd = os.open(priv_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
+ with os.fdopen(fd, "w") as f:
+ f.write(private_key_text)
+ 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:
+ _deploy_public_key_cache = f.read().strip()
+ return _deploy_public_key_cache
+ 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:
- _deploy_public_key_cache = derived
- return _deploy_public_key_cache
+ 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,
- )
- return ""
- 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 ""
- try:
- with open(pub_path, "w") as f:
- f.write(public_key_text + "\n")
- except (PermissionError, OSError) as exc:
- logger.warning("Could not write deploy public key to %s: %s", pub_path, exc)
- logger.info("Generated Callis server deploy key and saved to %s", priv_path)
- _deploy_public_key_cache = public_key_text
- return public_key_text
+ logger.warning(
+ "Could not recover deploy public key after concurrent creation at %s; "
+ "returning empty string.",
+ priv_path,
+ )
+ return ""
+ 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 ""
+ try:
+ with open(pub_path, "w") as f:
+ f.write(public_key_text + "\n")
+ except (PermissionError, OSError) as exc:
+ logger.warning("Could not write deploy public key to %s: %s", pub_path, exc)
+ logger.info("Generated Callis server deploy key and saved to %s", priv_path)
+ _deploy_public_key_cache = public_key_text
+ return public_key_text
# ---------------------------------------------------------------------------
diff --git a/api/routers/users.py b/api/routers/users.py
index 9381106..213f5b8 100644
--- a/api/routers/users.py
+++ b/api/routers/users.py
@@ -434,9 +434,9 @@ async def generate_key(
try:
key_info = parse_ssh_public_key(public_key_text)
- except ValueError:
+ except ValueError as e:
safe_user_id = (user_id or "").replace("\r", "").replace("\n", "")
- logger.error("Key generation internal error for user %s: %s", safe_user_id, e)
+ 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(
From 7542c5cd1f36500333802c0a9f357863bf590bbc Mon Sep 17 00:00:00 2001
From: pacnpal <183241239+pacnpal@users.noreply.github.com>
Date: Tue, 7 Apr 2026 20:16:36 -0400
Subject: [PATCH 14/38] Potential fix for pull request finding 'CodeQL / Unused
global variable'
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
---
api/core.py | 20 ++++++++++----------
1 file changed, 10 insertions(+), 10 deletions(-)
diff --git a/api/core.py b/api/core.py
index 379c775..ced4de2 100644
--- a/api/core.py
+++ b/api/core.py
@@ -598,8 +598,8 @@ def get_server_deploy_public_key() -> str:
with _deploy_key_lock:
# Re-check inside the lock to handle concurrent first-call racing.
- if _deploy_public_key_cache is not None:
- return _deploy_public_key_cache
+ if _unused_deploy_public_key_cache is not None:
+ return _unused_deploy_public_key_cache
priv_path = _DEPLOY_KEY_PATH
pub_path = priv_path + ".pub"
@@ -607,8 +607,8 @@ def get_server_deploy_public_key() -> str:
# Fast path: public key file already exists.
try:
with open(pub_path) as f:
- _deploy_public_key_cache = f.read().strip()
- return _deploy_public_key_cache
+ _unused_deploy_public_key_cache = f.read().strip()
+ return _unused_deploy_public_key_cache
except FileNotFoundError:
pass
except OSError as exc:
@@ -618,7 +618,7 @@ def get_server_deploy_public_key() -> str:
# Private key exists but public key file is missing — derive it.
pub_text = _derive_public_key_from_private_file(priv_path, pub_path)
if pub_text is not None:
- _deploy_public_key_cache = pub_text
+ _unused_deploy_public_key_cache = pub_text
return pub_text
# Generate a fresh keypair and persist it.
@@ -638,8 +638,8 @@ def get_server_deploy_public_key() -> str:
# back to deriving it from the private key file.
try:
with open(pub_path) as f:
- _deploy_public_key_cache = f.read().strip()
- return _deploy_public_key_cache
+ _unused_deploy_public_key_cache = f.read().strip()
+ return _unused_deploy_public_key_cache
except FileNotFoundError:
pass
except OSError as exc:
@@ -650,8 +650,8 @@ def get_server_deploy_public_key() -> str:
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
+ _unused_deploy_public_key_cache = derived
+ return _unused_deploy_public_key_cache
logger.warning(
"Could not recover deploy public key after concurrent creation at %s; "
@@ -673,7 +673,7 @@ def get_server_deploy_public_key() -> str:
except (PermissionError, OSError) as exc:
logger.warning("Could not write deploy public key to %s: %s", pub_path, exc)
logger.info("Generated Callis server deploy key and saved to %s", priv_path)
- _deploy_public_key_cache = public_key_text
+ _unused_deploy_public_key_cache = public_key_text
return public_key_text
From 5d5f523ddfaa671ba207bb97efb22fa7bcb0ab85 Mon Sep 17 00:00:00 2001
From: pacnpal <183241239+pacnpal@users.noreply.github.com>
Date: Tue, 7 Apr 2026 20:16:48 -0400
Subject: [PATCH 15/38] Potential fix for pull request finding 'CodeQL / Unused
global variable'
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
---
api/core.py | 22 ++++++++++++++++++++++
1 file changed, 22 insertions(+)
diff --git a/api/core.py b/api/core.py
index ced4de2..a55de8e 100644
--- a/api/core.py
+++ b/api/core.py
@@ -596,6 +596,28 @@ def get_server_deploy_public_key() -> str:
if _deploy_public_key_cache is not None:
return _deploy_public_key_cache
+ global _deploy_public_key_cache
+ # Lazily initialize the module-level cache variable on first use.
+ if "_deploy_public_key_cache" not in globals():
+ _deploy_public_key_cache = None
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
with _deploy_key_lock:
# Re-check inside the lock to handle concurrent first-call racing.
if _unused_deploy_public_key_cache is not None:
From 54b1b24f7864ab39896eac3bce02a2e0be76df7c Mon Sep 17 00:00:00 2001
From: pacnpal <183241239+pacnpal@users.noreply.github.com>
Date: Tue, 7 Apr 2026 20:19:28 -0400
Subject: [PATCH 16/38] Potential fix for pull request finding 'CodeQL / Unused
global variable'
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
---
api/core.py | 11 ++++++-----
1 file changed, 6 insertions(+), 5 deletions(-)
diff --git a/api/core.py b/api/core.py
index a55de8e..b40fcbd 100644
--- a/api/core.py
+++ b/api/core.py
@@ -36,6 +36,7 @@
from models import AuditAction, AuditLog, Setting
logger = logging.getLogger("callis")
+_deploy_public_key_cache: str | None = None
def get_app_version() -> str:
@@ -596,10 +597,7 @@ def get_server_deploy_public_key() -> str:
if _deploy_public_key_cache is not None:
return _deploy_public_key_cache
- global _deploy_public_key_cache
- # Lazily initialize the module-level cache variable on first use.
- if "_deploy_public_key_cache" not in globals():
- _deploy_public_key_cache = None
+ with _deploy_key_lock:
@@ -618,7 +616,10 @@ def get_server_deploy_public_key() -> str:
- with _deploy_key_lock:
+
+
+
+ with _deploy_key_lock:
# Re-check inside the lock to handle concurrent first-call racing.
if _unused_deploy_public_key_cache is not None:
return _unused_deploy_public_key_cache
From 0000860e6a11ee2abd66e9bde3c420fcab49b4df Mon Sep 17 00:00:00 2001
From: pacnpal <183241239+pacnpal@users.noreply.github.com>
Date: Wed, 8 Apr 2026 07:47:24 -0400
Subject: [PATCH 17/38] Potential fix for pull request finding 'CodeQL /
Potentially uninitialized local variable'
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
---
api/core.py | 30 ++++--------------------------
1 file changed, 4 insertions(+), 26 deletions(-)
diff --git a/api/core.py b/api/core.py
index b40fcbd..fc3ec4d 100644
--- a/api/core.py
+++ b/api/core.py
@@ -598,31 +598,9 @@ def get_server_deploy_public_key() -> str:
return _deploy_public_key_cache
with _deploy_key_lock:
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- with _deploy_key_lock:
# Re-check inside the lock to handle concurrent first-call racing.
- if _unused_deploy_public_key_cache is not None:
- return _unused_deploy_public_key_cache
+ if _deploy_public_key_cache is not None:
+ return _deploy_public_key_cache
priv_path = _DEPLOY_KEY_PATH
pub_path = priv_path + ".pub"
@@ -630,8 +608,8 @@ def get_server_deploy_public_key() -> str:
# Fast path: public key file already exists.
try:
with open(pub_path) as f:
- _unused_deploy_public_key_cache = f.read().strip()
- return _unused_deploy_public_key_cache
+ _deploy_public_key_cache = f.read().strip()
+ return _deploy_public_key_cache
except FileNotFoundError:
pass
except OSError as exc:
From 06ac4aa0875bf1c8c961f8a30f9247072a18695a Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 8 Apr 2026 12:14:11 +0000
Subject: [PATCH 18/38] fix: remove duplicate cache var, fix _unused_
assignments, full-page non-htmx key response
Agent-Logs-Url: https://github.com/pacnpal/callis/sessions/03dc41aa-6a35-4e15-ad81-02c17e498016
Co-authored-by: pacnpal <183241239+pacnpal@users.noreply.github.com>
---
api/core.py | 13 ++++++-------
api/routers/users.py | 13 ++++++++++++-
api/templates/generated_key_page.html | 24 ++++++++++++++++++++++++
3 files changed, 42 insertions(+), 8 deletions(-)
create mode 100644 api/templates/generated_key_page.html
diff --git a/api/core.py b/api/core.py
index fc3ec4d..379c775 100644
--- a/api/core.py
+++ b/api/core.py
@@ -36,7 +36,6 @@
from models import AuditAction, AuditLog, Setting
logger = logging.getLogger("callis")
-_deploy_public_key_cache: str | None = None
def get_app_version() -> str:
@@ -619,7 +618,7 @@ def get_server_deploy_public_key() -> str:
# Private key exists but public key file is missing — derive it.
pub_text = _derive_public_key_from_private_file(priv_path, pub_path)
if pub_text is not None:
- _unused_deploy_public_key_cache = pub_text
+ _deploy_public_key_cache = pub_text
return pub_text
# Generate a fresh keypair and persist it.
@@ -639,8 +638,8 @@ def get_server_deploy_public_key() -> str:
# back to deriving it from the private key file.
try:
with open(pub_path) as f:
- _unused_deploy_public_key_cache = f.read().strip()
- return _unused_deploy_public_key_cache
+ _deploy_public_key_cache = f.read().strip()
+ return _deploy_public_key_cache
except FileNotFoundError:
pass
except OSError as exc:
@@ -651,8 +650,8 @@ def get_server_deploy_public_key() -> str:
derived = _derive_public_key_from_private_file(priv_path, pub_path)
if derived is not None:
- _unused_deploy_public_key_cache = derived
- return _unused_deploy_public_key_cache
+ _deploy_public_key_cache = derived
+ return _deploy_public_key_cache
logger.warning(
"Could not recover deploy public key after concurrent creation at %s; "
@@ -674,7 +673,7 @@ def get_server_deploy_public_key() -> str:
except (PermissionError, OSError) as exc:
logger.warning("Could not write deploy public key to %s: %s", pub_path, exc)
logger.info("Generated Callis server deploy key and saved to %s", priv_path)
- _unused_deploy_public_key_cache = public_key_text
+ _deploy_public_key_cache = public_key_text
return public_key_text
diff --git a/api/routers/users.py b/api/routers/users.py
index 213f5b8..4c4834f 100644
--- a/api/routers/users.py
+++ b/api/routers/users.py
@@ -484,4 +484,15 @@ async def generate_key(
},
headers={"Cache-Control": "no-store", "Pragma": "no-cache"},
)
- return RedirectResponse(url=request.url_for("user_detail", user_id=user_id), status_code=303)
+ return templates.TemplateResponse(
+ request,
+ "generated_key_page.html",
+ context={
+ "private_key": private_key_text,
+ "label": label,
+ "fingerprint": key_info["fingerprint"],
+ "target_user_id": user_id,
+ "user": user,
+ },
+ headers={"Cache-Control": "no-store", "Pragma": "no-cache"},
+ )
diff --git a/api/templates/generated_key_page.html b/api/templates/generated_key_page.html
new file mode 100644
index 0000000..e55bd0c
--- /dev/null
+++ b/api/templates/generated_key_page.html
@@ -0,0 +1,24 @@
+{% extends "base.html" %}
+{% block title %}Generated SSH Key - {{ instance_name() }}{% endblock %}
+{% block content %}
+Generated SSH Key
+
+ ⚠ Save this private key now. It will not be shown again once you leave this page.
+
+
+ Label: {{ label }} —
+ Type: ssh-ed25519 —
+ Fingerprint: {{ fingerprint }}
+
+
+ Copy
+ Download
+
+{{ private_key }}
+
+ Save this file as ~/.ssh/id_ed25519 (or a name of your choice) and set its permissions:
+ chmod 600 ~/.ssh/id_ed25519.
+ Update your SSH config's IdentityFile to point to the file you saved.
+
+Back to user profile
+{% endblock %}
From 153c33a1b5d2336fb531af998046a619d9798b3b Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 8 Apr 2026 12:27:56 +0000
Subject: [PATCH 19/38] fix: correct dialog wording, defer revokeObjectURL,
extract _check_key_limit helper
Agent-Logs-Url: https://github.com/pacnpal/callis/sessions/eb53c199-a2c6-48c4-892c-d64737c33ced
Co-authored-by: pacnpal <183241239+pacnpal@users.noreply.github.com>
---
api/routers/users.py | 36 ++++++++++-------------
api/static/app.js | 2 +-
api/templates/partials/generated_key.html | 2 +-
api/templates/user_detail.html | 2 +-
4 files changed, 19 insertions(+), 23 deletions(-)
diff --git a/api/routers/users.py b/api/routers/users.py
index 4c4834f..508e39a 100644
--- a/api/routers/users.py
+++ b/api/routers/users.py
@@ -277,6 +277,20 @@ async def delete_user(
return RedirectResponse(url="/users", status_code=303)
+async def _check_key_limit(user_id: str, db: AsyncSession) -> None:
+ """Raise HTTP 400 if the user has reached the configured per-user 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",
+ )
+
+
@router.post("/users/{user_id}/keys")
async def upload_key(
request: Request,
@@ -295,16 +309,7 @@ async def upload_key(
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 and parse the key
try:
@@ -413,16 +418,7 @@ async def generate_key(
raise HTTPException(status_code=400, detail="Cannot generate 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)
# Default label when blank
label = label.strip()
diff --git a/api/static/app.js b/api/static/app.js
index 38b2e5f..a3708b2 100644
--- a/api/static/app.js
+++ b/api/static/app.js
@@ -135,5 +135,5 @@ document.addEventListener("click", function (e) {
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
- URL.revokeObjectURL(url);
+ setTimeout(function () { URL.revokeObjectURL(url); }, 0);
});
diff --git a/api/templates/partials/generated_key.html b/api/templates/partials/generated_key.html
index d64a6d3..0d27880 100644
--- a/api/templates/partials/generated_key.html
+++ b/api/templates/partials/generated_key.html
@@ -1,6 +1,6 @@
- ⚠ Save this private key now. It will not be shown again once you close this dialog.
+ ⚠ Save this private key now. Copy or download it before closing this dialog.
Label: {{ label }} —
diff --git a/api/templates/user_detail.html b/api/templates/user_detail.html
index 9048d16..777f5fc 100644
--- a/api/templates/user_detail.html
+++ b/api/templates/user_detail.html
@@ -118,7 +118,7 @@
SSH Config
Generate SSH Key
-
A new Ed25519 key pair will be generated. You will be shown the private key once — save it before closing this dialog.
+
A new Ed25519 key pair will be generated. Copy or download the private key immediately — it cannot be recovered after this session.
From a51a1d0447652caccf181768654b45fd960f28be Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 8 Apr 2026 12:48:09 +0000
Subject: [PATCH 20/38] Apply review #4075013116 and #4075102842: label
validation, pub key file hardening, cache failure, dialog button type
Agent-Logs-Url: https://github.com/pacnpal/callis/sessions/18aa7d30-6461-4289-87e9-d4556e2552d1
Co-authored-by: pacnpal <183241239+pacnpal@users.noreply.github.com>
---
api/core.py | 22 ++++++++++++++++++----
api/routers/users.py | 25 ++++++++++++++++++++++++-
api/templates/user_detail.html | 2 +-
3 files changed, 43 insertions(+), 6 deletions(-)
diff --git a/api/core.py b/api/core.py
index 379c775..7972db4 100644
--- a/api/core.py
+++ b/api/core.py
@@ -607,8 +607,14 @@ def get_server_deploy_public_key() -> str:
# Fast path: public key file already exists.
try:
with open(pub_path) as f:
- _deploy_public_key_cache = f.read().strip()
- return _deploy_public_key_cache
+ first_line = f.readline().strip()
+ if first_line:
+ if not any(ord(c) < 32 or ord(c) == 127 for c in first_line):
+ _deploy_public_key_cache = first_line
+ return _deploy_public_key_cache
+ logger.warning(
+ "Deploy public key file %s has unexpected format; ignoring.", pub_path
+ )
except FileNotFoundError:
pass
except OSError as exc:
@@ -638,8 +644,15 @@ def get_server_deploy_public_key() -> str:
# back to deriving it from the private key file.
try:
with open(pub_path) as f:
- _deploy_public_key_cache = f.read().strip()
- return _deploy_public_key_cache
+ first_line = f.readline().strip()
+ if first_line:
+ if not any(ord(c) < 32 or ord(c) == 127 for c in first_line):
+ _deploy_public_key_cache = first_line
+ return _deploy_public_key_cache
+ logger.warning(
+ "Deploy public key file %s has unexpected format after concurrent creation; ignoring.",
+ pub_path,
+ )
except FileNotFoundError:
pass
except OSError as exc:
@@ -666,6 +679,7 @@ def get_server_deploy_public_key() -> str:
priv_path,
exc,
)
+ _deploy_public_key_cache = ""
return ""
try:
with open(pub_path, "w") as f:
diff --git a/api/routers/users.py b/api/routers/users.py
index 508e39a..ba2b411 100644
--- a/api/routers/users.py
+++ b/api/routers/users.py
@@ -277,6 +277,26 @@ async def delete_user(
return RedirectResponse(url="/users", status_code=303)
+_LABEL_MAX_LEN = 100
+
+
+def _validate_label(label: str) -> str:
+ """Strip and validate a key label.
+
+ Returns the stripped label, or raises HTTP 400 if the label contains
+ control characters or exceeds the maximum allowed length.
+ """
+ label = label.strip()
+ if any(ord(c) < 32 or ord(c) == 127 for c in label):
+ raise HTTPException(status_code=400, detail="Label must not contain control characters")
+ if len(label) > _LABEL_MAX_LEN:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Label must not exceed {_LABEL_MAX_LEN} characters",
+ )
+ return label
+
+
async def _check_key_limit(user_id: str, db: AsyncSession) -> None:
"""Raise HTTP 400 if the user has reached the configured per-user key limit."""
max_keys = await get_runtime_setting("max_keys_per_user")
@@ -311,6 +331,9 @@ async def upload_key(
# Check key limit
await _check_key_limit(user_id, db)
+ # Validate label
+ label = _validate_label(label)
+
# Validate and parse the key
try:
key_info = parse_ssh_public_key(public_key)
@@ -421,7 +444,7 @@ async def generate_key(
await _check_key_limit(user_id, db)
# Default label when blank
- label = label.strip()
+ label = _validate_label(label)
if not label:
label = f"Generated {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M')}"
diff --git a/api/templates/user_detail.html b/api/templates/user_detail.html
index 777f5fc..de61707 100644
--- a/api/templates/user_detail.html
+++ b/api/templates/user_detail.html
@@ -114,7 +114,7 @@ SSH Config
From ced6d10d95e216edff52db3a53893abf2cd30419 Mon Sep 17 00:00:00 2001
From: pacnpal <183241239+pacnpal@users.noreply.github.com>
Date: Wed, 8 Apr 2026 08:51:05 -0400
Subject: [PATCH 21/38] Potential fix for pull request finding 'CodeQL / Empty
except'
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
---
api/core.py | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/api/core.py b/api/core.py
index 7972db4..b7b5684 100644
--- a/api/core.py
+++ b/api/core.py
@@ -654,7 +654,12 @@ def get_server_deploy_public_key() -> str:
pub_path,
)
except FileNotFoundError:
- pass
+ # 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",
From 6b76dea446233a8dca661986a2492904069dae1a Mon Sep 17 00:00:00 2001
From: pacnpal <183241239+pacnpal@users.noreply.github.com>
Date: Wed, 8 Apr 2026 08:51:24 -0400
Subject: [PATCH 22/38] Potential fix for pull request finding 'CodeQL / Empty
except'
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
---
api/core.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/api/core.py b/api/core.py
index b7b5684..4ab8157 100644
--- a/api/core.py
+++ b/api/core.py
@@ -616,6 +616,7 @@ def get_server_deploy_public_key() -> str:
"Deploy public key file %s has unexpected format; ignoring.", pub_path
)
except FileNotFoundError:
+ # Missing public key file is expected on first run; derive or generate it below.
pass
except OSError as exc:
logger.warning("Could not read deploy public key at %s: %s", pub_path, exc)
From 3ad5f33d84aec1a515c36590be99e0b2a413b835 Mon Sep 17 00:00:00 2001
From: pacnpal <183241239+pacnpal@users.noreply.github.com>
Date: Wed, 8 Apr 2026 08:51:39 -0400
Subject: [PATCH 23/38] Potential fix for pull request finding 'CodeQL / Unused
global variable'
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
---
api/core.py | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/api/core.py b/api/core.py
index 4ab8157..f77b109 100644
--- a/api/core.py
+++ b/api/core.py
@@ -35,6 +35,10 @@
from models import AuditAction, AuditLog, Setting
+__all__ = [
+ "_deploy_public_key_cache",
+]
+
logger = logging.getLogger("callis")
From 47980f254d2864b47e62a3af1b47d1b188b2d6d5 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 8 Apr 2026 12:58:01 +0000
Subject: [PATCH 24/38] Apply review #4075215094: validate pub key with
parse_ssh_public_key(), safer authorized_keys instructions
Agent-Logs-Url: https://github.com/pacnpal/callis/sessions/82b1c32b-a2ad-4841-b2d1-56aa50a31949
Co-authored-by: pacnpal <183241239+pacnpal@users.noreply.github.com>
---
api/core.py | 25 ++++++++++++++++---------
api/templates/hosts.html | 2 +-
2 files changed, 17 insertions(+), 10 deletions(-)
diff --git a/api/core.py b/api/core.py
index f77b109..30a60c4 100644
--- a/api/core.py
+++ b/api/core.py
@@ -613,12 +613,16 @@ def get_server_deploy_public_key() -> str:
with open(pub_path) as f:
first_line = f.readline().strip()
if first_line:
- if not any(ord(c) < 32 or ord(c) == 127 for c in 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; ignoring.",
+ pub_path,
+ )
+ else:
_deploy_public_key_cache = first_line
return _deploy_public_key_cache
- logger.warning(
- "Deploy public key file %s has unexpected format; ignoring.", pub_path
- )
except FileNotFoundError:
# Missing public key file is expected on first run; derive or generate it below.
pass
@@ -651,13 +655,16 @@ def get_server_deploy_public_key() -> str:
with open(pub_path) as f:
first_line = f.readline().strip()
if first_line:
- if not any(ord(c) < 32 or ord(c) == 127 for c in 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
- logger.warning(
- "Deploy public key file %s has unexpected format after concurrent creation; ignoring.",
- pub_path,
- )
except FileNotFoundError:
# Public key file was not found after concurrent creation attempt;
# fall back to deriving it from the private key file below.
diff --git a/api/templates/hosts.html b/api/templates/hosts.html
index b4c8793..79408df 100644
--- a/api/templates/hosts.html
+++ b/api/templates/hosts.html
@@ -138,6 +138,6 @@
Callis Server Key
Copy
{{ server_deploy_key }}
-On each target host run: echo '{{ server_deploy_key }}' >> ~/.ssh/authorized_keys
+On each target host, open ~/.ssh/authorized_keys in a text editor and paste the copied key above on its own line.
{% endif %}
{% endblock %}
From 53be5ce39e7364f6668bc657d65c22100c6553df Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 8 Apr 2026 13:34:53 +0000
Subject: [PATCH 25/38] Fix OSError fallthrough, sanitize keypair comment,
reject blank upload labels
Agent-Logs-Url: https://github.com/pacnpal/callis/sessions/a6637eca-1e88-469d-9ace-38db8702fec2
Co-authored-by: pacnpal <183241239+pacnpal@users.noreply.github.com>
---
api/core.py | 18 ++++++++++++++----
api/routers/users.py | 2 ++
2 files changed, 16 insertions(+), 4 deletions(-)
diff --git a/api/core.py b/api/core.py
index 30a60c4..739a186 100644
--- a/api/core.py
+++ b/api/core.py
@@ -524,7 +524,13 @@ def generate_ssh_keypair(comment: str = "") -> tuple[str, str]:
format=PublicFormat.OpenSSH,
).decode().rstrip()
if comment:
- public_key_text = f"{public_key_text} {comment}"
+ # Strip and reject control characters to ensure the public key line
+ # remains a single valid authorized_keys entry.
+ comment = comment.strip()
+ if any(ord(c) < 32 or ord(c) == 127 for c in comment):
+ raise ValueError("SSH key comment must not contain control characters")
+ if comment:
+ public_key_text = f"{public_key_text} {comment}"
return private_key_text, public_key_text
@@ -627,10 +633,14 @@ def get_server_deploy_public_key() -> str:
# Missing public key file is expected on first run; derive or generate it below.
pass
except OSError as exc:
- logger.warning("Could not read deploy public key at %s: %s", pub_path, exc)
- return ""
+ logger.warning(
+ "Could not read deploy public key at %s: %s; "
+ "falling through to derive/generate.",
+ pub_path, exc,
+ )
+ # Fall through to deriving from the private key or generating a new keypair.
- # Private key exists but public key file is missing — derive it.
+ # Private key exists but public key file is missing or unreadable — derive it.
pub_text = _derive_public_key_from_private_file(priv_path, pub_path)
if pub_text is not None:
_deploy_public_key_cache = pub_text
diff --git a/api/routers/users.py b/api/routers/users.py
index ba2b411..98447bf 100644
--- a/api/routers/users.py
+++ b/api/routers/users.py
@@ -333,6 +333,8 @@ async def upload_key(
# Validate label
label = _validate_label(label)
+ if not label:
+ raise HTTPException(status_code=400, detail="Label cannot be blank")
# Validate and parse the key
try:
From b60e5de2dcaa52199fc8dcd0134b72148a90c4b2 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 8 Apr 2026 14:11:45 +0000
Subject: [PATCH 26/38] Remove spurious __all__, validate derived deploy key
with parse_ssh_public_key()
Agent-Logs-Url: https://github.com/pacnpal/callis/sessions/29467850-a4f5-4669-af53-7f10b0065f60
Co-authored-by: pacnpal <183241239+pacnpal@users.noreply.github.com>
---
api/core.py | 14 ++++++++++----
1 file changed, 10 insertions(+), 4 deletions(-)
diff --git a/api/core.py b/api/core.py
index 739a186..a87a09b 100644
--- a/api/core.py
+++ b/api/core.py
@@ -35,10 +35,6 @@
from models import AuditAction, AuditLog, Setting
-__all__ = [
- "_deploy_public_key_cache",
-]
-
logger = logging.getLogger("callis")
@@ -570,6 +566,16 @@ def _derive_public_key_from_private_file(priv_path: str, pub_path: str) -> str |
encoding=Encoding.OpenSSH,
format=PublicFormat.OpenSSH,
).decode().strip()
+ try:
+ parse_ssh_public_key(pub_text)
+ except (TypeError, ValueError) as exc:
+ logger.warning(
+ "Derived public key from %s is not a valid SSH public key; "
+ "falling back to generating a fresh keypair: %s",
+ priv_path,
+ exc,
+ )
+ return None
try:
with open(pub_path, "w") as fh:
fh.write(pub_text + "\n")
From c2036676948bcac1b292d2c466d929e899d06a34 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 8 Apr 2026 14:13:30 +0000
Subject: [PATCH 27/38] Add clarifying comment before file write after
validation in _derive_public_key_from_private_file
Agent-Logs-Url: https://github.com/pacnpal/callis/sessions/29467850-a4f5-4669-af53-7f10b0065f60
Co-authored-by: pacnpal <183241239+pacnpal@users.noreply.github.com>
---
api/core.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/api/core.py b/api/core.py
index a87a09b..b3b6e43 100644
--- a/api/core.py
+++ b/api/core.py
@@ -576,6 +576,7 @@ def _derive_public_key_from_private_file(priv_path: str, pub_path: str) -> str |
exc,
)
return None
+ # Validation passed — persist and return the derived public key.
try:
with open(pub_path, "w") as fh:
fh.write(pub_text + "\n")
From 7390aa49c90f69b50e449d509ed08aa7784524c3 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 8 Apr 2026 14:55:58 +0000
Subject: [PATCH 28/38] Apply review 4075823868: clear dialog on close, htmx
error fragments, fix docstring
Agent-Logs-Url: https://github.com/pacnpal/callis/sessions/f8f69c19-171a-4a67-86cf-67e6a2af7d29
Co-authored-by: pacnpal <183241239+pacnpal@users.noreply.github.com>
---
api/core.py | 6 ++++--
api/routers/users.py | 26 ++++++++++++++++++++++----
api/static/app.js | 13 +++++++++++++
api/templates/user_detail.html | 1 +
4 files changed, 40 insertions(+), 6 deletions(-)
diff --git a/api/core.py b/api/core.py
index b3b6e43..075700a 100644
--- a/api/core.py
+++ b/api/core.py
@@ -605,8 +605,10 @@ def get_server_deploy_public_key() -> str:
import anyio
key = await anyio.to_thread.run_sync(get_server_deploy_public_key)
- The result is cached in memory after the first successful read so that
- subsequent calls return immediately without any I/O.
+ The result is cached in memory after the first call so that subsequent
+ calls return immediately without any I/O. Persistent failures (e.g.
+ /data is not writable) are also cached as an empty string so that callers
+ do not repeatedly retry expensive I/O on every request.
"""
global _deploy_public_key_cache
# Lock-free fast path — avoids acquiring the lock on every call once cached.
diff --git a/api/routers/users.py b/api/routers/users.py
index 98447bf..cbfd9c7 100644
--- a/api/routers/users.py
+++ b/api/routers/users.py
@@ -1,3 +1,4 @@
+import html
import logging
from urllib.parse import urlparse
from datetime import datetime, timezone
@@ -332,9 +333,18 @@ async def upload_key(
await _check_key_limit(user_id, db)
# Validate label
- label = _validate_label(label)
- if not label:
- raise HTTPException(status_code=400, detail="Label cannot be blank")
+ 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'{html.escape(exc.detail)} ',
+ status_code=200,
+ headers={"HX-Retarget": "#upload-error-msg", "HX-Reswap": "innerHTML"},
+ )
+ raise
# Validate and parse the key
try:
@@ -446,7 +456,15 @@ async def generate_key(
await _check_key_limit(user_id, db)
# Default label when blank
- label = _validate_label(label)
+ try:
+ label = _validate_label(label)
+ except HTTPException as exc:
+ if request.headers.get("HX-Request"):
+ return HTMLResponse(
+ f'{html.escape(exc.detail)}
',
+ status_code=200,
+ )
+ raise
if not label:
label = f"Generated {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M')}"
diff --git a/api/static/app.js b/api/static/app.js
index a3708b2..4a8fe38 100644
--- a/api/static/app.js
+++ b/api/static/app.js
@@ -64,6 +64,19 @@ document.addEventListener("click", function (e) {
}
});
+// When the generate-key dialog closes, reset the form body so the private key
+// is no longer in the DOM if the dialog is reopened.
+(function () {
+ var genDialog = document.getElementById("generate-key-dialog");
+ var genKeyBody = document.getElementById("generate-key-body");
+ if (genDialog && genKeyBody) {
+ var _genKeyBodyInitial = genKeyBody.innerHTML;
+ genDialog.addEventListener("close", function () {
+ genKeyBody.innerHTML = _genKeyBodyInitial;
+ });
+ }
+}());
+
// Copy-to-clipboard handler (SSH config and other copyable blocks)
// Uses navigator.clipboard when available (requires HTTPS), falls back to
// execCommand('copy') for HTTP/LAN deployments.
diff --git a/api/templates/user_detail.html b/api/templates/user_detail.html
index de61707..b1be8ee 100644
--- a/api/templates/user_detail.html
+++ b/api/templates/user_detail.html
@@ -59,6 +59,7 @@ SSH Keys
placeholder="ssh-ed25519 <public-key> comment"
aria-describedby="public_key_help">
Paste the contents of your .pub file. Accepted types: Ed25519 (recommended) or RSA 4096-bit+ . Generate one with: ssh-keygen -t ed25519
+
Upload Key
From 9a2f2d72f612e1273bea625112dc0d54252db6c9 Mon Sep 17 00:00:00 2001
From: pacnpal <183241239+pacnpal@users.noreply.github.com>
Date: Wed, 8 Apr 2026 11:47:32 -0400
Subject: [PATCH 29/38] Update api/routers/users.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
api/routers/users.py | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/api/routers/users.py b/api/routers/users.py
index cbfd9c7..b381acb 100644
--- a/api/routers/users.py
+++ b/api/routers/users.py
@@ -463,6 +463,10 @@ async def generate_key(
return HTMLResponse(
f'
{html.escape(exc.detail)}
',
status_code=200,
+ headers={
+ "HX-Retarget": "#generate-key-error",
+ "HX-Reswap": "innerHTML",
+ },
)
raise
if not label:
From 0efd866e3a01093e03679d05d88da523792e9386 Mon Sep 17 00:00:00 2001
From: pacnpal <183241239+pacnpal@users.noreply.github.com>
Date: Wed, 8 Apr 2026 11:47:46 -0400
Subject: [PATCH 30/38] Update api/core.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
api/core.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/api/core.py b/api/core.py
index 075700a..e19ecde 100644
--- a/api/core.py
+++ b/api/core.py
@@ -707,6 +707,7 @@ def get_server_deploy_public_key() -> str:
"returning empty string.",
priv_path,
)
+ _deploy_public_key_cache = ""
return ""
except (PermissionError, OSError) as exc:
logger.warning(
From f696430892df3638f6987b69343e9b84f4382f75 Mon Sep 17 00:00:00 2001
From: pacnpal <183241239+pacnpal@users.noreply.github.com>
Date: Wed, 8 Apr 2026 11:50:56 -0400
Subject: [PATCH 31/38] Potential fix for pull request finding 'CodeQL / Unused
global variable'
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
---
api/core.py | 2 --
1 file changed, 2 deletions(-)
diff --git a/api/core.py b/api/core.py
index e19ecde..2aa6e2e 100644
--- a/api/core.py
+++ b/api/core.py
@@ -707,7 +707,6 @@ def get_server_deploy_public_key() -> str:
"returning empty string.",
priv_path,
)
- _deploy_public_key_cache = ""
return ""
except (PermissionError, OSError) as exc:
logger.warning(
@@ -716,7 +715,6 @@ def get_server_deploy_public_key() -> str:
priv_path,
exc,
)
- _deploy_public_key_cache = ""
return ""
try:
with open(pub_path, "w") as f:
From 9afc4046ef518ef9b8e1b760b48ebf518dae0dd5 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 8 Apr 2026 15:57:50 +0000
Subject: [PATCH 32/38] Apply review 4076444569: add #generate-key-error to
dialog, extend HTMX 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>
---
api/routers/users.py | 66 ++++++++++++++++++----------------
api/templates/user_detail.html | 1 +
2 files changed, 36 insertions(+), 31 deletions(-)
diff --git a/api/routers/users.py b/api/routers/users.py
index b381acb..8e16902 100644
--- a/api/routers/users.py
+++ b/api/routers/users.py
@@ -326,17 +326,36 @@ async def upload_key(
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
- await _check_key_limit(user_id, db)
-
- # Validate label
try:
+ if not target.is_active:
+ raise HTTPException(status_code=400, detail="Cannot upload keys for inactive user")
+
+ # Check key limit
+ await _check_key_limit(user_id, db)
+
+ # Validate label
label = _validate_label(label)
if not label:
raise HTTPException(status_code=400, detail="Label cannot be blank")
+
+ # Validate and parse the key
+ try:
+ key_info = parse_ssh_public_key(public_key)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+
+ # Check for duplicate fingerprint
+ dup_result = await db.execute(
+ select(SSHKey).where(
+ SSHKey.user_id == user_id,
+ SSHKey.fingerprint == key_info["fingerprint"],
+ SSHKey.is_active == True,
+ )
+ )
+ if dup_result.scalar_one_or_none():
+ raise HTTPException(status_code=400, detail="This key is already registered")
+
except HTTPException as exc:
if request.headers.get("HX-Request"):
return HTMLResponse(
@@ -346,23 +365,6 @@ async def upload_key(
)
raise
- # Validate and parse the key
- try:
- key_info = parse_ssh_public_key(public_key)
- except ValueError as e:
- raise HTTPException(status_code=400, detail=str(e))
-
- # Check for duplicate fingerprint
- dup_result = await db.execute(
- select(SSHKey).where(
- SSHKey.user_id == user_id,
- SSHKey.fingerprint == key_info["fingerprint"],
- SSHKey.is_active == True,
- )
- )
- if dup_result.scalar_one_or_none():
- raise HTTPException(status_code=400, detail="This key is already registered")
-
new_key = SSHKey(
user_id=user_id,
label=label,
@@ -449,15 +451,19 @@ async def generate_key(
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 generate keys for inactive user")
-
- # Check key limit
- await _check_key_limit(user_id, db)
- # Default label when blank
try:
+ if not target.is_active:
+ raise HTTPException(status_code=400, detail="Cannot generate keys for inactive user")
+
+ # Check key limit
+ await _check_key_limit(user_id, db)
+
+ # Default label when blank
label = _validate_label(label)
+ if not label:
+ label = f"Generated {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M')}"
+
except HTTPException as exc:
if request.headers.get("HX-Request"):
return HTMLResponse(
@@ -469,8 +475,6 @@ async def generate_key(
},
)
raise
- if not label:
- label = f"Generated {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M')}"
# Generate Ed25519 keypair; use username as the key comment
private_key_text, public_key_text = generate_ssh_keypair(comment=target.username)
diff --git a/api/templates/user_detail.html b/api/templates/user_detail.html
index b1be8ee..b36869b 100644
--- a/api/templates/user_detail.html
+++ b/api/templates/user_detail.html
@@ -127,6 +127,7 @@
Generate SSH Key
A name to identify this key (e.g. the device it belongs to). Leave blank for a default name.
+
Generate Key
From 346b012cca39f593b95155ac5461e3f5694408e8 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 8 Apr 2026 16:53:30 +0000
Subject: [PATCH 33/38] Apply review #4076509145: duplicate fingerprint check,
htmx.process on 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>
---
api/core.py | 2 ++
api/routers/users.py | 20 ++++++++++++++++++++
api/static/app.js | 3 +++
3 files changed, 25 insertions(+)
diff --git a/api/core.py b/api/core.py
index 2aa6e2e..e19ecde 100644
--- a/api/core.py
+++ b/api/core.py
@@ -707,6 +707,7 @@ def get_server_deploy_public_key() -> str:
"returning empty string.",
priv_path,
)
+ _deploy_public_key_cache = ""
return ""
except (PermissionError, OSError) as exc:
logger.warning(
@@ -715,6 +716,7 @@ def get_server_deploy_public_key() -> str:
priv_path,
exc,
)
+ _deploy_public_key_cache = ""
return ""
try:
with open(pub_path, "w") as f:
diff --git a/api/routers/users.py b/api/routers/users.py
index 8e16902..0cd09b9 100644
--- a/api/routers/users.py
+++ b/api/routers/users.py
@@ -486,6 +486,26 @@ async def generate_key(
logger.exception("Key generation internal error for user %s: %s", safe_user_id, e)
raise HTTPException(status_code=500, detail="Key generation failed")
+ # Check for duplicate fingerprint (generated keys are unique in practice, but guard anyway)
+ dup_result = await db.execute(
+ select(SSHKey).where(
+ SSHKey.user_id == user_id,
+ SSHKey.fingerprint == key_info["fingerprint"],
+ SSHKey.is_active == True,
+ )
+ )
+ if dup_result.scalar_one_or_none():
+ if request.headers.get("HX-Request"):
+ return HTMLResponse(
+ '
This key is already registered
',
+ status_code=200,
+ headers={
+ "HX-Retarget": "#generate-key-error",
+ "HX-Reswap": "innerHTML",
+ },
+ )
+ raise HTTPException(status_code=400, detail="This key is already registered")
+
new_key = SSHKey(
user_id=user_id,
label=label,
diff --git a/api/static/app.js b/api/static/app.js
index 4a8fe38..5b9d3bf 100644
--- a/api/static/app.js
+++ b/api/static/app.js
@@ -73,6 +73,9 @@ document.addEventListener("click", function (e) {
var _genKeyBodyInitial = genKeyBody.innerHTML;
genDialog.addEventListener("close", function () {
genKeyBody.innerHTML = _genKeyBodyInitial;
+ if (window.htmx) {
+ window.htmx.process(genKeyBody);
+ }
});
}
}());
From fd4264d79d1ce99a15c5962913260f9db2c7300f Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 8 Apr 2026 17:54:01 +0000
Subject: [PATCH 34/38] Apply review #4077092787: inactive user UX, OOB upload
error clear, FileExistsError 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>
---
api/core.py | 3 +--
api/routers/users.py | 2 +-
api/templates/partials/key_list.html | 1 +
api/templates/user_detail.html | 4 ++++
4 files changed, 7 insertions(+), 3 deletions(-)
diff --git a/api/core.py b/api/core.py
index e19ecde..06d247c 100644
--- a/api/core.py
+++ b/api/core.py
@@ -704,10 +704,9 @@ def get_server_deploy_public_key() -> str:
logger.warning(
"Could not recover deploy public key after concurrent creation at %s; "
- "returning empty string.",
+ "returning empty string. Cache left unset so the next call can retry.",
priv_path,
)
- _deploy_public_key_cache = ""
return ""
except (PermissionError, OSError) as exc:
logger.warning(
diff --git a/api/routers/users.py b/api/routers/users.py
index 0cd09b9..4b62e60 100644
--- a/api/routers/users.py
+++ b/api/routers/users.py
@@ -386,7 +386,7 @@ async def upload_key(
)
if request.headers.get("HX-Request"):
- # Return updated key list partial
+ # Return updated key list partial (key_list.html includes an OOB clear for #upload-error-msg).
result = await db.execute(
select(SSHKey).where(SSHKey.user_id == user_id, SSHKey.is_active == True)
)
diff --git a/api/templates/partials/key_list.html b/api/templates/partials/key_list.html
index edde560..fab9470 100644
--- a/api/templates/partials/key_list.html
+++ b/api/templates/partials/key_list.html
@@ -1,3 +1,4 @@
+
{% if keys %}
diff --git a/api/templates/user_detail.html b/api/templates/user_detail.html
index b36869b..0a1eb76 100644
--- a/api/templates/user_detail.html
+++ b/api/templates/user_detail.html
@@ -43,6 +43,7 @@ SSH Keys
{% endif %}
+{% if target_user.is_active %}
@@ -62,6 +63,9 @@ SSH Keys
Upload Key
+{% else %}
+Key management is disabled for inactive users.
+{% endif %}
{% if keys %}
From bb2bc86fb8e0647aff21c97836fcc5ed40f3202b Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 8 Apr 2026 18:09:25 +0000
Subject: [PATCH 35/38] Fix OOB swap innerHTML and nested-p in generate_key
error responses
Agent-Logs-Url: https://github.com/pacnpal/callis/sessions/2438665f-6f4f-4eba-aeba-e190de4c0736
Co-authored-by: pacnpal <183241239+pacnpal@users.noreply.github.com>
---
api/routers/users.py | 4 ++--
api/templates/partials/key_list.html | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/api/routers/users.py b/api/routers/users.py
index 4b62e60..ebc7b4e 100644
--- a/api/routers/users.py
+++ b/api/routers/users.py
@@ -467,7 +467,7 @@ async def generate_key(
except HTTPException as exc:
if request.headers.get("HX-Request"):
return HTMLResponse(
- f'
{html.escape(exc.detail)}
',
+ html.escape(exc.detail),
status_code=200,
headers={
"HX-Retarget": "#generate-key-error",
@@ -497,7 +497,7 @@ async def generate_key(
if dup_result.scalar_one_or_none():
if request.headers.get("HX-Request"):
return HTMLResponse(
- '
This key is already registered
',
+ "This key is already registered",
status_code=200,
headers={
"HX-Retarget": "#generate-key-error",
diff --git a/api/templates/partials/key_list.html b/api/templates/partials/key_list.html
index fab9470..737befc 100644
--- a/api/templates/partials/key_list.html
+++ b/api/templates/partials/key_list.html
@@ -1,4 +1,4 @@
-
+
{% if keys %}
From f6301dadbe7a67188b4f83cb1bb76e163a89f1fb Mon Sep 17 00:00:00 2001
From: pacnpal <183241239+pacnpal@users.noreply.github.com>
Date: Wed, 8 Apr 2026 14:20:24 -0400
Subject: [PATCH 36/38] Update api/routers/users.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
api/routers/users.py | 18 ++++++++----------
1 file changed, 8 insertions(+), 10 deletions(-)
diff --git a/api/routers/users.py b/api/routers/users.py
index ebc7b4e..f2b8167 100644
--- a/api/routers/users.py
+++ b/api/routers/users.py
@@ -551,15 +551,13 @@ async def generate_key(
},
headers={"Cache-Control": "no-store", "Pragma": "no-cache"},
)
- return templates.TemplateResponse(
- request,
- "generated_key_page.html",
- context={
- "private_key": private_key_text,
- "label": label,
- "fingerprint": key_info["fingerprint"],
- "target_user_id": user_id,
- "user": user,
- },
+
+ logger.warning(
+ "Rejected non-HTMX SSH key generation response for user_id=%s",
+ user_id,
+ )
+ return HTMLResponse(
+ content="SSH key generation requires the HTMX-enabled web UI.",
+ status_code=400,
headers={"Cache-Control": "no-store", "Pragma": "no-cache"},
)
From 9eb35c5c02d5af4731c13b972c52db73c50c7f6a Mon Sep 17 00:00:00 2001
From: pacnpal <183241239+pacnpal@users.noreply.github.com>
Date: Wed, 8 Apr 2026 14:22:12 -0400
Subject: [PATCH 37/38] Potential fix for pull request finding 'CodeQL / Log
Injection'
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
---
api/routers/users.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/api/routers/users.py b/api/routers/users.py
index f2b8167..0473d5e 100644
--- a/api/routers/users.py
+++ b/api/routers/users.py
@@ -552,9 +552,10 @@ async def generate_key(
headers={"Cache-Control": "no-store", "Pragma": "no-cache"},
)
+ safe_user_id = str(user_id).replace("\r", "").replace("\n", "")
logger.warning(
"Rejected non-HTMX SSH key generation response for user_id=%s",
- user_id,
+ safe_user_id,
)
return HTMLResponse(
content="SSH key generation requires the HTMX-enabled web UI.",
From 916c5709f58fbaf28facf944abd31ba007ab71ca Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 8 Apr 2026 19:53:20 +0000
Subject: [PATCH 38/38] Remove unused generated_key_page.html template
Agent-Logs-Url: https://github.com/pacnpal/callis/sessions/32cc038c-9969-4843-8320-3770732a3c23
Co-authored-by: pacnpal <183241239+pacnpal@users.noreply.github.com>
---
api/templates/generated_key_page.html | 24 ------------------------
1 file changed, 24 deletions(-)
delete mode 100644 api/templates/generated_key_page.html
diff --git a/api/templates/generated_key_page.html b/api/templates/generated_key_page.html
deleted file mode 100644
index e55bd0c..0000000
--- a/api/templates/generated_key_page.html
+++ /dev/null
@@ -1,24 +0,0 @@
-{% extends "base.html" %}
-{% block title %}Generated SSH Key - {{ instance_name() }}{% endblock %}
-{% block content %}
-Generated SSH Key
-
- ⚠ Save this private key now. It will not be shown again once you leave this page.
-
-
- Label: {{ label }} —
- Type: ssh-ed25519 —
- Fingerprint: {{ fingerprint }}
-
-
- Copy
- Download
-
-{{ private_key }}
-
- Save this file as ~/.ssh/id_ed25519 (or a name of your choice) and set its permissions:
- chmod 600 ~/.ssh/id_ed25519.
- Update your SSH config's IdentityFile to point to the file you saved.
-
-Back to user profile
-{% endblock %}