ldap-upstream/AGENTS.md
2026-11-01 22:17:59 -06:00

8.9 KiB

AGENTS.md

What this is

A standalone LDAPv3 read-only bridge in front of a NetAuth server (github.com/netauth/netauth). It translates LDAP bind/search operations into NetAuth RPC calls via the netauth/protocol protobuf types. It is not a full LDAP server and never stores data; it only reshapes and serves live NetAuth data. Single static binary (ldapd), intended to run bound to localhost.

Commands

go build ./...
go vet ./...
go test ./...             # or: go test -race ./internal/ldap/
go run .                  # needs a config file or NETAUTH_LDAP_DOMAIN
go mod vendor             # repo vendors deps; vendor/ and dist/ are gitignored

# Production-style build (matches Dockerfile)
CGO_ENABLED=0 GOOS=linux go build -a -ldflags '-extldflags "-static"' -o ldapd .

CI (.github/workflows/test.yml) runs build, vet and go test -race (Go 1.25) on pushes to master and on PRs. Release is via goreleaser triggered by v* tags in .github/workflows/release.yml; its before-hooks run go mod vendor and scripts/prepare.sh (regenerates NOTICE from vendored license files via scripts/vendor-licenses, which needs the vendor tree present). Note dist/config.yaml is a stale generated artifact, not the config actually used — the real one is .goreleaser.yml at the repo root.

Layout

main.go                    # wiring only: config, logging, client, serve
internal/ldap/             # the LDAP server (package ldap)
  ldap.go                  # New(), route (re)building, Serve/ServeTLS, SetDomain
  handler.go               # panic guard wrapper + per-op failure responders
  conn.go                  # per-connection bind state (bound/markBound/markUnbound)
  type.go                  # server struct + naClient interface (the seam for fakes)
  option.go                # functional options: WithLogger, WithNetAuth, WithAnonBind
  bind.go                  # bind handler (auth against NetAuth)
  search.go                # RootDSE, entity & group search handlers
  attributes.go            # RFC 4511 attribute selection matching
  bleve_mapper.go          # LDAP filter -> Bleve query string translation
  util.go                  # DN parsing (entityIDFromDN)
  *_test.go                # tests; see "Testing" below
internal/buildinfo/        # Version var, advertised in RootDSE; set by goreleaser

Architecture: main builds a viper config + netauth client, constructs the server with functional options, and calls Serve/ServeTLS. The underlying LDAP engine is a RouteMux (net/http-style routing on LDAP ops) with handlers keyed by base DN and scope. Every handler is wrapped by server.guard so a panic cannot take the process down (the vendored server dispatches requests in goroutines with no recovery of its own).

Non-obvious gotchas

  • Forked LDAP libraries: the server stack is github.com/vjeantet/ldapserver + github.com/vjeantet/goldap (MIT, actively maintained upstream of the old ps78674 fork). Import as ldap "github.com/vjeantet/ldapserver". Check the vendored API before assuming upstream signatures. Limits that remain: no panic recovery in the request path (that is what server.guard is for), Server.Handle panics on double registration, and it logs through one process-global stdlib-style Logger variable, which WithLogger swaps once and then routes through an atomic sink (see protocolSink in option.go) to stay race-free while connections outlive a server instance.
  • Shutdown: Stop() is only safe once Serve/ServeTLS has returned (the listener is bound synchronously before Serve returns, which is what makes bind errors reportable). Tests close s.Listener instead of calling Stop, because the library's done channel has no synchronisation edge and the race detector flags it.
  • TLS is configured here, not in the library: ServeTLS builds a tls.Config with MinVersion: TLS 1.2 and wraps the listener itself; the library does not enforce a minimum version.
  • Per-connection state: use GetData/SetData on the connection (the connection interface in conn.go).
  • The server struct is its own Handler: New() registers *server once (the fork forbids re-registration), and ServeLDAP delegates to whatever s.routes mux is current. SetDomain rebuilds the mux in place, which is why it is safe to call repeatedly. Never call x.Handle() again.
  • SetDomain is required and validated: returns an error for an empty domain or empty labels; searches do not route until it succeeds. Naming context is always dc=netauth,dc=<label>,.... Tree layout under it is fixed: uid=<id>,ou=entities,<base> and cn=<name>,ou=groups,<base>.
  • Bind state is per connection: searches (except RootDSE) are refused with insufficientAccessRights until a successful bind on the same connection (conn.go). A failed bind clears the state per RFC 4511. Anonymous binds succeed only when WithAnonBind(true) and also mark the connection bound.
  • Filter support (bleve_mapper.go): equality, present, AND, OR, NOT (nested only) and simple substrings (js*, *smith, a*th) are supported; only uid (→ ID) and cn (→ Name). Everything else errors rather than returning wrong results. Rules worth knowing before touching this file:
    • Plain values are quoted; wildcard values must match ^[\w.\-*]+$ to prevent Bleve query injection.
    • A top-level (or all-negative) filter errors instead of silently returning zero results, because Bleve cannot anchor a query made only of - clauses.
    • AND branches become +term; negated branches stay -term un-prefixed.
  • DN parsing: entityIDFromDN splits on , without handling escaped commas (accepted defect: NetAuth IDs cannot contain commas). It validates the DN has enough components and that the naming context matches exactly.
  • Search fan-out: entity results include memberOf and group results include member, each costing one extra NetAuth RPC per entry. These are only fetched when the client's attribute selection asks for them (attributes.go); an empty selection or * means all.
  • Logging is dual-library: app logging uses hashicorp/go-hclog; the vendored ldapserver logs through a small stdlib-style interface (ldap.Logger), which WithLogger replaces with an adapter that funnels protocol traces into hclog at Trace level via NETAUTH_LOGLEVEL. Use s.l (hclog, keyed with "key", value pairs) inside the server package.
  • NetAuth dependency seam: server code talks to NetAuth only through the naClient interface in type.go. If you add a NetAuth call, add it to that interface, not directly to the concrete client.

Testing

Tests live in internal/ldap/*_test.go. Two constraints shape them:

  • goldap types are unconstructible from outside: the message/filter types have unexported fields and no exported constructors. Tests hand-encode BER and run it through the real decoder; see the ber* helpers in testutil_test.go (berBindRequest, berSearchRequestWithAttrs, berEqualityFilter, berSubstringFilter, ...). Follow that pattern rather than trying to build request structs directly.
  • Result codes have no getters: the result types expose setters only, so resultCode() in result_test.go re-encodes a response and parses the code back out of the DER.
  • Fakes: implement naClient (fakeClient in bind_test.go records calls and returns canned protobuf data). Per-connection state is faked by setting s.conn to return a fakeConn (boundTestServer in search_test.go). Search tests must bind before searching.
  • Handlers take the ldap.ResponseWriter interface, so assertions use recordingWriter (handler_test.go), which captures the exact response sequence.
  • Serve/TLS tests use freeAddr (serve_test.go) to reserve a port and close s.Listener for cleanup; see the Shutdown gotcha for why they do not call Stop.

Configuration

viper-driven; config file config.{yaml,...} searched in /etc/netauth/, $HOME/.netauth/, .. A missing config file is tolerated (env-only deployments); a corrupt one is fatal (exit 5). Env vars use the NETAUTH_ prefix with _ replacing . (e.g. NETAUTH_LDAP_BIND, NETAUTH_LDAP_DOMAIN, NETAUTH_LOGLEVEL). Known keys (defaults in main.go): ldap.bind (localhost:389), ldap.tls (false), ldap.key, ldap.cert, ldap.allow_anon (false), ldap.domain (no default — required; startup exits with 3 if invalid).

Style notes

  • Commit message convention: component: Capitalized description (e.g. internal/ldap: Add option to bind anonmyously).
  • Handlers are methods on *server named handle<X>; response pattern is construct response → w.Write(res) on every exit path.
  • Loop style favors for i := range x throughout.
  • Errors returned to LDAP clients go in diagnostic messages; keep them non-technical, they are shown verbatim by LDAP browsers.