""" 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 logging logger = logging.getLogger("jumphost.rdp_proxy.guacd") class GuacamoleProtocolError(Exception): pass def encode_instruction(*elements: str) -> str: parts = [] for element in elements: encoded = element.encode("utf-8") parts.append(f"{len(encoded)}.{element}") return ",".join(parts) + ";" 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_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 reader.readexactly(length)).decode("utf-8") 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), Zertifikatspruefung ist standardmaessig AKTIV (ignore-cert=false) -- bei selbstsignierten Zertifikaten auf den Zielsystemen muss dies bewusst pro Host ueberschrieben werden, kein stiller Bypass. """ return { "hostname": host["address"], "port": str(host["port"]), "username": host.get("rdp_username") or "", "password": password, "domain": host.get("rdp_domain") or "", "security": "nla" if host.get("rdp_require_nla", True) else "any", "ignore-cert": "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 host.get("clipboard_enabled") else "true", "disable-paste": "false" if host.get("clipboard_enabled") else "true", "resize-method": "display-update", }