""" 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 logger = logging.getLogger("jumphost.rdp_proxy.guacd") class GuacamoleProtocolError(Exception): pass 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) + ";" 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).""" elements: list[str] = [] i = 0 n = len(text) while i < n: dot = text.index(".", i) length = int(text[i:dot]) start = dot + 1 end = start + length elements.append(text[start:end]) sep = text[end] if end < n else "" i = end + 1 if sep == ";": break if sep != ",": raise GuacamoleProtocolError(f"Unerwartetes Trennzeichen in {text!r} an Position {end}") return elements class GuacdTunnel: def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, connection_id: str) -> None: self.reader = reader self.writer = writer 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, ) -> GuacdTunnel: reader, writer = await asyncio.open_connection(guacd_host, guacd_port) writer.write(encode_instruction("select", protocol).encode("utf-8")) await writer.drain() 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:] 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() values = [params.get(name, "") for name in arg_names] writer.write(encode_instruction("connect", *values).encode("utf-8")) await writer.drain() 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 "" return GuacdTunnel(reader, writer, connection_id) def build_rdp_params(host: dict, password: str) -> 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. WICHTIG: `host` muss aus app.ssh_proxy.proxy.load_host() stammen. Diese Funktion liest rdp_username/rdp_domain/rdp_require_nla/clipboard_enabled/ rdp_ignore_cert -- genau diese Spalten hat load_host() frueher NICHT selektiert, wodurch hier still ein leerer Benutzername in die connect-Instruktion ging und die Anmeldung am Zielsystem zwangslaeufig scheiterte. 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_username", "rdp_require_nla", "clipboard_enabled", "rdp_ignore_cert") if key not in host ] if missing: raise GuacamoleProtocolError( f"Hostdatensatz unvollstaendig, fehlende Felder: {', '.join(missing)}" ) username = (host.get("rdp_username") or "").strip() if not username: raise GuacamoleProtocolError( "Fuer diesen Host ist kein RDP-Benutzername hinterlegt " "(Adminbereich -> Server -> RDP-Benutzer)." ) clipboard_enabled = bool(host.get("clipboard_enabled")) return { "hostname": host["address"], "port": str(host["port"]), "username": username, "password": password, "domain": host.get("rdp_domain") or "", "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": f"/var/lib/jumphost/rdp-drives/{host['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", }