second commit
This commit is contained in:
0
app/rdp_proxy/__init__.py
Normal file
0
app/rdp_proxy/__init__.py
Normal file
166
app/rdp_proxy/guacd_client.py
Normal file
166
app/rdp_proxy/guacd_client.py
Normal file
@ -0,0 +1,166 @@
|
||||
"""
|
||||
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",
|
||||
}
|
||||
170
app/rdp_proxy/ws_tunnel.py
Normal file
170
app/rdp_proxy/ws_tunnel.py
Normal 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)
|
||||
Reference in New Issue
Block a user