connect fix 3

This commit is contained in:
2026-08-20 23:35:42 +02:00
parent fc32b1e4ef
commit bf2a02679d
14 changed files with 956 additions and 49 deletions

View File

@ -16,6 +16,7 @@ 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")
@ -26,11 +27,18 @@ class GuacamoleProtocolError(Exception):
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) + ";"
"""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:
@ -42,6 +50,21 @@ async def _read_until(reader: asyncio.StreamReader, delimiter: bytes) -> bytes:
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:
@ -50,7 +73,7 @@ async def read_instruction(reader: asyncio.StreamReader) -> list[str]:
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")
content = await _read_chars(reader, length)
elements.append(content)
sep = await reader.readexactly(1)
if sep == b";":
@ -143,24 +166,50 @@ 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.
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": host.get("rdp_username") or "",
"username": username,
"password": password,
"domain": host.get("rdp_domain") or "",
"security": "nla" if host.get("rdp_require_nla", True) else "any",
"ignore-cert": "false",
"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 host.get("clipboard_enabled") else "true",
"disable-paste": "false" if host.get("clipboard_enabled") else "true",
"disable-copy": "false" if clipboard_enabled else "true",
"disable-paste": "false" if clipboard_enabled else "true",
"resize-method": "display-update",
}

View File

@ -11,6 +11,7 @@ from __future__ import annotations
import asyncio
import logging
import uuid as uuid_mod
from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
@ -35,6 +36,50 @@ from app.ssh_proxy.proxy import HostNotConfiguredError, load_host
logger = logging.getLogger("jumphost.rdp_proxy.ws")
router = APIRouter()
# Leerer Opcode = tunnelinterne Instruktion des Guacamole-Protokolls
# (Guacamole.Tunnel.INTERNAL_DATA_OPCODE in guacamole-common-js): Tunnel-UUID
# beim Verbindungsaufbau und ping/pong waehrend der Sitzung.
INTERNAL_DATA_OPCODE = ""
# Das vom Browser angeforderte WebSocket-Subprotokoll. guacamole-common-js
# oeffnet den Socket IMMER als `new WebSocket(url, "guacamole")`. Bestaetigt
# der Server das Subprotokoll nicht im Handshake, verwirft der Browser die
# Verbindung nach RFC 6455 sofort wieder -- die Sitzung kam dadurch nie
# zustande und es erreichte kein einziger Frame die Anwendung.
GUACAMOLE_SUBPROTOCOL = "guacamole"
async def _accept(websocket: WebSocket) -> None:
"""Nimmt die Verbindung an und bestaetigt dabei das Subprotokoll, sofern
der Client eines angeboten hat (siehe GUACAMOLE_SUBPROTOCOL)."""
offered = websocket.scope.get("subprotocols") or []
if GUACAMOLE_SUBPROTOCOL in offered:
await websocket.accept(subprotocol=GUACAMOLE_SUBPROTOCOL)
else:
await websocket.accept()
async def _reject(websocket: WebSocket, code: int, reason: str, *, accepted: bool) -> None:
"""Beendet eine Sitzung vor ihrem eigentlichen Beginn -- mit Logeintrag
und mit einem fuer den Benutzer lesbaren Grund.
Bisher endeten alle diese Pfade in einem nackten `websocket.close(code=...)`
ohne jede Protokollierung. Im Verbindungslog war deshalb ueberhaupt nichts
zu sehen, wenn eine RDP-Sitzung an einer dieser Vorbedingungen scheiterte.
Der Grundtext wird als WebSocket-Close-Reason mitgegeben:
guacamole-common-js reicht ihn (siehe close_tunnel()) als
Guacamole.Status.message an client.onerror weiter, wo static/js/rdp.js ihn
direkt anzeigt. Voraussetzung dafuer ist ein zustande gekommener
Handshake -- vor `accept()` sieht der Browser nur einen HTTP-Fehler.
"""
logger.warning("RDP-Verbindung abgelehnt (code=%s): %s", code, reason)
# Close-Reason ist auf 123 Byte begrenzt (RFC 6455).
reason_bytes = reason.encode("utf-8")[:123]
if not accepted:
await _accept(websocket)
await websocket.close(code=code, reason=reason_bytes.decode("utf-8", errors="ignore"))
async def _guacd_to_ws(tunnel, websocket: WebSocket, recorder: SessionRecorder) -> None:
while True:
@ -49,9 +94,22 @@ async def _ws_to_guacd(tunnel, websocket: WebSocket, recorder: SessionRecorder,
message = await websocket.receive_text()
try:
instr = parse_instruction_text(message)
except GuacamoleProtocolError:
except (GuacamoleProtocolError, ValueError, IndexError):
continue # ungueltige Clientnachricht ignorieren statt die Verbindung zu killen
if instr and instr[0] == INTERNAL_DATA_OPCODE:
# Tunnelinterne Instruktion von guacamole-common-js (leerer
# Opcode), z.B. "0.,4.ping,13.<timestamp>;". Diese gehoert dem
# Tunnel, NICHT guacd -- bisher wurde sie unbesehen an guacd
# weitergereicht, das damit nichts anfangen kann. Ein ping wird
# gespiegelt: der Browser bricht den Tunnel nach
# tunnel.receiveTimeout (Standard 15 s) ohne empfangene Daten mit
# "Server timeout" ab, eine stille Sitzung liefe also in einen
# Fehler.
if len(instr) > 1 and instr[1] == "ping":
await websocket.send_text(encode_instruction(*instr))
continue
if not clipboard_enabled and instr and instr[0] == "clipboard":
continue # Defense-in-Depth: Clipboard serverseitig blocken
@ -70,27 +128,50 @@ async def rdp_tunnel(
):
user = await get_current_user_ws(websocket)
if user is None:
# Einziger Pfad, der bewusst OHNE vorheriges accept() schliesst: eine
# nicht authentifizierte Verbindung soll gar nicht erst zustande
# kommen. Alle folgenden Ablehnungen laufen ueber _reject(), damit der
# Benutzer im Browser den tatsaechlichen Grund zu sehen bekommt.
logger.warning("RDP-Verbindung abgelehnt: keine gueltige Sitzung (host_id=%s)", host_id)
await websocket.close(code=4401)
return
conn = get_db()
client_ip = websocket.client.host if websocket.client else "unknown"
if not user.is_admin and not await user_has_role_for_host(
conn, user_id=user.id, host_id=host_id, role_name="rdp_connect"
):
await websocket.close(code=4403)
await _reject(
websocket, 4403,
f"Keine Berechtigung 'rdp_connect' fuer Host {host_id}",
accepted=False,
)
return
await websocket.accept()
client_ip = websocket.client.host if websocket.client else "unknown"
await _accept(websocket)
# Tunnel-UUID als erste Instruktion senden (leerer Opcode, siehe
# INTERNAL_DATA_OPCODE). guacamole-common-js setzt den Tunnel erst beim
# Empfang der ersten Instruktion auf OPEN und startet damit seine
# Timeoutueberwachung neu -- ohne diesen Frame blieb der Client bis zum
# ersten Bild von guacd in "Warte auf Server ..." haengen und lief bei
# einem langsamen RDP-Handshake in den 15-Sekunden-Timeout.
tunnel_uuid = str(uuid_mod.uuid4())
await websocket.send_text(encode_instruction(INTERNAL_DATA_OPCODE, tunnel_uuid))
try:
host = await load_host(conn, host_id)
except HostNotConfiguredError as exc:
await websocket.close(code=4404)
await _reject(websocket, 4404, str(exc), accepted=True)
return
if host["protocol"] != "rdp":
await websocket.close(code=4400)
await _reject(
websocket, 4400,
f"Host {host['hostname']} ist kein RDP-Ziel (protocol={host['protocol']})",
accepted=True,
)
return
cred_cursor = await conn.execute(
@ -98,7 +179,12 @@ async def rdp_tunnel(
)
cred_row = await cred_cursor.fetchone()
if cred_row is None:
await websocket.close(code=4404)
await _reject(
websocket, 4404,
f"Fuer Host {host['hostname']} ist kein RDP-Passwort hinterlegt "
"(Adminbereich -> Zugangsdaten).",
accepted=True,
)
return
cursor = await conn.execute(
@ -122,6 +208,7 @@ async def rdp_tunnel(
active_sessions.register(session_id, asyncio.current_task())
end_reason = "logout"
error_text: str | None = None
tunnel = None
tasks: list[asyncio.Task] = []
password = None
@ -159,6 +246,11 @@ async def rdp_tunnel(
except (GuacamoleProtocolError, ConnectionError, OSError) as exc:
logger.warning("RDP-Sessionfehler (session_id=%s): %s", session_id, exc)
end_reason = "error"
# Grund an den Browser durchreichen (guacamole-common-js zeigt die
# Close-Reason als Guacamole.Status.message an, siehe _reject).
# Betrifft u.a. den haeufigsten Konfigurationsfehler: kein
# RDP-Benutzername am Host, siehe build_rdp_params().
error_text = str(exc) or exc.__class__.__name__
except asyncio.CancelledError:
# Zwangs-Beendigung durch einen Superadmin ueber die Sessionview
# (POST /admin/sessions/{id}/terminate, siehe app/security/active_sessions.py).
@ -172,6 +264,7 @@ async def rdp_tunnel(
# jede Fehlermeldung/Protokollierung sofort beendete.
logger.exception("Unerwarteter Fehler in RDP-Sitzung %s: %s", session_id, exc)
end_reason = "error"
error_text = f"Interner Fehler: {exc.__class__.__name__}"
finally:
active_sessions.unregister(session_id)
del password # Klartext-Passwort so schnell wie moeglich freigeben
@ -192,7 +285,12 @@ async def rdp_tunnel(
)
await conn.commit()
try:
await websocket.close()
if error_text:
await websocket.close(
code=4500, reason=error_text.encode("utf-8")[:123].decode("utf-8", errors="ignore")
)
else:
await websocket.close()
except Exception:
# Cleanup-Pfad: der Session-Datensatz und Audit-Log-Eintrag sind
# zu diesem Zeitpunkt bereits geschrieben; ein bereits vom Client