Add SSH connection validation for settings

This commit is contained in:
2026-05-07 18:31:49 +02:00
parent 2219a5730f
commit 84dc2bd2f6
9 changed files with 235 additions and 5 deletions
+1
View File
@@ -126,6 +126,7 @@ docker compose up --build
2. After the API is running, open the app, go to **Settings**, and add machine entries: 2. After the API is running, open the app, go to **Settings**, and add machine entries:
- **Local**: monitors the API host itself without SSH. - **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. - **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. - 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. 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.
@@ -48,7 +48,7 @@ class RemoteSSHClient:
private_key_passphrase: str | None = None, private_key_passphrase: str | None = None,
password: str | None = None, password: str | None = None,
known_hosts_path: str | None = None, known_hosts_path: str | None = None,
timeout: int = 20, timeout: int = 30,
): ):
if not host or not username: if not host or not username:
raise ValueError("SSH host and username are required") raise ValueError("SSH host and username are required")
@@ -73,7 +73,19 @@ class RemoteSSHClient:
if self._client: if self._client:
return self._client return self._client
if self.known_hosts_path: 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 = paramiko.SSHClient()
client.load_system_host_keys() client.load_system_host_keys()
if self.known_hosts_path and Path(self.known_hosts_path).is_file(): if self.known_hosts_path and Path(self.known_hosts_path).is_file():
@@ -85,12 +97,28 @@ class RemoteSSHClient:
"username": self.username, "username": self.username,
"password": self.password, "password": self.password,
"timeout": self.timeout, "timeout": self.timeout,
"banner_timeout": self.timeout,
"auth_timeout": self.timeout,
} }
if self.private_key: if self.private_key:
connect_kwargs["pkey"] = self._load_private_key(self.private_key, self.private_key_passphrase) connect_kwargs["pkey"] = self._load_private_key(self.private_key, self.private_key_passphrase)
else: else:
connect_kwargs["key_filename"] = self.key_filename 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 self._client = client
return client return client
@@ -9,8 +9,11 @@ import paramiko
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field 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.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.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.media_index import MediaIndex
from media_library_viewer_api.services.settings_store import SettingsStore 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() 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) @router.post("/machines", status_code=status.HTTP_201_CREATED)
def post_machine( def post_machine(
machine: MonitoringMachineInput, machine: MonitoringMachineInput,
@@ -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)}" 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) sock = socket.create_connection((host, int(port or 22)), timeout=timeout)
transport = paramiko.Transport(sock) transport = paramiko.Transport(sock)
try: try:
@@ -31,6 +31,11 @@ def _fetch_server_key(host: str, port: int, timeout: int = 10) -> paramiko.PKey:
if key is None: if key is None:
raise RuntimeError(f"Unable to read SSH host key for {host}:{port}") raise RuntimeError(f"Unable to read SSH host key for {host}:{port}")
return key 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: finally:
transport.close() transport.close()
sock.close() sock.close()
+1
View File
@@ -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 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: 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: 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.
+3
View File
@@ -34,6 +34,7 @@ import type {
ResolvedPath, ResolvedPath,
ResetLocalDatabaseInput, ResetLocalDatabaseInput,
ResetLocalDatabaseResponse, ResetLocalDatabaseResponse,
SSHValidationResult,
DashboardShortcut, DashboardShortcut,
DashboardShortcutInput, DashboardShortcutInput,
} from "../types"; } from "../types";
@@ -330,6 +331,8 @@ export const saveMonitoringMachine = (machine: MonitoringMachineInput) =>
} }
return response.json() as Promise<MonitoringMachine>; return response.json() as Promise<MonitoringMachine>;
}); });
export const testMonitoringMachineSSH = (machine: MonitoringMachineInput) =>
post<SSHValidationResult>("/api/settings/machines/test-ssh", machine);
export const deleteMonitoringMachine = (machineId: string) => export const deleteMonitoringMachine = (machineId: string) =>
del<{ status: string }>( del<{ status: string }>(
`/api/settings/machines/${encodeURIComponent(machineId)}`, `/api/settings/machines/${encodeURIComponent(machineId)}`,
+8
View File
@@ -13,12 +13,14 @@ import {
saveTask, saveTask,
deleteTask, deleteTask,
runTask, runTask,
testMonitoringMachineSSH,
} from "../api/client"; } from "../api/client";
import type { import type {
MonitoringMachineInput, MonitoringMachineInput,
ResetLocalDatabaseInput, ResetLocalDatabaseInput,
SavedTaskInput, SavedTaskInput,
SSHKeyInput, SSHKeyInput,
SSHValidationResult,
} from "../types"; } from "../types";
export function useMonitoringSettings() { export function useMonitoringSettings() {
@@ -134,6 +136,12 @@ export function useSaveMonitoringMachine() {
}); });
} }
export function useTestMonitoringMachineSSH() {
return useMutation<SSHValidationResult, Error, MonitoringMachineInput>({
mutationFn: testMonitoringMachineSSH,
});
}
export function useDeleteMonitoringMachine() { export function useDeleteMonitoringMachine() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
+74 -1
View File
@@ -28,6 +28,7 @@ import {
useSSHKeys, useSSHKeys,
useSaveMonitoringMachine, useSaveMonitoringMachine,
useSaveSSHKey, useSaveSSHKey,
useTestMonitoringMachineSSH,
} from "../hooks/useSettings"; } from "../hooks/useSettings";
import { DialogFooter } from "../components/DialogFooter"; import { DialogFooter } from "../components/DialogFooter";
import { HoverEditButton } from "../components/HoverEditButton"; import { HoverEditButton } from "../components/HoverEditButton";
@@ -76,6 +77,10 @@ function MachineEditor({
machine, machine,
sshKeys, sshKeys,
onChange, onChange,
onValidateSSH,
isValidatingSSH,
sshValidationMessage,
sshValidationError,
}: { }: {
title: string; title: string;
hint?: string; hint?: string;
@@ -86,6 +91,10 @@ function MachineEditor({
| MonitoringMachineInput | MonitoringMachineInput
| ((current: MonitoringMachineInput) => MonitoringMachineInput), | ((current: MonitoringMachineInput) => MonitoringMachineInput),
) => void; ) => void;
onValidateSSH: () => void;
isValidatingSSH: boolean;
sshValidationMessage: string;
sshValidationError: string;
}) { }) {
const draft = machine; const draft = machine;
const setDraft = onChange; const setDraft = onChange;
@@ -417,6 +426,35 @@ function MachineEditor({
SSH machines usually need monitoring or files enabled. SSH machines usually need monitoring or files enabled.
</Alert> </Alert>
)} )}
{!isLocal && (
<Stack spacing={1}>
<Alert severity="info">
Validate SSH before saving: this records the first trusted host key
in the backend-managed known_hosts file, then checks SSH auth.
</Alert>
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap" }}>
<Button
variant="outlined"
onClick={onValidateSSH}
disabled={
isValidatingSSH ||
!draft.host.trim() ||
!draft.username.trim()
}
>
{isValidatingSSH
? "Validating SSH..."
: "Validate SSH + trust host"}
</Button>
</Stack>
{sshValidationMessage && (
<Alert severity="success">{sshValidationMessage}</Alert>
)}
{sshValidationError && (
<Alert severity="error">{sshValidationError}</Alert>
)}
</Stack>
)}
{hasJellyseerr && !draft.jellyseerr_url && ( {hasJellyseerr && !draft.jellyseerr_url && (
<Alert severity="info"> <Alert severity="info">
Jellyseerr is enabled, but no URL is configured yet. Jellyseerr is enabled, but no URL is configured yet.
@@ -828,7 +866,10 @@ export function Settings() {
const { data: sshKeys = [] } = useSSHKeys(); const { data: sshKeys = [] } = useSSHKeys();
const saveMachine = useSaveMonitoringMachine(); const saveMachine = useSaveMonitoringMachine();
const deleteMachine = useDeleteMonitoringMachine(); const deleteMachine = useDeleteMonitoringMachine();
const testMachineSSH = useTestMonitoringMachineSSH();
const [tab, setTab] = useState<SettingsTab>("machines"); const [tab, setTab] = useState<SettingsTab>("machines");
const [sshValidationMessage, setSSHValidationMessage] = useState("");
const [sshValidationError, setSSHValidationError] = useState("");
const [machineDialogOpen, setMachineDialogOpen] = useState(false); const [machineDialogOpen, setMachineDialogOpen] = useState(false);
const [machineDraft, setMachineDraft] = useState<MonitoringMachineInput>( const [machineDraft, setMachineDraft] = useState<MonitoringMachineInput>(
emptyMachine(), emptyMachine(),
@@ -848,26 +889,54 @@ export function Settings() {
const sshMachines = orderedMachines.filter( const sshMachines = orderedMachines.filter(
(machine) => machine.mode === "ssh", (machine) => machine.mode === "ssh",
); );
const clearSSHValidation = () => {
setSSHValidationMessage("");
setSSHValidationError("");
};
const beginLocal = () => { const beginLocal = () => {
clearSSHValidation();
setMachineDraft(emptyMachine("local")); setMachineDraft(emptyMachine("local"));
setMachineDialogOpen(true); setMachineDialogOpen(true);
}; };
const beginRemote = () => { const beginRemote = () => {
clearSSHValidation();
setMachineDraft(emptyMachine("ssh")); setMachineDraft(emptyMachine("ssh"));
setMachineDialogOpen(true); setMachineDialogOpen(true);
}; };
const openEditMachine = (machine: MonitoringMachineInput) => { const openEditMachine = (machine: MonitoringMachineInput) => {
clearSSHValidation();
setMachineDraft(machine); setMachineDraft(machine);
setMachineDialogOpen(true); setMachineDialogOpen(true);
}; };
const closeMachineDialog = () => { const closeMachineDialog = () => {
clearSSHValidation();
setMachineDialogOpen(false); setMachineDialogOpen(false);
}; };
const updateMachineDraft = (
draft:
| MonitoringMachineInput
| ((current: MonitoringMachineInput) => MonitoringMachineInput),
) => {
clearSSHValidation();
setMachineDraft(draft);
};
const saveMachineDraft = async (draft: MonitoringMachineInput) => { const saveMachineDraft = async (draft: MonitoringMachineInput) => {
clearSSHValidation();
await saveMachine.mutateAsync(draft); await saveMachine.mutateAsync(draft);
setMachineDialogOpen(false); setMachineDialogOpen(false);
setMachineDraft(emptyMachine(draft.mode)); 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 ( return (
<Stack spacing={2.25}> <Stack spacing={2.25}>
<Stack spacing={0.5}> <Stack spacing={0.5}>
@@ -1209,7 +1278,11 @@ export function Settings() {
} }
machine={machineDraft} machine={machineDraft}
sshKeys={sshKeys} sshKeys={sshKeys}
onChange={setMachineDraft} onChange={updateMachineDraft}
onValidateSSH={validateMachineSSH}
isValidatingSSH={testMachineSSH.isPending}
sshValidationMessage={sshValidationMessage}
sshValidationError={sshValidationError}
/> />
</DialogContent> </DialogContent>
<DialogFooter <DialogFooter
+8
View File
@@ -283,6 +283,14 @@ export interface ResetLocalDatabaseResponse {
media_index_files: string[]; media_index_files: string[];
} }
export interface SSHValidationResult {
status: string;
message: string;
host: string;
port: number;
known_hosts_updated: boolean;
}
export interface MonitoringPollerStatus { export interface MonitoringPollerStatus {
worker_running: boolean; worker_running: boolean;
stop_requested: boolean; stop_requested: boolean;