umbau 1.0
This commit is contained in:
@ -18,12 +18,206 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import codecs
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger("jumphost.rdp_proxy.guacd")
|
||||
|
||||
|
||||
def rdp_drive_path(host_id: int, session_id: int) -> Path:
|
||||
"""Pfad des pro Sitzung umgeleiteten RDP-Laufwerks (Befund E6,
|
||||
Umsetzungsauftrag Teil E).
|
||||
|
||||
Vorher war der Pfad NUR je Host angelegt (`.../rdp-drives/{host_id}`,
|
||||
fuer alle Sitzungen auf diesem Host identisch) -- zwei gleichzeitige
|
||||
Benutzer auf demselben Windows-Ziel teilten sich damit ein Verzeichnis:
|
||||
A sah/ueberschrieb/las die Dateien von B. Je-Sitzung-Pfade schliessen das
|
||||
aus; Aufraeumen beim Sitzungsende siehe app/rdp_proxy/ws_tunnel.py
|
||||
(`_cleanup_rdp_drive_path`).
|
||||
"""
|
||||
return settings.data_dir / "rdp-drives" / str(host_id) / str(session_id)
|
||||
|
||||
# Der von "connect"/"select" bis zur eigentlichen Bilduebertragung genutzte
|
||||
# StreamReader-Puffer muss groesser als der asyncio-Default (64 KiB) sein --
|
||||
# einzelne Guacamole-Instruktionen (z.B. ein base64-kodierter Bild-Blob) sind
|
||||
# regelmaessig groesser. Ohne diesen erhoehten Wert wirft readuntil()/
|
||||
# readexactly() ein LimitOverrunError, sobald mehr als 64 KiB unverarbeitet
|
||||
# im Puffer stehen (Umsetzungsauftrag_Sonnet5.md Befund A1).
|
||||
GUACD_STREAM_LIMIT = 4 * 1024 * 1024
|
||||
|
||||
# Guacamole-Protokoll-Versions-Token, das guacd >= 1.1.0 als ERSTEN Eintrag
|
||||
# der "args"-Instruktion mitschickt (Befund C1). Es sieht wie ein normaler
|
||||
# Parametername aus ("VERSION_1_5_0"), ist aber keiner -- es signalisiert die
|
||||
# hoechste vom Server unterstuetzte Protokollversion und muss vom Client als
|
||||
# ERSTER Wert der "connect"-Instruktion bestaetigt werden, sonst faellt guacd
|
||||
# auf Protokoll 1.0.0 zurueck (verliert dabei timezone/required/msg).
|
||||
_VERSION_TOKEN_RE = re.compile(r"VERSION_\d+_\d+_\d+")
|
||||
|
||||
# Guacamole-Protokoll-Statuscodes (dezimal) -> deutsche Klartexte (Teil B.2).
|
||||
# Quelle: Guacamole.Status.Code in guacamole-common-js (Status.js), Werte
|
||||
# dort hexadezimal definiert. Nicht gelistete Codes fallen auf einen
|
||||
# generischen Text zurueck, der den Rohcode weiterhin sichtbar macht -- ein
|
||||
# unbekannter Code ist damit trotzdem diagnostizierbar, nicht stumm.
|
||||
GUAC_STATUS_MESSAGES: dict[int, str] = {
|
||||
0: "Erfolgreich",
|
||||
256: "Nicht unterstuetzte Operation",
|
||||
512: "Serverfehler (guacd/FreeRDP intern)",
|
||||
513: "guacd ist ausgelastet, bitte spaeter erneut versuchen",
|
||||
514: "Das Zielsystem antwortet nicht (Zeitueberschreitung)",
|
||||
515: "Das Zielsystem meldet einen Fehler",
|
||||
516: "Angeforderte Ressource nicht gefunden",
|
||||
517: "Ressourcenkonflikt (Ressource bereits in Verwendung)",
|
||||
518: "Ressource ist bereits geschlossen",
|
||||
519: "Ziel nicht erreichbar",
|
||||
520: "Ziel derzeit nicht verfuegbar",
|
||||
521: "Sitzung durch eine andere Sitzung auf dem Ziel beendet",
|
||||
522: "Sitzung wegen Inaktivitaet auf dem Ziel beendet",
|
||||
523: "Sitzung auf dem Ziel zwangsweise beendet",
|
||||
768: "Ungueltige Verbindungsparameter",
|
||||
769: "Anmeldung fehlgeschlagen (Benutzername/Passwort pruefen)",
|
||||
771: "Zugriff verweigert (auch nach erfolgreicher Anmeldung nicht erlaubt)",
|
||||
776: "Zeitueberschreitung beim Verbindungsaufbau (Zertifikat/TLS-Handshake pruefen)",
|
||||
781: "Zu viele Daten gesendet",
|
||||
783: "Nicht unterstuetzter Datentyp gesendet",
|
||||
797: "Zu viele gleichzeitige Ressourcen angefordert",
|
||||
}
|
||||
|
||||
|
||||
def guac_status_text(code: int) -> str:
|
||||
"""Deutscher Klartext zu einem Guacamole-Statuscode, mit Rohcode als Fallback."""
|
||||
text = GUAC_STATUS_MESSAGES.get(code)
|
||||
if text:
|
||||
return f"{text} (Code {code})"
|
||||
return f"guacd meldet unbekannten Status {code}"
|
||||
|
||||
|
||||
class GuacamoleProtocolError(Exception):
|
||||
pass
|
||||
"""Fehler auf Ebene des Guacamole-Instruktionsprotokolls (kaputte
|
||||
Laengenangabe, unerwartetes Trennzeichen, unerwartete Instruktion an
|
||||
einer Stelle, an der eine andere erwartet wurde)."""
|
||||
|
||||
|
||||
class GuacdUnreachableError(Exception):
|
||||
"""guacd ist unter guacd_host:guacd_port nicht erreichbar oder antwortet
|
||||
nicht innerhalb der konfigurierten Zeit (Befund B.2, Teil B)."""
|
||||
|
||||
def __init__(self, guacd_host: str, guacd_port: int, cause: BaseException) -> None:
|
||||
self.guacd_host = guacd_host
|
||||
self.guacd_port = guacd_port
|
||||
self.cause_type = cause.__class__.__name__
|
||||
if isinstance(cause, (asyncio.TimeoutError, TimeoutError)):
|
||||
detail = "keine Antwort innerhalb der konfigurierten Zeit (Timeout)"
|
||||
elif isinstance(cause, ConnectionRefusedError):
|
||||
detail = "Verbindung abgelehnt -- vermutlich laeuft/lauscht guacd nicht"
|
||||
else:
|
||||
detail = str(cause) or cause.__class__.__name__
|
||||
super().__init__(f"guacd unter {guacd_host}:{guacd_port} nicht erreichbar: {detail}")
|
||||
|
||||
|
||||
class GuacdDisconnectedError(GuacamoleProtocolError):
|
||||
"""guacd hat die Verbindung mitten im Protokoll unerwartet beendet (EOF).
|
||||
Haeufigste Ursache: ein Absturz des FreeRDP-Plugins in guacd."""
|
||||
|
||||
|
||||
class GuacdStatusError(GuacamoleProtocolError):
|
||||
"""guacd hat eine 'error'-Instruktion gesendet (Protokoll-Referenz:
|
||||
Argumente sind [message, status] in dieser Reihenfolge)."""
|
||||
|
||||
def __init__(self, status_code: int, guac_message: str) -> None:
|
||||
self.status_code = status_code
|
||||
self.guac_message = guac_message
|
||||
super().__init__(guac_status_text(status_code))
|
||||
|
||||
|
||||
async def _read_length_prefix(reader: asyncio.StreamReader) -> int:
|
||||
"""Liest die Zeichen-Laengenangabe vor dem '.' einer Instruktion.
|
||||
|
||||
Nutzt StreamReader.readuntil() (gepuffert, C-implementiert) statt der
|
||||
fruehren Byte-fuer-Byte-Schleife -- fuer eine Laengenangabe von hoechstens
|
||||
einigen Ziffern ist der Unterschied klein, aber jede Vereinfachung hier
|
||||
zahlt auf denselben Umbau wie _read_chars() ein (Befund A1).
|
||||
"""
|
||||
try:
|
||||
raw = await reader.readuntil(b".")
|
||||
except asyncio.IncompleteReadError as exc:
|
||||
raise GuacdDisconnectedError(
|
||||
"guacd hat die Verbindung unerwartet beendet (EOF beim Lesen einer Laengenangabe)"
|
||||
) from exc
|
||||
except asyncio.LimitOverrunError as exc:
|
||||
raise GuacamoleProtocolError(
|
||||
"Laengenangabe ueberschreitet den Lesepuffer -- vermutlich kaputtes Protokoll"
|
||||
) from exc
|
||||
length_bytes = raw[:-1] # Trennzeichen "." abschneiden
|
||||
try:
|
||||
return int(length_bytes)
|
||||
except ValueError as exc:
|
||||
raise GuacamoleProtocolError(f"Ungueltige Laengenangabe: {length_bytes!r}") from exc
|
||||
|
||||
|
||||
async def _read_chars(reader: asyncio.StreamReader, count: int) -> str:
|
||||
"""Liest genau `count` ZEICHEN (nicht Bytes) UTF-8-kodiert vom Stream.
|
||||
|
||||
Befund A1 (Umsetzungsauftrag_Sonnet5.md Teil A.3): die vorherige Fassung
|
||||
rief readexactly(1) einmal PRO ZEICHEN auf -- bei einem base64-kodierten
|
||||
Bild-Blob (der weit ueberwiegende Anteil des RDP-Bildstroms) waren das
|
||||
ohne weiteres hunderttausende einzelne awaits fuer eine einzige
|
||||
Instruktion, mit entsprechendem Event-Loop-Overhead (Phase 16: Faktor
|
||||
644x gegenueber gepuffertem Lesen).
|
||||
|
||||
Ausnutzung der Guacamole-Invariante "Laenge zaehlt Zeichen, nicht Bytes":
|
||||
jedes UTF-8-Zeichen belegt MINDESTENS ein Byte, `count` Bytes liefern
|
||||
also NIE mehr als `count` Zeichen. `count` Bytes auf einmal zu lesen ist
|
||||
damit immer eine sichere (nie zu grosse) Untergrenze fuer die noch
|
||||
fehlende Byteanzahl:
|
||||
- Reiner ASCII-Inhalt (praktisch der gesamte Bildstrom, da Base64 nur
|
||||
ASCII-Zeichen kennt): EIN readexactly() statt `count` einzelner --
|
||||
der Normalfall, fuer den dieser Umbau gemacht ist.
|
||||
- Mehrbyte-Zeichen (z.B. ein Umlaut in einem Passwort/Hostnamen/einer
|
||||
Zwischenablage-Uebertragung): der inkrementelle Decoder puffert dann
|
||||
intern unvollstaendige Byte-Reste, `chars` waechst langsamer als
|
||||
`count` Bytes gelesen wurden, die Schleife liest gezielt genau den
|
||||
fehlenden Rest nach -- korrekt und weiterhin ohne Byte-fuer-Byte-Awaits.
|
||||
Ein readexactly() kann dabei nie ueber das Ende des aktuellen Elements
|
||||
hinaus in den nachfolgenden Trenner/das naechste Element lesen: aus
|
||||
`remaining` Bytes koennen hoechstens `remaining` Zeichen entstehen, die
|
||||
Schleife fordert also nie mehr Bytes an, als fuer die noch fehlenden
|
||||
Zeichen mindestens noetig sind.
|
||||
"""
|
||||
decoder = codecs.getincrementaldecoder("utf-8")()
|
||||
chars: list[str] = []
|
||||
remaining = count
|
||||
while remaining > 0:
|
||||
try:
|
||||
chunk = await reader.readexactly(remaining)
|
||||
except asyncio.IncompleteReadError as exc:
|
||||
raise GuacdDisconnectedError(
|
||||
"guacd hat die Verbindung unerwartet beendet (EOF mitten in einer Instruktion)"
|
||||
) from exc
|
||||
decoded = decoder.decode(chunk)
|
||||
if decoded:
|
||||
chars.append(decoded)
|
||||
remaining = count - sum(len(c) for c in chars)
|
||||
return "".join(chars)
|
||||
|
||||
|
||||
async def read_instruction(reader: asyncio.StreamReader) -> list[str]:
|
||||
elements: list[str] = []
|
||||
while True:
|
||||
length = await _read_length_prefix(reader)
|
||||
content = await _read_chars(reader, length)
|
||||
elements.append(content)
|
||||
try:
|
||||
sep = await reader.readexactly(1)
|
||||
except asyncio.IncompleteReadError as exc:
|
||||
raise GuacdDisconnectedError(
|
||||
"guacd hat die Verbindung unerwartet beendet (EOF nach einem Element, vor dem Trennzeichen)"
|
||||
) from exc
|
||||
if sep == b";":
|
||||
return elements
|
||||
if sep != b",":
|
||||
raise GuacamoleProtocolError(f"Unerwartetes Trennzeichen: {sep!r}")
|
||||
|
||||
|
||||
def encode_instruction(*elements: str) -> str:
|
||||
@ -41,72 +235,71 @@ def encode_instruction(*elements: str) -> str:
|
||||
return ",".join(f"{len(element)}.{element}" for element in elements) + ";"
|
||||
|
||||
|
||||
async def _read_until(reader: asyncio.StreamReader, delimiter: bytes) -> bytes:
|
||||
buf = bytearray()
|
||||
while True:
|
||||
b = await reader.readexactly(1)
|
||||
if b == delimiter:
|
||||
return bytes(buf)
|
||||
buf += b
|
||||
|
||||
|
||||
async def _read_chars(reader: asyncio.StreamReader, count: int) -> str:
|
||||
"""Liest genau `count` ZEICHEN (nicht Bytes) UTF-8-kodiert vom Stream.
|
||||
|
||||
Gegenstueck zur Laengenzaehlung in encode_instruction(): ein
|
||||
`readexactly(count)` haette bei Mehrbyte-Zeichen zu wenig Bytes gelesen
|
||||
und den Parser mitten in einem Zeichen stehen lassen."""
|
||||
decoder = codecs.getincrementaldecoder("utf-8")()
|
||||
chars: list[str] = []
|
||||
while len(chars) < count:
|
||||
chunk = decoder.decode(await reader.readexactly(1))
|
||||
if chunk:
|
||||
chars.extend(chunk)
|
||||
return "".join(chars)
|
||||
|
||||
|
||||
async def read_instruction(reader: asyncio.StreamReader) -> list[str]:
|
||||
elements: list[str] = []
|
||||
while True:
|
||||
length_bytes = await _read_until(reader, b".")
|
||||
try:
|
||||
length = int(length_bytes)
|
||||
except ValueError as exc:
|
||||
raise GuacamoleProtocolError(f"Ungueltige Laengenangabe: {length_bytes!r}") from exc
|
||||
content = await _read_chars(reader, length)
|
||||
elements.append(content)
|
||||
sep = await reader.readexactly(1)
|
||||
if sep == b";":
|
||||
return elements
|
||||
if sep != b",":
|
||||
raise GuacamoleProtocolError(f"Unerwartetes Trennzeichen: {sep!r}")
|
||||
|
||||
|
||||
def parse_instruction_text(text: str) -> list[str]:
|
||||
"""Parst genau EINE Instruktion aus einem bereits vollstaendig vorliegenden
|
||||
String (z.B. eine einzelne WebSocket-Textnachricht vom Browser)."""
|
||||
"""Parst genau EINE Instruktion vom Anfang eines Strings (Kompatibilitaets-
|
||||
Wrapper um parse_instructions_text() fuer Aufrufer, die garantiert nur
|
||||
eine einzelne Instruktion erwarten)."""
|
||||
return parse_instructions_text(text)[0]
|
||||
|
||||
|
||||
def parse_instructions_text(text: str) -> list[list[str]]:
|
||||
"""Parst ALLE Instruktionen aus einem bereits vollstaendig vorliegenden
|
||||
String (z.B. eine einzelne WebSocket-Textnachricht vom Browser).
|
||||
|
||||
Befund D5 (Umsetzungsauftrag_Sonnet5.md Teil A.3): guacamole-common-js
|
||||
kann mehrere Instruktionen in EINER WebSocket-Textnachricht buendeln
|
||||
(z.B. beim Leeren einer Sendewarteschlange). Die vorherige Fassung
|
||||
parste nur die ERSTE Instruktion, leitete aber die KOMPLETTE Nachricht
|
||||
unveraendert weiter -- der Zwischenablage-Filter in _ws_to_guacd() prueft
|
||||
daher nur instr[0]; eine 'clipboard'-Instruktion an zweiter oder
|
||||
spaeterer Stelle in derselben Nachricht wurde ungefiltert durchgereicht
|
||||
(Policy-Umgehung). Aufrufer muessen jede zurueckgegebene Instruktion
|
||||
einzeln pruefen/filtern und die ueberlebenden Instruktionen mit
|
||||
encode_instruction() neu zusammensetzen, statt den Originaltext erneut
|
||||
zu versenden.
|
||||
"""
|
||||
instructions: list[list[str]] = []
|
||||
elements: list[str] = []
|
||||
i = 0
|
||||
n = len(text)
|
||||
if n == 0:
|
||||
raise GuacamoleProtocolError("Leere Instruktionsnachricht")
|
||||
while i < n:
|
||||
dot = text.index(".", i)
|
||||
length = int(text[i:dot])
|
||||
try:
|
||||
dot = text.index(".", i)
|
||||
except ValueError as exc:
|
||||
raise GuacamoleProtocolError(f"Fehlender '.' in Laengenangabe ab Position {i} in {text!r}") from exc
|
||||
try:
|
||||
length = int(text[i:dot])
|
||||
except ValueError as exc:
|
||||
raise GuacamoleProtocolError(f"Ungueltige Laengenangabe in {text!r} an Position {i}") from exc
|
||||
start = dot + 1
|
||||
end = start + length
|
||||
if end > n:
|
||||
raise GuacamoleProtocolError(f"Laengenangabe {length} reicht ueber das Nachrichtenende hinaus in {text!r}")
|
||||
elements.append(text[start:end])
|
||||
sep = text[end] if end < n else ""
|
||||
i = end + 1
|
||||
if sep == ";":
|
||||
break
|
||||
if sep != ",":
|
||||
instructions.append(elements)
|
||||
elements = []
|
||||
elif sep != ",":
|
||||
raise GuacamoleProtocolError(f"Unerwartetes Trennzeichen in {text!r} an Position {end}")
|
||||
return elements
|
||||
if elements:
|
||||
# Nachricht endete ohne abschliessendes ';' -- unvollstaendige
|
||||
# Instruktion, kein gueltiger Zustand fuer eine einzelne, komplett
|
||||
# vorliegende WS-Textnachricht.
|
||||
raise GuacamoleProtocolError(f"Unvollstaendige Instruktion (fehlendes ';') in {text!r}")
|
||||
return instructions
|
||||
|
||||
|
||||
class GuacdTunnel:
|
||||
def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, connection_id: str) -> None:
|
||||
def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, connection_id: str = "") -> None:
|
||||
self.reader = reader
|
||||
self.writer = writer
|
||||
# Wird von der Lese-Pumpe (app/rdp_proxy/ws_tunnel.py::_guacd_to_ws)
|
||||
# beim Empfang der 'ready'-Instruktion nachtraeglich gesetzt -- siehe
|
||||
# open_tunnel(), das NICHT mehr selbst auf 'ready' wartet (Befund A2).
|
||||
self.connection_id = connection_id
|
||||
|
||||
async def close(self) -> None:
|
||||
@ -130,44 +323,102 @@ async def open_tunnel(
|
||||
screen_width: int = 1024,
|
||||
screen_height: int = 768,
|
||||
dpi: int = 96,
|
||||
connect_timeout: float | None = 10.0,
|
||||
handshake_timeout: float | None = 10.0,
|
||||
) -> GuacdTunnel:
|
||||
reader, writer = await asyncio.open_connection(guacd_host, guacd_port)
|
||||
"""Baut den guacd-Tunnel auf und sendet 'select'/Groessen-/'connect'-
|
||||
Instruktionen. Wartet bewusst NICHT auf 'ready' (Befund A2, Teil A.3):
|
||||
|
||||
writer.write(encode_instruction("select", protocol).encode("utf-8"))
|
||||
await writer.drain()
|
||||
guacd startet nach 'connect' im Hintergrund den eigentlichen RDP-/NLA-
|
||||
Handshake zum Windows-Ziel; der kann je nach Ziel/Netz mehrere Sekunden
|
||||
dauern. Wuerde hier synchron auf 'ready' gewartet, empfaengt der Browser
|
||||
in dieser ganzen Zeit KEINE Daten -- guacamole-common-js bricht nach
|
||||
receiveTimeout (Standard 15s, vendor/guacamole-common.js) mit
|
||||
"Server timeout" ab, noch bevor der Windows-Anmeldevorgang ueberhaupt
|
||||
abgeschlossen ist. Aufrufer starten die Lese-Pumpe direkt im Anschluss
|
||||
an diese Funktion (siehe app/rdp_proxy/ws_tunnel.py::rdp_tunnel); 'ready'
|
||||
(und ein etwaiges 'error') werden dort wie jede andere Instruktion
|
||||
empfangen und behandelt (_guacd_to_ws) -- ab dem Senden von 'connect'
|
||||
ist also durchgehend jemand am Lesen, unabhaengig davon, wie lange guacd
|
||||
fuer den eigentlichen RDP-Handshake braucht.
|
||||
|
||||
args_instr = await read_instruction(reader)
|
||||
if args_instr[0] != "args":
|
||||
raise GuacamoleProtocolError(f"Erwartete 'args', erhalten: {args_instr[0]}")
|
||||
arg_names = args_instr[1:]
|
||||
guacd_host/guacd_port stecken bei jedem Fehler in der Exception-Nachricht
|
||||
(Befund B.2): die fruehere Fassung liess einen nackten OSError
|
||||
durchreichen, ohne wohin die Anwendung ueberhaupt verbinden wollte.
|
||||
"""
|
||||
try:
|
||||
reader, writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(guacd_host, guacd_port, limit=GUACD_STREAM_LIMIT),
|
||||
timeout=connect_timeout,
|
||||
)
|
||||
except (OSError, asyncio.TimeoutError) as exc:
|
||||
raise GuacdUnreachableError(guacd_host, guacd_port, exc) from exc
|
||||
|
||||
handshake = (
|
||||
encode_instruction("size", str(screen_width), str(screen_height), str(dpi))
|
||||
+ encode_instruction("audio")
|
||||
+ encode_instruction("video")
|
||||
+ encode_instruction("image", "image/png", "image/jpeg")
|
||||
)
|
||||
writer.write(handshake.encode("utf-8"))
|
||||
await writer.drain()
|
||||
try:
|
||||
writer.write(encode_instruction("select", protocol).encode("utf-8"))
|
||||
await writer.drain()
|
||||
|
||||
values = [params.get(name, "") for name in arg_names]
|
||||
writer.write(encode_instruction("connect", *values).encode("utf-8"))
|
||||
await writer.drain()
|
||||
try:
|
||||
args_instr = await asyncio.wait_for(read_instruction(reader), timeout=handshake_timeout)
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise GuacdUnreachableError(guacd_host, guacd_port, exc) from exc
|
||||
if not args_instr or args_instr[0] != "args":
|
||||
raise GuacamoleProtocolError(
|
||||
f"Erwartete 'args', erhalten: {args_instr[0] if args_instr else '(leer)'}"
|
||||
)
|
||||
|
||||
ready_instr = await read_instruction(reader)
|
||||
if ready_instr[0] != "ready":
|
||||
raise GuacamoleProtocolError(f"Verbindungsaufbau fehlgeschlagen: {ready_instr}")
|
||||
connection_id = ready_instr[1] if len(ready_instr) > 1 else ""
|
||||
# C1: Versions-Token erkennen und aus den echten Parameternamen
|
||||
# herausloesen (siehe _VERSION_TOKEN_RE oben).
|
||||
raw_arg_names = args_instr[1:]
|
||||
version_token: str | None = None
|
||||
if raw_arg_names and _VERSION_TOKEN_RE.fullmatch(raw_arg_names[0]):
|
||||
version_token = raw_arg_names[0]
|
||||
arg_names = raw_arg_names[1:]
|
||||
else:
|
||||
arg_names = raw_arg_names
|
||||
|
||||
return GuacdTunnel(reader, writer, connection_id)
|
||||
handshake = (
|
||||
encode_instruction("size", str(screen_width), str(screen_height), str(dpi))
|
||||
+ encode_instruction("audio")
|
||||
+ encode_instruction("video")
|
||||
+ encode_instruction("image", "image/png", "image/jpeg")
|
||||
)
|
||||
writer.write(handshake.encode("utf-8"))
|
||||
await writer.drain()
|
||||
|
||||
# C3: width/height/dpi zusaetzlich als benannte connect-Parameter
|
||||
# mitgeben, sofern guacd sie in seiner args-Liste anbietet -- FreeRDP
|
||||
# nutzt sie fuer die initiale Aufloesungs-/DPI-Aushandlung mit dem
|
||||
# Windows-Ziel, unabhaengig von der separaten 'size'-Instruktion oben.
|
||||
effective_params = dict(params)
|
||||
effective_params.setdefault("width", str(screen_width))
|
||||
effective_params.setdefault("height", str(screen_height))
|
||||
effective_params.setdefault("dpi", str(dpi))
|
||||
|
||||
values = [effective_params.get(name, "") for name in arg_names]
|
||||
if version_token is not None:
|
||||
# Der Client bestaetigt exakt das von guacd angebotene
|
||||
# Versions-Token, statt (wie zuvor) implizit einen Leerstring zu
|
||||
# senden und damit auf Protokoll 1.0.0 herabgestuft zu werden.
|
||||
values = [version_token, *values]
|
||||
writer.write(encode_instruction("connect", *values).encode("utf-8"))
|
||||
await writer.drain()
|
||||
except BaseException:
|
||||
writer.close()
|
||||
raise
|
||||
|
||||
return GuacdTunnel(reader, writer)
|
||||
|
||||
|
||||
def build_rdp_params(
|
||||
host: dict,
|
||||
password: str,
|
||||
*,
|
||||
session_id: int,
|
||||
username: str | None = None,
|
||||
domain: str | None = None,
|
||||
client_name: str | None = None,
|
||||
timezone: str | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Baut die Parameter-Map fuer die connect-Instruktion aus dem Host-Datensatz.
|
||||
|
||||
@ -181,6 +432,16 @@ def build_rdp_params(
|
||||
Benutzername gehoert zur Anmeldung, nicht zum Zielsystem. Fehlen sie, wird
|
||||
auf hosts.rdp_username/rdp_domain zurueckgegriffen (Altbestand).
|
||||
|
||||
`client_name` (Befund C3) identifiziert die Sitzung auf dem Windows-Ziel
|
||||
selbst (z.B. in Taskmanager/`qwinsta`) -- ohne ihn zeigt Windows nur einen
|
||||
generischen FreeRDP-Bezeichner, was die Zuordnung "welche Sitzung gehoert
|
||||
zu welchem Jumphost-Benutzer" beim Ziel-Admin erschwert.
|
||||
|
||||
`timezone` (Befund C3) wird, sofern vom Client uebermittelt (siehe
|
||||
app/rdp_proxy/ws_tunnel.py, Query-Parameter), an FreeRDP durchgereicht --
|
||||
ohne diesen Parameter uebernimmt Windows die Zeitzone des JUMPHOST-
|
||||
Servers statt der des Benutzers.
|
||||
|
||||
WICHTIG: `host` muss aus app.ssh_proxy.proxy.load_host() stammen. Diese
|
||||
Funktion liest rdp_require_nla/clipboard_enabled/rdp_ignore_cert -- genau
|
||||
diese Spalten hat load_host() frueher NICHT selektiert, wodurch hier still
|
||||
@ -200,7 +461,20 @@ def build_rdp_params(
|
||||
# Benutzername/Domaene kommen seit Migration 0010 aus den Zugangsdaten
|
||||
# (rdp_credentials). Der Wert am Host wird nur noch als Fallback fuer
|
||||
# Datensaetze gelesen, die vor der Migration angelegt wurden.
|
||||
effective_username = (username or "").strip() or (host.get("rdp_username") or "").strip()
|
||||
#
|
||||
# Befund C2: Migration 0012 hat fehlende Benutzernamen frueher mit dem
|
||||
# Platzhaltertext '(kein Benutzername)' statt eines leeren Strings
|
||||
# befuellt (inzwischen per Migration 0013 rueckwirkend korrigiert). Der
|
||||
# Platzhalter wird hier zusaetzlich defensiv wie ein leerer String
|
||||
# behandelt, falls er (z.B. durch manuelle Eingabe im Adminbereich)
|
||||
# erneut auftaucht -- sonst ginge er unbemerkt als echter RDP-Anmelde-
|
||||
# name an Windows und erzeugt dort ein irrefuehrendes "Anmeldung
|
||||
# fehlgeschlagen" statt der eigentlichen Ursache.
|
||||
_PLACEHOLDER_USERNAME = "(kein Benutzername)"
|
||||
raw_username = (username or "").strip()
|
||||
if raw_username == _PLACEHOLDER_USERNAME:
|
||||
raw_username = ""
|
||||
effective_username = raw_username or (host.get("rdp_username") or "").strip()
|
||||
if not effective_username:
|
||||
raise GuacamoleProtocolError(
|
||||
"Fuer diesen Host ist kein RDP-Benutzername hinterlegt. Der Benutzername "
|
||||
@ -210,7 +484,7 @@ def build_rdp_params(
|
||||
effective_domain = (domain or "").strip() or (host.get("rdp_domain") or "").strip()
|
||||
|
||||
clipboard_enabled = bool(host.get("clipboard_enabled"))
|
||||
return {
|
||||
params = {
|
||||
"hostname": host["address"],
|
||||
"port": str(host["port"]),
|
||||
"username": effective_username,
|
||||
@ -220,9 +494,13 @@ def build_rdp_params(
|
||||
"ignore-cert": "true" if host.get("rdp_ignore_cert", True) else "false",
|
||||
"disable-audio": "true",
|
||||
"enable-drive": "true" if host.get("file_transfer_enabled") else "false",
|
||||
"drive-path": f"/var/lib/jumphost/rdp-drives/{host['id']}",
|
||||
"drive-path": str(rdp_drive_path(host["id"], session_id)),
|
||||
"create-drive-path": "true",
|
||||
"disable-copy": "false" if clipboard_enabled else "true",
|
||||
"disable-paste": "false" if clipboard_enabled else "true",
|
||||
"resize-method": "display-update",
|
||||
"client-name": (client_name or "jumphost").strip()[:64],
|
||||
}
|
||||
if timezone:
|
||||
params["timezone"] = timezone
|
||||
return params
|
||||
|
||||
@ -11,6 +11,8 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
import uuid as uuid_mod
|
||||
|
||||
from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
|
||||
@ -18,17 +20,22 @@ 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.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_instruction_text,
|
||||
parse_instructions_text,
|
||||
rdp_drive_path,
|
||||
read_instruction,
|
||||
)
|
||||
from app.ssh_proxy.proxy import HostNotConfiguredError, load_host
|
||||
@ -48,6 +55,28 @@ INTERNAL_DATA_OPCODE = ""
|
||||
# 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
|
||||
@ -59,62 +88,141 @@ async def _accept(websocket: WebSocket) -> None:
|
||||
await websocket.accept()
|
||||
|
||||
|
||||
async def _reject(websocket: WebSocket, code: int, reason: str, *, accepted: bool) -> None:
|
||||
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 mitgegeben:
|
||||
guacamole-common-js reicht ihn (siehe close_tunnel()) als
|
||||
Guacamole.Status.message an client.onerror weiter, wo static/js/rdp.js ihn
|
||||
direkt anzeigt. Voraussetzung dafuer ist ein zustande gekommener
|
||||
Handshake -- vor `accept()` sieht der Browser nur einen HTTP-Fehler.
|
||||
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 (code=%s): %s", code, reason)
|
||||
# Close-Reason ist auf 123 Byte begrenzt (RFC 6455).
|
||||
reason_bytes = reason.encode("utf-8")[:123]
|
||||
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) -> None:
|
||||
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:
|
||||
instr = parse_instruction_text(message)
|
||||
except (GuacamoleProtocolError, ValueError, IndexError):
|
||||
continue # ungueltige Clientnachricht ignorieren statt die Verbindung zu killen
|
||||
|
||||
if instr and instr[0] == INTERNAL_DATA_OPCODE:
|
||||
# Tunnelinterne Instruktion von guacamole-common-js (leerer
|
||||
# Opcode), z.B. "0.,4.ping,13.<timestamp>;". Diese gehoert dem
|
||||
# Tunnel, NICHT guacd -- bisher wurde sie unbesehen an guacd
|
||||
# weitergereicht, das damit nichts anfangen kann. Ein ping wird
|
||||
# gespiegelt: der Browser bricht den Tunnel nach
|
||||
# tunnel.receiveTimeout (Standard 15 s) ohne empfangene Daten mit
|
||||
# "Server timeout" ab, eine stille Sitzung liefe also in einen
|
||||
# Fehler.
|
||||
if len(instr) > 1 and instr[1] == "ping":
|
||||
await websocket.send_text(encode_instruction(*instr))
|
||||
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
|
||||
|
||||
if not clipboard_enabled and instr and instr[0] == "clipboard":
|
||||
continue # Defense-in-Depth: Clipboard serverseitig blocken
|
||||
# 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
|
||||
|
||||
recorder.record("input", message)
|
||||
tunnel.writer.write(message.encode("utf-8"))
|
||||
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()
|
||||
|
||||
|
||||
@ -125,14 +233,28 @@ async def rdp_tunnel(
|
||||
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. Alle folgenden Ablehnungen laufen ueber _reject(), damit der
|
||||
# Benutzer im Browser den tatsaechlichen Grund zu sehen bekommt.
|
||||
logger.warning("RDP-Verbindung abgelehnt: keine gueltige Sitzung (host_id=%s)", host_id)
|
||||
# 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
|
||||
|
||||
@ -145,7 +267,30 @@ async def rdp_tunnel(
|
||||
await _reject(
|
||||
websocket, 4403,
|
||||
f"Keine Berechtigung 'rdp_connect' fuer Host {host_id}",
|
||||
accepted=False,
|
||||
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
|
||||
|
||||
@ -156,21 +301,27 @@ async def rdp_tunnel(
|
||||
# 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.
|
||||
tunnel_uuid = str(uuid_mod.uuid4())
|
||||
# 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)
|
||||
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,
|
||||
accepted=True, correlation_id=correlation_id, host_id=host_id, username=user.username,
|
||||
)
|
||||
return
|
||||
|
||||
@ -179,11 +330,40 @@ async def rdp_tunnel(
|
||||
# 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.
|
||||
# 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 rc.password_enc, rc.username, rc.domain FROM host_rdp_credential_map m "
|
||||
"JOIN rdp_credentials rc ON rc.id = m.rdp_credential_id WHERE m.host_id = ?",
|
||||
(host_id,),
|
||||
"SELECT password_enc, username, domain FROM rdp_credentials WHERE id = ?",
|
||||
(rdp_credential_id,),
|
||||
)
|
||||
cred_row = await cred_cursor.fetchone()
|
||||
if cred_row is None:
|
||||
@ -191,7 +371,7 @@ async def rdp_tunnel(
|
||||
websocket, 4404,
|
||||
f"Fuer Host {host['hostname']} ist kein RDP-Passwort hinterlegt "
|
||||
"(Adminbereich -> Zugangsdaten).",
|
||||
accepted=True,
|
||||
accepted=True, correlation_id=correlation_id, host_id=host_id, username=user.username,
|
||||
)
|
||||
return
|
||||
|
||||
@ -206,17 +386,22 @@ async def rdp_tunnel(
|
||||
)
|
||||
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},
|
||||
details={"host_id": host_id, "hostname": host["hostname"], "session_id": session_id, "corr": correlation_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,
|
||||
# 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())
|
||||
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
|
||||
@ -229,19 +414,26 @@ async def rdp_tunnel(
|
||||
# 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(), username=cred_row[1], domain=cred_row[2]
|
||||
host, password.decode(), session_id=session_id, username=cred_row[1], domain=cred_row[2],
|
||||
client_name=f"jumphost-{user.username}", timezone=safe_timezone,
|
||||
)
|
||||
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"],
|
||||
)
|
||||
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)
|
||||
_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)
|
||||
@ -253,11 +445,37 @@ async def rdp_tunnel(
|
||||
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): %s", session_id, 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
|
||||
# Close-Reason als Guacamole.Status.message an, siehe _reject).
|
||||
# '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__
|
||||
@ -272,9 +490,10 @@ async def rdp_tunnel(
|
||||
# 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: %s", session_id, exc)
|
||||
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
|
||||
@ -282,8 +501,13 @@ async def rdp_tunnel(
|
||||
task.cancel()
|
||||
if tunnel:
|
||||
await tunnel.close()
|
||||
recorder.close()
|
||||
logger.debug("RDP-Sitzung %s beendet: reason=%s", session_id, end_reason)
|
||||
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 = ?",
|
||||
@ -291,13 +515,15 @@ async def rdp_tunnel(
|
||||
)
|
||||
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},
|
||||
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=error_text.encode("utf-8")[:123].decode("utf-8", errors="ignore")
|
||||
code=4500, reason=full_reason.encode("utf-8")[:123].decode("utf-8", errors="ignore")
|
||||
)
|
||||
else:
|
||||
await websocket.close()
|
||||
|
||||
Reference in New Issue
Block a user