diff --git a/api/core.py b/api/core.py index 3315476..06d247c 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 @@ -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 @@ -487,6 +496,237 @@ 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 + 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 + + +_DEPLOY_KEY_PATH = "/data/callis_deploy_key" +_deploy_public_key_cache: str | None = None +# 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 + + +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 + + 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 + + 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: + # 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 + 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: + first_line = f.readline().strip() + if first_line: + try: + parse_ssh_public_key(first_line) + except (TypeError, ValueError): + logger.warning( + "Deploy public key file %s is not a valid SSH public key after concurrent creation; ignoring.", + pub_path, + ) + else: + _deploy_public_key_cache = first_line + return _deploy_public_key_cache + except FileNotFoundError: + # Public key file was not found after concurrent creation attempt; + # fall back to deriving it from the private key file below. + logger.debug( + "Deploy public key file %s not found after concurrent creation attempt.", + pub_path, + ) + except OSError as exc: + logger.warning( + "Could not read deploy public key at %s after concurrent creation: %s", + pub_path, exc, + ) + + derived = _derive_public_key_from_private_file(priv_path, pub_path) + if derived is not None: + _deploy_public_key_cache = derived + return _deploy_public_key_cache + + logger.warning( + "Could not recover deploy public key after concurrent creation at %s; " + "returning empty string. Cache left unset so the next call can retry.", + 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, + ) + _deploy_public_key_cache = "" + 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 + + # --------------------------------------------------------------------------- # Audit logging (append-only) # --------------------------------------------------------------------------- diff --git a/api/routers/hosts.py b/api/routers/hosts.py index 38e7383..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 @@ -8,7 +9,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 @@ -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) 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}, ) @@ -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, ) diff --git a/api/routers/users.py b/api/routers/users.py index 0c7af8d..0473d5e 100644 --- a/api/routers/users.py +++ b/api/routers/users.py @@ -1,4 +1,7 @@ +import html +import logging 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,11 +10,12 @@ 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 router = APIRouter() +logger = logging.getLogger("callis") templates = Jinja2Templates(directory="templates") register_template_filters(templates) @@ -274,6 +278,40 @@ 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") + 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, @@ -288,37 +326,44 @@ 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 - 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", - ) - # 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 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") + 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( + f'{html.escape(exc.detail)}', + status_code=200, + headers={"HX-Retarget": "#upload-error-msg", "HX-Reswap": "innerHTML"}, + ) + raise new_key = SSHKey( user_id=user_id, @@ -341,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) ) @@ -391,3 +436,129 @@ 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") + + 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( + html.escape(exc.detail), + status_code=200, + headers={ + "HX-Retarget": "#generate-key-error", + "HX-Reswap": "innerHTML", + }, + ) + raise + + # Generate Ed25519 keypair; use username as the key comment + private_key_text, public_key_text = generate_ssh_keypair(comment=target.username) + + try: + key_info = parse_ssh_public_key(public_key_text) + except ValueError as e: + safe_user_id = (user_id or "").replace("\r", "").replace("\n", "") + logger.exception("Key generation internal error for user %s: %s", safe_user_id, e) + raise HTTPException(status_code=500, detail="Key generation failed") + + # 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, + 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() + + 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"}, + ) + + safe_user_id = str(user_id).replace("\r", "").replace("\n", "") + logger.warning( + "Rejected non-HTMX SSH key generation response for user_id=%s", + safe_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"}, + ) diff --git a/api/static/app.js b/api/static/app.js index ebcc878..5b9d3bf 100644 --- a/api/static/app.js +++ b/api/static/app.js @@ -64,6 +64,22 @@ 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; + if (window.htmx) { + window.htmx.process(genKeyBody); + } + }); + } +}()); + // 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. @@ -115,3 +131,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); + setTimeout(function () { URL.revokeObjectURL(url); }, 0); +}); diff --git a/api/templates/hosts.html b/api/templates/hosts.html index 5f8fad3..79408df 100644 --- a/api/templates/hosts.html +++ b/api/templates/hosts.html @@ -127,4 +127,17 @@
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.
{{ server_deploy_key }}
+On each target host, open ~/.ssh/authorized_keys in a text editor and paste the copied key above on its own line.
+ Label: {{ label }} —
+ Type: ssh-ed25519 —
+ Fingerprint: {{ fingerprint }}
+
{{ 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.
+