diff --git a/AGENTS.md b/AGENTS.md index 0de438d..a48e4cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,47 +8,53 @@ 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. -There are **no tests anywhere** in this repo (no `*_test.go` files). Do not -assume `go test ./...` validates anything. - ## Commands ```sh -go build ./... # build +go build ./... go vet ./... -go run . # run locally (reads config like the real binary, see below) +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 . ``` -Release is via [goreleaser](.goreleaser.yml) triggered by `v*` tags in -[.github/workflows/release.yml](.github/workflows/release.yml); the before-hooks +CI ([.github/workflows/test.yml](.github/workflows/test.yml)) runs build, vet +and `go test -race` 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 generated/stale goreleaser artifact, not the config -actually used. +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(), routing, SetDomain(), Serve/ServeTLS + 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) -internal/buildinfo/ # Version var ("dev"), advertised in RootDSE + *_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. +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 @@ -56,56 +62,77 @@ keyed by base DN and scope. `github.com/ps78674/ldapserver` + `github.com/ps78674/goldap`, not the more common `nwaples/ldapserver` / `go-ldap` packages. Import as `ldap "github.com/ps78674/ldapserver"`. Check the vendored API before - assuming upstream signatures. -- **Routes depend on the domain**: `New()` only registers NotFound/Abandon/Bind - and the RootDSE route. The `ou=entities` / `ou=groups` search routes are - registered as a side effect of `SetDomain()`, which **must be called** after - `New()` or searches won't route at all. Calling it twice re-registers routes. -- **Base DN construction**: `SetDomain` takes a plain domain (`example.com`), - splits on `.`, prepends `dc=netauth`, yielding `dc=netauth,dc=example,dc=com`. - Tree layout under it is fixed: `uid=,ou=entities,` and - `cn=,ou=groups,`. -- **Supported LDAP filters are extremely limited** (`bleve_mapper.go`): only - equality match, OR, and attribute-present filters; only the attributes `uid` - (→ `ID:`) and `cn` (→ `Name:`); values are passed through as raw Bleve query - strings to NetAuth's search. Everything else returns - `unwillingToPerform`/"Filter type not supported". -- **Anonymous bind fall-through** (`bind.go`): when `allowAnon` is enabled and - the bind DN is empty, a success response is written but the handler **does - not return** — execution continues into `entityIDFromDN("")`, which errors - and writes a second response. This appears to be a bug; be careful not to - "fix" it blindly if behavior parity matters, and definitely add a response - write guard if you touch it. -- **Only simple bind** — no SASL; anything else is rejected outright. -- **DN parsing known defect**: `entityIDFromDN` splits on `,` without handling - escaped commas (documented as acceptable since NetAuth IDs can't contain - commas). Error strings there have typos ("is underneath", "must be rooted"); - leave them alone unless asked. -- **Search fan-out**: each entity result triggers an extra `EntityGroups` RPC - for `memberOf`, and each group result triggers `GroupMembers` for `member`. - A broad search is N+1 RPCs against NetAuth. Group expansion is done - server-side by NetAuth (flattened, fully expanded groups). + assuming upstream signatures. The fork has real limits: TLS floor is + SSL 3.0, `Server.Handle` calls `log.Fatalln` on double registration, and + there is no panic recovery in the request path. +- **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=