Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
c5ef132
feat: add SSH key generation for callis<->client and callis<->host pa…
Copilot Apr 7, 2026
cc846e7
fix: address code review feedback on key generation
Copilot Apr 7, 2026
030609d
Potential fix for pull request finding 'CodeQL / Empty except'
pacnpal Apr 7, 2026
029a8f9
Potential fix for pull request finding 'CodeQL / Empty except'
pacnpal Apr 7, 2026
8c48908
Potential fix for pull request finding 'CodeQL / Unused global variable'
pacnpal Apr 7, 2026
6fdf7bb
Potential fix for pull request finding 'CodeQL / Unused global variable'
pacnpal Apr 7, 2026
27a4262
Potential fix for pull request finding 'CodeQL / Unused global variable'
pacnpal Apr 7, 2026
fcbada4
Potential fix for pull request finding 'CodeQL / Unreachable code'
pacnpal Apr 7, 2026
8ce1c3b
fix: return empty string on key persistence failure; hide Generate Ke…
Copilot Apr 7, 2026
e122b65
fix: log key-gen errors server-side; check/fix deploy key file permis…
Copilot Apr 7, 2026
b48c1b2
fix: exception logging, HX-Request guard on generate_key, anyio threa…
Copilot Apr 7, 2026
19ecbb8
Potential fix for pull request finding 'CodeQL / Log Injection'
pacnpal Apr 7, 2026
7838983
fix: bind ValueError as e, logger.exception(), thread-safe deploy key…
Copilot Apr 8, 2026
7542c5c
Potential fix for pull request finding 'CodeQL / Unused global variable'
pacnpal Apr 8, 2026
5d5f523
Potential fix for pull request finding 'CodeQL / Unused global variable'
pacnpal Apr 8, 2026
54b1b24
Potential fix for pull request finding 'CodeQL / Unused global variable'
pacnpal Apr 8, 2026
0000860
Potential fix for pull request finding 'CodeQL / Potentially uninitia…
pacnpal Apr 8, 2026
06ac4aa
fix: remove duplicate cache var, fix _unused_ assignments, full-page …
Copilot Apr 8, 2026
153c33a
fix: correct dialog wording, defer revokeObjectURL, extract _check_ke…
Copilot Apr 8, 2026
a51a1d0
Apply review #4075013116 and #4075102842: label validation, pub key f…
Copilot Apr 8, 2026
ced6d10
Potential fix for pull request finding 'CodeQL / Empty except'
pacnpal Apr 8, 2026
6b76dea
Potential fix for pull request finding 'CodeQL / Empty except'
pacnpal Apr 8, 2026
3ad5f33
Potential fix for pull request finding 'CodeQL / Unused global variable'
pacnpal Apr 8, 2026
47980f2
Apply review #4075215094: validate pub key with parse_ssh_public_key(…
Copilot Apr 8, 2026
53be5ce
Fix OSError fallthrough, sanitize keypair comment, reject blank uploa…
Copilot Apr 8, 2026
b60e5de
Remove spurious __all__, validate derived deploy key with parse_ssh_p…
Copilot Apr 8, 2026
c203667
Add clarifying comment before file write after validation in _derive_…
Copilot Apr 8, 2026
7390aa4
Apply review 4075823868: clear dialog on close, htmx error fragments,…
Copilot Apr 8, 2026
9a2f2d7
Update api/routers/users.py
pacnpal Apr 8, 2026
0efd866
Update api/core.py
pacnpal Apr 8, 2026
f696430
Potential fix for pull request finding 'CodeQL / Unused global variable'
pacnpal Apr 8, 2026
9afc404
Apply review 4076444569: add #generate-key-error to dialog, extend HT…
Copilot Apr 8, 2026
346b012
Apply review #4076509145: duplicate fingerprint check, htmx.process o…
Copilot Apr 8, 2026
fd4264d
Apply review #4077092787: inactive user UX, OOB upload error clear, F…
Copilot Apr 8, 2026
bb2bc86
Fix OOB swap innerHTML and nested-p in generate_key error responses
Copilot Apr 8, 2026
f6301da
Update api/routers/users.py
pacnpal Apr 8, 2026
9eb35c5
Potential fix for pull request finding 'CodeQL / Log Injection'
pacnpal Apr 8, 2026
916c570
Remove unused generated_key_page.html template
Copilot Apr 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
240 changes: 240 additions & 0 deletions api/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -16,6 +17,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_ssh_private_key,
)
import jwt
from jwt.exceptions import PyJWTError as JWTError
import bcrypt
Expand Down Expand Up @@ -487,6 +496,237 @@
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
authorized_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:
# 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
Comment on lines +519 to +530
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

generate_ssh_keypair() appends the comment string directly onto the public key line. If a future caller passes untrusted input (e.g., containing newlines/control characters), this could produce a multi-line/invalid authorized_keys entry. Consider stripping and rejecting control characters (and/or collapsing whitespace) in comment before appending.

Copilot uses AI. Check for mistakes.


_DEPLOY_KEY_PATH = "/data/callis_deploy_key"
_deploy_public_key_cache: str | None = None
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
# 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:
"""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`` 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)
pub_text = priv.public_key().public_bytes(
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
# Validation passed — persist and return the derived public key.
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
Comment on lines +541 to +590
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.


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).

**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 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.
if _deploy_public_key_cache is not None:
return _deploy_public_key_cache
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed

with _deploy_key_lock:
# Re-check inside the lock to handle concurrent first-call racing.
Comment on lines +600 to +619
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring says the deploy key result is cached “after the first successful read”, but the implementation also caches failures (e.g. sets _deploy_public_key_cache to an empty string on persistence errors). This makes the function stop retrying within the process even if the underlying filesystem issue is later fixed. Either update the docstring to match the behavior or avoid caching the empty-string failure so later calls can retry.

Copilot uses AI. Check for mistakes.
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:
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; ignoring.",
pub_path,
)
else:
_deploy_public_key_cache = first_line
return _deploy_public_key_cache
except FileNotFoundError:
Comment on lines +626 to +641
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When reading an existing .pub file, this uses f.read().strip(), which will preserve embedded newlines if the file is malformed or tampered with (breaking the expectation that an OpenSSH public key is a single line, and producing confusing output in the UI copy/echo snippets). Consider reading only the first line and/or validating that the loaded key contains no control characters before caching/returning it.

Copilot uses AI. Check for mistakes.
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
# 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; "
"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 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

Check notice

Code scanning / CodeQL

Unused global variable Note

The global variable '_deploy_public_key_cache' is not used.
return pub_text
Comment on lines +652 to +656
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When deriving the deploy public key from an existing private key file, the derived pub_text is cached and returned without validation. This means a malformed or disallowed key type (e.g., not ssh-ed25519/ssh-rsa) could be surfaced to the UI and persisted to .pub even though parse_ssh_public_key() is used to validate the fast-path .pub read. Consider validating the derived key with parse_ssh_public_key() before caching/writing it, and falling back to generating a fresh keypair if it’s invalid.

Copilot uses AI. Check for mistakes.

# 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:
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:
Comment on lines +674 to +687
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the concurrent-creation recovery branch, the code again writes into _unused_deploy_public_key_cache (a local) rather than _deploy_public_key_cache, so the cache still won’t be populated. Update the global cache variable when reading/deriving succeeds.

Copilot uses AI. Check for mistakes.
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
# 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. Cache left unset so the next call can retry.",
priv_path,
)
Comment thread
pacnpal marked this conversation as resolved.
return ""
Comment on lines +705 to +710
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +669 to +710
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
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,
)
_deploy_public_key_cache = ""

Check notice

Code scanning / CodeQL

Unused global variable Note

The global variable '_deploy_public_key_cache' is not used.
return ""
Comment on lines +711 to +719
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When persisting the deploy key fails (permission/read-only /data), this function returns an empty string without caching that failure. Callers like /hosts will then re-run the full generation + I/O attempt on every request, which can be expensive and noisy in logs. Consider caching the failure result (e.g., set the cache to "" or a sentinel with a retry backoff) so repeated calls don’t continually regenerate keys until the process restarts or an explicit refresh is triggered.

Copilot uses AI. Check for mistakes.
Comment on lines +711 to +719
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 uses AI. Check for mistakes.
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

Check notice

Code scanning / CodeQL

Unused global variable Note

The global variable '_deploy_public_key_cache' is not used.
return public_key_text
Comment on lines +725 to +727
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After generating and persisting a new deploy keypair, the function returns public_key_text without ever setting _deploy_public_key_cache (it assigns to _unused_deploy_public_key_cache instead). This defeats the intended lock-free cached fast path on subsequent calls within the process. Set _deploy_public_key_cache before returning.

Copilot uses AI. Check for mistakes.


# ---------------------------------------------------------------------------
# Audit logging (append-only)
# ---------------------------------------------------------------------------
Expand Down
11 changes: 8 additions & 3 deletions api/routers/hosts.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
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
from sqlalchemy import select
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

Expand All @@ -35,16 +36,18 @@ async def host_list(

# Load all active users for assignment dropdowns (admin only)
all_users = []
server_deploy_key = ""
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()
server_deploy_key = await anyio.to_thread.run_sync(get_server_deploy_public_key)

Comment on lines +39 to 46
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_server_deploy_public_key() performs synchronous disk I/O (stat/open/read/chmod and possibly key generation) and is called from this async request handler. That can block the event loop on the first request and on error paths. Consider precomputing/loading this at startup, or moving the file/key operations to a threadpool (e.g., anyio.to_thread.run_sync) so the async route stays non-blocking.

Copilot uses AI. Check for mistakes.
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": server_deploy_key},
)


Expand All @@ -66,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},
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,
)

Expand Down
Loading
Loading