Validate SSH when saving machines

This commit is contained in:
2026-05-07 20:59:23 +02:00
parent 0fa97633ce
commit 02f6617f71
5 changed files with 155 additions and 6 deletions
+3 -1
View File
@@ -126,7 +126,9 @@ 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 machine editor groups Connection, Monitoring / Files, Jellyfin, and Jellyseerr settings under separate headings so each service area is easier to scan.
- Saving an SSH machine now validates the banner/auth flow and records the first trusted host key into the backend-managed `known_hosts` file, surfacing any errors if the host cannot be reached or authenticated.
- Use **Validate SSH + trust host** in the machine editor before saving if you want to test the banner/auth flow explicitly.
- 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.
@@ -88,6 +88,42 @@ def _resolve_ssh_client(
return client, host, port 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") @router.post("/machines/test-ssh")
def test_machine_ssh( def test_machine_ssh(
machine: MonitoringMachineInput, machine: MonitoringMachineInput,
@@ -146,6 +182,9 @@ def post_machine(
) -> dict[str, Any]: ) -> dict[str, Any]:
saved = store.upsert_machine(machine.model_dump(exclude_none=True), machine.id) saved = store.upsert_machine(machine.model_dump(exclude_none=True), machine.id)
poller = get_monitoring_poller() poller = get_monitoring_poller()
try:
_validate_saved_machine_ssh(MonitoringMachineInput.model_validate(saved), store)
finally:
poller.start() poller.start()
poller.kick() poller.kick()
return saved return saved
@@ -161,6 +200,9 @@ def put_machine(
raise HTTPException(status_code=404, detail="Machine not found") raise HTTPException(status_code=404, detail="Machine not found")
saved = store.upsert_machine(machine.model_dump(exclude_none=True), machine_id) saved = store.upsert_machine(machine.model_dump(exclude_none=True), machine_id)
poller = get_monitoring_poller() poller = get_monitoring_poller()
try:
_validate_saved_machine_ssh(MonitoringMachineInput.model_validate(saved), store)
finally:
poller.start() poller.start()
poller.kick() poller.kick()
return saved return saved
+2
View File
@@ -277,3 +277,5 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo
- 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. - 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.
- 2026-05-07: Saving an SSH machine should also validate the banner/auth flow and update the backend-managed known_hosts entry for the current host, surfacing any save-time SSH errors to the user.
- 2026-05-07: Machine settings should visually separate Connection, Monitoring / Files, Jellyfin, Jellyseerr, and Notes into clearly labeled sections.
+1 -1
View File
@@ -129,7 +129,7 @@ export function useSaveMonitoringMachine() {
return useMutation({ return useMutation({
mutationFn: (machine: MonitoringMachineInput) => mutationFn: (machine: MonitoringMachineInput) =>
saveMonitoringMachine(machine), saveMonitoringMachine(machine),
onSuccess: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["settings"] }); queryClient.invalidateQueries({ queryKey: ["settings"] });
queryClient.invalidateQueries({ queryKey: ["monitoring"] }); queryClient.invalidateQueries({ queryKey: ["monitoring"] });
}, },
+103
View File
@@ -147,6 +147,23 @@ function MachineEditor({
</Stack> </Stack>
</Stack> </Stack>
<Grid container spacing={1.25}> <Grid container spacing={1.25}>
<Grid size={{ xs: 12 }}>
<Box sx={{ pt: 0.25 }}>
<Typography
variant="overline"
sx={{
display: "block",
lineHeight: 1.1,
color: "text.secondary",
}}
>
General
</Typography>
<Typography variant="body2" color="text.secondary">
Name, mode, enabled state, and service roles.
</Typography>
</Box>
</Grid>
<Grid size={{ xs: 12, md: 6 }}> <Grid size={{ xs: 12, md: 6 }}>
<TextField <TextField
fullWidth fullWidth
@@ -202,6 +219,23 @@ function MachineEditor({
</Grid> </Grid>
{!isLocal && ( {!isLocal && (
<> <>
<Grid size={{ xs: 12 }}>
<Box sx={{ pt: 0.5 }}>
<Typography
variant="overline"
sx={{
display: "block",
lineHeight: 1.1,
color: "text.secondary",
}}
>
Connection
</Typography>
<Typography variant="body2" color="text.secondary">
SSH host, port, username, and credentials.
</Typography>
</Box>
</Grid>
<Grid size={{ xs: 12, md: 4 }}> <Grid size={{ xs: 12, md: 4 }}>
<TextField <TextField
fullWidth fullWidth
@@ -289,6 +323,23 @@ function MachineEditor({
</Grid> </Grid>
</> </>
)} )}
<Grid size={{ xs: 12 }}>
<Box sx={{ pt: 0.5 }}>
<Typography
variant="overline"
sx={{
display: "block",
lineHeight: 1.1,
color: "text.secondary",
}}
>
Monitoring / Files
</Typography>
<Typography variant="body2" color="text.secondary">
Media root and path mapping used by monitoring and browsing.
</Typography>
</Box>
</Grid>
<Grid size={{ xs: 12, md: 6 }}> <Grid size={{ xs: 12, md: 6 }}>
<TextField <TextField
fullWidth fullWidth
@@ -305,6 +356,23 @@ function MachineEditor({
</Grid> </Grid>
{hasJellyfin && ( {hasJellyfin && (
<> <>
<Grid size={{ xs: 12 }}>
<Box sx={{ pt: 0.5 }}>
<Typography
variant="overline"
sx={{
display: "block",
lineHeight: 1.1,
color: "text.secondary",
}}
>
Jellyfin
</Typography>
<Typography variant="body2" color="text.secondary">
Library host and user selection for media browsing.
</Typography>
</Box>
</Grid>
<Grid size={{ xs: 12, md: 6 }}> <Grid size={{ xs: 12, md: 6 }}>
<TextField <TextField
fullWidth fullWidth
@@ -352,6 +420,24 @@ function MachineEditor({
)} )}
{hasJellyseerr && ( {hasJellyseerr && (
<> <>
<Grid size={{ xs: 12 }}>
<Box sx={{ pt: 0.5 }}>
<Typography
variant="overline"
sx={{
display: "block",
lineHeight: 1.1,
color: "text.secondary",
}}
>
Jellyseerr
</Typography>
<Typography variant="body2" color="text.secondary">
Optional request-manager enrichment for users and
requests.
</Typography>
</Box>
</Grid>
<Grid size={{ xs: 12, md: 6 }}> <Grid size={{ xs: 12, md: 6 }}>
<TextField <TextField
fullWidth fullWidth
@@ -394,6 +480,23 @@ function MachineEditor({
/> />
</Grid> </Grid>
)} )}
<Grid size={{ xs: 12 }}>
<Box sx={{ pt: 0.5 }}>
<Typography
variant="overline"
sx={{
display: "block",
lineHeight: 1.1,
color: "text.secondary",
}}
>
Notes
</Typography>
<Typography variant="body2" color="text.secondary">
Free-form administrator notes for this machine.
</Typography>
</Box>
</Grid>
<Grid size={{ xs: 12 }}> <Grid size={{ xs: 12 }}>
<TextField <TextField
fullWidth fullWidth