# 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 + 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=