""" 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 ( SSH_SETUP_ERRORS, HostNotConfiguredError, PrivateKeyUnusableError, connect_to_host, describe_connection_error, load_host, ) logger = logging.getLogger("jumphost.ssh_proxy.ws") router = APIRouter() MAX_SESSION_SECONDS = 8 * 3600 IDLE_TIMEOUT_SECONDS = 15 * 60 async def _reject(websocket: WebSocket, code: int, reason: str, *, accepted: bool) -> None: """Beendet eine SSH-Sitzung vor ihrem eigentlichen Beginn -- mit einem fuer den Benutzer lesbaren Grund als WebSocket-Close-Reason, analog zu app/rdp_proxy/ws_tunnel.py::_reject (Troubleshooting-Verbesserung: vorher endeten diese Pfade in einem nackten `websocket.close(code=...)`, static/js/terminal.js zeigte dann nur ein generisches 'Verbindung beendet' ohne jeden Grund an). Voraussetzung fuer eine sichtbare Reason ist ein zustande gekommener Handshake -- vor accept() sieht der Browser nur einen HTTP-/WS-Fehler ohne Text (siehe die bewusste Ausnahme fuer den Nicht-angemeldet-Fall unten).""" logger.warning("SSH-Verbindung abgelehnt (code=%s): %s", code, reason) reason_bytes = reason.encode("utf-8")[:123] if not accepted: await websocket.accept() await websocket.close(code=code, reason=reason_bytes.decode("utf-8", errors="ignore")) async def _pump_ssh_to_ws( process: asyncssh.SSHClientProcess, websocket: WebSocket, recorder: SessionRecorder, session_id: int ): 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()) # Live-Mitschau (GET /ws/sessions/{id}/watch, siehe unten): jeder # Chunk geht zusaetzlich an alle aktuell zuschauenden Superadmins. # Best-effort/read-only -- ein Beobachter kann diese Sitzung nicht # beeinflussen, und sein Fehlen/Trennen hat keinen Einfluss hierauf. active_sessions.broadcast(session_id, data) 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" ): await _reject( websocket, 4403, f"Keine Berechtigung 'ssh_connect' fuer Host {host_id}", accepted=False, ) 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: # Grund SOWOHL als JSON-Frame (falls der Client schon zuhoert) ALS # AUCH als Close-Reason senden (falls nicht) -- terminal.js zeigt # beides an, je nachdem, was zuerst ankommt. await websocket.send_json({"type": "error", "message": str(exc)}) await _reject(websocket, 4404, str(exc), accepted=True) 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, session_id)) 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 SSH_SETUP_ERRORS as exc: # Alles, was den Sitzungsaufbau verhindert und der Benutzer selbst # einordnen kann: fehlender Benutzername/Schluessel, nicht gepinnter # oder abweichender Host-Key, unerreichbares Ziel. Bewusst mit # Klartextmeldung statt "interner Fehler" -- der Grund steht in der # Konfiguration, nicht im Code. logger.warning("SSH-Sitzung %s abgebrochen: %s", session_id, exc) end_reason = "error" try: await websocket.send_json( {"type": "error", "message": describe_connection_error(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) @router.websocket("/ws/sessions/{session_id}/watch") async def watch_ssh_session(websocket: WebSocket, session_id: int): """Live-Mitschau einer laufenden SSH-Sitzung fuer Superadmins ('Aktive Session mitschauen', Sessionview im Adminbereich). Rein lesend: der Beobachter bekommt exakt dieselben "output"-Frames wie der Sitzungsinhaber selbst (siehe _pump_ssh_to_ws()/active_sessions. broadcast()), kann aber keine Eingaben in die fremde Sitzung schicken -- dafuer gibt es hier bewusst keinen "input"-Pfad. Eigener Top-Level-Router unter /ws/... (siehe app/admin/log_ws.py fuer die ausfuehrliche Begruendung: der nginx-Reverse-Proxy setzt WebSocket-Upgrade-Header nur fuer /ws/-Pfade). Nur auf demselben Server-Prozess moeglich wie 'Beenden' -- active_sessions.add_watcher() gibt None zurueck, wenn die Sitzung hier nicht (mehr) laeuft.""" user = await get_current_user_ws(websocket) if user is None or not user.is_admin: await websocket.close(code=4403) return conn = get_db() row = await (await conn.execute( "SELECT protocol, ended_at FROM sessions WHERE id = ?", (session_id,) )).fetchone() if row is None or row[0] != "ssh" or row[1] is not None: await websocket.close(code=4404) return queue = active_sessions.add_watcher(session_id) if queue is None: # Sitzung existiert, laeuft aber nicht (mehr) auf diesem Worker- # Prozess -- analog zum 'killable'-Feld bei 'Beenden'. await websocket.close(code=4409) return await websocket.accept() client_ip = websocket.client.host if websocket.client else "unknown" await write_audit_event( conn, event_type="session_watch_started", user_id=user.id, client_ip=client_ip, details={"session_id": session_id}, ) await conn.commit() logger.debug("Superadmin %s beobachtet SSH-Sitzung %s live", user.username, session_id) try: while True: data = await queue.get() await websocket.send_json({"type": "output", "data": base64.b64encode(data).decode()}) except WebSocketDisconnect: pass finally: active_sessions.remove_watcher(session_id, queue) try: await websocket.close() except Exception: logger.debug("Watch-WebSocket war beim Schliessen bereits getrennt", exc_info=True)