From fd88251e07df1addc8d053d4138118b376b15604 Mon Sep 17 00:00:00 2001 From: Carsten Abele Date: Fri, 14 Aug 2026 13:29:47 +0200 Subject: [PATCH] feat(check): handlungsleitende Hinweise bei TLS- und Zertifikatsfehlern Der bisherige Fehlertext gab den rohen x509-Fehler weiter, ohne zu sagen, was zu tun ist. check unterscheidet jetzt unbekannte CA, unpassenden Hostnamen, SHA-1-Signatur, abgelaufenes Zertifikat, zu alte TLS-Version und fehlende gemeinsame Cipher-Suite und nennt jeweils die passende Stellschraube. Der technische Originalfehler bleibt erhalten. Reine Netzwerkfehler bekommen bewusst keinen Hinweis, damit kein falscher Verdacht entsteht. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68 --- internal/check/check.go | 48 +++++++++++++++++++++++++ internal/check/check_test.go | 68 ++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/internal/check/check.go b/internal/check/check.go index c74bb2a..35d847c 100644 --- a/internal/check/check.go +++ b/internal/check/check.go @@ -226,6 +226,51 @@ func (c *Checker) checkFirewall(ctx context.Context) (time.Time, error) { } } +// directoryHint übersetzt typische TLS- und Zertifikatsfehler in einen +// handlungsleitenden Zusatz. Der technische Originalfehler bleibt erhalten; +// nur ein Hinweis kommt dazu. Passt nichts, bleibt der Zusatz leer — ein +// reiner Netzwerkfehler soll keinen falschen Verdacht wecken. +func directoryHint(err error) string { + msg := strings.ToLower(err.Error()) + contains := func(needles ...string) bool { + for _, n := range needles { + if strings.Contains(msg, n) { + return true + } + } + return false + } + + switch { + case contains("insecure algorithm sha1", "sha1-rsa"): + return "Das Zertifikat des Domain Controllers ist mit SHA-1 signiert. " + + "Go akzeptiert das seit Version 1.24 nicht mehr und bietet auch keinen " + + "Schalter dafür — das Zertifikat muss mit SHA-256 neu ausgestellt werden." + case contains("certificate signed by unknown authority", "unknown authority", "unable to verify"): + return "Die ausstellende CA ist auf diesem Host nicht bekannt. Die interne " + + "Stammzertifizierungsstelle als PEM ablegen und in ad.ca_file eintragen " + + "(die Datei muss die gesamte Kette bis zur Wurzel enthalten). " + + "Ohne ad.ca_file wird nur der Trust-Store des Betriebssystems benutzt." + case contains("certificate is valid for", "certificate is not valid for any names", "x509: certificate relies on legacy"): + return "Das Zertifikat passt nicht zum angefragten Namen. In ad.servers muss " + + "genau der Name stehen, der im Zertifikat als Subject Alternative Name " + + "eingetragen ist — IP-Adressen sind deshalb nicht zulässig." + case contains("certificate has expired", "not yet valid"): + return "Das Zertifikat des Domain Controllers ist abgelaufen oder noch nicht " + + "gültig. Zertifikat erneuern und die Systemzeit beider Seiten prüfen." + case contains("protocol version not supported", "unsupported protocol version"): + return "Der Domain Controller spricht kein TLS 1.2. Für Altsysteme wie " + + "Windows Server 2012 R2 lässt sich ad.min_tls_version auf \"1.0\" setzen; " + + "besser ist, TLS 1.2 auf dem Domain Controller zu aktivieren." + case contains("no cipher suite supported by both", "handshake failure"): + return "Client und Domain Controller haben keine gemeinsame Cipher-Suite. " + + "Zuerst ad.min_tls_version prüfen. Braucht der Domain Controller zwingend " + + "RSA-Schlüsselaustausch, hilft nur GODEBUG=tlsrsakex=1 in der systemd-Unit." + default: + return "" + } +} + // checkDirectory bindet den Service-Account und löst die Gruppe auf. func (c *Checker) checkDirectory(ctx context.Context) (string, error) { if c.Dir == nil { @@ -233,6 +278,9 @@ func (c *Checker) checkDirectory(ctx context.Context) (string, error) { } dn, err := c.Dir.ResolveGroupDN(ctx) if err != nil { + if hint := directoryHint(err); hint != "" { + return "", fmt.Errorf("Verzeichnisdienst nicht nutzbar: %w\n\n%s", err, hint) + } return "", fmt.Errorf("Verzeichnisdienst nicht nutzbar: %w", err) } if dn == "" { diff --git a/internal/check/check_test.go b/internal/check/check_test.go index 5d9def6..90dd541 100644 --- a/internal/check/check_test.go +++ b/internal/check/check_test.go @@ -117,6 +117,74 @@ func TestMissingAPIPermissionIsDistinguished(t *testing.T) { } } +func TestDirectoryTLSErrorsGiveActionableHints(t *testing.T) { + cases := []struct { + name string + err string + want string // Textbaustein, der in der Ausgabe stehen muss + }{ + { + "unbekannte CA", + `LDAPS-Verbindung zu dc01.firma.local:636: x509: certificate signed by unknown authority`, + "ad.ca_file", + }, + { + "Hostname passt nicht", + `LDAPS-Verbindung zu dc01.firma.local:636: x509: certificate is valid for DC01, not dc01.firma.local`, + "ad.servers", + }, + { + "SHA-1-Signatur", + `LDAPS-Verbindung zu dc01:636: x509: cannot verify signature: insecure algorithm SHA1-RSA`, + "SHA-256", + }, + { + "Zertifikat abgelaufen", + `LDAPS-Verbindung zu dc01:636: x509: certificate has expired or is not yet valid`, + "abgelaufen", + }, + { + "TLS-Version zu alt", + `LDAPS-Verbindung zu dc01:636: tls: protocol version not supported`, + "min_tls_version", + }, + { + "keine gemeinsame Cipher-Suite", + `LDAPS-Verbindung zu dc01:636: tls: no cipher suite supported by both client and server`, + "tlsrsakex", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c := newChecker(t, &fakePinger{at: checkNow}, &fakeDir{dnErr: errors.New(tc.err)}) + rep := c.Run(context.Background(), "") + if rep.OK() { + t.Fatal("TLS-Fehler muss auffallen") + } + out := report(t, rep) + if !strings.Contains(out, tc.want) { + t.Errorf("Hinweis %q fehlt in der Ausgabe:\n%s", tc.want, out) + } + // Der technische Originalfehler muss erhalten bleiben. + if !strings.Contains(out, "dc01") { + t.Errorf("der Originalfehler darf nicht verloren gehen:\n%s", out) + } + }) + } +} + +func TestPlainNetworkErrorGetsNoMisleadingHint(t *testing.T) { + c := newChecker(t, &fakePinger{at: checkNow}, + &fakeDir{dnErr: errors.New("dc01.firma.local: connection refused")}) + rep := c.Run(context.Background(), "") + out := report(t, rep) + for _, unwanted := range []string{"ad.ca_file", "min_tls_version", "SHA-256"} { + if strings.Contains(out, unwanted) { + t.Errorf("ein reiner Netzwerkfehler darf keinen TLS-Hinweis erzeugen (%q):\n%s", unwanted, out) + } + } +} + func TestUnreachableDCIsNamed(t *testing.T) { c := newChecker(t, &fakePinger{at: checkNow}, &fakeDir{dnErr: errors.New("dc01.firma.local: connection refused")})