Initial scaffold: FastAPI backend + Vue 3 frontend + Docker setup
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.
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user