ldap-upstream/internal/ldap/ldap.go
Michael Aldridge d74308280b internal/ldap: Reply with the result type a request expects
An LDAP client only accepts the result that pairs with the request it
sent, and silently ignores anything else. Unrouted operations were
answered with a generic result, which encodes with the wrong tag, so a
search with an unexpected base DN (for example the empty base that
ldapsearch uses when -b is omitted) left the client waiting forever
instead of reporting an error.

Every failure path now terminates the request with the matching result
type, panics included, so a single wrapper covers all operations rather
than one responder per operation. NetAuth calls carry a 30s deadline
because the gRPC client dials lazily and applies none of its own, and
refused operations and failed authentication are logged, which is what
an operator needs when a client appears to hang.

💘 Generated with Crush

Assisted-by: Crush:qwen38_flash_next_mtp
2026-11-02 19:59:04 -06:00

167 lines
5 KiB
Go

package ldap
import (
"crypto/tls"
"errors"
"fmt"
"strings"
ldap "github.com/vjeantet/ldapserver"
)
// New returns a new ldap server instance
func New(opts ...Option) *server {
x := new(server)
for _, o := range opts {
o(x)
}
x.Server = ldap.NewServer()
// The underlying server only permits a single handler and aborts
// the process on a second registration, so a stable delegating
// handler is registered once and SetDomain swaps the routed mux
// underneath it.
x.Handle(x)
x.buildRoutes()
return x
}
// buildRoutes (re)creates the route mux. It is called by New and again
// by SetDomain so that the naming-convention dependent routes always
// reflect the current domain without ever leaving duplicates behind.
func (s *server) buildRoutes() {
routes := ldap.NewRouteMux()
routes.NotFound(s.guard(s.handleNotFound))
routes.Abandon(s.guard(s.handleAbandon))
routes.Bind(s.guard(s.handleBind))
routes.Search(s.guard(s.handleSearchDSE)).
BaseDn("").
Scope(ldap.SearchRequestScopeBaseObject).
Filter("(objectclass=*)").
Label("Search - ROOT DSE")
if len(s.nc) > 0 {
base := strings.Join(s.nc, ",")
routes.Search(s.guard(s.handleSearchEntities)).
BaseDn("ou=entities," + base).
Scope(ldap.SearchRequestHomeSubtree).
Label("Search - Entities")
routes.Search(s.guard(s.handleSearchGroups)).
BaseDn("ou=groups," + base).
Scope(ldap.SearchRequestHomeSubtree).
Label("Search - Groups")
}
s.routes = routes
}
// Serve serves a plaintext DSA on the provided bind string. It
// returns once the listener is bound; the server keeps running in the
// background until Stop is called.
func (s *server) Serve(bind string) error {
return s.listenAndServe(bind)
}
// ServeTLS serves a TLS encrypted DSA on the provided bindstring
// using a key/cert pair located at the paths provided. Key and
// certificate should be PEM encoded.
func (s *server) ServeTLS(bind, keypath, certpath string) error {
cert, err := tls.LoadX509KeyPair(certpath, keypath)
if err != nil {
return fmt.Errorf("error creating certificate chain: %s", err)
}
cfg := &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
}
return s.listenAndServe(bind, func(srv *ldap.Server) {
srv.Listener = tls.NewListener(srv.Listener, cfg)
})
}
// listenAndServe binds the listening socket synchronously so that
// bind errors are reported to the caller, then serves in the
// background. The underlying server assigns its Listener field before
// invoking the option functions it is handed, so closing the started
// channel from an option guarantees that bookkeeping (and any TLS
// wrapping) is complete, and in a reliably ordered way, before Serve
// returns. Stop depends on reading that field.
func (s *server) listenAndServe(bind string, opts ...func(*ldap.Server)) error {
started := make(chan struct{})
failed := make(chan error, 1)
go func() {
opts := append(opts, func(*ldap.Server) { close(started) })
if err := s.Server.ListenAndServe(bind, opts...); err != nil {
select {
case <-started:
s.l.Error("Error from main server thread", "error", err)
default:
failed <- err
}
}
}()
select {
case <-started:
return nil
case err := <-failed:
return fmt.Errorf("error creating listener: %s", err)
}
}
func (s *server) handleNotFound(w ldap.ResponseWriter, r *ldap.Message) {
s.l.Warn("Refusing unrouted operation", "op", r.ProtocolOpName(), "message", r.String())
writeResult(w, r, ldap.LDAPResultUnwillingToPerform,
"Operation not implemented by server, searches must use a base DN of ou=entities or ou=groups underneath the naming context")
}
func (s *server) handleAbandon(w ldap.ResponseWriter, m *ldap.Message) {
var req = m.GetAbandonRequest()
// retreive the request to abandon, and send a abort signal to it
if requestToAbandon, ok := m.Client.GetMessageByID(int(req)); ok {
requestToAbandon.Abandon()
}
}
// SetDomain configures the naming context from a plain domain name
// such as "example.com". NetAuth has no notion of a base DN, so it is
// supplied here: the domain is split on dots, each label becomes a
// dc component, and dc=netauth is prepended. It must be called after
// New and is safe to call more than once.
func (s *server) SetDomain(domain string) error {
if domain == "" {
return errors.New("a domain must be configured")
}
labels := strings.Split(domain, ".")
nc := []string{"dc=netauth"}
for _, l := range labels {
l = strings.TrimSpace(l)
if l == "" {
return errors.New("domain contains an empty label")
}
nc = append(nc, "dc="+l)
}
s.nc = nc
s.buildRoutes()
return nil
}
// ServeLDAP implements the underlying server's Handler interface. It
// is registered once with the server and forwards to whatever route
// mux is current, which lets SetDomain rebuild routing without
// re-registering a handler (the server aborts on a second
// registration).
func (s *server) ServeLDAP(w ldap.ResponseWriter, r *ldap.Message) {
if s.routes == nil {
writeResult(w, r, ldap.LDAPResultUnavailable, "server is not configured with a domain")
return
}
s.routes.ServeLDAP(w, r)
}