connect fix 3
This commit is contained in:
@ -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",
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user