From 84dc2bd2f62a38952ee97a03daaaf649219ff9b3 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Thu, 7 May 2026 18:31:49 +0200 Subject: [PATCH] Add SSH connection validation for settings --- backend/README.md | 1 + .../media_library_viewer_api/clients/ssh.py | 34 +++++- .../routers/settings.py | 103 ++++++++++++++++++ .../services/known_hosts.py | 7 +- docs/REQUIREMENTS.md | 1 + frontend/src/api/client.ts | 3 + frontend/src/hooks/useSettings.ts | 8 ++ frontend/src/pages/Settings.tsx | 75 ++++++++++++- frontend/src/types/index.ts | 8 ++ 9 files changed, 235 insertions(+), 5 deletions(-) diff --git a/backend/README.md b/backend/README.md index 7413550..22a66e5 100644 --- a/backend/README.md +++ b/backend/README.md @@ -126,6 +126,7 @@ docker compose up --build 2. After the API is running, open the app, go to **Settings**, and add machine entries: - **Local**: monitors the API host itself without SSH. - **SSH**: monitors another machine using a host, username, and a private key pasted directly into the machine settings, with an optional passphrase. + - Use **Validate SSH + trust host** in the machine editor before saving to test the banner/auth flow and record the first trusted host key into the backend-managed `known_hosts` file. - The first successful SSH connection uses trust-on-first-use: the backend records that machine's host key into its managed `known_hosts` file automatically, then continues verifying it strictly on later connects. 3. Open **Monitoring** to see one section per configured machine. Each section uses its own collector state, disk path, metrics queries, and recent action history, which are populated automatically by the backend poller. diff --git a/backend/src/media_library_viewer_api/clients/ssh.py b/backend/src/media_library_viewer_api/clients/ssh.py index f670e4a..de7049f 100644 --- a/backend/src/media_library_viewer_api/clients/ssh.py +++ b/backend/src/media_library_viewer_api/clients/ssh.py @@ -48,7 +48,7 @@ class RemoteSSHClient: private_key_passphrase: str | None = None, password: str | None = None, known_hosts_path: str | None = None, - timeout: int = 20, + timeout: int = 30, ): if not host or not username: raise ValueError("SSH host and username are required") @@ -73,7 +73,19 @@ class RemoteSSHClient: if self._client: return self._client if self.known_hosts_path: - ensure_known_host(self.host, self.port, Path(self.known_hosts_path), strict=True) + try: + ensure_known_host(self.host, self.port, Path(self.known_hosts_path), strict=True) + except Exception as exc: + message = str(exc).lower() + if "protocol banner" in message: + raise RuntimeError( + f"SSH banner not received from {self.host}:{self.port}. " + "The host key could not be recorded because the backend could not talk to SSH." + ) from exc + raise RuntimeError( + f"SSH host key lookup failed for {self.host}:{self.port}. " + "Confirm the host and port are correct and that SSH is reachable." + ) from exc client = paramiko.SSHClient() client.load_system_host_keys() if self.known_hosts_path and Path(self.known_hosts_path).is_file(): @@ -85,12 +97,28 @@ class RemoteSSHClient: "username": self.username, "password": self.password, "timeout": self.timeout, + "banner_timeout": self.timeout, + "auth_timeout": self.timeout, } if self.private_key: connect_kwargs["pkey"] = self._load_private_key(self.private_key, self.private_key_passphrase) else: connect_kwargs["key_filename"] = self.key_filename - client.connect(**connect_kwargs) + try: + client.connect(**connect_kwargs) + except Exception as exc: + message = str(exc).lower() + if "protocol banner" in message: + raise RuntimeError( + f"SSH banner not received from {self.host}:{self.port}. " + "Confirm the host, port, and firewall; the backend could not complete the SSH handshake." + ) from exc + if "no authentication methods available" in message or "authentication failed" in message: + raise RuntimeError( + f"SSH authentication failed for {self.host}:{self.port}. " + "Check the selected key, passphrase, username, or password." + ) from exc + raise self._client = client return client diff --git a/backend/src/media_library_viewer_api/routers/settings.py b/backend/src/media_library_viewer_api/routers/settings.py index 5d97485..b02772e 100644 --- a/backend/src/media_library_viewer_api/routers/settings.py +++ b/backend/src/media_library_viewer_api/routers/settings.py @@ -9,8 +9,11 @@ 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_monitoring_poller, get_settings_store from media_library_viewer_api.services.db_maintenance import remove_sqlite_database +from media_library_viewer_api.services.known_hosts import ensure_known_host from media_library_viewer_api.services.media_index import MediaIndex from media_library_viewer_api.services.settings_store import SettingsStore @@ -49,6 +52,106 @@ def get_machines(store: SettingsStore = Depends(get_settings_store)) -> list[dic 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 + + +@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() + try: + known_hosts_updated = ensure_known_host(host, port, settings.ssh_known_hosts_file, strict=True) + except RuntimeError 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 could not record the host key. " + "Confirm the SSH service, host, and port are reachable." + ), + ) from exc + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=message) from exc + + 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 {'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, diff --git a/backend/src/media_library_viewer_api/services/known_hosts.py b/backend/src/media_library_viewer_api/services/known_hosts.py index dcc6696..5cc594b 100644 --- a/backend/src/media_library_viewer_api/services/known_hosts.py +++ b/backend/src/media_library_viewer_api/services/known_hosts.py @@ -22,7 +22,7 @@ def _host_alias(host: str, port: int) -> str: return host if int(port or 22) == 22 else f"[{host}]:{int(port or 22)}" -def _fetch_server_key(host: str, port: int, timeout: int = 10) -> paramiko.PKey: +def _fetch_server_key(host: str, port: int, timeout: int = 30) -> paramiko.PKey: sock = socket.create_connection((host, int(port or 22)), timeout=timeout) transport = paramiko.Transport(sock) try: @@ -31,6 +31,11 @@ def _fetch_server_key(host: str, port: int, timeout: int = 10) -> paramiko.PKey: if key is None: raise RuntimeError(f"Unable to read SSH host key for {host}:{port}") return key + except Exception as exc: + raise RuntimeError( + f"Unable to read SSH protocol banner from {host}:{port}. " + "Confirm the host is running an SSH server on that port and is reachable from the backend." + ) from exc finally: transport.close() sock.close() diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index 3e4cf2b..6c3411e 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -276,3 +276,4 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo - 2026-05-07: The app versioning scheme should be hybrid: auto-detect package/build metadata when available, but allow explicit overrides for deployments that need fixed labels. - 2026-05-07: The shell should display both frontend and backend version labels so deployed builds are easy to identify without opening a separate diagnostics screen. - 2026-05-07: SSH host verification should use trust-on-first-use for new machines by recording the first observed host key into the backend-managed known_hosts file, while still rejecting later key mismatches. +- 2026-05-07: The SSH machine editor should expose a validation button that tests banner/auth flow and records the host key before save so users get clear feedback when a host is unreachable. diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index af17ffb..c12fd9f 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -34,6 +34,7 @@ import type { ResolvedPath, ResetLocalDatabaseInput, ResetLocalDatabaseResponse, + SSHValidationResult, DashboardShortcut, DashboardShortcutInput, } from "../types"; @@ -330,6 +331,8 @@ export const saveMonitoringMachine = (machine: MonitoringMachineInput) => } return response.json() as Promise; }); +export const testMonitoringMachineSSH = (machine: MonitoringMachineInput) => + post("/api/settings/machines/test-ssh", machine); export const deleteMonitoringMachine = (machineId: string) => del<{ status: string }>( `/api/settings/machines/${encodeURIComponent(machineId)}`, diff --git a/frontend/src/hooks/useSettings.ts b/frontend/src/hooks/useSettings.ts index d0a29d6..233559e 100644 --- a/frontend/src/hooks/useSettings.ts +++ b/frontend/src/hooks/useSettings.ts @@ -13,12 +13,14 @@ import { saveTask, deleteTask, runTask, + testMonitoringMachineSSH, } from "../api/client"; import type { MonitoringMachineInput, ResetLocalDatabaseInput, SavedTaskInput, SSHKeyInput, + SSHValidationResult, } from "../types"; export function useMonitoringSettings() { @@ -134,6 +136,12 @@ export function useSaveMonitoringMachine() { }); } +export function useTestMonitoringMachineSSH() { + return useMutation({ + mutationFn: testMonitoringMachineSSH, + }); +} + export function useDeleteMonitoringMachine() { const queryClient = useQueryClient(); return useMutation({ diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index d959103..7e49b4a 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -28,6 +28,7 @@ import { useSSHKeys, useSaveMonitoringMachine, useSaveSSHKey, + useTestMonitoringMachineSSH, } from "../hooks/useSettings"; import { DialogFooter } from "../components/DialogFooter"; import { HoverEditButton } from "../components/HoverEditButton"; @@ -76,6 +77,10 @@ function MachineEditor({ machine, sshKeys, onChange, + onValidateSSH, + isValidatingSSH, + sshValidationMessage, + sshValidationError, }: { title: string; hint?: string; @@ -86,6 +91,10 @@ function MachineEditor({ | MonitoringMachineInput | ((current: MonitoringMachineInput) => MonitoringMachineInput), ) => void; + onValidateSSH: () => void; + isValidatingSSH: boolean; + sshValidationMessage: string; + sshValidationError: string; }) { const draft = machine; const setDraft = onChange; @@ -417,6 +426,35 @@ function MachineEditor({ SSH machines usually need monitoring or files enabled. )} + {!isLocal && ( + + + Validate SSH before saving: this records the first trusted host key + in the backend-managed known_hosts file, then checks SSH auth. + + + + + {sshValidationMessage && ( + {sshValidationMessage} + )} + {sshValidationError && ( + {sshValidationError} + )} + + )} {hasJellyseerr && !draft.jellyseerr_url && ( Jellyseerr is enabled, but no URL is configured yet. @@ -828,7 +866,10 @@ export function Settings() { const { data: sshKeys = [] } = useSSHKeys(); const saveMachine = useSaveMonitoringMachine(); const deleteMachine = useDeleteMonitoringMachine(); + const testMachineSSH = useTestMonitoringMachineSSH(); const [tab, setTab] = useState("machines"); + const [sshValidationMessage, setSSHValidationMessage] = useState(""); + const [sshValidationError, setSSHValidationError] = useState(""); const [machineDialogOpen, setMachineDialogOpen] = useState(false); const [machineDraft, setMachineDraft] = useState( emptyMachine(), @@ -848,26 +889,54 @@ export function Settings() { const sshMachines = orderedMachines.filter( (machine) => machine.mode === "ssh", ); + const clearSSHValidation = () => { + setSSHValidationMessage(""); + setSSHValidationError(""); + }; const beginLocal = () => { + clearSSHValidation(); setMachineDraft(emptyMachine("local")); setMachineDialogOpen(true); }; const beginRemote = () => { + clearSSHValidation(); setMachineDraft(emptyMachine("ssh")); setMachineDialogOpen(true); }; const openEditMachine = (machine: MonitoringMachineInput) => { + clearSSHValidation(); setMachineDraft(machine); setMachineDialogOpen(true); }; const closeMachineDialog = () => { + clearSSHValidation(); setMachineDialogOpen(false); }; + const updateMachineDraft = ( + draft: + | MonitoringMachineInput + | ((current: MonitoringMachineInput) => MonitoringMachineInput), + ) => { + clearSSHValidation(); + setMachineDraft(draft); + }; const saveMachineDraft = async (draft: MonitoringMachineInput) => { + clearSSHValidation(); await saveMachine.mutateAsync(draft); setMachineDialogOpen(false); setMachineDraft(emptyMachine(draft.mode)); }; + const validateMachineSSH = async () => { + clearSSHValidation(); + try { + const result = await testMachineSSH.mutateAsync(machineDraft); + setSSHValidationMessage(result.message); + } catch (error) { + setSSHValidationError( + error instanceof Error ? error.message : String(error), + ); + } + }; return ( @@ -1209,7 +1278,11 @@ export function Settings() { } machine={machineDraft} sshKeys={sshKeys} - onChange={setMachineDraft} + onChange={updateMachineDraft} + onValidateSSH={validateMachineSSH} + isValidatingSSH={testMachineSSH.isPending} + sshValidationMessage={sshValidationMessage} + sshValidationError={sshValidationError} />