"""Settings page + settings API. GET /settings — admin-only HTML form GET /api/v1/settings/redacted — admin-only diagnostic dump POST /api/v1/settings — save (admin) + audit secret rotations POST /api/v1/settings/test-smtp — admin-only SMTP probe POST /api/v1/settings/test-ssh — admin-only SSH probe """ from __future__ import annotations import aiosqlite from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import HTMLResponse from app import auth, mailer, poller, settings_store from app.config import settings from app.database import get_db from app.renderer import templates from ._helpers import client_ip, secret_status, stale_context, SECRET_FIELDS router = APIRouter() @router.get("/settings", response_class=HTMLResponse) async def settings_page( request: Request, db: aiosqlite.Connection = Depends(get_db), ): auth.require_admin(request) # Editable values — real values for form fields (secrets excluded) editable = { # SMTP "smtp_host": settings.smtp_host, "smtp_port": settings.smtp_port, "smtp_ssl_mode": settings.smtp_ssl_mode or "starttls", "smtp_timeout": settings.smtp_timeout, "smtp_user": settings.smtp_user, "smtp_from": settings.smtp_from, "smtp_to": settings.smtp_to, "smtp_report_hour": settings.smtp_report_hour, "smtp_daily_report_enabled": settings.smtp_daily_report_enabled, "smtp_alert_on_fail": settings.smtp_alert_on_fail, "smtp_alert_on_pass": settings.smtp_alert_on_pass, # Webhook "webhook_url": settings.webhook_url, # Burn-in behaviour "stuck_job_hours": settings.stuck_job_hours, "max_parallel_burnins": settings.max_parallel_burnins, "temp_warn_c": settings.temp_warn_c, "temp_crit_c": settings.temp_crit_c, "bad_block_threshold": settings.bad_block_threshold, "surface_validate_block_size": settings.surface_validate_block_size, "surface_validate_block_buffer": settings.surface_validate_block_buffer, "surface_validate_passes": settings.surface_validate_passes, # SSH credentials (take effect immediately — each SSH call reads live settings) "ssh_host": settings.ssh_host, "ssh_port": settings.ssh_port, "ssh_user": settings.ssh_user, # Note: ssh_password and ssh_key intentionally omitted from display (sensitive) # System settings (restart required to fully apply) "truenas_base_url": settings.truenas_base_url, "truenas_verify_tls": settings.truenas_verify_tls, "poll_interval_seconds": settings.poll_interval_seconds, "stale_threshold_seconds": settings.stale_threshold_seconds, "allowed_ips": settings.allowed_ips, "log_level": settings.log_level, # Note: truenas_api_key intentionally omitted from display (sensitive) } from app import ssh_client as _ssh ps = poller.get_state() 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, "poller": ps, **stale_context(ps), }) @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.""" auth.require_admin(request) 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(request: Request, body: dict): """Save editable runtime settings. Secrets are only updated if non-empty.""" user = auth.require_admin(request) # 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)) # Audit secret rotations — never log the value, only the field name + # operator + source IP. Lets the 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") async def test_smtp(request: Request): """Test the current SMTP configuration without sending an email.""" auth.require_admin(request) result = await mailer.test_smtp_connection() if not result["ok"]: raise HTTPException(status_code=502, detail=result["error"]) return {"ok": True} @router.post("/api/v1/settings/test-ssh") async def test_ssh(request: Request): """Test the current SSH configuration.""" auth.require_admin(request) from app import ssh_client result = await ssh_client.test_connection() if not result["ok"]: raise HTTPException(status_code=502, detail=result.get("error", "Connection failed")) return {"ok": True}