7.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 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/ps78674/ldapserver+github.com/ps78674/goldap, not the more commonnwaples/ldapserver/go-ldappackages. Import asldap "github.com/ps78674/ldapserver". Check the vendored API before assuming upstream signatures. The fork has real limits: TLS floor is SSL 3.0,Server.Handlecallslog.Fatallnon double registration, and there is no panic recovery in the request path. - The server struct is its own Handler:
New()registers*serveronce (the fork forbids re-registration), andServeLDAPdelegates to whatevers.routesmux is current.SetDomainrebuilds the mux in place, which is why it is safe to call repeatedly. Never callx.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>andcn=<name>,ou=groups,<base>. - Bind state is per connection: searches (except RootDSE) are refused with
insufficientAccessRightsuntil a successful bind on the same connection (conn.go). A failed bind clears the state per RFC 4511. Anonymous binds succeed only whenWithAnonBind(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; onlyuid(→ID) andcn(→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-termun-prefixed.
- Plain values are quoted; wildcard values must match
- DN parsing:
entityIDFromDNsplits 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
memberOfand group results includemember, 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 useslogrus, andmain.gohijacks the global logrus logger to funnel protocol traces into hclog at forced Trace level viaNETAUTH_LOGLEVEL. Uses.l(hclog, keyed with"key", valuepairs) inside the server package. - NetAuth dependency seam: server code talks to NetAuth only through the
naClientinterface intype.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 intestutil_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()inresult_test.gore-encodes a response and parses the code back out of the DER. - Fakes: implement
naClient(fakeClientinbind_test.gorecords calls and returns canned protobuf data). Per-connection state is faked by settings.connto return afakeConn(boundTestServerinsearch_test.go). Search tests must bind before searching. - Handlers take the
ldap.ResponseWriterinterface, so assertions userecordingWriter(handler_test.go), which captures the exact response sequence.
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
*servernamedhandle<X>; response pattern is construct response →w.Write(res)on every exit path. - Loop style favors
for i := range xthroughout. - Errors returned to LDAP clients go in diagnostic messages; keep them non-technical, they are shown verbatim by LDAP browsers.