ldap-upstream/AGENTS.md
Michael Aldridge d74308280b internal/ldap: Reply with the result type a request expects
An LDAP client only accepts the result that pairs with the request it
sent, and silently ignores anything else. Unrouted operations were
answered with a generic result, which encodes with the wrong tag, so a
search with an unexpected base DN (for example the empty base that
ldapsearch uses when -b is omitted) left the client waiting forever
instead of reporting an error.

Every failure path now terminates the request with the matching result
type, panics included, so a single wrapper covers all operations rather
than one responder per operation. NetAuth calls carry a 30s deadline
because the gRPC client dials lazily and applies none of its own, and
refused operations and failed authentication are logged, which is what
an operator needs when a client appears to hang.

💘 Generated with Crush

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

180 lines
9.9 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>`.
- **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.