167 lines
6.0 KiB
Python
167 lines
6.0 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 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",
|
|
}
|