fixes and improvements

This commit is contained in:
2026-05-07 20:36:37 +02:00
parent 420dc34272
commit c73cfa27c3
4 changed files with 51 additions and 22 deletions
@@ -20,6 +20,8 @@ from typing import Any
import paramiko
from media_library_viewer_api.services.known_hosts import has_known_host
logger = logging.getLogger(__name__)
@@ -64,17 +66,23 @@ class RemoteSSHClient:
def connect(self) -> paramiko.SSHClient:
"""Create or reuse the Paramiko connection.
Host keys are expected to be managed ahead of time by startup synthesis
or explicit validation flows. Runtime connections only load the managed
known_hosts file and then let Paramiko enforce strict checking.
Host keys are trusted on first successful use when a managed
known_hosts path is configured. Subsequent connections stay strict and
reject host-key changes.
"""
if self._client:
return self._client
client = paramiko.SSHClient()
client.load_system_host_keys()
if self.known_hosts_path and Path(self.known_hosts_path).is_file():
client.load_host_keys(self.known_hosts_path)
client.set_missing_host_key_policy(paramiko.RejectPolicy())
known_hosts_file = Path(self.known_hosts_path) if self.known_hosts_path else None
trusted_before = bool(
known_hosts_file and has_known_host(self.host, self.port, known_hosts_file)
)
if known_hosts_file and known_hosts_file.is_file():
client.load_host_keys(str(known_hosts_file))
client.set_missing_host_key_policy(
paramiko.RejectPolicy() if trusted_before else paramiko.AutoAddPolicy()
)
connect_kwargs: dict[str, Any] = {
"hostname": self.host,
"port": self.port,
@@ -103,6 +111,9 @@ class RemoteSSHClient:
"Check the selected key, passphrase, username, or password."
) from exc
raise
if known_hosts_file and not trusted_before:
known_hosts_file.parent.mkdir(parents=True, exist_ok=True)
client.save_host_keys(str(known_hosts_file))
self._client = client
return client
@@ -12,7 +12,7 @@ import logging
from functools import lru_cache
from typing import Any
from fastapi import Request
from fastapi import HTTPException, Request
from media_library_viewer_api.clients.jellyfin import JellyfinClient
from media_library_viewer_api.clients.jellyseerr import JellyseerrClient
@@ -82,6 +82,27 @@ def _ssh_client_for(cache_key: tuple[str, str, str, int, str, str | None, str |
)
try:
client.connect()
except RuntimeError as exc:
message = str(exc)
lowered = message.lower()
logger.exception("Failed to establish SSH connection to %s", host or "<unset>")
if "banner" in lowered:
raise HTTPException(
status_code=502,
detail=(
f"SSH banner not received from {host}:{port}. "
"Confirm the host, port, and firewall; the backend could not complete the SSH handshake."
),
) from exc
if "authentication failed" in lowered or "no authentication methods available" in lowered:
raise HTTPException(
status_code=401,
detail=(
f"SSH authentication failed for {host}:{port}. "
"Check the selected key, passphrase, username, or password."
),
) from exc
raise HTTPException(status_code=502, detail=message) from exc
except Exception:
logger.exception("Failed to establish SSH connection to %s", host or "<unset>")
raise
@@ -13,7 +13,7 @@ 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.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
@@ -98,20 +98,7 @@ def test_machine_ssh(
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
known_hosts_updated = not has_known_host(host, port, settings.ssh_known_hosts_file)
try:
client.connect()
@@ -41,6 +41,16 @@ def _fetch_server_key(host: str, port: int, timeout: int = 30) -> paramiko.PKey:
sock.close()
def has_known_host(host: str, port: int, known_hosts_path: Path) -> bool:
"""Return whether the given host/port is already present in known_hosts."""
if not host or not known_hosts_path.exists():
return False
host_alias = _host_alias(host, port)
host_keys = paramiko.HostKeys()
host_keys.load(str(known_hosts_path))
return host_keys.lookup(host_alias) is not None
def ensure_known_host(host: str, port: int, known_hosts_path: Path, *, strict: bool = True) -> bool:
"""Ensure a host key entry exists for the given host/port.