cf7c29639c
Backend: config/db/security/logging core, SQLAlchemy models (Server, Credential, UpdateJob, UpdateLog, AuditLog, User), services (winrm, ssh, cau, audit, job_runner), REST API (auth, servers, updates, audit), Socket.io WebSocket layer. Frontend: Vue 3 + TS + Pinia + Tailwind, Views (Dashboard, Servers, Updates, Audit, Login), axios + socket.io-client, nginx prod config.
119 lines
4.4 KiB
Python
119 lines
4.4 KiB
Python
"""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)
|