feat(ldap): support multiple comma-separated LDAP hosts for failover (#6898) (#40201)

Allows an LDAP auth source to list multiple comma-separated servers in
the Host field so logins continue working when one server is
unreachable. Entries can be plain hosts using the configured Port and
Security Protocol fields or `ldap://`, `ldaps://`, or `ldap+starttls://`
URLs with their own port and protocol (as proposed in
https://github.com/go-gitea/gitea/pull/31649#issuecomment-2505555133),
with TLS protocol consistency enforced so an entry cannot silently
downgrade encryption.

Hosts are tried in order with a 250 ms head start each so an
unresponsive server does not block logins for the full 60 s connect
timeout, and only connection failures fail over to the next host so
failed binds are not retried across servers. Existing single-host
configurations continue to work unchanged without a migration.

Docs: https://gitea.com/gitea/docs/pulls/635
Fixes https://github.com/go-gitea/gitea/issues/6898
/claim #6898

---------

Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
Trung Hehe
2026-11-09 21:45:12 +00:00
committed by GitHub
parent 3561478109
commit 06b393e402
7 changed files with 131 additions and 16 deletions

View File

@@ -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",

View File

@@ -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)

View File

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

View File

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

View File

@@ -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")
}

View File

@@ -42,7 +42,7 @@
</div>
<div class="required field">
<label for="host">{{ctx.Locale.Tr "admin.auths.host"}}</label>
<input id="host" name="host" value="{{$cfg.Host}}" placeholder="mydomain.com" required>
<input id="host" name="host" value="{{$cfg.Host}}" placeholder="mydomain.com, backup.mydomain.com" required>
</div>
<div class="required field">
<label for="port">{{ctx.Locale.Tr "admin.auths.port"}}</label>

View File

@@ -14,7 +14,7 @@
</div>
<div class="required field">
<label for="host">{{ctx.Locale.Tr "admin.auths.host"}}</label>
<input id="host" name="host" value="{{.host}}" placeholder="mydomain.com">
<input id="host" name="host" value="{{.host}}" placeholder="mydomain.com, backup.mydomain.com">
</div>
<div class="required field">
<label for="port">{{ctx.Locale.Tr "admin.auths.port"}}</label>