"""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, }