Files
manage/backend/src/media_library_viewer_api/routers/settings.py
T
Developer 08a3b616f6 refactor(monitoring): decommission legacy SSH-scraping poller (slice 1)
The 2026-06-16/17 observability update externalised metrics to
Prometheus + node_exporter + Grafana, but the legacy Manage-side
SSH-scraping monitor was never removed. It duplicated the new stack,
ran SSH df on every machine every 300s, and fed nothing (its UI was
deleted in e2ad731). This slice decommissions the duplication.

Removed (backend):
- services/monitoring_poller.py (MonitoringPoller) — entire file
- services/monitoring_actions.py (disk_space, run_machine_operation,
  poll_machine_snapshot, build_machine_client) — entire file;
  run_machine_operation had only 2 callers (the poller + /disk), both gone
- tests/test_monitoring_actions.py
- endpoints: POST /api/monitoring/poller, GET /machines/{id}/actions,
  GET /disk (and the now-dead _resolve_machine helper)
- lifespan wiring (main.py), dependency wrapper (dependencies.py),
  poller.start()/kick() from machine save (routers/settings.py)
- SettingsStore: monitoring_machine_actions table CREATE + 2 indexes +
  record/list/prune_machine_actions methods; DROP TABLE IF EXISTS on
  startup cleans existing DBs (user-approved)
- config knobs: monitoring_poll_interval_seconds,
  monitoring_poll_initial_delay_seconds, monitoring_action_retention_days
- test_api.py: TestMonitoring._ensure_machine + test_disk

Kept (fits the new model): /machines, /prometheus-targets, /alerts,
/alertmanager-status, /alertmanager-webhook; the disk_usage JOB template
(manual on-demand, not monitoring); node_exporter_* machine fields
(they point Prometheus at the right host).

Gate: backend pytest 173 passed; ruff clean.
2026-06-17 20:48:56 +00:00

329 lines
12 KiB
Python

"""Settings router for persistent machine definitions, app credentials, and data reset."""
from __future__ import annotations
import logging
from io import StringIO
from typing import Any
import paramiko
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from media_library_viewer_api.clients.ssh import RemoteSSHClient
from media_library_viewer_api.config import get_settings
from media_library_viewer_api.dependencies import get_settings_store
from media_library_viewer_api.services.db_maintenance import remove_sqlite_database
from media_library_viewer_api.services.known_hosts import has_known_host
from media_library_viewer_api.services.media_index import MediaIndex
from media_library_viewer_api.services.settings_store import SettingsStore
from media_library_viewer_api.services.targets import write_prometheus_targets
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/settings", tags=["settings"])
class MonitoringMachineInput(BaseModel):
"""Payload for creating or updating a machine."""
id: str | None = None
name: str = Field(default="")
mode: str = Field(default="local", description="local or ssh")
enabled: bool = True
services: list[str] = Field(default_factory=list)
host: str = ""
port: int = 22
username: str = ""
key_directory: str = ""
key_name: str = ""
ssh_key_id: str = ""
ssh_private_key: str = ""
ssh_private_key_passphrase: str = ""
password: str = ""
media_root: str = ""
path_prefix: str = ""
jellyfin_url: str = ""
jellyfin_user_id: str = ""
jellyfin_api_key: str = ""
jellyseerr_url: str = ""
jellyseerr_api_key: str = ""
notes: str = ""
@router.get("/machines")
def get_machines(store: SettingsStore = Depends(get_settings_store)) -> list[dict[str, Any]]:
return store.list_machines()
def _resolve_ssh_client(
machine: MonitoringMachineInput,
store: SettingsStore,
) -> tuple[RemoteSSHClient, str, int]:
host = machine.host.strip()
username = machine.username.strip()
port = int(machine.port or 22)
if not host or not username:
raise HTTPException(status_code=400, detail="SSH machine is missing host or username")
private_key = machine.ssh_private_key
passphrase = machine.ssh_private_key_passphrase
if machine.ssh_key_id:
ssh_key = store.get_ssh_key(machine.ssh_key_id)
if ssh_key:
private_key = str(ssh_key.get("private_key") or private_key)
passphrase = str(ssh_key.get("passphrase") or passphrase)
key_filename = ""
if machine.key_directory and machine.key_name:
key_filename = f"{machine.key_directory}/{machine.key_name}"
settings = get_settings()
client = RemoteSSHClient(
host=host,
username=username,
port=port,
key_filename=key_filename or None,
private_key=private_key or None,
private_key_passphrase=passphrase or None,
password=machine.password or None,
known_hosts_path=str(settings.ssh_known_hosts_file),
)
return client, host, port
def _raise_ssh_validation_error(host: str, port: int, exc: Exception) -> None:
message = str(exc)
lowered = message.lower()
if "protocol banner" in lowered:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=(f"SSH banner not received from {host}:{port}; the backend could not complete the SSH handshake."),
) from exc
if "no authentication methods available" in lowered or "authentication failed" in lowered:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=(
f"SSH authentication failed for {host}:{port}. "
"Check the selected SSH key, passphrase, username, or password."
),
) from exc
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"SSH validation failed for {host}:{port}: {message}",
) from exc
def _validate_saved_machine_ssh(machine: MonitoringMachineInput, store: SettingsStore) -> None:
if str(machine.mode or "").strip().lower() != "ssh":
return
client, host, port = _resolve_ssh_client(machine, store)
try:
client.connect()
except Exception as exc:
_raise_ssh_validation_error(host, port, exc)
finally:
client.close()
def _write_prometheus_targets(store: SettingsStore) -> None:
"""Regenerate Prometheus file-SD targets after machine changes."""
try:
write_prometheus_targets(store)
except Exception:
logger.exception("Failed to write Prometheus file-SD targets")
@router.post("/machines/test-ssh")
def test_machine_ssh(
machine: MonitoringMachineInput,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
if str(machine.mode or "").strip().lower() != "ssh":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="SSH validation only applies to SSH machines"
)
client, host, port = _resolve_ssh_client(machine, store)
settings = get_settings()
known_hosts_updated = not has_known_host(host, port, settings.ssh_known_hosts_file)
try:
client.connect()
except Exception as exc:
message = str(exc)
lowered = message.lower()
if "protocol banner" in lowered:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=(
f"SSH banner not received from {host}:{port}; the backend recorded the host key, "
"but SSH auth could not be validated. Confirm the SSH service is running."
),
) from exc
if "no authentication methods available" in lowered or "authentication failed" in lowered:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=(
f"SSH banner received from {host}:{port}, but authentication failed. "
"Check the selected SSH key, passphrase, username, or password."
),
) from exc
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"SSH validation failed for {host}:{port}: {message}",
) from exc
finally:
client.close()
return {
"status": "ok",
"message": (
f"SSH connection succeeded for {host}:{port}; host key "
f"{'was recorded' if known_hosts_updated else 'was already trusted'} and authentication worked."
),
"host": host,
"port": port,
"known_hosts_updated": known_hosts_updated,
}
@router.post("/machines", status_code=status.HTTP_201_CREATED)
def post_machine(
machine: MonitoringMachineInput,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
saved = store.upsert_machine(machine.model_dump(exclude_none=True), machine.id)
_write_prometheus_targets(store)
saved_machine = MonitoringMachineInput.model_validate(saved)
_validate_saved_machine_ssh(saved_machine, store)
return saved
@router.put("/machines/{machine_id}")
def put_machine(
machine_id: str,
machine: MonitoringMachineInput,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
if not store.get_machine(machine_id):
raise HTTPException(status_code=404, detail="Machine not found")
saved = store.upsert_machine(machine.model_dump(exclude_none=True), machine_id)
_write_prometheus_targets(store)
saved_machine = MonitoringMachineInput.model_validate(saved)
_validate_saved_machine_ssh(saved_machine, store)
return saved
@router.delete("/machines/{machine_id}")
def delete_machine(machine_id: str, store: SettingsStore = Depends(get_settings_store)) -> dict[str, str]:
if not store.get_machine(machine_id):
raise HTTPException(status_code=404, detail="Machine not found")
store.delete_machine(machine_id)
_write_prometheus_targets(store)
return {"status": "deleted"}
class SSHKeyInput(BaseModel):
id: str | None = None
name: str = Field(default="")
private_key: str = Field(default="")
passphrase: str = Field(default="")
public_key: str = Field(default="")
fingerprint: str = Field(default="")
notes: str = Field(default="")
class SSHKeyGenerateInput(BaseModel):
name: str = Field(default="")
passphrase: str = Field(default="")
notes: str = Field(default="")
bits: int = Field(default=4096, ge=2048, le=8192)
@router.post("/ssh-keys/generate")
def generate_ssh_key(
payload: SSHKeyGenerateInput,
) -> dict[str, Any]:
key = paramiko.RSAKey.generate(bits=payload.bits)
private_buffer = StringIO()
key.write_private_key(private_buffer, password=payload.passphrase or None)
private_key = private_buffer.getvalue()
public_key = f"{key.get_name()} {key.get_base64()}"
fingerprint = ":".join(f"{b:02x}" for b in key.get_fingerprint())
return {
"name": payload.name,
"private_key": private_key,
"passphrase": payload.passphrase,
"notes": payload.notes,
"public_key": public_key,
"fingerprint": fingerprint,
}
@router.get("/ssh-keys")
def get_ssh_keys(store: SettingsStore = Depends(get_settings_store)) -> list[dict[str, Any]]:
return store.list_ssh_keys()
@router.post("/ssh-keys", status_code=status.HTTP_201_CREATED)
def post_ssh_key(
key: SSHKeyInput,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
return store.upsert_ssh_key(key.model_dump(exclude_none=True), key.id)
@router.put("/ssh-keys/{key_id}")
def put_ssh_key(
key_id: str,
key: SSHKeyInput,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
if not store.get_ssh_key(key_id):
raise HTTPException(status_code=404, detail="SSH key not found")
return store.upsert_ssh_key(key.model_dump(exclude_none=True), key_id)
@router.delete("/ssh-keys/{key_id}")
def delete_ssh_key(key_id: str, store: SettingsStore = Depends(get_settings_store)) -> dict[str, str]:
if not store.get_ssh_key(key_id):
raise HTTPException(status_code=404, detail="SSH key not found")
store.delete_ssh_key(key_id)
return {"status": "deleted"}
class ResetLocalDatabaseInput(BaseModel):
confirm_phrase: str = Field(..., description="Must be RESET LOCAL DATABASE")
acknowledge_settings_loss: bool = False
acknowledge_media_index_loss: bool = False
acknowledge_irreversible: bool = False
@router.post("/reset-local-database")
def reset_local_database(
payload: ResetLocalDatabaseInput,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
expected = "RESET LOCAL DATABASE"
if payload.confirm_phrase.strip().upper() != expected:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Confirmation phrase does not match")
if not (
payload.acknowledge_settings_loss and payload.acknowledge_media_index_loss and payload.acknowledge_irreversible
):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="All confirmation checkboxes must be selected"
)
settings_removed = remove_sqlite_database(store.db_path)
media_index = MediaIndex()
media_removed = remove_sqlite_database(media_index.db_path)
return {
"status": "reset",
"settings_db_removed": bool(settings_removed),
"media_index_removed": bool(media_removed),
"settings_files": settings_removed,
"media_index_files": media_removed,
}