""" 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, *, username: str | None = None, domain: 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). 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. effective_username = (username or "").strip() 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")) return { "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": 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", }