524 lines
24 KiB
Python
524 lines
24 KiB
Python
"""
|
|
Guacamole-Protokoll-Tunnel zu guacd (siehe Konzept 3, 4.3).
|
|
|
|
guacd selbst spricht RDP zum Windows-Ziel; der Jumphost tauscht mit guacd nur
|
|
das textbasierte Guacamole-Protokoll aus (laengenpraefigierte Elemente,
|
|
Instruktionen durch ';' abgeschlossen). Zugangsdaten werden ausschliesslich
|
|
serverseitig in die "connect"-Instruktion eingefuegt -- der Browser sieht sie
|
|
nie (analog zum SSH-Keyhandling in app/ssh_proxy/proxy.py).
|
|
|
|
Hinweis: Die exakten von guacd erwarteten RDP-Parameter (Namen/Reihenfolge)
|
|
haengen von der eingesetzten guacd/FreeRDP-Version ab. Diese Implementierung
|
|
fragt sie dynamisch per "args"-Instruktion ab (kein Hardcoding einer festen
|
|
Parameterliste) und ist daher robust gegen kleinere Versionsunterschiede --
|
|
sollte vor Produktivbetrieb dennoch gegen die Ziel-guacd-Version getestet werden.
|
|
"""
|
|
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`).
|
|
|
|
Bug-Fix (Live-Test, 2./3.9.): urspruenglich als ZWEI Verzeichnis-Ebenen
|
|
unter rdp-drives/ angelegt (.../rdp-drives/{host_id}/{session_id}).
|
|
guacds RDP-Parameter "create-drive-path" legt aber nachweislich NUR das
|
|
direkte Blattverzeichnis an, keine fehlenden Elternverzeichnisse (kein
|
|
"mkdir -p") -- fuer einen Host, der noch keine Sitzung mit Dateitransfer
|
|
hatte, existierte .../rdp-drives/{host_id}/ nie, wodurch guacd mit
|
|
"Unable to create directory ... No such file or directory" abbrach. Ein
|
|
Versuch, das Elternverzeichnis stattdessen im App-Prozess selbst
|
|
anzulegen, scheiterte am echten Testserver an den Dateirechten (rdp-drives/
|
|
gehoert dem guacd-Systembenutzer, Modus 750 -- der App-Prozess hat dort
|
|
keinerlei Zugriff). Deshalb jetzt EINE flache Ebene mit host_id UND
|
|
session_id im Verzeichnisnamen selbst: weiterhin pro Sitzung eindeutig
|
|
(Befund E6 bleibt gewahrt), aber guacd muss nur noch einen einzigen neuen
|
|
Eintrag direkt unter dem bereits existierenden rdp-drives/ anlegen --
|
|
funktioniert nachweislich, siehe Docstring oben ("vorher war der Pfad NUR
|
|
je Host angelegt", also genau diese Verschachtelungstiefe).
|
|
"""
|
|
return settings.data_dir / "rdp-drives" / f"{host_id}_{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):
|
|
"""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:
|
|
"""Kodiert eine Guacamole-Instruktion.
|
|
|
|
Die Laengenangabe zaehlt ZEICHEN, nicht Bytes -- so ist das Guacamole-
|
|
Protokoll definiert und so zaehlen sowohl guacd als auch
|
|
guacamole-common-js. Hier stand frueher die Laenge der UTF-8-Bytes:
|
|
fuer reines ASCII identisch, bei jedem Zeichen ausserhalb davon aber zu
|
|
gross. Ein einziger Umlaut -- etwa in einem RDP-Passwort, einem
|
|
Benutzernamen oder einem Text aus der Zwischenablage -- verschob damit
|
|
den gesamten nachfolgenden Datenstrom, und guacd brach die Verbindung
|
|
unmittelbar nach der connect-Instruktion wieder ab.
|
|
"""
|
|
return ",".join(f"{len(element)}.{element}" for element in elements) + ";"
|
|
|
|
|
|
def parse_instruction_text(text: str) -> list[str]:
|
|
"""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:
|
|
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 == ";":
|
|
instructions.append(elements)
|
|
elements = []
|
|
elif sep != ",":
|
|
raise GuacamoleProtocolError(f"Unerwartetes Trennzeichen in {text!r} an Position {end}")
|
|
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:
|
|
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:
|
|
try:
|
|
self.writer.close()
|
|
await self.writer.wait_closed()
|
|
except Exception:
|
|
# Aufraeumpfad: ein bereits getrenntes/fehlerhaftes Socket beim
|
|
# Schliessen darf den Session-Teardown (Audit-Log-Eintrag,
|
|
# DB-Update in ws_tunnel.py) nicht verhindern. Bewusst breit
|
|
# gefangen, aber protokolliert statt stillschweigend verschluckt.
|
|
logger.debug("Fehler beim Schliessen des guacd-Tunnels (ignoriert)", exc_info=True)
|
|
|
|
|
|
async def open_tunnel(
|
|
*,
|
|
guacd_host: str,
|
|
guacd_port: int,
|
|
protocol: str,
|
|
params: dict[str, str],
|
|
screen_width: int = 1024,
|
|
screen_height: int = 768,
|
|
dpi: int = 96,
|
|
connect_timeout: float | None = 10.0,
|
|
handshake_timeout: float | None = 10.0,
|
|
) -> GuacdTunnel:
|
|
"""Baut den guacd-Tunnel auf und sendet 'select'/Groessen-/'connect'-
|
|
Instruktionen. Wartet bewusst NICHT auf 'ready' (Befund A2, Teil A.3):
|
|
|
|
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.
|
|
|
|
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
|
|
|
|
try:
|
|
writer.write(encode_instruction("select", protocol).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)'}"
|
|
)
|
|
|
|
# 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
|
|
|
|
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.
|
|
|
|
Sicherheitsdefaults (siehe Konzept 6.3/6.7): NLA wird erzwungen sofern
|
|
rdp_require_nla gesetzt ist (Standard). Die Zertifikatspruefung ist pro Host
|
|
ueber hosts.rdp_ignore_cert schaltbar (Migration 0009, Default: ignorieren) --
|
|
ohne interne PKI praesentiert jedes Windows-Ziel ein selbstsigniertes
|
|
Zertifikat, guacd/FreeRDP bricht dann bereits vor dem ersten Bild ab.
|
|
|
|
`username`/`domain` kommen aus rdp_credentials (Migration 0010) -- der
|
|
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
|
|
eine unbrauchbare connect-Instruktion entstand. Fehlt eine der Spalten
|
|
jetzt trotzdem, ist das ein Programmierfehler und soll auffallen statt
|
|
still zu einer kaputten Verbindung zu fuehren.
|
|
"""
|
|
missing = [
|
|
key for key in ("rdp_require_nla", "clipboard_enabled", "rdp_ignore_cert")
|
|
if key not in host
|
|
]
|
|
if missing:
|
|
raise GuacamoleProtocolError(
|
|
f"Hostdatensatz unvollstaendig, fehlende Felder: {', '.join(missing)}"
|
|
)
|
|
|
|
# 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.
|
|
#
|
|
# 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 "
|
|
"gehoert zu den Zugangsdaten (Adminbereich -> Zugangsdaten -> "
|
|
"RDP/Windows-Passwoerter)."
|
|
)
|
|
effective_domain = (domain or "").strip() or (host.get("rdp_domain") or "").strip()
|
|
|
|
clipboard_enabled = bool(host.get("clipboard_enabled"))
|
|
params = {
|
|
"hostname": host["address"],
|
|
"port": str(host["port"]),
|
|
"username": effective_username,
|
|
"password": password,
|
|
"domain": effective_domain,
|
|
"security": "nla" if host.get("rdp_require_nla", True) else "any",
|
|
"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": 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
|