# 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 ```sh 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](.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](.goreleaser.yml) triggered by `v*` tags in [.github/workflows/release.yml](.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 + request-paired result writer 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`). - **Result messages must pair with the request**: an LDAP client silently ignores any result whose application tag does not pair with the request it sent (a search is only completed by a searchResDone), so a mismatch hangs the client forever. Never write a bare `ldap.NewResponse(...)` as a reply; use `writeResult(w, m, code, msg)` (handler.go), which picks the right result type via `resultForRequest`. `handleNotFound` is the user-visible case: any search with an unexpected base DN lands there. - **NetAuth calls are deadline-bound**: every handler puts a `context.WithTimeout` (`rpcTimeout`, 30s, bind.go) on its context before calling `naClient`; the gRPC client dials lazily with no deadline of its own, so an unbounded context hangs the handler and the client behind it. - **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=