diff --git a/ansible/roles/guacd/defaults/main.yml b/ansible/roles/guacd/defaults/main.yml index 8addf3d..b5b2a48 100644 --- a/ansible/roles/guacd/defaults/main.yml +++ b/ansible/roles/guacd/defaults/main.yml @@ -59,5 +59,10 @@ guacd_log_level: "info" # guacd_tls_min_protocol genau darauf setzen statt pauschal auf TLSv1. guacd_tls_legacy_compat: false guacd_tls_min_protocol: "TLSv1" +# Obergrenze, leer = keine. Wird gebraucht, wenn das Ziel bei einem +# ClientHello mit angebotenem TLS 1.3 einfach auflegt (SCHANNEL schickt dann +# ein TCP-RST statt einer sauberen handshake_failure-Warnung) -- dann +# "TLSv1.2" eintragen. Das Diagnoseskript nennt den noetigen Wert. +guacd_tls_max_protocol: "" guacd_tls_security_level: 0 guacd_tls_unsafe_legacy_renegotiation: true diff --git a/ansible/roles/guacd/tasks/main.yml b/ansible/roles/guacd/tasks/main.yml index b554258..4e252dd 100644 --- a/ansible/roles/guacd/tasks/main.yml +++ b/ansible/roles/guacd/tasks/main.yml @@ -271,6 +271,21 @@ create: true notify: restart guacd +- name: Falsch benannte Protokollierstufe aus guacd.conf entfernen + # BUGFIX (3.9.): hier stand zuerst "log_level = ...". Diesen Parameter + # kennt guacd.conf NICHT -- er heisst "max_log_level" (guacd.conf(5): + # pid_file, max_log_level, bind_host, bind_port, server_certificate, + # server_key). guacd bricht bei einem unbekannten Schluessel den Start + # komplett ab ("Parse error at line N: Invalid parameter or section name"), + # es gibt also keine stille Fehlbedienung -- der Dienst startete gar nicht + # mehr. Diese Aufraeumzeile entfernt die falsche Zeile auf Hosts, auf denen + # die fehlerhafte Fassung bereits ausgerollt wurde. + ansible.builtin.lineinfile: + path: /etc/guacamole/guacd.conf + regexp: '^\s*log_level\s*=' + state: absent + notify: restart guacd + - name: guacd-Protokollierstufe setzen # Auf "info" (Standard) protokolliert guacd bereits, ob die # Zertifikatspruefung wegen ignore-cert uebersprungen wurde @@ -280,8 +295,8 @@ # guacd_log_level voruebergehend auf "debug" setzen (siehe defaults). ansible.builtin.lineinfile: path: /etc/guacamole/guacd.conf - regexp: '^log_level' - line: "log_level = {{ guacd_log_level }}" + regexp: '^\s*max_log_level\s*=' + line: "max_log_level = {{ guacd_log_level }}" create: true notify: restart guacd diff --git a/ansible/roles/guacd/templates/openssl-guacd.cnf.j2 b/ansible/roles/guacd/templates/openssl-guacd.cnf.j2 index 0062a91..dd68dd0 100644 --- a/ansible/roles/guacd/templates/openssl-guacd.cnf.j2 +++ b/ansible/roles/guacd/templates/openssl-guacd.cnf.j2 @@ -33,6 +33,9 @@ system_default = system_default_sect [system_default_sect] MinProtocol = {{ guacd_tls_min_protocol }} +{% if guacd_tls_max_protocol | default('', true) | length > 0 %} +MaxProtocol = {{ guacd_tls_max_protocol }} +{% endif %} CipherString = DEFAULT@SECLEVEL={{ guacd_tls_security_level }} {% if guacd_tls_unsafe_legacy_renegotiation %} # Manche aeltere Windows-/SCHANNEL-Staende koennen die in OpenSSL 3 diff --git a/scripts/diagnose_rdp_tls.py b/scripts/diagnose_rdp_tls.py index 0a9e7c1..f15aea0 100644 --- a/scripts/diagnose_rdp_tls.py +++ b/scripts/diagnose_rdp_tls.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 -"""Stellt genau den TLS-Verbindungsaufbau nach, an dem guacd/FreeRDP scheitert. +"""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.): @@ -14,27 +15,28 @@ 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 also schon vorher -gescheitert, bei der Aushandlung von Protokollversion und Cipher. Die -Hosteinstellung "Zertifikat ignorieren" kann daran nichts aendern. +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. -Genau das prueft dieses Skript. Es macht, was FreeRDP macht: +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 funktioniert "openssl s_client -connect ziel:3389" hier auch - nicht: es faengt sofort mit ClientHello an und laeuft ins Leere. - 3. Auswerten, welche Sicherheitsstufe der Server auswaehlt - 4. TLS-Handshake auf derselben Verbindung -- und zwar MEHRFACH, mit - verschiedenen OpenSSL-Vorgaben: - a) System-Standard: exakt das, was guacd/FreeRDP auf diesem Host - bekommen. Debian 12 setzt in /etc/ssl/openssl.cnf - MinProtocol = TLSv1.2 und CipherString = DEFAULT@SECLEVEL=2. - b) Alles erlaubt (SECLEVEL=0, ab TLS 1.0) - c/d) TLS 1.0 bzw. TLS 1.2 einzeln erzwungen - Der Vergleich sagt, ob die OpenSSL-Vorgaben des JUMPHOSTS die Ursache - sind (a scheitert, b klappt) oder das Ziel selbst (alles scheitert). + 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 @@ -46,6 +48,12 @@ 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 @@ -72,6 +80,85 @@ _FAILURE_CODES = { 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.""" @@ -81,14 +168,6 @@ def _x224_connection_request(requested: int) -> bytes: return struct.pack(">BBH", 0x03, 0x00, 4 + len(x224)) + x224 -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 _recv_exact(sock: socket.socket, count: int) -> bytes: buf = b"" while len(buf) < count: @@ -99,11 +178,18 @@ def _recv_exact(sock: socket.socket, count: int) -> bytes: 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: - """Fuehrt die X.224-Aushandlung durch, gibt das gewaehlte Protokoll zurueck.""" + """X.224-Aushandlung; gibt das vom Server gewaehlte Protokoll zurueck.""" sock.sendall(_x224_connection_request(requested)) - response = _read_tpkt(sock) - body = response[4:] + body = _read_tpkt(sock)[4:] if len(body) < 7: raise ValueError("X.224-Antwort zu kurz") payload = body[7:] @@ -113,56 +199,33 @@ def negotiate(sock: socket.socket, requested: int) -> int: kind = payload[0] if kind == 0x03: (code,) = struct.unpack(" ssl.SSLContext: - ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) - # Wie guacd mit ignore-cert: keinerlei Pruefung. Damit ist ausgeschlossen, - # dass ein Ergebnis hier an der Zertifikatspruefung haengt. - ctx.check_hostname = False - ctx.verify_mode = ssl.CERT_NONE - if variant == "system": - return ctx # erbt /etc/ssl/openssl.cnf -- genau wie guacd - if variant == "relaxed": - ctx.minimum_version = ssl.TLSVersion.TLSv1 - ctx.set_ciphers("ALL:@SECLEVEL=0") - return ctx - if variant in ("tls1.0", "tls1.1", "tls1.2", "tls1.3"): - version = { - "tls1.0": ssl.TLSVersion.TLSv1, - "tls1.1": ssl.TLSVersion.TLSv1_1, - "tls1.2": ssl.TLSVersion.TLSv1_2, - "tls1.3": ssl.TLSVersion.TLSv1_3, - }[variant] - ctx.set_ciphers("ALL:@SECLEVEL=0") - ctx.minimum_version = version - ctx.maximum_version = version - return ctx - raise ValueError(variant) - - -def try_handshake(host: str, port: int, requested: int, variant: str, timeout: float): - """Gibt (erfolg, text) zurueck.""" +def try_handshake(host, port, requested, variante: Variante, timeout): try: with socket.create_connection((host, port), timeout=timeout) as sock: selected = negotiate(sock, requested) if selected == PROTOCOL_RDP and requested != PROTOCOL_RDP: - return (False, "Server waehlt 'Native RDP' -- er bietet auf dieser Stufe gar kein TLS an") - ctx = _context(variant) - with ctx.wrap_socket(sock, server_hostname=host) as tls: + return (False, "Server waehlt 'Native RDP' -- kein TLS auf dieser Stufe") + with variante.context().wrap_socket(sock, server_hostname=host) as tls: cert = tls.getpeercert(binary_form=True) - info = f"{tls.version()}, {tls.cipher()[0]}, Zertifikat {len(cert)} Byte" - return (True, info) + return (True, f"{tls.version()}, {tls.cipher()[0]}, Zertifikat {len(cert)} Byte") except ssl.SSLError as exc: - return (False, f"{type(exc).__name__}: {exc.reason if getattr(exc, 'reason', None) else exc}") + grund = getattr(exc, "reason", None) or exc + return (False, f"{type(exc).__name__}: {grund}") + except ConnectionResetError: + # Wichtig zu unterscheiden: KEIN lokaler OpenSSL-Abbruch, sondern die + # Gegenstelle legt auf. SCHANNEL (Windows) schickt ein TCP-RST statt + # einer sauberen handshake_failure-Warnung, wenn im ClientHello nichts + # Verwendbares steht -- also ein Aushandlungsproblem, kein Netzfehler. + return (False, "Connection reset by peer -- das Ziel hat aufgelegt " + "(nichts Passendes im ClientHello gefunden)") except (OSError, ValueError) as exc: return (False, f"{type(exc).__name__}: {exc}") @@ -174,20 +237,16 @@ def main() -> int: parser.add_argument("--timeout", type=float, default=10.0) args = parser.parse_args() - import ssl as _ssl - print(f"Jumphost: {_ssl.OPENSSL_VERSION}") + 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", "CipherString", "MaxProtocol", "Options")) - ] + 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") - # 1) Welche Sicherheitsstufe waehlt der Server, wenn man ihm alles anbietet? print("== Schritt 1: X.224-Aushandlung ==") requested = PROTOCOL_SSL | PROTOCOL_HYBRID try: @@ -196,8 +255,8 @@ def main() -> int: print(f" Angeboten: TLS + CredSSP/NLA -> Server waehlt: " f"{_PROTOCOL_NAMES.get(selected, hex(selected))}") except Exception as exc: - print(f" FEHLGESCHLAGEN: {exc}") - print("\n Damit erübrigt sich der TLS-Test -- die RDP-Vorstufe scheitert bereits.") + print(f" FEHLGESCHLAGEN: {exc}\n") + print(" Damit eruebrigt sich der TLS-Test -- die RDP-Vorstufe scheitert bereits.") return 1 if selected == PROTOCOL_RDP: @@ -206,43 +265,52 @@ def main() -> int: print(" (GPO: 'Bestimmte Sicherheitsebene fuer RDP-Verbindungen anfordern').") return 1 - # 2) TLS-Handshake unter verschiedenen OpenSSL-Vorgaben - print("\n== Schritt 2: TLS-Handshake (Zertifikatspruefung ueberall AUS, wie ignore-cert) ==") - varianten = [ - ("system", "System-Standard (das, was guacd/FreeRDP bekommt)"), - ("relaxed", "Alles erlaubt (SECLEVEL=0, ab TLS 1.0)"), - ("tls1.0", "nur TLS 1.0"), - ("tls1.1", "nur TLS 1.1"), - ("tls1.2", "nur TLS 1.2"), - ("tls1.3", "nur TLS 1.3"), - ] + print("\n== Schritt 2: TLS-Handshake, aufsteigend gelockert ==") + print(" (Zertifikatspruefung ueberall AUS -- wie bei ignore-cert)\n") ergebnisse = {} - for variant, label in varianten: - ok, text = try_handshake(args.host, args.port, requested, variant, args.timeout) - ergebnisse[variant] = ok - print(f" [{'OK ' if ok else 'FEHL'}] {label}\n {text}") + 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}") - # 3) Auswertung print("\n== Befund ==") if ergebnisse["system"]: - print(" Der TLS-Handshake gelingt mit den System-Vorgaben. Die Ursache liegt") - print(" dann NICHT in der TLS-Schicht, sondern eine Stufe weiter (CredSSP/NLA,") - print(" Anmeldedaten) oder in guacd/FreeRDP selbst.") - print(" Naechster Schritt: guacd_log_level auf 'debug' und erneut verbinden.") + 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 - if ergebnisse["relaxed"]: - moeglich = [v for v in ("tls1.0", "tls1.1", "tls1.2", "tls1.3") if ergebnisse[v]] - print(" EINDEUTIG: Der Handshake scheitert NUR an den OpenSSL-Vorgaben DIESES") - print(" Jumphosts, nicht am Ziel und nicht am Zertifikat.") - print(f" Das Ziel spricht: {', '.join(moeglich) if moeglich else '(nur mit SECLEVEL=0)'}") - print(" Debian 12 setzt systemweit MinProtocol=TLSv1.2 und DEFAULT@SECLEVEL=2;") - print(" guacd erbt das. Abhilfe: eine eigene OPENSSL_CONF NUR fuer die") - print(" guacd-Unit (nicht systemweit lockern).") + + 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 in JEDER Variante -- die Ursache liegt am Ziel") - print(" (10.0.0.12) oder dazwischen, nicht an den OpenSSL-Vorgaben des Jumphosts.") - print(" Am Windows-Ziel pruefen: SCHANNEL-Registry (aktivierte TLS-Versionen),") - print(" Cipher-Suite-Richtlinie, und ob ein RDP-Zertifikat hinterlegt ist.") + + 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