fixes and improvements

This commit is contained in:
2026-05-06 21:25:12 +02:00
parent 5277f21577
commit b0d84399ab
12 changed files with 288 additions and 67 deletions
@@ -14,6 +14,7 @@ import logging
import posixpath
import shlex
from dataclasses import dataclass
from io import StringIO
from pathlib import Path
from typing import Any
@@ -41,6 +42,8 @@ class RemoteSSHClient:
username: str,
port: int = 22,
key_filename: str | None = None,
private_key: str | None = None,
private_key_passphrase: str | None = None,
password: str | None = None,
known_hosts_path: str | None = None,
timeout: int = 20,
@@ -51,6 +54,8 @@ class RemoteSSHClient:
self.username = username
self.port = port
self.key_filename = key_filename or None
self.private_key = private_key or None
self.private_key_passphrase = private_key_passphrase or None
self.password = password or None
self.known_hosts_path = known_hosts_path or None
self.timeout = timeout
@@ -70,17 +75,32 @@ class RemoteSSHClient:
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())
client.connect(
self.host,
port=self.port,
username=self.username,
key_filename=self.key_filename,
password=self.password,
timeout=self.timeout,
)
connect_kwargs: dict[str, Any] = {
"hostname": self.host,
"port": self.port,
"username": self.username,
"password": self.password,
"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)
self._client = client
return client
@staticmethod
def _load_private_key(private_key: str, passphrase: str | None = None) -> paramiko.PKey:
key_classes = [paramiko.Ed25519Key, paramiko.RSAKey, paramiko.ECDSAKey]
last_error: Exception | None = None
for key_class in key_classes:
try:
return key_class.from_private_key(StringIO(private_key), password=passphrase or None)
except Exception as exc: # pragma: no cover - try multiple algorithms
last_error = exc
raise RuntimeError("Unable to load SSH private key") from last_error
def close(self) -> None:
if self._client:
self._client.close()
@@ -57,22 +57,24 @@ def _jellyseerr_client_for(cache_key: tuple[str, str]) -> JellyseerrClient | Non
@lru_cache(maxsize=32)
def _ssh_client_for(cache_key: tuple[str, str, str, int, str, str | None, str | None]) -> RemoteSSHClient:
machine_id, host, username, port, key_filename, password, known_hosts_path = cache_key
def _ssh_client_for(cache_key: tuple[str, str, str, int, str, str | None, str | None, str | None]) -> RemoteSSHClient:
machine_id, host, username, port, key_filename, password, private_key, known_hosts_path = cache_key
logger.info(
"Creating SSH client machine_id=%s host=%s user=%s port=%s key=%s password=%s",
"Creating SSH client machine_id=%s host=%s user=%s port=%s key=%s password=%s private_key=%s",
machine_id or "<default>",
host or "<unset>",
username or "<unset>",
port,
key_filename or "<unset>",
"set" if password else "missing",
"set" if private_key else "missing",
)
client = RemoteSSHClient(
host=host,
username=username,
port=port,
key_filename=key_filename or None,
private_key=private_key or None,
password=password or None,
known_hosts_path=known_hosts_path or None,
)
@@ -137,17 +139,35 @@ def get_jellyseerr_client(request: Request = None) -> JellyseerrClient | None:
def get_ssh_client(request: Request = None) -> RemoteSSHClient:
"""Return a cached SSH client for the selected machine or legacy env fallback."""
machine = get_settings_store().get_machine_config(_request_machine_id(request)) or _resolve_machine("ssh", request)
if machine and machine.get("host") and machine.get("username") and machine.get("key_directory") and machine.get("key_name"):
store = get_settings_store()
machine_id = _request_machine_id(request)
machine = store.get_machine_config(machine_id) if machine_id else None
if machine is None:
machine_ref = _resolve_machine("ssh", request)
machine = store.get_machine_config(machine_ref["id"]) if machine_ref else None
if machine and machine.get("host") and machine.get("username"):
known_hosts_path = get_settings().ssh_known_hosts_file
ensure_known_host(str(machine.get("host")), int(machine.get("port") or 22), known_hosts_path)
key_data = None
key_passphrase = None
ssh_key_id = str(machine.get("ssh_key_id") or "").strip()
if ssh_key_id:
ssh_key = store.get_ssh_key(ssh_key_id)
if ssh_key:
key_data = ssh_key.get("private_key") or None
key_passphrase = ssh_key.get("passphrase") or None
if not key_data and machine.get("ssh_private_key"):
key_data = machine.get("ssh_private_key") or None
key_passphrase = machine.get("ssh_private_key_passphrase") or None
cache_key = (
machine["id"],
machine["host"],
machine["username"],
int(machine.get("port") or 22),
f"{machine.get('key_directory')}/{machine.get('key_name')}",
f"{machine.get('key_directory')}/{machine.get('key_name')}" if machine.get("key_directory") and machine.get("key_name") else "",
machine.get("password") or None,
key_data,
key_passphrase,
str(known_hosts_path),
)
return _ssh_client_for(cache_key)
@@ -165,7 +185,7 @@ def get_ssh_client(request: Request = None) -> RemoteSSHClient:
if not settings.ssh_key_path:
raise RuntimeError("No SSH machine is configured and SSH key settings must be configured")
ensure_known_host(settings.ssh_host, settings.ssh_port, settings.ssh_known_hosts_file)
return _ssh_client_for(("legacy", settings.ssh_host, settings.ssh_username, settings.ssh_port, settings.ssh_key_path, settings.ssh_password or None, str(settings.ssh_known_hosts_file)))
return _ssh_client_for(("legacy", settings.ssh_host, settings.ssh_username, settings.ssh_port, settings.ssh_key_path, settings.ssh_password or None, None, None, str(settings.ssh_known_hosts_file)))
def get_mail_queue() -> MailQueue:
+2 -1
View File
@@ -13,7 +13,7 @@ from fastapi.middleware.cors import CORSMiddleware
from media_library_viewer_api.auth import require_jwt_auth, validate_auth_settings
from media_library_viewer_api.config import get_settings
from media_library_viewer_api.logging_utils import configure_logging, describe_settings
from media_library_viewer_api.routers import dashboard, monitoring, media, files, jobs, users
from media_library_viewer_api.routers import dashboard, monitoring, media, files, jobs, users, tasks
from media_library_viewer_api.routers.settings import router as settings_router
from media_library_viewer_api.dependencies import get_mail_queue, get_monitoring_poller, get_settings_store
from media_library_viewer_api.services.known_hosts import ensure_known_hosts_for_machines
@@ -98,6 +98,7 @@ app.include_router(media.router)
app.include_router(files.router)
app.include_router(jobs.router)
app.include_router(users.router)
app.include_router(tasks.router)
app.include_router(settings_router)