From 2741910a72d839da29f453da34894831e2d81592 Mon Sep 17 00:00:00 2001 From: Midas Wollinger Date: Thu, 3 Sep 2026 08:24:30 +0200 Subject: [PATCH] umbau 1.3.1 --- _to_delete/git-index.lock.1788415639 | 0 ansible/roles/guacd/defaults/main.yml | 22 ++ ansible/roles/guacd/tasks/main.yml | 23 ++ .../roles/guacd/templates/guacd.service.j2 | 7 + .../guacd/templates/openssl-guacd.cnf.j2 | 41 +++ scripts/diagnose_rdp_tls.py | 250 ++++++++++++++++++ 6 files changed, 343 insertions(+) create mode 100644 _to_delete/git-index.lock.1788415639 create mode 100644 ansible/roles/guacd/templates/openssl-guacd.cnf.j2 create mode 100644 scripts/diagnose_rdp_tls.py diff --git a/_to_delete/git-index.lock.1788415639 b/_to_delete/git-index.lock.1788415639 new file mode 100644 index 0000000..e69de29 diff --git a/ansible/roles/guacd/defaults/main.yml b/ansible/roles/guacd/defaults/main.yml index 7055c89..8addf3d 100644 --- a/ansible/roles/guacd/defaults/main.yml +++ b/ansible/roles/guacd/defaults/main.yml @@ -39,3 +39,25 @@ guacd_git_ref: "main" # zuruecksetzen: "debug"/"trace" sind gespraechig und koennen Details der # Zielumgebung ins Journal schreiben. guacd_log_level: "info" + +# --- TLS-Kompatibilitaet guacd -> Windows-Ziel (Live-Test 3.9.) ------------- +# +# Standardmaessig AUS: guacd erbt dann die strengen systemweiten +# OpenSSL-Vorgaben von Debian 12 (MinProtocol=TLSv1.2, DEFAULT@SECLEVEL=2). +# +# Auf true setzen, wenn scripts/diagnose_rdp_tls.py den Befund +# "scheitert NUR an den OpenSSL-Vorgaben dieses Jumphosts" liefert -- also +# der TLS-Handshake zum Windows-Ziel mit den System-Vorgaben scheitert, mit +# gelockerten aber gelingt. Symptom in der Oberflaeche: +# "Ziel nicht erreichbar (Code 519): SSL/TLS connection failed +# (untrusted/self-signed certificate?)", OHNE dass im guacd-Journal eine +# "Certificate validation ..."-Zeile steht. +# +# Wirkt NUR auf den guacd-Prozess (Environment=OPENSSL_CONF in dessen Unit), +# nicht systemweit. Immer nur so weit lockern wie noetig: das Diagnoseskript +# nennt die tatsaechlich vom Ziel gesprochene TLS-Version, danach +# guacd_tls_min_protocol genau darauf setzen statt pauschal auf TLSv1. +guacd_tls_legacy_compat: false +guacd_tls_min_protocol: "TLSv1" +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 3cbb0db..b554258 100644 --- a/ansible/roles/guacd/tasks/main.yml +++ b/ansible/roles/guacd/tasks/main.yml @@ -293,6 +293,29 @@ group: guacd mode: "0750" +- name: TLS-Kompatibilitaetskonfiguration fuer guacd ausrollen (nur wenn eingeschaltet) + # Greift ausschliesslich ueber Environment=OPENSSL_CONF in der guacd-Unit + # und damit nur fuer diesen einen Prozess -- siehe Kopfkommentar der + # Vorlage. Standardmaessig ausgeschaltet. + ansible.builtin.template: + src: openssl-guacd.cnf.j2 + dest: /etc/guacamole/openssl-guacd.cnf + owner: root + group: root + mode: "0644" + when: guacd_tls_legacy_compat | bool + notify: restart guacd + +- name: TLS-Kompatibilitaetskonfiguration wieder entfernen, wenn abgeschaltet + # Damit ein Zurueckstellen von guacd_tls_legacy_compat auf false die + # Lockerung auch tatsaechlich zurueckdreht, statt eine verwaiste Datei + # liegen zu lassen, auf die dann nur noch die Unit nicht mehr zeigt. + ansible.builtin.file: + path: /etc/guacamole/openssl-guacd.cnf + state: absent + when: not (guacd_tls_legacy_compat | bool) + notify: restart guacd + - name: Override-Verzeichnis fuer guacd-Unit anlegen ansible.builtin.file: path: /etc/systemd/system/guacd.service.d diff --git a/ansible/roles/guacd/templates/guacd.service.j2 b/ansible/roles/guacd/templates/guacd.service.j2 index a22988d..d88977d 100644 --- a/ansible/roles/guacd/templates/guacd.service.j2 +++ b/ansible/roles/guacd/templates/guacd.service.j2 @@ -7,6 +7,13 @@ [Service] User=guacd Group=guacd +{% if guacd_tls_legacy_compat %} +# TLS-Kompatibilitaet fuer die Strecke guacd -> Windows-Ziel (Live-Test 3.9.). +# Setzt die OpenSSL-Vorgaben NUR fuer diesen Prozess; systemweit bleibt +# /etc/ssl/openssl.cnf unberuehrt. Siehe openssl-guacd.cnf.j2 und die +# Variablen guacd_tls_* in defaults/main.yml. +Environment=OPENSSL_CONF=/etc/guacamole/openssl-guacd.cnf +{% endif %} NoNewPrivileges=true ProtectSystem=strict ProtectHome=true diff --git a/ansible/roles/guacd/templates/openssl-guacd.cnf.j2 b/ansible/roles/guacd/templates/openssl-guacd.cnf.j2 new file mode 100644 index 0000000..0062a91 --- /dev/null +++ b/ansible/roles/guacd/templates/openssl-guacd.cnf.j2 @@ -0,0 +1,41 @@ +# OpenSSL-Konfiguration AUSSCHLIESSLICH fuer den guacd-Prozess. +# +# Wird ueber Environment=OPENSSL_CONF= in der guacd-Unit gesetzt (siehe +# guacd.service.j2) und gilt damit NUR fuer guacd/FreeRDP -- nicht fuer +# nginx, nicht fuer die Jumphost-App, nicht fuer SSH, nicht fuer apt. +# Systemweit (/etc/ssl/openssl.cnf) bleibt alles unveraendert streng. +# +# Wozu: Debian 12 setzt systemweit MinProtocol = TLSv1.2 und +# CipherString = DEFAULT@SECLEVEL=2. Ein Windows-Ziel, dessen SCHANNEL- +# Konfiguration nur aeltere TLS-Versionen oder schwaechere Cipher anbietet, +# ist damit von diesem Host aus NICHT erreichbar: OpenSSL bricht den +# Handshake ab, BEVOR ueberhaupt ein Zertifikat geprueft wird. guacd meldet +# das als "SSL/TLS connection failed (untrusted/self-signed certificate?)" +# -- der Klammerzusatz ist eine feste Vermutung im guacd-Quelltext und in +# diesem Fall irrefuehrend; die Hosteinstellung "Zertifikat ignorieren" +# kann daran folglich nichts aendern. +# +# ABWAEGUNG (bewusst zu treffen, deshalb standardmaessig AUS): +# Diese Datei senkt die TLS-Anforderungen fuer die Strecke +# Jumphost -> Windows-Ziel. Die Strecke Browser -> Jumphost ist davon nicht +# betroffen. Innerhalb eines abgeschotteten Verwaltungsnetzes ist das +# vertretbar; die saubere Loesung bleibt, TLS 1.2 auf dem Windows-Ziel zu +# aktivieren. Nur so weit lockern wie noetig -- guacd_tls_min_protocol und +# guacd_tls_security_level einzeln nachziehen, statt pauschal auf TLSv1/0. + +openssl_conf = default_conf + +[default_conf] +ssl_conf = ssl_sect + +[ssl_sect] +system_default = system_default_sect + +[system_default_sect] +MinProtocol = {{ guacd_tls_min_protocol }} +CipherString = DEFAULT@SECLEVEL={{ guacd_tls_security_level }} +{% if guacd_tls_unsafe_legacy_renegotiation %} +# Manche aeltere Windows-/SCHANNEL-Staende koennen die in OpenSSL 3 +# standardmaessig abgeschaltete unsichere Neuverhandlung verlangen. +Options = UnsafeLegacyRenegotiation +{% endif %} diff --git a/scripts/diagnose_rdp_tls.py b/scripts/diagnose_rdp_tls.py new file mode 100644 index 0000000..0a9e7c1 --- /dev/null +++ b/scripts/diagnose_rdp_tls.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +"""Stellt genau den TLS-Verbindungsaufbau nach, an dem guacd/FreeRDP scheitert. + +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 also schon vorher +gescheitert, bei der Aushandlung von Protokollversion und Cipher. Die +Hosteinstellung "Zertifikat ignorieren" kann daran nichts aendern. + +Genau das prueft dieses Skript. Es macht, was FreeRDP macht: + + 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). + +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 + +# 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", +} + + +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 _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: + chunk = sock.recv(count - len(buf)) + if not chunk: + raise ConnectionError("Gegenstelle hat die Verbindung waehrend der Aushandlung geschlossen") + buf += chunk + return buf + + +def negotiate(sock: socket.socket, requested: int) -> int: + """Fuehrt die X.224-Aushandlung durch, gibt das gewaehlte Protokoll zurueck.""" + sock.sendall(_x224_connection_request(requested)) + response = _read_tpkt(sock) + body = response[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(" 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.""" + 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: + cert = tls.getpeercert(binary_form=True) + info = f"{tls.version()}, {tls.cipher()[0]}, Zertifikat {len(cert)} Byte" + return (True, info) + except ssl.SSLError as exc: + return (False, f"{type(exc).__name__}: {exc.reason if getattr(exc, 'reason', None) else exc}") + except (OSError, ValueError) as exc: + return (False, f"{type(exc).__name__}: {exc}") + + +def main() -> 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() + + import ssl as _ssl + 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")) + ] + 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: + 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}") + print("\n Damit erübrigt 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 + + # 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"), + ] + 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}") + + # 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.") + 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).") + 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.") + return 1 + + +if __name__ == "__main__": + sys.exit(main())