ldap-upstream/AGENTS.md
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

196 lines
11 KiB
Markdown

# 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=<label>,...`. Tree layout under it is fixed:
`uid=<id>,ou=entities,<base>` and `cn=<name>,ou=groups,<base>`.
- **Single-entry (base-object) searches are served**: `handleSearchEntry`
(search.go) answers a base-object search addressed at one entity or
group DN, which is how HashiCorp Vault reads the bound user's
attributes (it always sends `base=<user DN>`, `scope=base`,
`(objectClass=*)` on every login and there is no Vault knob to turn
that off). It is routed by a catch-all `routes.Search(...)` with **no
BaseDn** and `Scope(SearchRequestScopeBaseObject)`, registered last
so the specific routes win; the DN itself is dispatched by
`entryFromDN` (util.go). The scope restriction is load-bearing:
without it any stale subtree base would match and return
`searchResDone(50)` instead of the `53` that
`TestSetDomainReplacesRoutes` asserts. Semantics: unknown name →
`noSuchObject`(32); entry that does not match the filter → success
with zero entries (RFC 4511); filter forms outside the small set in
`entryFilterMatches` → `unwillingToPerform`(53) rather than a wrong
answer. Entries carry `objectClass: top, extensibleObject`.
- **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. Panic failures
are handled centrally: `guard(h)` (single arg) writes an
`operationsError` result paired with the request type via
`writeResult`, so handlers do not each need a failure responder.
- 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.