""" 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 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 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, build_rdp_params, encode_instruction, open_tunnel, parse_instruction_text, read_instruction, ) from app.ssh_proxy.proxy import HostNotConfiguredError, load_host logger = logging.getLogger("jumphost.rdp_proxy.ws") router = APIRouter() async def _guacd_to_ws(tunnel, websocket: WebSocket, recorder: SessionRecorder) -> None: while True: instr = await read_instruction(tunnel.reader) 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) -> None: while True: message = await websocket.receive_text() try: instr = parse_instruction_text(message) except GuacamoleProtocolError: continue # ungueltige Clientnachricht ignorieren statt die Verbindung zu killen if not clipboard_enabled and instr and instr[0] == "clipboard": continue # Defense-in-Depth: Clipboard serverseitig blocken recorder.record("input", message) tunnel.writer.write(message.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), ): user = await get_current_user_ws(websocket) if user is None: 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="rdp_connect" ): 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: await websocket.close(code=4404) return if host["protocol"] != "rdp": await websocket.close(code=4400) return cred_cursor = await conn.execute( "SELECT password_enc FROM rdp_credentials WHERE host_id = ?", (host_id,) ) cred_row = await cred_cursor.fetchone() if cred_row is None: await websocket.close(code=4404) return password = decrypt_secret(cred_row[0], associated_data=b"rdp_password") 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}, ) await conn.commit() logger.debug( "RDP-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" tunnel = None tasks: list[asyncio.Task] = [] try: params = build_rdp_params(host, password.decode()) 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, ) logger.debug("RDP-Sitzung %s: guacd-Tunnel zu %s aufgebaut", session_id, 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) ), ] 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 (GuacamoleProtocolError, ConnectionError, OSError) as exc: logger.warning("RDP-Sessionfehler (session_id=%s): %s", session_id, exc) end_reason = "error" 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 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() recorder.close() logger.debug("RDP-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="rdp_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: # 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)