diff --git a/cmd/vpnportal/check.go b/cmd/vpnportal/check.go index bb23c4c..6a52315 100644 --- a/cmd/vpnportal/check.go +++ b/cmd/vpnportal/check.go @@ -72,6 +72,7 @@ func buildBackends(cfg *config.Config) (*opnsense.Client, *auth.AD, error) { VPNGroup: cfg.AD.VPNGroup, CAFile: cfg.AD.CAFile, MinTLSVersion: auth.ParseTLSVersion(cfg.AD.MinTLSVersion), + MaxTLSVersion: auth.ParseTLSVersion(cfg.AD.MaxTLSVersion), Timeout: time.Duration(cfg.AD.Timeout), }) if err != nil { diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 6f4c1c6..b04018a 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -85,6 +85,16 @@ ad: # Fall aktiv — die lässt sich für LDAP bewusst nicht abschalten. # min_tls_version: "1.2" + # Hoechste angebotene TLS-Version. Default 1.3. + # + # Auf "1.2" setzen, wenn der Domain Controller die Verbindung beim + # Verbindungsaufbau kommentarlos zuruecksetzt. Alte Schannel-Stacks kommen + # mit einem TLS-1.3-ClientHello nicht zurecht und brechen ab, bevor sie ein + # Zertifikat schicken. Erkennbar in openssl s_client an: + # SSL handshake has read 0 bytes and written bytes + # no peer certificate available + # max_tls_version: "1.3" + # Unprivilegierter Domain-Benutzer genügt — er muss nur lesen dürfen. bind_user: "svc-vpnportal@firma.local" bind_password: "BITTE-ERSETZEN" diff --git a/internal/auth/ad.go b/internal/auth/ad.go index d6e6dfe..6eca8f7 100644 --- a/internal/auth/ad.go +++ b/internal/auth/ad.go @@ -34,6 +34,8 @@ type Options struct { // MinTLSVersion ist die niedrigste akzeptierte TLS-Version (Konstante aus // crypto/tls). 0 bedeutet TLS 1.2. MinTLSVersion uint16 + // MaxTLSVersion deckelt die angebotene TLS-Version. 0 bedeutet TLS 1.3. + MaxTLSVersion uint16 Timeout time.Duration // Dial ist injizierbar; nil = echte LDAP-Verbindung. @@ -76,7 +78,7 @@ func NewAD(opts Options) (*AD, error) { opts.Port = 636 } if opts.Dial == nil { - opts.Dial = realDialer(opts.Port, opts.TLSMode, opts.CAFile, opts.MinTLSVersion, opts.Timeout) + opts.Dial = realDialer(opts.Port, opts.TLSMode, opts.CAFile, opts.MinTLSVersion, opts.MaxTLSVersion, opts.Timeout) } a := &AD{opts: opts} if opts.OnFailover != nil { diff --git a/internal/auth/dial.go b/internal/auth/dial.go index 0433fb1..038aa91 100644 --- a/internal/auth/dial.go +++ b/internal/auth/dial.go @@ -45,11 +45,17 @@ func ParseTLSVersion(s string) uint16 { // aktiv — für LDAP gibt es bewusst keine Insecure-Option. Lediglich die // Protokoll-Mindestversion ist konfigurierbar, damit Altsysteme wie ein // Windows Server 2012 R2 ohne TLS 1.2 erreichbar bleiben. -func tlsConfigFor(server, caFile string, minVersion uint16) (*tls.Config, error) { +func tlsConfigFor(server, caFile string, minVersion, maxVersion uint16) (*tls.Config, error) { if minVersion == 0 { minVersion = tls.VersionTLS12 } - cfg := &tls.Config{ServerName: server, MinVersion: minVersion} + if maxVersion == 0 { + maxVersion = tls.VersionTLS13 + } + if maxVersion < minVersion { + maxVersion = minVersion + } + cfg := &tls.Config{ServerName: server, MinVersion: minVersion, MaxVersion: maxVersion} if minVersion < tls.VersionTLS12 { // Go bietet die CBC-Suiten alter Schannel-Stacks nicht mehr von sich // aus an. Ohne sie scheitert der Handshake mit einem 2012 R2, der auf @@ -94,9 +100,9 @@ func legacyCipherSuites() []uint16 { } // realDialer erzeugt die Dial-Funktion für den Produktivbetrieb. -func realDialer(port int, tlsMode, caFile string, minVersion uint16, timeout time.Duration) func(context.Context, string) (conn, error) { +func realDialer(port int, tlsMode, caFile string, minVersion, maxVersion uint16, timeout time.Duration) func(context.Context, string) (conn, error) { return func(ctx context.Context, server string) (conn, error) { - tlsCfg, err := tlsConfigFor(server, caFile, minVersion) + tlsCfg, err := tlsConfigFor(server, caFile, minVersion, maxVersion) if err != nil { return nil, err } diff --git a/internal/auth/dial_test.go b/internal/auth/dial_test.go index 604a365..7e7fefe 100644 --- a/internal/auth/dial_test.go +++ b/internal/auth/dial_test.go @@ -26,7 +26,7 @@ func TestParseTLSVersion(t *testing.T) { } func TestTLSConfigDefaultsToTLS12(t *testing.T) { - cfg, err := tlsConfigFor("dc01.firma.local", "", 0) + cfg, err := tlsConfigFor("dc01.firma.local", "", 0, 0) if err != nil { t.Fatal(err) } @@ -42,10 +42,40 @@ func TestTLSConfigDefaultsToTLS12(t *testing.T) { if cfg.CipherSuites != nil { t.Error("bei TLS 1.2+ sollen Gos sichere Vorgaben gelten, keine eigene Liste") } + if cfg.MaxVersion != tls.VersionTLS13 { + t.Errorf("MaxVersion = %#04x, want TLS 1.3", cfg.MaxVersion) + } +} + +func TestTLSConfigCapsMaxVersion(t *testing.T) { + // Alte Schannel-Stacks brechen an einem TLS-1.3-ClientHello kommentarlos ab. + // Dann darf 1.3 gar nicht erst angeboten werden. + cfg, err := tlsConfigFor("dc01.firma.local", "", tls.VersionTLS12, tls.VersionTLS12) + if err != nil { + t.Fatal(err) + } + if cfg.MaxVersion != tls.VersionTLS12 { + t.Errorf("MaxVersion = %#04x, want TLS 1.2", cfg.MaxVersion) + } + if cfg.MinVersion != tls.VersionTLS12 { + t.Errorf("MinVersion = %#04x, want TLS 1.2", cfg.MinVersion) + } +} + +func TestTLSConfigMaxNeverBelowMin(t *testing.T) { + // Eine widerspruechliche Kombination darf keine unmoegliche Konfiguration + // erzeugen; die Validierung faengt sie ohnehin vorher ab. + cfg, err := tlsConfigFor("dc01", "", tls.VersionTLS12, tls.VersionTLS10) + if err != nil { + t.Fatal(err) + } + if cfg.MaxVersion < cfg.MinVersion { + t.Errorf("MaxVersion %#04x < MinVersion %#04x", cfg.MaxVersion, cfg.MinVersion) + } } func TestTLSConfigLegacyAddsCBCSuites(t *testing.T) { - cfg, err := tlsConfigFor("dc01.firma.local", "", tls.VersionTLS10) + cfg, err := tlsConfigFor("dc01.firma.local", "", tls.VersionTLS10, 0) if err != nil { t.Fatal(err) } @@ -92,7 +122,7 @@ func TestTLSConfigLegacyAddsCBCSuites(t *testing.T) { } func TestTLSConfigRejectsBadCAFile(t *testing.T) { - if _, err := tlsConfigFor("dc01", "/gibt/es/nicht.pem", tls.VersionTLS12); err == nil { + if _, err := tlsConfigFor("dc01", "/gibt/es/nicht.pem", tls.VersionTLS12, 0); err == nil { t.Fatal("fehlende CA-Datei muss abgelehnt werden") } } diff --git a/internal/check/check.go b/internal/check/check.go index 35d847c..db4dd72 100644 --- a/internal/check/check.go +++ b/internal/check/check.go @@ -262,6 +262,13 @@ func directoryHint(err error) string { 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("connection reset by peer", "eof", "broken pipe"): + return "Der Domain Controller hat die Verbindung zurückgesetzt, ohne ein " + + "Zertifikat zu schicken. Zwei häufige Ursachen: (1) Auf dem Server ist " + + "gar kein LDAPS-Zertifikat installiert — Windows nimmt Port 636 dann " + + "zwar an, bricht aber ab. (2) Ein alter Schannel-Stack kommt mit dem " + + "TLS-1.3-ClientHello nicht zurecht; dann ad.max_tls_version auf \"1.2\" " + + "setzen. Prüfen mit: openssl s_client -connect :636 -tls1_2" 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 " + diff --git a/internal/check/check_test.go b/internal/check/check_test.go index 90dd541..2b702ad 100644 --- a/internal/check/check_test.go +++ b/internal/check/check_test.go @@ -153,6 +153,16 @@ func TestDirectoryTLSErrorsGiveActionableHints(t *testing.T) { `LDAPS-Verbindung zu dc01:636: tls: no cipher suite supported by both client and server`, "tlsrsakex", }, + { + "Verbindung zurueckgesetzt", + `LDAPS-Verbindung zu dc01:636: read tcp 10.0.0.5->10.0.0.250:636: read: connection reset by peer`, + "max_tls_version", + }, + { + "Verbindung sofort geschlossen", + `LDAPS-Verbindung zu dc01:636: EOF`, + "LDAPS-Zertifikat", + }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/internal/config/config.go b/internal/config/config.go index ebe6004..5c460e6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -67,7 +67,11 @@ type ADConfig struct { // MinTLSVersion ist die niedrigste akzeptierte TLS-Version zum // Verzeichnisdienst: "1.0", "1.1", "1.2" (Default) oder "1.3". // Unter 1.2 nur für Altsysteme, die nichts Besseres können. - MinTLSVersion string `yaml:"min_tls_version"` + MinTLSVersion string `yaml:"min_tls_version"` + // MaxTLSVersion deckelt die angebotene TLS-Version, Default "1.3". + // Auf "1.2" setzen, wenn ein alter Schannel-Stack den Verbindungsaufbau + // bereits am TLS-1.3-ClientHello abbricht. + MaxTLSVersion string `yaml:"max_tls_version"` BindUser string `yaml:"bind_user"` BindPassword string `yaml:"bind_password"` BindPasswordFile string `yaml:"bind_password_file"` @@ -101,6 +105,7 @@ func Defaults() *Config { Port: 636, TLSMode: "ldaps", MinTLSVersion: "1.2", + MaxTLSVersion: "1.3", Timeout: Duration(8 * time.Second), }, Matching: MatchingConfig{CNPattern: "{username}"}, diff --git a/internal/config/validate.go b/internal/config/validate.go index 33fab20..c9274d5 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -110,10 +110,22 @@ func (c *Config) Validate() error { if !slices.Contains([]string{"ldaps", "starttls"}, c.AD.TLSMode) { errs = append(errs, fmt.Errorf("ad.tls_mode %q ist ungültig (erlaubt: ldaps, starttls)", c.AD.TLSMode)) } - if !slices.Contains(MinTLSVersions, c.AD.MinTLSVersion) { + minOK := slices.Contains(MinTLSVersions, c.AD.MinTLSVersion) + maxOK := slices.Contains(MinTLSVersions, c.AD.MaxTLSVersion) + if !minOK { errs = append(errs, fmt.Errorf("ad.min_tls_version %q ist ungültig (erlaubt: %s)", c.AD.MinTLSVersion, strings.Join(MinTLSVersions, ", "))) } + if !maxOK { + errs = append(errs, fmt.Errorf("ad.max_tls_version %q ist ungültig (erlaubt: %s)", + c.AD.MaxTLSVersion, strings.Join(MinTLSVersions, ", "))) + } + if minOK && maxOK && + slices.Index(MinTLSVersions, c.AD.MaxTLSVersion) < slices.Index(MinTLSVersions, c.AD.MinTLSVersion) { + errs = append(errs, fmt.Errorf( + "ad.max_tls_version %q liegt unter ad.min_tls_version %q — so kommt keine Verbindung zustande", + c.AD.MaxTLSVersion, c.AD.MinTLSVersion)) + } if c.AD.Port <= 0 || c.AD.Port > 65535 { errs = append(errs, fmt.Errorf("ad.port %d liegt außerhalb 1–65535", c.AD.Port)) } diff --git a/internal/config/validate_test.go b/internal/config/validate_test.go index 31fa32f..ddcf052 100644 --- a/internal/config/validate_test.go +++ b/internal/config/validate_test.go @@ -138,6 +138,49 @@ func TestValidateRejectsUnknownMinTLSVersion(t *testing.T) { } } +func TestMaxTLSVersionDefaultsTo13(t *testing.T) { + cfg := validCfg(t) + if cfg.AD.MaxTLSVersion != "1.3" { + t.Errorf("MaxTLSVersion = %q, want Default 1.3", cfg.AD.MaxTLSVersion) + } + if err := cfg.Validate(); err != nil { + t.Fatalf("Default muss gültig sein: %v", err) + } +} + +func TestValidateAcceptsCappedMaxTLSVersion(t *testing.T) { + // Alte Schannel-Stacks brechen an einem TLS-1.3-ClientHello ab. Dann muss + // sich die Hoechstversion auf 1.2 deckeln lassen. + cfg := validCfg(t) + cfg.AD.MaxTLSVersion = "1.2" + if err := cfg.Validate(); err != nil { + t.Fatalf("max_tls_version 1.2 muss zulässig sein: %v", err) + } +} + +func TestValidateRejectsMaxBelowMin(t *testing.T) { + cfg := validCfg(t) + cfg.AD.MinTLSVersion = "1.2" + cfg.AD.MaxTLSVersion = "1.1" + err := cfg.Validate() + if err == nil { + t.Fatal("max_tls_version unter min_tls_version muss abgelehnt werden") + } + if !strings.Contains(err.Error(), "max_tls_version") { + t.Errorf("Fehler muss das Feld nennen: %v", err) + } +} + +func TestValidateRejectsUnknownMaxTLSVersion(t *testing.T) { + for _, v := range []string{"1.4", "tls1.3", "", "13"} { + cfg := validCfg(t) + cfg.AD.MaxTLSVersion = v + if err := cfg.Validate(); err == nil { + t.Errorf("max_tls_version %q muss abgelehnt werden", v) + } + } +} + func TestMinTLSVersionIsWeakBelow12(t *testing.T) { cfg := validCfg(t) for v, wantWeak := range map[string]bool{"1.0": true, "1.1": true, "1.2": false, "1.3": false} {