From fc7fb4c7145de4a80acaa85fd18ab7569d2b0733 Mon Sep 17 00:00:00 2001 From: Brandon Walter <51866976+echoparkbaby@users.noreply.github.com> Date: Sun, 3 May 2026 09:48:24 -0400 Subject: [PATCH] refactor: extract settings routes (1.0.0-36) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulls /settings + /api/v1/settings* + /api/v1/settings/redacted + /test-smtp + /test-ssh into routes/settings.py (155 LoC). All five endpoints share the admin gate from auth.require_admin and the secret_status / SECRET_FIELDS helpers, so the boundary is clean. routes/__init__.py shrank from 960 -> 815 LoC. Cleanup bonus: dropped an orphan "# Print view (must be BEFORE /{job_id} int route)" comment that referenced the print-view endpoint already extracted to history.py. Verification: 59/59 tests pass; /settings 401 (auth-gated as expected); /login still 200; container boots clean at 1.0.0-36. Remaining slices: routes/burnin.py (start + cancel + export.csv + {job_id}) and routes/drives.py (the biggest, with the unlock route that's currently interleaved between the burnin endpoints in __init__.py — drives extraction unblocks burnin extraction). Co-Authored-By: Claude Opus 4.7 (1M context) --- app/config.py | 2 +- app/routes/__init__.py | 147 +-------------------------------------- app/routes/settings.py | 153 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 156 insertions(+), 146 deletions(-) create mode 100644 app/routes/settings.py diff --git a/app/config.py b/app/config.py index 65329f3..75b4d43 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-35" + app_version: str = "1.0.0-36" # ---- Authentication (1.0.0-22) ---- # session_secret: HMAC key for signing session cookies. Empty = generate diff --git a/app/routes/__init__.py b/app/routes/__init__.py index 485a9e7..3adcc4c 100644 --- a/app/routes/__init__.py +++ b/app/routes/__init__.py @@ -49,6 +49,7 @@ import app.routes.history as _history_routes # noqa: E402 import app.routes.audit as _audit_routes # noqa: E402 import app.routes.stats as _stats_routes # noqa: E402 import app.routes.report as _report_routes # noqa: E402 +import app.routes.settings as _settings_routes # noqa: E402 router.include_router(_auth_routes.router) router.include_router(_system_routes.router) @@ -56,6 +57,7 @@ router.include_router(_history_routes.router) router.include_router(_audit_routes.router) router.include_router(_stats_routes.router) router.include_router(_report_routes.router) +router.include_router(_settings_routes.router) # --------------------------------------------------------------------------- # Internal helpers @@ -793,151 +795,6 @@ async def reset_drive( -# --------------------------------------------------------------------------- -# Settings page -# --------------------------------------------------------------------------- - -@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), - }) - - -# _SECRET_FIELDS and _secret_status are now imported from ._helpers above. - - -@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(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 `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} - - - - -# --------------------------------------------------------------------------- -# Print view (must be BEFORE /{job_id} int route) # --------------------------------------------------------------------------- diff --git a/app/routes/settings.py b/app/routes/settings.py new file mode 100644 index 0000000..d775cf4 --- /dev/null +++ b/app/routes/settings.py @@ -0,0 +1,153 @@ +"""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}