Validate SSH when saving machines
This commit is contained in:
+3
-1
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"] });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user