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)
|
||||
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
|
|
|||
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