549 lines
26 KiB
Python
549 lines
26 KiB
Python
"""
|
|
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.<timestamp>;". 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/<host_id>/
|
|
# 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)
|