08a3b616f6
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.
329 lines
12 KiB
Python
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,
|
|
}
|