#!/usr/bin/env python3 """Stellt genau den TLS-Verbindungsaufbau nach, an dem guacd/FreeRDP scheitert, und ermittelt die MILDESTE Lockerung, die ihn gelingen laesst. Hintergrund (Live-Test 3.9.): guacd meldet "RDP server closed/refused connection: SSL/TLS connection failed (untrusted/self-signed certificate?)" und die Anwendung reicht das als "Ziel nicht erreichbar (Code 519)" weiter. Der Klammerzusatz ist eine VERMUTUNG von guacd, kein Befund -- guacd setzt diesen festen Text fuer FreeRDPs Fehlercode ERRCONNECT_TLS_CONNECT_FAILED. Dass es NICHT am Zertifikat liegt, zeigt das guacd-Journal selbst: guacd protokolliert beim Pruefen eines Zertifikats immer eine der beiden Zeilen "Certificate validation bypassed" (ignore-cert greift) oder "Certificate validation failed". Steht KEINE von beiden im Journal, wurde der Zertifikats-Rueckruf nie erreicht -- der Handshake ist schon vorher gescheitert, bei der Aushandlung von Protokollversion, Cipher-Suite und Signaturverfahren. Die Hosteinstellung "Zertifikat ignorieren" kann daran nichts aendern. Was dieses Skript macht -- dasselbe wie FreeRDP: 1. TCP zu :3389 2. X.224-Verbindungsanfrage mit RDP_NEG_REQ (MS-RDPBCGR 2.2.1.1). Das ist die RDP-eigene Vorstufe, ohne die kein RDP-Server TLS spricht -- deshalb taugt "openssl s_client -connect ziel:3389" hier NICHT als Gegentest: es faengt sofort mit dem ClientHello an und laeuft ins Leere. 3. Auswerten, welche Sicherheitsstufe der Server waehlt. 4. TLS-Handshake auf derselben Verbindung, mehrfach, mit aufsteigend staerkerer Lockerung der OpenSSL-Vorgaben -- von "unveraendert" bis "alles erlaubt". Die Zertifikatspruefung ist ueberall AUS (wie ignore-cert), damit kein Ergebnis daran haengen kann. Die Reihenfolge ist Absicht: gemeldet wird die ERSTE (= mildeste) Variante, die funktioniert, zusammen mit den passenden Ansible-Variablen. Auf einem Jumphost pauschal auf SECLEVEL=0 zu gehen, wenn SECLEVEL=1 genuegt, waere unnoetig. Braucht nichts ausser Python 3 -- kein X11, kein xfreerdp, kein nmap. Aufruf: python3 scripts/diagnose_rdp_tls.py 10.0.0.12 """ from __future__ import annotations import argparse import socket import ssl import struct import sys import warnings # ssl.TLSVersion.TLSv1/TLSv1_1 sind in neueren Python-Fassungen als veraltet # markiert. Hier werden sie absichtlich benutzt -- um festzustellen, OB das # Ziel sie noch spricht. Die Warnungen wuerden die Ausgabe nur zumuellen. warnings.filterwarnings("ignore", category=DeprecationWarning, module=__name__) # MS-RDPBCGR 2.2.1.1.1 -- requestedProtocols PROTOCOL_RDP = 0x00000000 # "Native RDP", ohne TLS PROTOCOL_SSL = 0x00000001 # TLS PROTOCOL_HYBRID = 0x00000002 # CredSSP/NLA (setzt TLS voraus) PROTOCOL_RDSTLS = 0x00000004 PROTOCOL_HYBRID_EX = 0x00000008 _PROTOCOL_NAMES = { PROTOCOL_RDP: "Native RDP (ohne TLS)", PROTOCOL_SSL: "TLS", PROTOCOL_HYBRID: "CredSSP/NLA (ueber TLS)", PROTOCOL_RDSTLS: "RDSTLS", PROTOCOL_HYBRID_EX: "CredSSP Early User Auth", } # MS-RDPBCGR 2.2.1.2.2 -- failureCode im RDP_NEG_FAILURE _FAILURE_CODES = { 0x01: "SSL_REQUIRED_BY_SERVER -- der Server verlangt TLS, es wurde keines angeboten", 0x02: "SSL_NOT_ALLOWED_BY_SERVER -- der Server erlaubt kein TLS (Sicherheitsebene 'RDP')", 0x03: "SSL_CERT_NOT_ON_SERVER -- der Server hat kein Zertifikat fuer TLS", 0x04: "INCONSISTENT_FLAGS", 0x05: "HYBRID_REQUIRED_BY_SERVER -- der Server verlangt NLA", 0x06: "SSL_WITH_USER_AUTH_REQUIRED_BY_SERVER", } _VERSIONS = { "TLSv1": ssl.TLSVersion.TLSv1, "TLSv1.1": ssl.TLSVersion.TLSv1_1, "TLSv1.2": ssl.TLSVersion.TLSv1_2, "TLSv1.3": ssl.TLSVersion.TLSv1_3, } class Variante: """Eine zu testende OpenSSL-Einstellung. `ansible` ist None, wenn die Variante keine Empfehlung darstellt (System-Standard, oder reine Versionstests zur Information). """ def __init__(self, key, label, *, ciphers=None, min_version=None, max_version=None, ansible=None, kandidat=False): self.key = key self.label = label self.ciphers = ciphers self.min_version = min_version self.max_version = max_version self.ansible = ansible self.kandidat = kandidat # kommt als Empfehlung in Frage? def context(self) -> ssl.SSLContext: ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) # Wie guacd mit ignore-cert: keinerlei Pruefung. Damit ist # ausgeschlossen, dass ein Ergebnis an der Zertifikatspruefung haengt. ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE if self.ciphers: ctx.set_ciphers(self.ciphers) if self.min_version: ctx.minimum_version = _VERSIONS[self.min_version] if self.max_version: ctx.maximum_version = _VERSIONS[self.max_version] return ctx # Reihenfolge = aufsteigende Staerke der Lockerung. Die erste funktionierende # Variante mit kandidat=True ist die Empfehlung. VARIANTEN = [ Variante("system", "System-Standard (genau das, was guacd/FreeRDP heute bekommt)"), Variante("max12", "unveraendert streng, aber hoechstens TLS 1.2", max_version="TLSv1.2", kandidat=True, ansible={"guacd_tls_security_level": 2, "guacd_tls_min_protocol": "TLSv1.2", "guacd_tls_max_protocol": "TLSv1.2"}), Variante("sec1", "SECLEVEL=1 (SHA-1-Signaturen wieder erlaubt), bis TLS 1.3", ciphers="DEFAULT@SECLEVEL=1", kandidat=True, ansible={"guacd_tls_security_level": 1, "guacd_tls_min_protocol": "TLSv1.2", "guacd_tls_max_protocol": ""}), Variante("sec1_max12", "SECLEVEL=1 und hoechstens TLS 1.2", ciphers="DEFAULT@SECLEVEL=1", max_version="TLSv1.2", kandidat=True, ansible={"guacd_tls_security_level": 1, "guacd_tls_min_protocol": "TLSv1.2", "guacd_tls_max_protocol": "TLSv1.2"}), Variante("sec0_max12", "SECLEVEL=0, ab TLS 1.2, hoechstens TLS 1.2", ciphers="DEFAULT@SECLEVEL=0", min_version="TLSv1.2", max_version="TLSv1.2", kandidat=True, ansible={"guacd_tls_security_level": 0, "guacd_tls_min_protocol": "TLSv1.2", "guacd_tls_max_protocol": "TLSv1.2"}), Variante("relaxed", "alles erlaubt: ALL@SECLEVEL=0, ab TLS 1.0", ciphers="ALL@SECLEVEL=0", min_version="TLSv1", kandidat=True, ansible={"guacd_tls_security_level": 0, "guacd_tls_min_protocol": "TLSv1", "guacd_tls_max_protocol": ""}), ] # Nur zur Information: welche Versionen spricht das Ziel ueberhaupt? VERSIONSTESTS = [ Variante(f"nur {name}", f"nur {name}", ciphers="ALL@SECLEVEL=0", min_version=name, max_version=name) for name in ("TLSv1", "TLSv1.1", "TLSv1.2", "TLSv1.3") ] def _x224_connection_request(requested: int) -> bytes: """TPKT + X.224 Connection Request mit angehaengtem RDP_NEG_REQ.""" neg_req = struct.pack("BBHHB", 6 + len(neg_req), 0xE0, 0, 0, 0) + neg_req return struct.pack(">BBH", 0x03, 0x00, 4 + len(x224)) + x224 def _recv_exact(sock: socket.socket, count: int) -> bytes: buf = b"" while len(buf) < count: chunk = sock.recv(count - len(buf)) if not chunk: raise ConnectionError("Gegenstelle hat die Verbindung waehrend der Aushandlung geschlossen") buf += chunk return buf def _read_tpkt(sock: socket.socket) -> bytes: header = _recv_exact(sock, 4) if header[0] != 0x03: raise ValueError(f"Keine TPKT-Antwort (erstes Byte 0x{header[0]:02x}) -- spricht dort wirklich RDP?") (length,) = struct.unpack(">H", header[2:4]) return header + _recv_exact(sock, length - 4) def negotiate(sock: socket.socket, requested: int) -> int: """X.224-Aushandlung; gibt das vom Server gewaehlte Protokoll zurueck.""" sock.sendall(_x224_connection_request(requested)) body = _read_tpkt(sock)[4:] if len(body) < 7: raise ValueError("X.224-Antwort zu kurz") payload = body[7:] if not payload: # Kein RDP_NEG_RSP: aeltere Server antworten so und meinen "Native RDP". return PROTOCOL_RDP kind = payload[0] if kind == 0x03: (code,) = struct.unpack(" int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("host", help="Adresse des RDP-Ziels, z.B. 10.0.0.12") parser.add_argument("--port", type=int, default=3389) parser.add_argument("--timeout", type=float, default=10.0) args = parser.parse_args() print(f"Jumphost: {ssl.OPENSSL_VERSION}") try: with open("/etc/ssl/openssl.cnf", encoding="utf-8") as fh: relevant = [line.strip() for line in fh if line.strip().startswith(("MinProtocol", "MaxProtocol", "CipherString", "Options"))] print(" /etc/ssl/openssl.cnf: " + ("; ".join(relevant) if relevant else "keine Protokoll-/Cipher-Vorgaben")) except OSError: print(" /etc/ssl/openssl.cnf nicht lesbar") print(f"Ziel: {args.host}:{args.port}\n") print("== Schritt 1: X.224-Aushandlung ==") requested = PROTOCOL_SSL | PROTOCOL_HYBRID try: with socket.create_connection((args.host, args.port), timeout=args.timeout) as sock: selected = negotiate(sock, requested) print(f" Angeboten: TLS + CredSSP/NLA -> Server waehlt: " f"{_PROTOCOL_NAMES.get(selected, hex(selected))}") except Exception as exc: print(f" FEHLGESCHLAGEN: {exc}\n") print(" Damit eruebrigt sich der TLS-Test -- die RDP-Vorstufe scheitert bereits.") return 1 if selected == PROTOCOL_RDP: print("\n BEFUND: Das Ziel bietet auf der ausgehandelten Stufe kein TLS an.") print(" Am Windows-Ziel die Sicherheitsebene auf 'Verhandeln' oder 'SSL' stellen") print(" (GPO: 'Bestimmte Sicherheitsebene fuer RDP-Verbindungen anfordern').") return 1 print("\n== Schritt 2: TLS-Handshake, aufsteigend gelockert ==") print(" (Zertifikatspruefung ueberall AUS -- wie bei ignore-cert)\n") ergebnisse = {} for v in VARIANTEN: ok, text = try_handshake(args.host, args.port, requested, v, args.timeout) ergebnisse[v.key] = ok print(f" [{'OK ' if ok else 'FEHL'}] {v.label}\n {text}") print("\n== Schritt 3: welche TLS-Versionen spricht das Ziel? ==") gesprochen = [] for v in VERSIONSTESTS: ok, text = try_handshake(args.host, args.port, requested, v, args.timeout) if ok: gesprochen.append(v.label.replace("nur ", "")) print(f" [{'OK ' if ok else 'FEHL'}] {v.label}: {text}") print("\n== Befund ==") if ergebnisse["system"]: print(" Der TLS-Handshake gelingt bereits mit den System-Vorgaben.") print(" Die Ursache liegt dann NICHT in der TLS-Schicht, sondern eine Stufe") print(" weiter (CredSSP/NLA, Anmeldedaten) oder in guacd/FreeRDP selbst.") print(" Naechster Schritt: guacd_log_level auf 'debug', erneut verbinden,") print(" 'journalctl -u guacd -n 200' auswerten.") return 0 empfehlung = next((v for v in VARIANTEN if v.kandidat and ergebnisse.get(v.key)), None) if empfehlung is None: print(" Der Handshake scheitert in JEDER Variante -- die Ursache liegt am Ziel") print(f" ({args.host}) oder dazwischen, nicht an den OpenSSL-Vorgaben des Jumphosts.") print(" Am Windows-Ziel pruefen: SCHANNEL-Registry (aktivierte TLS-Versionen und") print(" Cipher-Suites), Gruppenrichtlinie 'SSL-Verschluesselungsverfahren', und ob") print(" ueberhaupt ein RDP-Zertifikat hinterlegt ist.") return 1 print(" Der Handshake scheitert an den OpenSSL-Vorgaben DIESES Jumphosts,") print(" nicht am Ziel und nicht am Zertifikat.") print(f" Das Ziel spricht: {', '.join(gesprochen) if gesprochen else '(unklar)'}") print(f"\n MILDESTE ausreichende Lockerung: {empfehlung.label}") print("\n In ansible/inventory/group_vars eintragen:") print(" guacd_tls_legacy_compat: true") for key, value in empfehlung.ansible.items(): gezeigt = '""' if value == "" else (f'"{value}"' if isinstance(value, str) else value) print(f" {key}: {gezeigt}") print("\n Das wirkt NUR auf den guacd-Prozess (Environment=OPENSSL_CONF in dessen") print(" Unit), nicht systemweit. Die saubere Loesung bleibt, das Windows-Ziel") print(" passend zu konfigurieren -- dies ist die Ueberbrueckung bis dahin.") return 1 if __name__ == "__main__": sys.exit(main())