umbau 1.3.1

This commit is contained in:
2026-09-03 08:24:30 +02:00
parent 96d9f331b5
commit 2741910a72
6 changed files with 343 additions and 0 deletions

View File

View File

@ -39,3 +39,25 @@ guacd_git_ref: "main"
# zuruecksetzen: "debug"/"trace" sind gespraechig und koennen Details der # zuruecksetzen: "debug"/"trace" sind gespraechig und koennen Details der
# Zielumgebung ins Journal schreiben. # Zielumgebung ins Journal schreiben.
guacd_log_level: "info" 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

View File

@ -293,6 +293,29 @@
group: guacd group: guacd
mode: "0750" 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 - name: Override-Verzeichnis fuer guacd-Unit anlegen
ansible.builtin.file: ansible.builtin.file:
path: /etc/systemd/system/guacd.service.d path: /etc/systemd/system/guacd.service.d

View File

@ -7,6 +7,13 @@
[Service] [Service]
User=guacd User=guacd
Group=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 NoNewPrivileges=true
ProtectSystem=strict ProtectSystem=strict
ProtectHome=true ProtectHome=true

View File

@ -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 %}

250
scripts/diagnose_rdp_tls.py Normal file
View File

@ -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 <ziel>: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("<BBHI", 0x01, 0x00, 8, requested)
# X.224 CR: LI, CR-CDT(0xE0), DST-REF(2), SRC-REF(2), CLASS(1)
x224 = 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("<I", payload[4:8])
raise ValueError(
"Der Server LEHNT die gewuenschte Sicherheitsstufe ab: "
+ _FAILURE_CODES.get(code, f"unbekannter failureCode 0x{code:02x}")
)
if kind != 0x02:
raise ValueError(f"Unerwartete Antwortart 0x{kind:02x} in der X.224-Antwort")
(selected,) = struct.unpack("<I", payload[4:8])
return selected
def _context(variant: str) -> 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())