refactor: extract settings routes (1.0.0-36)
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) <noreply@anthropic.com>
This commit is contained in:
parent
3c39344069
commit
fc7fb4c714
3 changed files with 156 additions and 146 deletions
|
|
@ -83,7 +83,7 @@ class Settings(BaseSettings):
|
||||||
ssh_key: str = "" # PEM private key content (paste full key including headers)
|
ssh_key: str = "" # PEM private key content (paste full key including headers)
|
||||||
|
|
||||||
# Application version — used by the /api/v1/updates/check endpoint
|
# 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) ----
|
# ---- Authentication (1.0.0-22) ----
|
||||||
# session_secret: HMAC key for signing session cookies. Empty = generate
|
# session_secret: HMAC key for signing session cookies. Empty = generate
|
||||||
|
|
|
||||||
|
|
@ -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.audit as _audit_routes # noqa: E402
|
||||||
import app.routes.stats as _stats_routes # noqa: E402
|
import app.routes.stats as _stats_routes # noqa: E402
|
||||||
import app.routes.report as _report_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(_auth_routes.router)
|
||||||
router.include_router(_system_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(_audit_routes.router)
|
||||||
router.include_router(_stats_routes.router)
|
router.include_router(_stats_routes.router)
|
||||||
router.include_router(_report_routes.router)
|
router.include_router(_report_routes.router)
|
||||||
|
router.include_router(_settings_routes.router)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Internal helpers
|
# 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)
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
153
app/routes/settings.py
Normal file
153
app/routes/settings.py
Normal file
|
|
@ -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}
|
||||||
Loading…
Add table
Reference in a new issue