Web-based LDAP administration interface for managing users, groups, and computers.
Supports both Active Directory and OpenLDAP with per-user credential binding.
- Browse, search, and filter users, groups, and computers
- View and edit user details, group memberships, and attributes
- Per-user LDAP credential binding (no shared admin account required)
- Active Directory and OpenLDAP support with automatic detection
- Direct-bind authentication fallback for non-person accounts
- Configurable via environment variables or command-line flags
- LDAP connection pooling with health checks
- LRU caching with background refresh
- Distroless Docker image (13 MB, nonroot, read-only filesystem)
- Multi-platform builds (linux/amd64, linux/arm64, darwin, windows)
This section gets the app running on your own machine. It is not a production setup — see Production setup before serving it to anyone else.
docker run -d --name ldap-manager \
-e LDAP_SERVER=ldaps://dc1.example.com:636 \
-e LDAP_BASE_DN="DC=example,DC=com" \
-e LDAP_READONLY_USER=readonly \
-e LDAP_READONLY_PASSWORD=password \
-e LDAP_IS_AD=true \
-p 3000:3000 \
ghcr.io/netresearch/ldap-manager:latestOpen http://localhost:3000 and log in with your LDAP credentials.
Plain HTTP works on localhost only. Cookies are marked Secure by default, and
a browser keeps such a cookie over plain HTTP only for loopback addresses. Reach
the same container by IP or hostname over http:// and every login fails with
csrf: token invalid, because the browser discarded the CSRF cookie. Put TLS in
front, or set COOKIE_SECURE=false.
The project includes a directory to develop against — OpenLDAP with seed data and ACLs, phpLDAPadmin, and an nginx TLS reverse proxy:
docker compose --profile dev up -d # OpenLDAP, phpLDAPadmin, nginx
make dev # the app itself, with templ watchingThis gives you:
- OpenLDAP with pre-configured users (
admin/admin,jdoe/password,jsmith/password) - phpLDAPadmin on http://localhost:8080
- nginx TLS reverse proxy on https://localhost:8443, forwarding to the app you run with
make dev
The app runs on the host rather than in a container, which is what makes the reload fast. HTTPS through nginx is also
the way to exercise the default COOKIE_SECURE=true locally — see Production setup.
Prerequisites: Go 1.27+, templ CLI.
templ generate
go build -o ldap-manager ./cmd/ldap-manager
./ldap-manager \
--ldap-server ldaps://dc1.example.com:636 \
--active-directory \
--base-dn DC=example,DC=com \
--readonly-user readonly \
--readonly-password readonlyThe server listens on port 3000 by default. Set the PORT environment variable to override.
Go + Fiber + Templ on the backend; Pico CSS +
a hand-written internal/web/static/app.css + vendored htmx on the
frontend. No Node.js toolchain — vendored files are refreshed with
bash scripts/vendor.sh, which verifies SHA-256 checksums against
scripts/vendor.lock.
The app speaks plain HTTP and has no TLS of its own. Terminate TLS in front of it
(Traefik, nginx, Caddy) and keep COOKIE_SECURE=true, which is the default.
Three settings have to agree, or logins fail with a CSRF error:
-
The browser must reach the site over HTTPS. Otherwise it discards the
Securesession and CSRF cookies, and the login POST arrives without them. -
The proxy must be trusted.
TRUSTED_PROXIESdefaults to127.0.0.0/8,::1/128and172.16.0.0/12, which covers a sidecar terminator. A terminator on another network — a Kubernetes pod range, an external load balancer — has to be named, or the app keeps believing it serves plain HTTP and the CSRF middleware rejects the browser'shttps://origin withcsrf: origin does not match host.Keep the list as narrow as the deployment allows. It also decides whose
X-Forwarded-Forthe login rate limiter counts against, so a trusted peer can name its own client IP; a list wide enough to include ordinary clients lets them rotate that header and evade the brute-force limit. -
COOKIE_SECURE=falsebelongs to plain-HTTP deployments only. It stops the cookie problem and nothing else: credentials still cross the network in the clear. Use it for a local trial, not for a service others log into.
All options can be set via environment variables, a .env file, or command-line flags. Run ./ldap-manager --help for
the full list.
| Environment Variable | Flag | Default | Description |
|---|---|---|---|
LDAP_SERVER |
--ldap-server |
(required) | LDAP URI (ldap:// or ldaps://) |
LDAP_BASE_DN |
--base-dn |
(required) | Base DN for LDAP searches |
LDAP_IS_AD |
--active-directory |
false |
Enable Active Directory mode |
LDAP_READONLY_USER |
--readonly-user |
Service account DN for background cache | |
LDAP_READONLY_PASSWORD |
--readonly-password |
Service account password | |
LDAP_ADMIN_GROUP |
--admin-group |
Group DN whose members may view the password-expiry roster | |
LDAP_TLS_SKIP_VERIFY |
--tls-skip-verify |
false |
Skip TLS certificate verification |
PORT |
3000 |
HTTP listen port | |
COOKIE_SECURE |
--cookie-secure |
true |
Require HTTPS for cookies |
TRUSTED_PROXIES |
--trusted-proxies |
127.0.0.0/8,::1/128,172.16.0.0/12 |
Peers whose X-Forwarded-* headers are believed |
PERSIST_SESSIONS |
--persist-sessions |
false |
Persist sessions to BoltDB |
SESSION_DURATION |
--session-duration |
30m |
Session lifetime |
LOG_LEVEL |
--log-level |
info |
Log level (trace, debug, info, warn, error) |
When no readonly user is configured, the app uses per-user LDAP credentials for all operations and the background cache is disabled.
/password-expiry lists accounts whose LDAP password is expiring, resolved live via
simple-ldap-go's expiry API. It is admin-only and needs the service
account.
An admin is a member of LDAP_ADMIN_GROUP or an account carrying Active Directory's adminCount=1. Note that
adminCount is sticky: Active Directory sets it when an account joins a protected group and never clears it on
removal, so an account that was ever privileged keeps roster access. Prefer LDAP_ADMIN_GROUP membership where you want
access to track current privilege. On OpenLDAP there is no adminCount, so LDAP_ADMIN_GROUP is the only way to grant
access — without it, the roster is reachable by no one. Group membership is read from the user's memberOf, which
Active Directory populates automatically; an OpenLDAP deployment must have the memberof overlay enabled for the group
gate to work.
The default view shows accounts due within a window (?days=, default 30, capped at 366); a Show all accounts
toggle adds the never-expires and unknown accounts with a status badge. On OpenLDAP, expiry needs the ppolicy overlay;
accounts the directory reports nothing about show as unknown.
For production deployments behind Traefik:
docker network create traefik
# In .env:
TRAEFIK_ENABLE=true
TRAEFIK_HOST=example.com
# Access: https://ldap-manager.example.com| Users List | User Detail |
|---|---|
![]() |
![]() |
| Groups List | Group Detail |
|---|---|
![]() |
![]() |
Published to GitHub Container Registry:
- 13 MB distroless runtime (nonroot UID 65532, read-only filesystem, no shell)
- Multi-platform:
linux/amd64,linux/arm64,linux/arm/v7 - OCI-compliant labels and health check built in
docker run -d --name ldap-manager \
-v /etc/ssl/certs:/etc/ssl/certs:ro -p 3000:3000 \
ghcr.io/netresearch/ldap-manager:latest \
--ldap-server ldaps://dc1.example.com:636 \
--base-dn DC=example,DC=comFull documentation is available in docs/:
- Documentation Index - Complete navigation with cross-references
- Installation Guide - Setup and deployment
- Configuration Reference - All options
- Architecture Overview - System design
- Development Setup - Local environment
- Contributing - Code standards and workflow
LDAP Manager's login page conforms to WCAG 2.2 Level AAA in comfortable density (the
default on touch devices, narrow viewports, and under prefers-reduced-motion). In compact density (the default on
desktop), the login page meets Level AA; all AAA success criteria are met except 2.5.5 Target Size (Enhanced), which is
a deliberate density-preference trade-off.
Conformance is enforced in CI by a contrast unit test (internal/web/contrast_test.go) and an axe-core AAA pass on
every E2E run (internal/e2e/axe_test.go). Additional routes will be brought under the same guarantee as they migrate
in subsequent slices.
The relationship graph view at /graph (and the List | Graph mode toggle on the user, group, and computer list pages)
meets WCAG 2.2 Level AA. An equivalent flat edge table is always rendered below the visual canvas, providing an
AAA-equivalent text alternative for every interaction and relationship the canvas displays. The graph pages are covered
by the same axe-core ratchet (internal/e2e/axe_graph_test.go).
Contributions welcome! Please open a Pull Request.
This project uses Conventional Commits and formats code with gofumpt + goimports.



