second commit

This commit is contained in:
2026-08-19 22:33:19 +02:00
parent 411812e954
commit 199f306993
107 changed files with 5984 additions and 0 deletions

170
app/rdp_proxy/ws_tunnel.py Normal file
View File

@ -0,0 +1,170 @@
"""
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
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.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()
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:
continue # ungueltige Clientnachricht ignorieren statt die Verbindung zu killen
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:
await websocket.close(code=4401)
return
conn = get_db()
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)
return
await websocket.accept()
client_ip = websocket.client.host if websocket.client else "unknown"
try:
host = await load_host(conn, host_id)
except HostNotConfiguredError as exc:
await websocket.close(code=4404)
return
if host["protocol"] != "rdp":
await websocket.close(code=4400)
return
cred_cursor = await conn.execute(
"SELECT password_enc FROM rdp_credentials WHERE host_id = ?", (host_id,)
)
cred_row = await cred_cursor.fetchone()
if cred_row is None:
await websocket.close(code=4404)
return
password = decrypt_secret(cred_row[0], associated_data=b"rdp_password")
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()
end_reason = "logout"
tunnel = None
tasks: list[asyncio.Task] = []
try:
params = build_rdp_params(host, password.decode())
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,
)
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"
finally:
del password # Klartext-Passwort so schnell wie moeglich freigeben
for task in tasks:
task.cancel()
if tunnel:
await tunnel.close()
recorder.close()
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:
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)