7107815a5c
Slice 1 of jellyfin-service-registry. Jellyfin is configured exclusively via the service registry now; the machine-level media_root/path_prefix fields were dead duplicates of the global config. - services/settings_store.py: DEFAULT_SERVICES no longer includes "jellyfin" (now ["monitoring", "files"]). Removed machine-level media_root/path_prefix from _default_local_machine, _row_to_machine, _normalize_machine_payload, _seed_local_machine, get_machine_config, and upsert_machine. _default_local_machine no longer reads global config, so the get_settings import is dropped. - routers/settings.py: removed media_root/path_prefix from MonitoringMachineInput (dead API input; store already ignored them). The global config remote_media_root/path_prefix properties + path_utils.py are unchanged (files.py and media_index still use them for Jellyfin->SSH path resolution). ruff clean; 239 backend tests pass.
310 lines
11 KiB
Python
310 lines
11 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
|
|
|
|
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 = ""
|
|
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()
|
|
|
|
|
|
@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)
|
|
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)
|
|
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)
|
|
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,
|
|
}
|