ldap-upstream/internal/ldap/ldap.go
Michael Aldridge 31b9b295aa internal/ldap: Answer base-object searches for one entry
HashiCorp Vault reads the attributes of the user it just bound as by
issuing a base-object "(objectClass=*)" search whose base DN is the
user's own DN, and has no option to turn that step off. Those searches
named a leaf entry rather than a container, so nothing routed them and
login failed with unwillingToPerform.

A single entry is now looked up by ID or name and returned under the DN
the client asked for, advertising objectClass as the other entries do.
An unknown name reports noSuchObject so clients can distinguish it from
an empty result, an entry that does not match the filter succeeds with
no entries as RFC 4511 requires, and filter shapes we cannot evaluate
are refused instead of guessed at. The catch-all route is limited to
base-object scope so searches with an unknown subtree base are still
refused, which keeps a renamed server from answering its old base DN.

💘 Generated with Crush

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

178 lines
5.6 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")
}
// Catch-all route for single-entry lookups, registered last so
// the specific routes above win. Clients such as HashiCorp Vault
// address one entry directly with a base-object search whose base
// DN is the entry itself; that DN has no fixed value to match on,
// so only the scope can be matched here and the DN is dispatched
// inside the handler. Subtree searches with an unknown base stay
// unrouted and are still refused outright.
routes.Search(s.guard(s.handleSearchEntry)).
Scope(ldap.SearchRequestScopeBaseObject).
Label("Search - Single Entry")
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)
}