305 lines
13 KiB
Python
305 lines
13 KiB
Python
"""
|
|
WebSocket-Bruecke Browser (guacamole-common-js) <-> guacd (Konzept 4.3).
|
|
|
|
Setzt pro Hostgruppe/Host konfigurierbare Policies durch, die guacd selbst
|
|
zwar schon per connect-Parameter bekommt (disable-copy/-paste, enable-drive),
|
|
zusaetzlich werden Clipboard-Instruktionen aber auch hier auf Protokollebene
|
|
gefiltert -- Defense-in-Depth, falls sich guacd-Parameter je nach Version
|
|
unterscheiden.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import uuid as uuid_mod
|
|
|
|
from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
|
|
|
|
from app.auth.deps import get_current_user_ws
|
|
from app.config import settings
|
|
from app.db import get_db
|
|
from app.rbac import user_has_role_for_host
|
|
from app.recordings.recorder import SessionRecorder
|
|
from app.security import active_sessions
|
|
from app.security.audit import write_audit_event
|
|
from app.security.crypto import decrypt_secret
|
|
from app.rdp_proxy.guacd_client import (
|
|
GuacamoleProtocolError,
|
|
build_rdp_params,
|
|
encode_instruction,
|
|
open_tunnel,
|
|
parse_instruction_text,
|
|
read_instruction,
|
|
)
|
|
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:
|
|
instr = await read_instruction(tunnel.reader)
|
|
text = encode_instruction(*instr)
|
|
recorder.record("output", text)
|
|
await websocket.send_text(text)
|
|
|
|
|
|
async def _ws_to_guacd(tunnel, websocket: WebSocket, recorder: SessionRecorder, *, clipboard_enabled: bool) -> None:
|
|
while True:
|
|
message = await websocket.receive_text()
|
|
try:
|
|
instr = parse_instruction_text(message)
|
|
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
|
|
|
|
recorder.record("input", message)
|
|
tunnel.writer.write(message.encode("utf-8"))
|
|
await tunnel.writer.drain()
|
|
|
|
|
|
@router.websocket("/ws/rdp/{host_id}")
|
|
async def rdp_tunnel(
|
|
websocket: WebSocket,
|
|
host_id: int,
|
|
width: int = Query(default=1280, ge=320, le=7680),
|
|
height: int = Query(default=800, ge=240, le=4320),
|
|
dpi: int = Query(default=96, ge=48, le=384),
|
|
):
|
|
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 _reject(
|
|
websocket, 4403,
|
|
f"Keine Berechtigung 'rdp_connect' fuer Host {host_id}",
|
|
accepted=False,
|
|
)
|
|
return
|
|
|
|
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 _reject(websocket, 4404, str(exc), accepted=True)
|
|
return
|
|
|
|
if host["protocol"] != "rdp":
|
|
await _reject(
|
|
websocket, 4400,
|
|
f"Host {host['hostname']} ist kein RDP-Ziel (protocol={host['protocol']})",
|
|
accepted=True,
|
|
)
|
|
return
|
|
|
|
# username/domain stehen seit Migration 0010 bei den Zugangsdaten und nicht
|
|
# mehr am Host -- der Benutzername gehoert zur Anmeldung, nicht zur
|
|
# Beschreibung des Zielsystems.
|
|
cred_cursor = await conn.execute(
|
|
"SELECT password_enc, username, domain FROM rdp_credentials WHERE host_id = ?",
|
|
(host_id,),
|
|
)
|
|
cred_row = await cred_cursor.fetchone()
|
|
if cred_row is None:
|
|
await _reject(
|
|
websocket, 4404,
|
|
f"Fuer Host {host['hostname']} ist kein RDP-Passwort hinterlegt "
|
|
"(Adminbereich -> Zugangsdaten).",
|
|
accepted=True,
|
|
)
|
|
return
|
|
|
|
cursor = await conn.execute(
|
|
"INSERT INTO sessions (user_id, host_id, protocol, client_ip) VALUES (?, ?, 'rdp', ?)",
|
|
(user.id, host_id, client_ip),
|
|
)
|
|
session_id = cursor.lastrowid
|
|
recorder = SessionRecorder(session_id)
|
|
await conn.execute(
|
|
"UPDATE sessions SET recording_path = ? WHERE id = ?", (str(recorder.path), session_id)
|
|
)
|
|
await write_audit_event(
|
|
conn, event_type="rdp_session_start", user_id=user.id, client_ip=client_ip,
|
|
details={"host_id": host_id, "hostname": host["hostname"], "session_id": session_id},
|
|
)
|
|
await conn.commit()
|
|
logger.debug(
|
|
"RDP-Sitzung %s gestartet: user=%s host=%s (%s:%s) client_ip=%s",
|
|
session_id, user.username, host["hostname"], host["address"], host["port"], client_ip,
|
|
)
|
|
active_sessions.register(session_id, asyncio.current_task())
|
|
|
|
end_reason = "logout"
|
|
error_text: str | None = None
|
|
tunnel = None
|
|
tasks: list[asyncio.Task] = []
|
|
password = None
|
|
try:
|
|
# Entschluesselung bewusst HIER (innerhalb des try/finally, NACH dem
|
|
# Anlegen der sessions-Zeile) statt vor dem INSERT: schlaegt sie fehl
|
|
# (z.B. cryptography.exceptions.InvalidTag bei einem nicht mehr zum
|
|
# KEK passenden Blob), landet der Fehler jetzt im Catch-all unten UND
|
|
# es existiert bereits ein Session-/Audit-Log-Eintrag dafuer -- vorher
|
|
# riss ein Fehler hier VOR jeglicher Protokollierung durch und die
|
|
# Sitzung verschwand spurlos (schwarzer Bildschirm, kein Log-Eintrag).
|
|
password = decrypt_secret(cred_row[0], associated_data=b"rdp_password")
|
|
params = build_rdp_params(
|
|
host, password.decode(), username=cred_row[1], domain=cred_row[2]
|
|
)
|
|
tunnel = await open_tunnel(
|
|
guacd_host=settings.guacd_host, guacd_port=settings.guacd_port,
|
|
protocol="rdp", params=params, screen_width=width, screen_height=height, dpi=dpi,
|
|
)
|
|
logger.debug("RDP-Sitzung %s: guacd-Tunnel zu %s aufgebaut", session_id, host["hostname"])
|
|
clipboard_enabled = bool(host.get("clipboard_enabled", True))
|
|
tasks = [
|
|
asyncio.create_task(_guacd_to_ws(tunnel, websocket, recorder)),
|
|
asyncio.create_task(
|
|
_ws_to_guacd(tunnel, websocket, recorder, clipboard_enabled=clipboard_enabled)
|
|
),
|
|
]
|
|
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_EXCEPTION)
|
|
for task in pending:
|
|
task.cancel()
|
|
for task in done:
|
|
exc = task.exception()
|
|
if exc:
|
|
raise exc
|
|
except WebSocketDisconnect:
|
|
end_reason = "logout"
|
|
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).
|
|
end_reason = "terminated_by_admin"
|
|
raise
|
|
except Exception as exc:
|
|
# Auffangnetz analog zu app/ssh_proxy/terminal_ws.py -- deckt u.a.
|
|
# cryptography.exceptions.InvalidTag (KEK passt nicht mehr zum
|
|
# verschluesselten RDP-Passwort) ab, die vorher unbehandelt bis vor
|
|
# das erste await in dieser Funktion durchriss und die Sitzung ohne
|
|
# 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
|
|
for task in tasks:
|
|
task.cancel()
|
|
if tunnel:
|
|
await tunnel.close()
|
|
recorder.close()
|
|
logger.debug("RDP-Sitzung %s beendet: reason=%s", session_id, end_reason)
|
|
await conn.execute(
|
|
"UPDATE sessions SET ended_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), end_reason = ? "
|
|
"WHERE id = ?",
|
|
(end_reason, session_id),
|
|
)
|
|
await write_audit_event(
|
|
conn, event_type="rdp_session_end", user_id=user.id, client_ip=client_ip,
|
|
details={"host_id": host_id, "session_id": session_id, "reason": end_reason},
|
|
)
|
|
await conn.commit()
|
|
try:
|
|
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
|
|
# getrenntes WebSocket darf das nicht rueckwirkend fehlschlagen lassen.
|
|
logger.debug("WebSocket war beim Schliessen bereits getrennt", exc_info=True)
|