diff --git a/cmd/admin_auth_ldap.go b/cmd/admin_auth_ldap.go index b7d50715107..befce655e2a 100644 --- a/cmd/admin_auth_ldap.go +++ b/cmd/admin_auth_ldap.go @@ -49,11 +49,11 @@ func commonLdapCLIFlags() []cli.Flag { }, &cli.StringFlag{ Name: "host", - Usage: "The address where the LDAP server can be reached.", + Usage: "Comma-separated LDAP hosts or ldap://, ldaps:// or ldap+starttls:// URLs, tried in order.", }, &cli.IntFlag{ Name: "port", - Usage: "The port to use when connecting to the LDAP server.", + Usage: "The port to use when connecting to hosts given without a URL.", }, &cli.StringFlag{ Name: "user-search-base", diff --git a/services/auth/source/ldap/README.md b/services/auth/source/ldap/README.md index 34c811703f6..9bed9caae94 100644 --- a/services/auth/source/ldap/README.md +++ b/services/auth/source/ldap/README.md @@ -32,11 +32,16 @@ share the following fields: * A name to assign to the new method of authorization. * Host **(required)** - * The address where the LDAP server can be reached. - * Example: mydomain.com + * Comma-separated LDAP servers. Each is a host using the Port and Security + Protocol fields, or an `ldap://`, `ldaps://` or `ldap+starttls://` URL + with an optional port. `ldap://` requires the Unencrypted Security + Protocol, the other URLs require an encrypted one. + * The next server is tried when the previous one fails or takes over + 250 ms to connect. Bind and search failures do not move on to it. + * Example: ldap1.mydomain.com, ldaps://ldap2.mydomain.com:636 * Port **(required)** - * The port to use when connecting to the server. + * The port to use when connecting to hosts given without a URL. * Example: 636 * Enable TLS Encryption (optional) diff --git a/services/auth/source/ldap/security_protocol.go b/services/auth/source/ldap/security_protocol.go index af83ce191d7..7ef38c4a05d 100644 --- a/services/auth/source/ldap/security_protocol.go +++ b/services/auth/source/ldap/security_protocol.go @@ -29,3 +29,9 @@ var SecurityProtocolNames = map[SecurityProtocol]string{ SecurityProtocolLDAPS: "LDAPS", SecurityProtocolStartTLS: "StartTLS", } + +var urlSecurityProtocols = map[string]SecurityProtocol{ + "ldap": SecurityProtocolUnencrypted, + "ldaps": SecurityProtocolLDAPS, + "ldap+starttls": SecurityProtocolStartTLS, +} diff --git a/services/auth/source/ldap/source_search.go b/services/auth/source/ldap/source_search.go index 52aec7563f2..8505b9e9c58 100644 --- a/services/auth/source/ldap/source_search.go +++ b/services/auth/source/ldap/source_search.go @@ -6,13 +6,17 @@ package ldap import ( "crypto/tls" + "errors" "fmt" "net" + "net/url" "strconv" "strings" + "time" "gitea.dev/modules/container" "gitea.dev/modules/log" + "gitea.dev/modules/util" "github.com/go-ldap/ldap/v3" ) @@ -111,26 +115,85 @@ func (source *Source) findUserDN(l *ldap.Conn, name string) (string, bool) { func dial(source *Source) (*ldap.Conn, error) { log.Trace("Dialing LDAP with security protocol (%v) without verifying: %v", source.SecurityProtocol, source.SkipVerify) + hosts := util.SplitTrimSpace(source.Host, ",") + if len(hosts) == 0 { + return nil, errors.New("no LDAP host configured") + } + + type dialResult struct { + index int + conn *ldap.Conn + err error + } + results := make(chan dialResult) + done := make(chan struct{}) + defer close(done) + + var errs []error + for started := 0; len(errs) < len(hosts); { + var headStart <-chan time.Time + if started < len(hosts) { + index := started + go func() { + conn, err := dialHost(source, hosts[index]) + select { + case results <- dialResult{index, conn, err}: + case <-done: + if conn != nil { + conn.Close() + } + } + }() + started++ + headStart = time.After(250 * time.Millisecond) + } + select { + case result := <-results: + if result.err == nil { + if result.index > 0 { + log.Warn("LDAP source %q is using fallback host %q, earlier hosts failed or are slow: %v", source.Name, hosts[result.index], errs) + } + return result.conn, nil + } + errs = append(errs, fmt.Errorf("%s: %w", hosts[result.index], result.err)) + case <-headStart: + } + } + return nil, fmt.Errorf("error during Dial: %w", errors.Join(errs...)) +} + +func dialHost(source *Source, host string) (*ldap.Conn, error) { + protocol, port := source.SecurityProtocol, strconv.Itoa(source.Port) + if strings.Contains(host, "://") { + hostURL, err := url.Parse(host) + if err != nil { + return nil, err + } + urlProtocol, ok := urlSecurityProtocols[hostURL.Scheme] + if !ok || hostURL.Hostname() == "" || hostURL.User != nil || source.UseTLS() != (urlProtocol != SecurityProtocolUnencrypted) { + return nil, errors.New("unsupported LDAP URL") + } + protocol, host, port = urlProtocol, hostURL.Hostname(), hostURL.Port() + } + tlsConfig := &tls.Config{ - ServerName: source.Host, + ServerName: host, InsecureSkipVerify: source.SkipVerify, } - hostPort := net.JoinHostPort(source.Host, strconv.Itoa(source.Port)) - if source.SecurityProtocol == SecurityProtocolLDAPS { - return ldap.DialURL("ldaps://"+hostPort, ldap.DialWithTLSConfig(tlsConfig)) - } - - conn, err := ldap.DialURL("ldap://" + hostPort) + dialURL := url.URL{Scheme: util.Iif(protocol == SecurityProtocolLDAPS, "ldaps", "ldap"), Host: net.JoinHostPort(host, port)} + conn, err := ldap.DialURL(dialURL.String(), ldap.DialWithTLSConfig(tlsConfig)) if err != nil { - return nil, fmt.Errorf("error during Dial: %w", err) + return nil, err } - if source.SecurityProtocol == SecurityProtocolStartTLS { + if protocol == SecurityProtocolStartTLS { + conn.SetTimeout(ldap.DefaultTimeout) if err = conn.StartTLS(tlsConfig); err != nil { conn.Close() return nil, fmt.Errorf("error during StartTLS: %w", err) } + conn.SetTimeout(0) } return conn, nil diff --git a/services/auth/source/ldap/source_search_test.go b/services/auth/source/ldap/source_search_test.go new file mode 100644 index 00000000000..f24954e5690 --- /dev/null +++ b/services/auth/source/ldap/source_search_test.go @@ -0,0 +1,41 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package ldap + +import ( + "fmt" + "net" + "net/http/httptest" + "net/netip" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDial(t *testing.T) { + silent, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer silent.Close() + tlsServer := httptest.NewTLSServer(nil) + defer tlsServer.Close() + + conn, err := dial(&Source{Host: " , ldap://127.0.0.1:1 , 127.0.0.1 , ", Port: int(netip.MustParseAddrPort(silent.Addr().String()).Port())}) + require.NoError(t, err) + conn.Close() + + start := time.Now() + conn, err = dial(&Source{Host: fmt.Sprintf("ldaps://%s, ldaps://%s", silent.Addr(), tlsServer.Listener.Addr()), SecurityProtocol: SecurityProtocolLDAPS, SkipVerify: true}) + require.NoError(t, err) + assert.Less(t, time.Since(start), 10*time.Second) + conn.Close() + + _, err = dial(&Source{Host: "ldaps://127.0.0.1:1, ldap://127.0.0.1", SecurityProtocol: SecurityProtocolStartTLS}) + assert.ErrorContains(t, err, "ldaps://127.0.0.1:1: ") + assert.ErrorContains(t, err, "ldap://127.0.0.1: unsupported LDAP URL") + + _, err = dial(&Source{Host: " , "}) + assert.EqualError(t, err, "no LDAP host configured") +} diff --git a/templates/admin/auth/edit.tmpl b/templates/admin/auth/edit.tmpl index 523820c5488..d72b93c8be9 100644 --- a/templates/admin/auth/edit.tmpl +++ b/templates/admin/auth/edit.tmpl @@ -42,7 +42,7 @@
- +
diff --git a/templates/admin/auth/source/ldap.tmpl b/templates/admin/auth/source/ldap.tmpl index e5852daa3d4..3ccc2ea1a4a 100644 --- a/templates/admin/auth/source/ldap.tmpl +++ b/templates/admin/auth/source/ldap.tmpl @@ -14,7 +14,7 @@
- +