ldapserver is a helper library for building server software capable of speaking the LDAP protocol. This could be an alternate implementation of LDAP, a custom LDAP proxy or even a completely different backend capable of "masquerading" its API as a LDAP Server.
The package supports
- All basic LDAP Operations (bind, search, add, compare, modify, delete, extended)
- Cancel extended operation (RFC 3909) with built-in handling
- SSL
- StartTLS
- Serve with a pre-existing
net.Listener(Serve()andServeTLS()) - Simple and SASL binds, with route matching on the authentication choice and access to the SASL mechanism
- Per-connection client data (
SetData/GetData) - Per-connection handler instances (
NewServerWithHandlerSource) - Connection lifecycle hooks (
OnNewConnection,OnClientClose) - Read and write timeouts
- Unbind request is implemented, but is handled internally to close the connection.
- Graceful stopping
- Basic request routing inspired by net/http ServeMux
- Referrals and SearchResultReference messages
- Response controls on outgoing messages
- Logger customisation (log interface)
If you don't set a route to handle AbandonRequest, the package will handle it for you. (signal sent to message.Done chan)
The Cancel extended operation (OID 1.3.6.1.1.8) is handled automatically by the server. When a client sends a Cancel request, the server:
- Decodes the target messageID from the request value
- Looks up the in-progress operation on the same connection
- Returns
NoSuchOperation(119) if the target is not found - Returns
CannotCancel(121) for non-cancelable operations (Bind, Abandon, StartTLS, Cancel) - Otherwise signals the target via
m.Doneand responds withCanceled(118)
Handlers should check m.Done to detect both Cancel and Abandon signals:
select {
case <-m.Done:
// Operation was canceled or abandoned
res := ldap.NewSearchResultDoneResponse(ldap.LDAPResultCanceled)
w.Write(res)
return
default:
}To override the built-in behavior (e.g. for logging or authorization), register a custom handler with routes.Cancel(handler).
See the examples/cancel directory for a complete working example.
When no route matches the request, the server will first try to call a special NotFound route, if nothing is specified, it will return an UnwillingToResponse Error code (53)
Feel free to contribute, comment :)
// Listen to 10389 port for LDAP Request
// and route bind request to the handleBind func
package main
import (
"log"
"os"
"os/signal"
"syscall"
ldap "github.com/vjeantet/ldapserver"
)
func main() {
//ldap logger
ldap.Logger = log.New(os.Stdout, "[server] ", log.LstdFlags)
//Create a new LDAP Server
server := ldap.NewServer()
routes := ldap.NewRouteMux()
routes.Bind(handleBind)
server.Handle(routes)
// listen on 10389
go server.ListenAndServe("127.0.0.1:10389")
// When CTRL+C, SIGINT and SIGTERM signal occurs
// Then stop server gracefully
ch := make(chan os.Signal)
signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM)
<-ch
close(ch)
server.Stop()
}
// handleBind return Success if username == "myLogin" , whatever the value of the password
func handleBind(w ldap.ResponseWriter, m *ldap.Message) {
r := m.GetBindRequest()
res := ldap.NewBindResponse(ldap.LDAPResultSuccess)
if string(r.Name()) == "myLogin" {
w.Write(res)
return
}
log.Printf("Bind failed User=%s, Pass=%s", string(r.Name()), string(r.AuthenticationSimple()))
res.SetResultCode(ldap.LDAPResultInvalidCredentials)
res.SetDiagnosticMessage("invalid credentials")
w.Write(res)
}Send a SearchResultReference to redirect the client to another server for part of the search:
ref := ldap.NewSearchResultReference("ldap://other.example/dc=ref,dc=example")
w.Write(ref)
res := ldap.NewSearchResultDoneResponse(ldap.LDAPResultSuccess)
w.Write(res)Return a referral result code (10) with one or more referral URLs:
res := ldap.NewSearchResultDoneResponse(ldap.LDAPResultReferral)
res.SetDiagnosticMessage("please follow the referral")
res.SetReferral(ldap.NewReferral("ldap://alt.example/dc=redirect,dc=example"))
w.Write(res)Attach controls to the LDAPMessage envelope using WriteWithControls:
res := ldap.NewSearchResultDoneResponse(ldap.LDAPResultSuccess)
ctrl := ldap.NewControl("1.2.3.4.5.6.7.8.9", false, nil)
ldap.WriteWithControls(w, res, ctrl)WriteWithControls accepts one or more controls as variadic arguments. It is backward-compatible - the ResponseWriter interface is unchanged.
See the examples/referrals_controls directory for a complete working example.
Instead of ListenAndServe, you can pass your own net.Listener to integrate the LDAP server into an existing application:
ln, _ := net.Listen("tcp", ":10389")
go server.Serve(ln)For LDAPS, set TLSConfig on the server and use ServeTLS:
server.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cert}}
ln, _ := net.Listen("tcp", ":10636")
go server.ServeTLS(ln)ReadTimeout and WriteTimeout are optional deadlines applied to each accepted connection. They default to zero, which means no deadline:
server.ReadTimeout = 30 * time.Second
server.WriteTimeout = 30 * time.SecondHandlers can store and retrieve arbitrary data on the current connection using SetData and GetData. This is useful for tracking session state (e.g. the authenticated DN after a bind):
// In the bind handler: store the authenticated identity
func handleBind(w ldap.ResponseWriter, m *ldap.Message) {
r := m.GetBindRequest()
m.Client.SetData(string(r.Name()))
w.Write(ldap.NewBindResponse(ldap.LDAPResultSuccess))
}
// In the search handler: retrieve it
func handleSearch(w ldap.ResponseWriter, m *ldap.Message) {
boundDN, _ := m.Client.GetData().(string)
log.Printf("Search by %s", boundDN)
// ...
}Each connection has its own independent data. GetData returns nil until SetData is called.
See the examples/client_data directory for a complete working example.
SetData/GetData stores an opaque value. When you would rather keep per-connection state in a dedicated struct with methods, build the server with NewServerWithHandlerSource. The server calls GetHandler() once per accepted connection, so each connection gets its own handler instance:
type myHandlerSource struct{}
// Called by the server on each new connection.
func (*myHandlerSource) GetHandler() ldap.Handler {
routes := ldap.NewRouteMux()
mh := &myHandler{}
routes.Bind(mh.handleBind)
routes.Search(mh.handleSearch)
return routes
}
// One instance per connection: fields are connection-scoped state.
type myHandler struct {
username string
wasBound bool
}
func main() {
server := ldap.NewServerWithHandlerSource(&myHandlerSource{})
go server.ListenAndServe("127.0.0.1:10389")
// ...
}A server built this way must not also call Handle() - doing so panics. This is typically used to let a failed Bind restrict or refuse subsequent operations on the same connection.
See the examples/handler_per_connection directory for a complete working example.
Called for each accepted connection, before any LDAP message is read. Returning a non-nil error closes the connection:
server.OnNewConnection = func(c net.Conn) error {
log.Printf("new connection from %s", c.RemoteAddr())
return nil
}Called once when a client session ends, after request processing has stopped and before the connection is closed. It receives the connection and whatever SetData stored on it:
server.OnClientClose = func(conn net.Conn, data any) {
boundDN, _ := data.(string)
log.Printf("session ended for %s (%s)", conn.RemoteAddr(), boundDN)
}Notes:
- The hook runs exactly once per connection, on unbind, on abrupt disconnect and on server shutdown.
datamay benil- the hook also runs for connections rejected byOnNewConnectionor closed beforeSetDatawas called.- The hook must not block: it runs on the shutdown path, and a blocking hook prevents
Stop()from returning. - A panic inside the hook is recovered and logged, so it cannot break shutdown.
Route matching can discriminate on the bind authentication choice with AuthenticationChoice, which accepts "simple" or "sasl":
routes.Bind(handleSimpleBind).AuthenticationChoice("simple")
routes.Bind(handleSaslBind).AuthenticationChoice("sasl")Inside a handler, the requested SASL mechanism is available through the bind request:
func handleSaslBind(w ldap.ResponseWriter, m *ldap.Message) {
r := m.GetBindRequest()
mech := string(r.AuthenticationSasl().Mechanism())
if strings.ToUpper(mech) != "EXTERNAL" {
res := ldap.NewBindResponse(ldap.LDAPResultAuthMethodNotSupported)
res.SetDiagnosticMessage("SASL mechanism not supported: " + mech)
w.Write(res)
return
}
// ...
}For SASL/EXTERNAL, the identity comes from the TLS layer rather than from the request. Get the connection with m.Client.GetConn() and, when it is a *tls.Conn, read the peer certificate subject:
if tlsConn, ok := m.Client.GetConn().(*tls.Conn); ok {
cert := tlsConn.ConnectionState().PeerCertificates[0]
user := cert.Subject.String()
// ...
}See the examples/sasl_external_pki directory for a complete working example: mutual TLS, a scripted PKI, authz DN rewriting and a TLS-conditional supportedSASLMechanisms in the Root DSE. That directory has its own README with the ldapsearch/ldapwhoami commands to exercise it.
Look into the examples folder:
| Example | What it shows |
|---|---|
simple |
Minimal server: bind and search routes |
complex |
All operations, Root DSE, StartTLS, route labels |
ssl |
LDAPS listener |
abandon |
Handling AbandonRequest and the m.Done signal |
cancel |
Cancel extended operation (RFC 3909), built-in and custom handling |
client_data |
Per-connection data with SetData/GetData |
handler_per_connection |
One handler instance per connection via NewServerWithHandlerSource |
referrals_controls |
SearchResultReference, referral result codes, response controls |
sasl_external_pki |
SASL/EXTERNAL over mutual TLS, with a PKI generation script |
client |
Sample PHP and Python client scripts used to exercise the examples |
go test -v # run all tests
go test -race -v # run all tests with the race detector
go test -v -run TestE2E # run only the E2E testsTestConcurrentRequestListAccess- verifies thread-safe access to the per-connection request mapTestShutdownListenerRace- checks for races during server shutdownTestValidBindRequest,TestValidBindAfterInvalidConnection- raw protocol-level bind scenariosTestInvalidFirstByte_NoServerCrash,TestGarbageBytes_NoServerCrash- server resilience to malformed inputTestStopRefusesNewConnections- confirms the listener is closed beforeStop()returnsTestParseCancelRequestValue*- Cancel request value ASN.1 decoding (valid IDs, nil, invalid, trailing data, zero)TestClientCloseHook_UnbindPassesDataAndRunsOnce-OnClientClosereceives the client data and fires once on unbindTestClientCloseHook_AbruptDisconnectPassesDataAndRunsOnce- same guarantees when the client drops the connectionTestClientCloseHook_PanicDoesNotBreakStop- a panickingOnClientClosehook does not preventStop()from completing
These tests start a full LDAP server (random port, all operations routed) and exercise it with a real LDAP client (github.com/go-ldap/ldap/v3).
| Test | What it covers |
|---|---|
TestE2E_BindSuccess |
Successful simple bind |
TestE2E_BindFailure |
Bind with wrong credentials returns InvalidCredentials (49) |
TestE2E_SearchDSE |
Root DSE search (BaseDN="", ScopeBaseObject) returns 1 entry with vendorName |
TestE2E_SearchGeneric |
Subtree search returns 2 entries with expected attributes |
TestE2E_SearchRouteConstraints |
Route matching by BaseDN/Scope/Filter directs requests to the correct handler |
TestE2E_Add |
Add entry returns Success |
TestE2E_Modify |
Modify entry (replace + add attributes) returns Success |
TestE2E_Delete |
Delete entry returns Success |
TestE2E_Compare |
Compare returns CompareTrue (6) |
TestE2E_ExtendedWhoAmI |
Extended WhoAmI operation returns Success |
TestE2E_UnbindClosesConnection |
After unbind/close, further operations fail |
TestE2E_NotFoundHandler |
Unrouted Extended request triggers NotFound handler (UnwillingToPerform, 53) |
TestE2E_FullSequence |
Bind, Add, Modify, Delete, Compare, Search on a single connection |
TestE2E_SearchResultReference |
Handler sends a SearchResultReference; client receives the referral URL |
TestE2E_LDAPResultReferral |
Handler returns SearchResultDone with result code Referral (10) |
TestE2E_ResponseControls |
Handler attaches a control via WriteWithControls; client sees the control OID |
TestE2E_CancelNoSuchOperation |
Cancel a non-existent messageID returns NoSuchOperation (119) |
TestE2E_CancelInProgressSearch |
Cancel a blocking search; both cancel response and search result return Canceled (118) |
TestE2E_ClientData |
SetData/GetData persists across operations on the same connection; two connections have isolated data |
TestE2E_ClientDataNilByDefault |
GetData returns nil on a fresh connection |
TestE2E_CancelUserDefinedHandler |
Custom routes.Cancel(handler) takes precedence over built-in auto-handling |