diff --git a/app/config.py b/app/config.py index 06f942c..2a4c099 100644 --- a/app/config.py +++ b/app/config.py @@ -83,7 +83,7 @@ class Settings(BaseSettings): ssh_key: str = "" # PEM private key content (paste full key including headers) # Application version — used by the /api/v1/updates/check endpoint - app_version: str = "1.0.0-25" + app_version: str = "1.0.0-26" # ---- Authentication (1.0.0-22) ---- # session_secret: HMAC key for signing session cookies. Empty = generate diff --git a/app/routes.py b/app/routes.py index e9996c4..87ecce4 100644 --- a/app/routes.py +++ b/app/routes.py @@ -1280,6 +1280,7 @@ async def settings_page( return templates.TemplateResponse(request, "settings.html", { "request": request, "editable": editable, + "secret_status": _secret_status(), "smtp_enabled": bool(settings.smtp_host), "ssh_configured": _ssh.is_configured(), "app_version": settings.app_version, @@ -1288,20 +1289,95 @@ async def settings_page( }) +# Field names that hold secrets and must never be rendered to the UI or +# included in the redacted-settings dump verbatim. +_SECRET_FIELDS = ("smtp_password", "ssh_password", "ssh_key", "truenas_api_key") + + +def _secret_status() -> dict[str, str]: + """Per-secret display string for the settings page so the operator can + see whether each secret is configured (and how) without ever rendering + the value. Distinguishes env-var, mounted-file, and DB-stored sources + for ssh_key — the others can only come from the live settings object.""" + import os as _os + from app.ssh_client import _MOUNTED_KEY_PATH + + def _has(field: str) -> bool: + v = getattr(settings, field, "") + return bool(v) + + # ssh_key gets the most granular treatment because we actively prefer + # the mounted file path in production but the textarea is still wired. + if _os.environ.get("SSH_KEY"): + ssh_key_status = "set (environment variable)" + elif _has("ssh_key"): + ssh_key_status = "set (stored in settings DB — prefer a mounted secret in production)" + elif _os.path.exists( + _os.environ.get("SSH_KEY_FILE", _MOUNTED_KEY_PATH) + ): + ssh_key_status = "set (mounted secret)" + else: + ssh_key_status = "unset" + + return { + "smtp_password": "set" if _has("smtp_password") else "unset", + "ssh_password": "set" if _has("ssh_password") else "unset", + "ssh_key": ssh_key_status, + "truenas_api_key": "set" if _has("truenas_api_key") else "unset", + } + + +@router.get("/api/v1/settings/redacted") +async def get_settings_redacted(request: Request): + """Admin-only diagnostic dump of every editable setting with secrets + replaced by '***'. Useful for ops triage ("what's actually loaded + right now?") without leaking the real values into the transcript.""" + user = request.state.current_user + if not user: + raise HTTPException(status_code=401, detail="Authentication required") + if not user.is_admin: + raise HTTPException(status_code=403, detail="Admin only") + out: dict[str, object] = {} + for field in settings_store._EDITABLE.keys(): + val = getattr(settings, field, None) + if field in _SECRET_FIELDS: + out[field] = "***" if val else None + else: + out[field] = val + out["_secret_status"] = _secret_status() + return out + + @router.post("/api/v1/settings") -async def save_settings(body: dict): +async def save_settings(request: Request, body: dict): """Save editable runtime settings. Secrets are only updated if non-empty.""" - # Don't overwrite secrets if client sent empty string - for secret_field in ("smtp_password", "truenas_api_key", "ssh_password", "ssh_key"): - if secret_field in body and body[secret_field] == "": - del body[secret_field] + user = request.state.current_user + # Don't overwrite secrets if client sent empty string. Track which + # ones DID get a real change so we can audit the rotation. + rotated: list[str] = [] + for secret_field in _SECRET_FIELDS: + if secret_field in body: + if body[secret_field] == "": + del body[secret_field] + else: + rotated.append(secret_field) try: saved = settings_store.save(body) except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) - return {"saved": True, "keys": saved} + # Audit secret rotations — never log the value, only the field name + + # operator + source IP. Lets `audit` page answer "who rotated the + # SMTP password last week?" + if rotated and user: + await auth.audit_auth_event( + "settings_secret_changed", + user.username, + f"Rotated secrets from {_client_ip(request)}: {', '.join(sorted(rotated))}", + ) + + return {"saved": True, "keys": saved, "rotated_secrets": rotated} @router.post("/api/v1/settings/test-smtp") diff --git a/app/static/app.css b/app/static/app.css index f1b2fb1..03539c2 100644 --- a/app/static/app.css +++ b/app/static/app.css @@ -2426,6 +2426,31 @@ tr.drawer-row-active { color: var(--yellow); } +/* ----------------------------------------------------------------------- + Settings: secret-status pills next to password/key labels +----------------------------------------------------------------------- */ +.secret-status { + display: inline-block; + margin-left: 6px; + padding: 1px 6px; + font-size: 10.5px; + font-weight: 500; + letter-spacing: 0.04em; + text-transform: uppercase; + border-radius: 3px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} +.secret-status.secret-set { + background: color-mix(in srgb, var(--green, #39c179) 14%, transparent); + color: var(--green, #39c179); + border: 1px solid color-mix(in srgb, var(--green, #39c179) 35%, transparent); +} +.secret-status.secret-unset { + background: color-mix(in srgb, var(--text-muted) 14%, transparent); + color: var(--text-muted); + border: 1px solid color-mix(in srgb, var(--text-muted) 35%, transparent); +} + /* ----------------------------------------------------------------------- Login screen ----------------------------------------------------------------------- */ diff --git a/app/templates/settings.html b/app/templates/settings.html index 17671aa..88b5f28 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -61,7 +61,7 @@ - + @@ -125,17 +125,19 @@ - + - +
/data/settings_overrides.json.
+ For production, mount the key as a Docker secret at
+ /run/secrets/ssh_key instead of pasting it here
+ — that path is checked automatically when no key is in settings.