"""SSH service: connect to Linux hosts via asyncssh, run updates with sudo.""" from collections.abc import AsyncIterator from dataclasses import dataclass from app.core.config import get_settings from app.core.exceptions import SSHError from app.core.logging import get_logger settings = get_settings() logger = get_logger(__name__) @dataclass class SSHCredentials: username: str password: str | None = None private_key: str | None = None passphrase: str | None = None class SSHService: """Async SSH operations via asyncssh.""" def __init__( self, hostname: str, port: int = 22, credentials: SSHCredentials | None = None, ) -> None: self.hostname = hostname self.port = port self.credentials = credentials def _connect_kwargs(self) -> dict: kwargs: dict = { "host": self.hostname, "port": self.port, "known_hosts": None, # scaffold: accept any host key "connect_timeout": settings.ssh_timeout, } if self.credentials: kwargs["username"] = self.credentials.username if self.credentials.password: kwargs["password"] = self.credentials.password if self.credentials.private_key: kwargs["client_keys"] = [ __import__("asyncssh").import_private_key( self.credentials.private_key, passphrase=self.credentials.passphrase, ) ] return kwargs async def test_connection(self) -> tuple[bool, str]: import asyncssh try: async with asyncssh.connect(**self._connect_kwargs()) as conn: result = await conn.run("hostname", check=True) return True, f"Verbunden mit {result.stdout.strip()}" # type: ignore[union-attr] except Exception as exc: # noqa: BLE001 return False, str(exc) async def run_command(self, command: str, sudo: bool = False) -> str: """Run a command, optionally via sudo -S with the stored password.""" import asyncssh if sudo: password = (self.credentials.password if self.credentials else None) or "" command = f"echo '{password}' | sudo -S {command}" async with asyncssh.connect(**self._connect_kwargs()) as conn: result = await conn.run(command, check=False) if result.returncode != 0: raise SSHError( f"Command failed (exit {result.returncode}): {str(result.stderr).strip()}" ) return str(result.stdout) async def detect_package_manager(self) -> str: """Detect apt, dnf, or yum on the target.""" import asyncssh async with asyncssh.connect(**self._connect_kwargs()) as conn: for pm in ("apt-get", "dnf", "yum"): result = await conn.run(f"command -v {pm}", check=False) if result.returncode == 0: return pm raise SSHError("Kein unterstützter Paketmanager gefunden (apt/dnf/yum)") async def stream_updates(self, reboot_if_required: bool = False) -> AsyncIterator[str]: """Run the distro update command and yield output lines live.""" import asyncssh pm = await self.detect_package_manager() if pm == "apt-get": cmd = "apt-get update && DEBIAN_FRONTEND=noninteractive apt-get upgrade -y" else: cmd = f"{pm} update -y" password = (self.credentials.password if self.credentials else None) or "" full_cmd = f"echo '{password}' | sudo -S sh -c '{cmd}'" async with asyncssh.connect(**self._connect_kwargs()) as conn: async with conn.create_process(full_cmd) as process: async for line in process.stdout: # type: ignore[union-attr] yield str(line).rstrip() await process.wait() if process.returncode != 0: raise SSHError(f"Update fehlgeschlagen (exit {process.returncode})") if reboot_if_required: async with asyncssh.connect(**self._connect_kwargs()) as conn: check = await conn.run("test -f /var/run/reboot-required", check=False) if check.returncode == 0: yield "REBOOT erforderlich — wird ausgeführt..." await conn.run(f"echo '{password}' | sudo -S reboot", check=False)