Files
ssh-jumphost/app/rdp_proxy/guacd_client.py
2026-08-21 06:14:10 +02:00

229 lines
8.9 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
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",
}