""" WebSocket-Bruecke Browser (guacamole-common-js) <-> guacd (Konzept 4.3). Setzt pro Hostgruppe/Host konfigurierbare Policies durch, die guacd selbst zwar schon per connect-Parameter bekommt (disable-copy/-paste, enable-drive), zusaetzlich werden Clipboard-Instruktionen aber auch hier auf Protokollebene gefiltert -- Defense-in-Depth, falls sich guacd-Parameter je nach Version unterscheiden. """ from __future__ import annotations import asyncio import logging import re import shutil import uuid as uuid_mod from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect from app.auth.deps import get_current_user_ws from app.config import settings from app.db import get_db from app.rbac import AmbiguousCredentialError, resolve_credential_for_user_on_host, 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.security.crypto import decrypt_secret from app.rdp_proxy.guacd_client import ( GuacamoleProtocolError, GuacdDisconnectedError, GuacdStatusError, GuacdUnreachableError, build_rdp_params, encode_instruction, guac_status_text, open_tunnel, parse_instructions_text, rdp_drive_path, read_instruction, ) from app.ssh_proxy.proxy import HostNotConfiguredError, load_host logger = logging.getLogger("jumphost.rdp_proxy.ws") router = APIRouter() # Leerer Opcode = tunnelinterne Instruktion des Guacamole-Protokolls # (Guacamole.Tunnel.INTERNAL_DATA_OPCODE in guacamole-common-js): Tunnel-UUID # beim Verbindungsaufbau und ping/pong waehrend der Sitzung. INTERNAL_DATA_OPCODE = "" # Das vom Browser angeforderte WebSocket-Subprotokoll. guacamole-common-js # oeffnet den Socket IMMER als `new WebSocket(url, "guacamole")`. Bestaetigt # der Server das Subprotokoll nicht im Handshake, verwirft der Browser die # Verbindung nach RFC 6455 sofort wieder -- die Sitzung kam dadurch nie # zustande und es erreichte kein einziger Frame die Anwendung. GUACAMOLE_SUBPROTOCOL = "guacamole" # Grobe Plausibilitaetspruefung fuer eine vom Client mitgeschickte IANA- # Zeitzone (z.B. "Europe/Vienna") -- kein Anspruch auf Vollstaendigkeit gegen # die tz-Datenbank, nur ein Schutz gegen offensichtlich falsche Werte, bevor # sie als RDP-connect-Parameter an guacd/FreeRDP weitergereicht werden. _TIMEZONE_RE = re.compile(r"^[A-Za-z0-9_+\-]+(/[A-Za-z0-9_+\-]+)*$") async def _cleanup_rdp_drive_path(host_id: int, session_id: int) -> None: """Entfernt das pro Sitzung umgeleitete RDP-Laufwerk beim Sitzungsende (Befund E6, Umsetzungsauftrag Teil E) -- sonst sammeln sich pro Sitzung Verzeichnisse unbegrenzt an. shutil.rmtree ist blockierendes Datei-I/O (siehe E.0) und laeuft deshalb in einem Thread statt direkt im Event-Loop.""" path = rdp_drive_path(host_id, session_id) try: await asyncio.to_thread(shutil.rmtree, path, True) except OSError: logger.warning( "Konnte RDP-Laufwerksverzeichnis fuer Sitzung %s (Host %s) nicht entfernen: %s", session_id, host_id, path, exc_info=True, ) async def _accept(websocket: WebSocket) -> None: """Nimmt die Verbindung an und bestaetigt dabei das Subprotokoll, sofern der Client eines angeboten hat (siehe GUACAMOLE_SUBPROTOCOL).""" offered = websocket.scope.get("subprotocols") or [] if GUACAMOLE_SUBPROTOCOL in offered: await websocket.accept(subprotocol=GUACAMOLE_SUBPROTOCOL) else: await websocket.accept() async def _send_error_instruction(websocket: WebSocket, message: str, status_code: int) -> None: """Sendet eine vollstaendige Guacamole-'error'-Instruktion ueber den noch offenen Tunnel, BEVOR die Verbindung geschlossen wird (Befund B.2, vormals ws_tunnel.py:296-308): die WebSocket-Close-Reason ist nach RFC 6455 auf 123 Byte begrenzt und war bisher der EINZIGE Uebertragungsweg fuer den Fehlertext. Eine 'error'-Instruktion im normalen Tunnel- Datenstrom hat kein derartiges Laengenlimit und wird von guacamole-common-js (vendor/guacamole-common.js, Guacamole.Tunnel- Instruktions-Handler) automatisch verarbeitet -- die Close-Reason bleibt als Fallback fuer den Fall, dass dieser Sendeversuch selbst scheitert (z.B. weil der Client bereits getrennt hat). """ try: await websocket.send_text(encode_instruction("error", message, str(status_code))) except Exception: logger.debug("Konnte 'error'-Instruktion nicht mehr senden (Tunnel bereits zu)", exc_info=True) async def _reject( websocket: WebSocket, code: int, reason: str, *, accepted: bool, correlation_id: str, host_id: int | None = None, username: str | None = None, ) -> None: """Beendet eine Sitzung vor ihrem eigentlichen Beginn -- mit Logeintrag und mit einem fuer den Benutzer lesbaren Grund. Bisher endeten alle diese Pfade in einem nackten `websocket.close(code=...)` ohne jede Protokollierung. Im Verbindungslog war deshalb ueberhaupt nichts zu sehen, wenn eine RDP-Sitzung an einer dieser Vorbedingungen scheiterte. Befund B.2 ("gesamter RDP-Pfad"): zusaetzlich fehlten strukturierte Felder (host_id, Benutzer) UND eine Korrelations-ID -- man sah zwar DASS etwas abgelehnt wurde, aber die Ablehnung liess sich nicht mit einer spaeteren Nutzerrueckfrage ("bei mir ging um 14:03 nichts") verknuepfen. Der Grundtext wird als WebSocket-Close-Reason UND als vollstaendige Guacamole-'error'-Instruktion mitgegeben (siehe _send_error_instruction); letztere unterliegt nicht der 123-Byte-Grenze der Close-Reason. """ logger.warning( "RDP-Verbindung abgelehnt [corr=%s] (code=%s, host_id=%s, user=%s): %s", correlation_id, code, host_id, username, reason, ) full_reason = f"{reason} (Ref: {correlation_id})" if not accepted: await _accept(websocket) await _send_error_instruction(websocket, full_reason, code) reason_bytes = full_reason.encode("utf-8")[:123] await websocket.close(code=code, reason=reason_bytes.decode("utf-8", errors="ignore")) async def _guacd_to_ws(tunnel, websocket: WebSocket, recorder: SessionRecorder) -> None: """Liest Instruktionen von guacd und reicht sie an den Browser weiter. Befund A2: laeuft ab direkt nach open_tunnel() (die NICHT mehr selbst auf 'ready' wartet), somit ist ab dem Senden von 'connect' durchgehend jemand am Lesen -- unabhaengig davon, wie lange guacd fuer den eigentlichen RDP-/NLA-Handshake braucht. 'ready' wird hier (statt in open_tunnel()) erkannt, um die connection_id festzuhalten, und dann wie jede andere Instruktion normal weitergereicht -- guacamole-common-js erwartet 'ready' ohnehin als regulaeren Teil des Instruktionsstroms. Eine 'error'-Instruktion (Befund B.2/C1) wird NICHT weitergereicht, sondern als GuacdStatusError geworfen: der aufrufende Code in rdp_tunnel() bildet daraus die deutsche Klartextmeldung fuer Log und Browser (guac_status_text/_send_error_instruction), statt dass der Browser den rohen Guacamole-Statuscode selbst interpretieren muss. """ while True: instr = await read_instruction(tunnel.reader) if instr and instr[0] == "ready": tunnel.connection_id = instr[1] if len(instr) > 1 else "" elif instr and instr[0] == "error": message = instr[1] if len(instr) > 1 else "" try: code = int(instr[2]) if len(instr) > 2 else 512 except ValueError: code = 512 raise GuacdStatusError(code, message) text = encode_instruction(*instr) recorder.record("output", text) await websocket.send_text(text) async def _ws_to_guacd(tunnel, websocket: WebSocket, recorder: SessionRecorder, *, clipboard_enabled: bool, correlation_id: str) -> None: while True: message = await websocket.receive_text() try: instructions = parse_instructions_text(message) except GuacamoleProtocolError: # Ungueltige Clientnachricht ignorieren statt die Verbindung zu # killen -- aber (Befund B.2, ws_tunnel.py:96-98) nicht mehr # lautlos: ein gekuerzter Nachrichtenausschnitt landet als DEBUG # im Log, damit ein wiederkehrendes Muster (z.B. ein kaputter # Client) ueberhaupt auffallen kann. logger.debug( "[corr=%s] Ungueltige WS-Nachricht ignoriert: %r", correlation_id, message[:200], ) continue # Befund D5: eine einzelne WebSocket-Textnachricht kann MEHRERE # Guacamole-Instruktionen buendeln. Jede wird einzeln gegen den # Zwischenablage-Filter geprueft (statt nur instr[0] der Nachricht) # und nur die ueberlebenden Instruktionen werden neu kodiert # weitergereicht -- eine gefilterte 'clipboard'-Instruktion darf sich # nicht mehr an zweiter/spaeterer Stelle in derselben Nachricht # vorbeischmuggeln koennen. forward: list[str] = [] for instr in instructions: if instr and instr[0] == INTERNAL_DATA_OPCODE: # Tunnelinterne Instruktion von guacamole-common-js (leerer # Opcode), z.B. "0.,4.ping,13.;". Gehoert dem # Tunnel, NICHT guacd. Ein ping wird gespiegelt: der Browser # bricht den Tunnel nach tunnel.receiveTimeout (Standard 15s) # ohne empfangene Daten mit "Server timeout" ab. if len(instr) > 1 and instr[1] == "ping": await websocket.send_text(encode_instruction(*instr)) continue if not clipboard_enabled and instr and instr[0] == "clipboard": continue # Defense-in-Depth: Clipboard serverseitig blocken forward.append(encode_instruction(*instr)) if not forward: continue combined = "".join(forward) recorder.record("input", combined) tunnel.writer.write(combined.encode("utf-8")) await tunnel.writer.drain() @router.websocket("/ws/rdp/{host_id}") async def rdp_tunnel( websocket: WebSocket, host_id: int, width: int = Query(default=1280, ge=320, le=7680), height: int = Query(default=800, ge=240, le=4320), dpi: int = Query(default=96, ge=48, le=384), timezone: str | None = Query(default=None, max_length=64), ): # Befund B.2 ("gesamter RDP-Pfad"): Korrelations-ID VOR jeder moeglichen # Ablehnung erzeugen (nicht erst nach dem RBAC-Check) -- damit hat # WIRKLICH JEDE Logzeile zu dieser Verbindung, inklusive der fruehesten # Ablehnungen, ein gemeinsames Merkmal. Wird nach erfolgreichem accept() # zusaetzlich als tunnel_uuid an den Browser gesendet (siehe unten) -- # dieselbe ID dient also sowohl der Server-Log-Korrelation als auch der # Client-seitigen Tunnel-Identifikation. correlation_id = str(uuid_mod.uuid4()) user = await get_current_user_ws(websocket) if user is None: # Einziger Pfad, der bewusst OHNE vorheriges accept() schliesst: eine # nicht authentifizierte Verbindung soll gar nicht erst zustande # kommen (vor accept() sieht der Browser ohnehin nur einen # HTTP-Fehler, eine Guacamole-'error'-Instruktion kann also nicht # ankommen). Alle folgenden Ablehnungen laufen ueber _reject(). logger.warning( "RDP-Verbindung abgelehnt [corr=%s]: keine gueltige Sitzung (host_id=%s)", correlation_id, host_id, ) await websocket.close(code=4401) return conn = get_db() client_ip = websocket.client.host if websocket.client else "unknown" if not user.is_admin and not await user_has_role_for_host( conn, user_id=user.id, host_id=host_id, role_name="rdp_connect" ): await _reject( websocket, 4403, f"Keine Berechtigung 'rdp_connect' fuer Host {host_id}", accepted=False, correlation_id=correlation_id, host_id=host_id, username=user.username, ) return # E.4 (Umsetzungsauftrag Teil E): Obergrenzen je Benutzer und global, # VOR dem DB-INSERT und jedem Ressourcenverbrauch geprueft -- vorher gab # es keine der beiden Grenzen, mit der Mehrsitzungs-Seitenleiste (Teil F) # wird es zur Normalitaet, dass ein Benutzer mehrere Sitzungen haelt. # Klartext-Ablehnung statt eines wortlosen Verbindungsendes (Teil B gilt # auch hier). if not user.is_admin and active_sessions.count_for_user(user.id) >= settings.max_sessions_per_user: await _reject( websocket, 4429, f"Sie haben bereits {settings.max_sessions_per_user} Sitzungen offen " "(Obergrenze je Benutzer erreicht).", accepted=False, correlation_id=correlation_id, host_id=host_id, username=user.username, ) return if active_sessions.count_total() >= settings.max_sessions_global: await _reject( websocket, 4429, "Der Server hat die maximale Anzahl gleichzeitiger Sitzungen erreicht, " "bitte spaeter erneut versuchen.", accepted=False, correlation_id=correlation_id, host_id=host_id, username=user.username, ) return await _accept(websocket) # Tunnel-UUID als erste Instruktion senden (leerer Opcode, siehe # INTERNAL_DATA_OPCODE). guacamole-common-js setzt den Tunnel erst beim # Empfang der ersten Instruktion auf OPEN und startet damit seine # Timeoutueberwachung neu -- ohne diesen Frame blieb der Client bis zum # ersten Bild von guacd in "Warte auf Server ..." haengen und lief bei # einem langsamen RDP-Handshake in den 15-Sekunden-Timeout. Dieselbe ID # wie correlation_id (siehe oben) -- damit ist ein vom Benutzer im UI # angezeigter Fehler direkt mit den Server-Logzeilen dieser Verbindung # verknuepfbar. tunnel_uuid = correlation_id await websocket.send_text(encode_instruction(INTERNAL_DATA_OPCODE, tunnel_uuid)) try: host = await load_host(conn, host_id) except HostNotConfiguredError as exc: await _reject( websocket, 4404, str(exc), accepted=True, correlation_id=correlation_id, host_id=host_id, username=user.username, ) return if host["protocol"] != "rdp": await _reject( websocket, 4400, f"Host {host['hostname']} ist kein RDP-Ziel (protocol={host['protocol']})", accepted=True, correlation_id=correlation_id, host_id=host_id, username=user.username, ) return # username/domain stehen seit Migration 0010 bei den Zugangsdaten und nicht # mehr am Host -- der Benutzername gehoert zur Anmeldung, nicht zur # Beschreibung des Zielsystems. Seit Migration 0012 sind RDP-Zugangsdaten # zudem ein eigenstaendiges, wiederverwendbares Objekt (analog SSH-Keys), # das einem Host ueber host_rdp_credential_map zugewiesen wird, statt # 1:1 am Host zu haengen. Seit Teil D Schritt 4 (Achse B) laeuft die # Auswahl NICHT mehr blind ueber den Host allein: # app.rbac.resolve_credential_for_user_on_host() beruecksichtigt # zusaetzlich, ob DIESER Benutzer ueber eine seiner Gruppen # (group_rdp_credential_grants) Zugriff auf den zugeordneten # Zugangsdatensatz hat. AmbiguousCredentialError kann hier strukturell # nicht auftreten (host_rdp_credential_map hat PK auf host_id, also # hoechstens ein Treffer) -- trotzdem defensiv abgefangen, statt # unbehandelt durchzureichen. try: rdp_credential_id = await resolve_credential_for_user_on_host( conn, user_id=user.id, host_id=host_id, kind="rdp_credential" ) except AmbiguousCredentialError as exc: logger.error("Unerwartete Mehrdeutigkeit bei RDP-Zugangsdaten: %s", exc) await _reject( websocket, 4404, f"Fuer Host {host['hostname']} sind mehrdeutige RDP-Zugangsdaten hinterlegt " "(Adminbereich -> Zugangsdaten).", accepted=True, correlation_id=correlation_id, host_id=host_id, username=user.username, ) return if rdp_credential_id is None: await _reject( websocket, 4404, f"Fuer Host {host['hostname']} ist kein RDP-Passwort hinterlegt oder keines Ihrer " "Benutzergruppen freigegeben (Adminbereich -> Zugangsdaten bzw. " "Benutzergruppen -> Zugangsdaten).", accepted=True, correlation_id=correlation_id, host_id=host_id, username=user.username, ) return cred_cursor = await conn.execute( "SELECT password_enc, username, domain FROM rdp_credentials WHERE id = ?", (rdp_credential_id,), ) cred_row = await cred_cursor.fetchone() if cred_row is None: await _reject( websocket, 4404, f"Fuer Host {host['hostname']} ist kein RDP-Passwort hinterlegt " "(Adminbereich -> Zugangsdaten).", accepted=True, correlation_id=correlation_id, host_id=host_id, username=user.username, ) return cursor = await conn.execute( "INSERT INTO sessions (user_id, host_id, protocol, client_ip) VALUES (?, ?, 'rdp', ?)", (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="rdp_session_start", user_id=user.id, client_ip=client_ip, details={"host_id": host_id, "hostname": host["hostname"], "session_id": session_id, "corr": correlation_id}, ) await conn.commit() # Befund B.2 (ws_tunnel.py:212-215): Sitzungsstart/-ende gehoeren auf # INFO, nicht DEBUG -- im Produktivbetrieb (basicConfig(level=INFO), # app/main.py) waren sie damit ohne den globalen DEBUG-Override in # app/security/log_stream.py schlicht NICHT sichtbar. logger.info( "RDP-Sitzung %s gestartet [corr=%s]: user=%s host=%s (%s:%s) client_ip=%s", session_id, correlation_id, user.username, host["hostname"], host["address"], host["port"], client_ip, ) active_sessions.register(session_id, asyncio.current_task(), user.id) end_reason = "logout" error_text: str | None = None error_code = 512 tunnel = None tasks: list[asyncio.Task] = [] password = None try: # Entschluesselung bewusst HIER (innerhalb des try/finally, NACH dem # Anlegen der sessions-Zeile) statt vor dem INSERT: schlaegt sie fehl # (z.B. cryptography.exceptions.InvalidTag bei einem nicht mehr zum # KEK passenden Blob), landet der Fehler jetzt im Catch-all unten UND # es existiert bereits ein Session-/Audit-Log-Eintrag dafuer -- vorher # riss ein Fehler hier VOR jeglicher Protokollierung durch und die # Sitzung verschwand spurlos (schwarzer Bildschirm, kein Log-Eintrag). password = decrypt_secret(cred_row[0], associated_data=b"rdp_password") safe_timezone = timezone if (timezone and _TIMEZONE_RE.fullmatch(timezone)) else None params = build_rdp_params( host, password.decode(), session_id=session_id, username=cred_row[1], domain=cred_row[2], client_name=f"jumphost-{user.username}", timezone=safe_timezone, ) if host.get("file_transfer_enabled"): # Bug-Fix (Live-Test, RDP-Teilstrecke, 2.9.): guacd/FreeRDPs # "create-drive-path" legt nur das BLATT-Verzeichnis an, keine # fehlenden Elternverzeichnisse (kein "mkdir -p"). Bisher wurde # ausschliesslich data_dir/recordings_dir beim Start angelegt # (siehe app/db.py) -- das Elternverzeichnis .../rdp-drives// # existierte fuer einen frischen Host nie, wodurch guacd mit # "Unable to create directory ... No such file or directory" # abbrach. Deshalb hier explizit VOR dem Tunnelaufbau mit # parents=True anlegen (blockierendes Datei-I/O -> Thread, wie # bei _cleanup_rdp_drive_path()). await asyncio.to_thread( rdp_drive_path(host_id, session_id).mkdir, parents=True, exist_ok=True ) tunnel = await open_tunnel( guacd_host=settings.guacd_host, guacd_port=settings.guacd_port, protocol="rdp", params=params, screen_width=width, screen_height=height, dpi=dpi, connect_timeout=settings.guacd_connect_timeout_s, handshake_timeout=settings.guacd_handshake_timeout_s, ) logger.info( "RDP-Sitzung %s [corr=%s]: guacd-Tunnel zu %s:%s aufgebaut, 'connect' fuer %s gesendet", session_id, correlation_id, settings.guacd_host, settings.guacd_port, host["hostname"], ) clipboard_enabled = bool(host.get("clipboard_enabled", True)) tasks = [ asyncio.create_task(_guacd_to_ws(tunnel, websocket, recorder)), asyncio.create_task( _ws_to_guacd(tunnel, websocket, recorder, clipboard_enabled=clipboard_enabled, correlation_id=correlation_id) ), ] done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_EXCEPTION) for task in pending: task.cancel() for task in done: exc = task.exception() if exc: raise exc except WebSocketDisconnect: end_reason = "logout" except GuacdUnreachableError as exc: # Befund B.2: eigene Fehlerklasse statt eines rohen OSError -- # unterscheidbar von "guacd erreichbar, aber Ziel/Anmeldung fehlerhaft". logger.warning("RDP-Sessionfehler (session_id=%s) [corr=%s]: %s", session_id, correlation_id, exc) end_reason = "error" error_text = str(exc) error_code = 514 # UPSTREAM_TIMEOUT als naechstliegender Guacamole-Code fuer "guacd nicht erreichbar" except GuacdStatusError as exc: # guacd hat eine 'error'-Instruktion gesendet (haeufigster Fall: # falsches Passwort/Zielsystem nicht erreichbar/Zertifikatsproblem). logger.warning( "RDP-Sessionfehler (session_id=%s) [corr=%s]: guacd meldet Status %s: %s", session_id, correlation_id, exc.status_code, exc.guac_message, ) end_reason = "error" error_text = f"{guac_status_text(exc.status_code)}: {exc.guac_message}" if exc.guac_message else guac_status_text(exc.status_code) error_code = exc.status_code except GuacdDisconnectedError as exc: # EOF mitten im Protokoll -- typischerweise ein Absturz von guacd/ # FreeRDP. Vorher landete das unbehandelt im generischen # (GuacamoleProtocolError, ConnectionError, OSError)-Zweig als # IncompleteReadError mit rohem Python-Klassennamen im Browser. logger.warning("RDP-Sessionfehler (session_id=%s) [corr=%s]: %s", session_id, correlation_id, exc) end_reason = "error" error_text = "guacd hat die Verbindung unerwartet beendet" error_code = 515 except (GuacamoleProtocolError, ConnectionError, OSError) as exc: logger.warning("RDP-Sessionfehler (session_id=%s) [corr=%s]: %s", session_id, correlation_id, exc) end_reason = "error" # Grund an den Browser durchreichen (guacamole-common-js zeigt die # 'error'-Instruktion/Close-Reason an, siehe _send_error_instruction). # Betrifft u.a. den haeufigsten Konfigurationsfehler: kein # RDP-Benutzername am Host, siehe build_rdp_params(). error_text = str(exc) or exc.__class__.__name__ 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 analog zu app/ssh_proxy/terminal_ws.py -- deckt u.a. # cryptography.exceptions.InvalidTag (KEK passt nicht mehr zum # verschluesselten RDP-Passwort) ab, die vorher unbehandelt bis vor # das erste await in dieser Funktion durchriss und die Sitzung ohne # jede Fehlermeldung/Protokollierung sofort beendete. logger.exception("Unerwarteter Fehler in RDP-Sitzung %s [corr=%s]: %s", session_id, correlation_id, exc) end_reason = "error" error_text = f"Interner Fehler: {exc.__class__.__name__}" error_code = 512 finally: active_sessions.unregister(session_id) del password # Klartext-Passwort so schnell wie moeglich freigeben for task in tasks: task.cancel() if tunnel: await tunnel.close() await recorder.aclose() # E6 (Umsetzungsauftrag Teil E): das je Sitzung umgeleitete # RDP-Laufwerk (siehe build_rdp_params()/rdp_drive_path()) wird beim # Sitzungsende entfernt -- sonst sammeln sich pro Sitzung # Verzeichnisse unbegrenzt auf der Platte an. await _cleanup_rdp_drive_path(host_id, session_id) logger.info("RDP-Sitzung %s beendet [corr=%s]: reason=%s", session_id, correlation_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="rdp_session_end", user_id=user.id, client_ip=client_ip, details={"host_id": host_id, "session_id": session_id, "reason": end_reason, "corr": correlation_id}, ) await conn.commit() try: if error_text: full_reason = f"{error_text} (Ref: {correlation_id})" await _send_error_instruction(websocket, full_reason, error_code) await websocket.close( code=4500, reason=full_reason.encode("utf-8")[:123].decode("utf-8", errors="ignore") ) else: await websocket.close() except Exception: # Cleanup-Pfad: der Session-Datensatz und Audit-Log-Eintrag sind # zu diesem Zeitpunkt bereits geschrieben; ein bereits vom Client # getrenntes WebSocket darf das nicht rueckwirkend fehlschlagen lassen. logger.debug("WebSocket war beim Schliessen bereits getrennt", exc_info=True)