""" Browser-Terminal <-> SSH-Ziel per WebSocket (xterm.js-kompatibel). Framing: JSON-Textframes. Client -> Server: {"type":"input","data":""} | {"type":"resize","cols":n,"rows":n} Server -> Client: {"type":"output","data":""} | {"type":"error","message":"..."} | {"type":"closed"} Jede Session wird aufgezeichnet (app.recordings.recorder) und im Audit-Log mit Start/Ende vermerkt (Konzept 4.2, 4.7, 6.5). """ from __future__ import annotations import asyncio import base64 import logging import asyncssh from fastapi import APIRouter, WebSocket, WebSocketDisconnect from app.auth.deps import get_current_user_ws from app.db import get_db from app.rbac import user_has_role_for_host from app.recordings.recorder import SessionRecorder from app.security import active_sessions from app.security.audit import write_audit_event from app.ssh_proxy.proxy import ( HostNotConfiguredError, PrivateKeyUnusableError, connect_to_host, load_host, ) logger = logging.getLogger("jumphost.ssh_proxy.ws") router = APIRouter() MAX_SESSION_SECONDS = 8 * 3600 IDLE_TIMEOUT_SECONDS = 15 * 60 async def _pump_ssh_to_ws(process: asyncssh.SSHClientProcess, websocket: WebSocket, recorder: SessionRecorder): try: while True: data = await process.stdout.read(65536) if not data: break if isinstance(data, str): data = data.encode("utf-8", errors="replace") recorder.record("output", base64.b64encode(data).decode()) await websocket.send_json({"type": "output", "data": base64.b64encode(data).decode()}) except (asyncssh.Error, ConnectionResetError): pass @router.websocket("/ws/ssh/{host_id}") async def ssh_terminal(websocket: WebSocket, host_id: int): user = await get_current_user_ws(websocket) if user is None: # Auch die Ablehnungen VOR dem Sitzungsbeginn gehoeren protokolliert: # bisher schloss dieser Pfad das Socket kommentarlos, im # Verbindungslog war der Fehlversuch dadurch unsichtbar. logger.warning("SSH-Verbindung abgelehnt: keine gueltige Sitzung (host_id=%s)", host_id) await websocket.close(code=4401) return conn = get_db() if not user.is_admin and not await user_has_role_for_host( conn, user_id=user.id, host_id=host_id, role_name="ssh_connect" ): logger.warning( "SSH-Verbindung abgelehnt: Benutzer %s hat keine Berechtigung 'ssh_connect' fuer Host %s", user.username, host_id, ) await websocket.close(code=4403) return await websocket.accept() client_ip = websocket.client.host if websocket.client else "unknown" try: host = await load_host(conn, host_id) except HostNotConfiguredError as exc: logger.warning("SSH-Verbindung abgelehnt (host_id=%s): %s", host_id, exc) await websocket.send_json({"type": "error", "message": str(exc)}) await websocket.close(code=4404) return cursor = await conn.execute( "INSERT INTO sessions (user_id, host_id, protocol, client_ip) VALUES (?, ?, 'ssh', ?)", (user.id, host_id, client_ip), ) session_id = cursor.lastrowid recorder = SessionRecorder(session_id) await conn.execute( "UPDATE sessions SET recording_path = ? WHERE id = ?", (str(recorder.path), session_id) ) await write_audit_event( conn, event_type="ssh_session_start", user_id=user.id, client_ip=client_ip, details={"host_id": host_id, "hostname": host["hostname"], "session_id": session_id}, ) await conn.commit() logger.debug( "SSH-Sitzung %s gestartet: user=%s host=%s (%s:%s) client_ip=%s", session_id, user.username, host["hostname"], host["address"], host["port"], client_ip, ) active_sessions.register(session_id, asyncio.current_task()) end_reason = "logout" ssh_conn = None process = None pump_task = None try: ssh_conn = await connect_to_host(conn, host_id) logger.debug("SSH-Sitzung %s: Verbindung zu %s hergestellt", session_id, host["hostname"]) process = await ssh_conn.create_process(term_type="xterm-256color") pump_task = asyncio.create_task(_pump_ssh_to_ws(process, websocket, recorder)) while True: try: msg = await asyncio.wait_for(websocket.receive_json(), timeout=IDLE_TIMEOUT_SECONDS) except asyncio.TimeoutError: end_reason = "idle_timeout" break if msg.get("type") == "input": raw = base64.b64decode(msg.get("data", "")) recorder.record("input", base64.b64encode(raw).decode()) process.stdin.write(raw.decode("utf-8", errors="replace")) elif msg.get("type") == "resize": cols, rows = int(msg.get("cols", 80)), int(msg.get("rows", 24)) process.change_terminal_size(cols, rows) except WebSocketDisconnect: end_reason = "logout" except asyncssh.Error as exc: logger.warning("SSH-Sessionfehler (session_id=%s): %s", session_id, exc) end_reason = "error" try: await websocket.send_json({"type": "error", "message": "Verbindung zum Zielsystem fehlgeschlagen"}) except Exception: # Best-Effort-Fehlermeldung an einen ggf. bereits getrennten Client; # der eigentliche Fehler ist bereits oben geloggt (logger.warning). logger.debug("Fehlermeldung konnte nicht mehr an Client gesendet werden", exc_info=True) except PrivateKeyUnusableError as exc: # Der haeufigste Grund, warum eine SSH-Sitzung nie zustande kam: der # hinterlegte Private Key ist passphrasegeschuetzt und die Passphrase # fehlt oder passt nicht. asyncssh wirft dafuer einen KeyImportError # (ein ValueError, KEIN asyncssh.Error), der frueher an allen # Handlern vorbei bis aus der Route hinauslief -- der Browser sah nur # ein wortloses Verbindungsende. Der Text ist bewusst konkret und # nennt die Stelle im Adminbereich, an der es zu beheben ist. logger.warning("SSH-Sitzung %s: Schluessel unbrauchbar: %s", session_id, exc) end_reason = "error" try: await websocket.send_json({"type": "error", "message": str(exc)}) except Exception: logger.debug("Fehlermeldung konnte nicht mehr an Client gesendet werden", exc_info=True) except HostNotConfiguredError as exc: logger.warning("SSH-Sitzung %s abgebrochen: %s", session_id, exc) end_reason = "error" try: await websocket.send_json({"type": "error", "message": str(exc)}) except Exception: logger.debug("Fehlermeldung konnte nicht mehr an Client gesendet werden", exc_info=True) except asyncio.CancelledError: # Zwangs-Beendigung durch einen Superadmin ueber die Sessionview # (POST /admin/sessions/{id}/terminate, siehe app/security/active_sessions.py). end_reason = "terminated_by_admin" raise except Exception as exc: # Auffangnetz fuer alles, was NICHT asyncssh.Error ist (z.B. # asyncssh.KeyImportError bei einem defekten/nicht mehr passenden # Schluessel, oder cryptography.exceptions.InvalidTag bei # decrypt_secret() falls der KEK nicht mehr zum verschluesselten # Schluessel passt) -- diese Typen sind KEINE asyncssh.Error- # Unterklassen und liefen bisher unbehandelt durch, wodurch die # Sitzung ohne jede Fehlermeldung abrupt getrennt wurde und im Log # irrefuehrend als reason=logout erschien (Default-Wert, der nie # aktualisiert wurde). Mit logger.exception landet der volle # Traceback jetzt im Verbindungslog. logger.exception("Unerwarteter Fehler in SSH-Sitzung %s: %s", session_id, exc) end_reason = "error" try: await websocket.send_json({ "type": "error", "message": f"Verbindung zum Zielsystem fehlgeschlagen (interner Fehler: {exc.__class__.__name__})", }) except Exception: logger.debug("Fehlermeldung konnte nicht mehr an Client gesendet werden", exc_info=True) finally: active_sessions.unregister(session_id) if pump_task: pump_task.cancel() if process: process.close() if ssh_conn: ssh_conn.close() recorder.close() logger.debug("SSH-Sitzung %s beendet: reason=%s", session_id, end_reason) await conn.execute( "UPDATE sessions SET ended_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), end_reason = ? " "WHERE id = ?", (end_reason, session_id), ) await write_audit_event( conn, event_type="ssh_session_end", user_id=user.id, client_ip=client_ip, details={"host_id": host_id, "session_id": session_id, "reason": end_reason}, ) await conn.commit() try: await websocket.close() except Exception: logger.debug("WebSocket war beim Schliessen bereits getrennt", exc_info=True)