""" 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