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

@ -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