From beca2fd7eb4460af971d0ac27f1357cd4bf4dd80 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 24 Apr 2026 12:56:46 +0700 Subject: [PATCH] Initial commit --- .gitignore | 2 + Dockerfile | 15 + LICENSE | 21 ++ Makefile | 28 ++ NOTICE | 31 +++ README.md | 99 +++++++ checker/collect.go | 485 ++++++++++++++++++++++++++++++++ checker/definition.go | 69 +++++ checker/interactive.go | 47 ++++ checker/provider.go | 63 +++++ checker/report.go | 548 +++++++++++++++++++++++++++++++++++++ checker/rules.go | 107 ++++++++ checker/rules_bind.go | 129 +++++++++ checker/rules_posture.go | 237 ++++++++++++++++ checker/rules_transport.go | 279 +++++++++++++++++++ checker/tls_related.go | 174 ++++++++++++ checker/types.go | 182 ++++++++++++ go.mod | 22 ++ go.sum | 112 ++++++++ main.go | 28 ++ plugin/plugin.go | 20 ++ 21 files changed, 2698 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 NOTICE create mode 100644 README.md create mode 100644 checker/collect.go create mode 100644 checker/definition.go create mode 100644 checker/interactive.go create mode 100644 checker/provider.go create mode 100644 checker/report.go create mode 100644 checker/rules.go create mode 100644 checker/rules_bind.go create mode 100644 checker/rules_posture.go create mode 100644 checker/rules_transport.go create mode 100644 checker/tls_related.go create mode 100644 checker/types.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 plugin/plugin.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bfcff61 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +checker-ldap +checker-ldap.so diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..23ed7a5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM golang:1.25-alpine AS builder + +ARG CHECKER_VERSION=custom-build + +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -tags standalone -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-ldap . + +FROM scratch +COPY --from=builder /checker-ldap /checker-ldap +USER 65534:65534 +EXPOSE 8080 +ENTRYPOINT ["/checker-ldap"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..07d44d8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 The happyDomain Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7f52e35 --- /dev/null +++ b/Makefile @@ -0,0 +1,28 @@ +CHECKER_NAME := checker-ldap +CHECKER_IMAGE := happydomain/$(CHECKER_NAME) +CHECKER_VERSION ?= custom-build + +CHECKER_SOURCES := main.go $(wildcard checker/*.go) + +GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION) + +.PHONY: all plugin docker test clean + +all: $(CHECKER_NAME) + +$(CHECKER_NAME): $(CHECKER_SOURCES) + go build -tags standalone -ldflags "$(GO_LDFLAGS)" -o $@ . + +plugin: $(CHECKER_NAME).so + +$(CHECKER_NAME).so: $(CHECKER_SOURCES) $(wildcard plugin/*.go) + go build -buildmode=plugin -ldflags "$(GO_LDFLAGS)" -o $@ ./plugin/ + +docker: + docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) . + +test: + go test -tags standalone ./... + +clean: + rm -f $(CHECKER_NAME) $(CHECKER_NAME).so diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..3d8d24a --- /dev/null +++ b/NOTICE @@ -0,0 +1,31 @@ +checker-ldap +Copyright (c) 2026 The happyDomain Authors + +This product is licensed under the MIT License (see LICENSE). + +------------------------------------------------------------------------------- +Third-party notices +------------------------------------------------------------------------------- + +This product includes software developed as part of the checker-sdk-go +project (https://git.happydns.org/happyDomain/checker-sdk-go), licensed +under the Apache License, Version 2.0: + + checker-sdk-go + Copyright 2020-2026 The happyDomain Authors + +This product includes software from the miekg/dns project +(https://github.com/miekg/dns), licensed under the BSD 3-Clause License: + + Copyright (c) 2009 The Go Authors. All rights reserved. + Copyright (c) 2011 Miek Gieben. All rights reserved. + Copyright (c) 2014 CloudFlare. All rights reserved. + +This product includes software from the go-ldap/ldap project +(https://github.com/go-ldap/ldap), licensed under the MIT License: + + Copyright (c) 2011-2015 Michael Mitton + Copyright (c) 2015-2016 The go-ldap Authors. + +You may obtain a copy of the Apache License 2.0 at: + http://www.apache.org/licenses/LICENSE-2.0 diff --git a/README.md b/README.md new file mode 100644 index 0000000..ff42106 --- /dev/null +++ b/README.md @@ -0,0 +1,99 @@ +# checker-ldap + +LDAP directory checker for [happyDomain](https://www.happydomain.org/). + +Probes a domain's LDAP deployment end-to-end: SRV discovery +(`_ldap._tcp`, `_ldaps._tcp`), transport security (StartTLS per RFC 2830, +implicit TLS on port 636), RootDSE introspection (supportedSASLMechanisms, +supportedControl, supportedLDAPVersion, namingContexts, vendor +fingerprint), anonymous exposure (anonymous bind + baseObject search), +plaintext-bind refusal posture, and -- when credentials are supplied -- +an authenticated bind with an optional `baseObject` read on a base DN. + +TLS certificate chain / SAN / expiry / cipher posture is **out of scope** +-- the dedicated TLS checker handles that. This checker only confirms that +a TLS session can be established and records the negotiated TLS version +and cipher for context. + +We publish each probed endpoint as a `DiscoveryEntry` of type +`tls.endpoint.v1` so that `checker-tls` (or any other consumer of that +contract) can run TLS posture checks against them without redoing the +SRV lookup. For `_ldap._tcp` targets we emit `STARTTLS: "ldap"` with +`RequireSTARTTLS: true`, so a misconfigured server that later drops +StartTLS shows up as a CRIT, not a WARN. For `_ldaps._tcp` we emit +direct-TLS endpoints (`STARTTLS: ""`). + +The TLS checker's resulting observations (under the `tls_probes` key) +are folded back into our rule aggregation and HTML report via the SDK's +`ObservationGetter.GetRelated` / `ReportContext.Related` path: a bad +certificate on an LDAP endpoint shows up on the LDAP service page, not +only in a separate TLS view. + +## What it checks + +For each of `_ldap._tcp` (with fallback to port 389) and `_ldaps._tcp` +(fallback to port 636): + +- **Reachability**: TCP connect on each resolved A/AAAA address, per + IP family, timing captured. +- **Transport security**: + - On `_ldap._tcp`: whether the server advertises StartTLS in its + RootDSE `supportedExtension` (OID 1.3.6.1.4.1.1466.20037), whether + the StartTLS upgrade succeeds, and whether cleartext simple binds + are refused with `confidentialityRequired` (resultCode 13) per + RFC 4513 §5.1.2. + - On `_ldaps._tcp`: whether the implicit TLS handshake succeeds. +- **RootDSE introspection**: + - `supportedLDAPVersion` -- flags a legacy LDAPv2 advertisement. + - `supportedSASLMechanisms` -- warns when only PLAIN/LOGIN are offered + and when no strong mechanism (SCRAM-*, EXTERNAL, GSSAPI) is present. + - `supportedControl`, `supportedExtension`, `namingContexts`, + `vendorName`, `vendorVersion` -- captured for the report. +- **Anonymous exposure**: + - Anonymous bind attempted; result noted. + - When anonymous bind succeeds and at least one naming context is + advertised, a `baseObject` search is issued on the first naming + context. Any returned entry is flagged as + `ldap.anon.search_allowed` -- the DIT is enumerable without + credentials. +- **Credential test (optional)**: when `bind_dn` and `bind_password` are + supplied, a simple bind is performed **only on a TLS-protected + channel**. When the bind succeeds and `base_dn` is supplied, a + `baseObject` search is performed on that DN to confirm the account + has read access to the intended subtree. + +## Most common failure scenarios (addressed in the report) + +1. **No encrypted endpoint reachable** → `ldap.no_encrypted_endpoint` / + CRIT. Operator must enable either LDAPS or StartTLS. +2. **StartTLS not offered on 389** → `ldap.starttls.missing` / CRIT. + Server-specific remediation included (OpenLDAP, 389-ds). +3. **StartTLS advertised but upgrade fails** → + `ldap.starttls.handshake_failed` / CRIT. Hints to run the TLS + checker for cipher/cert details. +4. **Cleartext bind accepted on 389 without StartTLS** → + `ldap.plain_bind.accepted` / CRIT. Remediation via `olcSecurity` on + OpenLDAP, `require_tls` on 389-ds. +5. **LDAPS handshake fails on 636** → `ldap.ldaps.handshake_failed` / + CRIT. +6. **Anonymous search exposes DIT** → `ldap.anon.search_allowed` / WARN. +7. **Only PLAIN/LOGIN SASL offered** → `ldap.sasl.plain_only` / WARN. +8. **LDAPv2 still advertised** → `ldap.legacy_v2` / WARN. +9. **RootDSE unreadable on an otherwise working endpoint** → + `ldap.rootdse.unreadable` / WARN. +10. **Provided bind DN / password fail** → `ldap.bind.failed` / CRIT -- + surfaces credential / lockout issues immediately. + +## Options + +| Id | Required | Description | +|-----------------|----------|-----------------------------------------------------------------------------| +| `domain` | yes | Auto-filled from the service scope (domain name). | +| `timeout` | no | Per-endpoint timeout in seconds (default: 10). | +| `bind_dn` | no | DN to bind as. Used only when `bind_password` is also set. | +| `bind_password` | no | Secret. Bound only after TLS is established; never sent over cleartext. | +| `base_dn` | no | Base DN to test read access against. Requires a successful authenticated bind. | + +## License + +MIT (see `LICENSE` and `NOTICE`). diff --git a/checker/collect.go b/checker/collect.go new file mode 100644 index 0000000..35d38d5 --- /dev/null +++ b/checker/collect.go @@ -0,0 +1,485 @@ +package checker + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "net" + "slices" + "strconv" + "strings" + "sync" + "time" + + ldapv3 "github.com/go-ldap/ldap/v3" + "github.com/miekg/dns" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// tlsProbeConfig builds a permissive *tls.Config for probing: hostname +// verification is skipped because cert validation is the TLS checker's +// job. We only care that a TLS session can be established at all. +func tlsProbeConfig(serverName string) *tls.Config { + return &tls.Config{ + ServerName: serverName, + InsecureSkipVerify: true, //nolint:gosec -- cert validation is the TLS checker's job + MinVersion: tls.VersionTLS10, + } +} + +// Collect runs the full LDAP probe for a domain. +func (p *ldapProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) { + domain, _ := sdk.GetOption[string](opts, "domain") + domain = strings.TrimSuffix(domain, ".") + if domain == "" { + return nil, fmt.Errorf("domain is required") + } + + bindDN, _ := sdk.GetOption[string](opts, "bind_dn") + bindPassword, _ := sdk.GetOption[string](opts, "bind_password") + baseDN, _ := sdk.GetOption[string](opts, "base_dn") + + timeoutSecs := sdk.GetFloatOption(opts, "timeout", 10) + if timeoutSecs < 1 { + timeoutSecs = 10 + } + perEndpoint := time.Duration(timeoutSecs * float64(time.Second)) + + data := &LDAPData{ + Domain: domain, + BaseDN: baseDN, + RunAt: time.Now().UTC().Format(time.RFC3339), + SRV: SRVLookup{Errors: map[string]string{}}, + } + + resolver := net.DefaultResolver + + var srvWG sync.WaitGroup + var srvErrMu sync.Mutex + srvWG.Add(2) + lookup := func(prefix string, dst *[]SRVRecord) { + defer srvWG.Done() + records, err := lookupSRV(ctx, resolver, prefix, domain) + if err != nil { + srvErrMu.Lock() + data.SRV.Errors[prefix] = err.Error() + srvErrMu.Unlock() + return + } + *dst = records + } + go lookup("_ldap._tcp.", &data.SRV.LDAP) + go lookup("_ldaps._tcp.", &data.SRV.LDAPS) + srvWG.Wait() + + totalSRV := len(data.SRV.LDAP) + len(data.SRV.LDAPS) + if totalSRV == 0 { + data.SRV.FallbackProbed = true + data.SRV.LDAP = []SRVRecord{{Target: domain, Port: 389}} + data.SRV.LDAPS = []SRVRecord{{Target: domain, Port: 636}} + } + + resolveAll(ctx, resolver, data.SRV.LDAP, data.SRV.LDAPS) + + data.BindTested = bindDN != "" && bindPassword != "" + + var plainEndpoints, ldapsEndpoints []EndpointProbe + var probeWG sync.WaitGroup + probeWG.Add(2) + go func() { + defer probeWG.Done() + plainEndpoints = probeSet(ctx, domain, ModePlain, "_ldap._tcp", data.SRV.LDAP, perEndpoint, bindDN, bindPassword, baseDN) + }() + go func() { + defer probeWG.Done() + ldapsEndpoints = probeSet(ctx, domain, ModeLDAPS, "_ldaps._tcp", data.SRV.LDAPS, perEndpoint, bindDN, bindPassword, baseDN) + }() + probeWG.Wait() + data.Endpoints = append(plainEndpoints, ldapsEndpoints...) + + return data, nil +} + +func probeSet(ctx context.Context, domain string, mode LDAPMode, prefix string, records []SRVRecord, timeout time.Duration, bindDN, bindPassword, baseDN string) []EndpointProbe { + type task struct { + rec SRVRecord + addr *probeAddr // nil means the SRV target has no A/AAAA records + } + var tasks []task + for _, rec := range records { + addrs := addressesForProbe(rec) + if len(addrs) == 0 { + tasks = append(tasks, task{rec: rec}) + continue + } + for _, a := range addrs { + tasks = append(tasks, task{rec: rec, addr: &a}) + } + } + + results := make([]EndpointProbe, len(tasks)) + var wg sync.WaitGroup + for i, t := range tasks { + wg.Add(1) + go func(i int, t task) { + defer wg.Done() + if t.addr == nil { + results[i] = EndpointProbe{ + Mode: mode, + SRVPrefix: prefix, + Target: t.rec.Target, + Port: t.rec.Port, + Error: "no A/AAAA records for target", + } + return + } + results[i] = probeEndpoint(ctx, domain, mode, prefix, t.rec, t.addr.ip, t.addr.isV6, timeout, bindDN, bindPassword, baseDN) + }(i, t) + } + wg.Wait() + return results +} + +type probeAddr struct { + ip string + isV6 bool +} + +func addressesForProbe(rec SRVRecord) []probeAddr { + var out []probeAddr + for _, ip := range rec.IPv4 { + out = append(out, probeAddr{ip: ip, isV6: false}) + } + for _, ip := range rec.IPv6 { + out = append(out, probeAddr{ip: ip, isV6: true}) + } + return out +} + +// probeEndpoint runs the full probe on a single (host, ip, port) tuple: +// TCP connect → optional StartTLS or direct-TLS handshake → RootDSE read → +// plaintext-bind posture check (only on LDAP:389 before TLS) → optional +// authenticated bind + base-DN read. +func probeEndpoint(ctx context.Context, domain string, mode LDAPMode, prefix string, rec SRVRecord, ip string, isV6 bool, timeout time.Duration, bindDN, bindPassword, baseDN string) EndpointProbe { + start := time.Now() + result := EndpointProbe{ + Mode: mode, + SRVPrefix: prefix, + Target: rec.Target, + Port: rec.Port, + Address: net.JoinHostPort(ip, strconv.Itoa(int(rec.Port))), + IsIPv6: isV6, + } + defer func() { result.ElapsedMS = time.Since(start).Milliseconds() }() + + dialCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + dialer := &net.Dialer{} + rawConn, err := dialer.DialContext(dialCtx, "tcp", result.Address) + if err != nil { + result.Error = "tcp: " + err.Error() + return result + } + result.TCPConnected = true + defer rawConn.Close() + // renewDeadline keeps the underlying TCP deadline rolling per major + // step so a slow TLS handshake doesn't starve the RootDSE / bind / + // base-read calls that follow. + renewDeadline := func() { _ = rawConn.SetDeadline(time.Now().Add(timeout)) } + renewDeadline() + + // For the plaintext-bind posture check on Mode=ldap, we first spin up + // a separate short-lived connection before upgrading this one to TLS. + // A single raw connection can't both "test cleartext bind refusal" and + // "then StartTLS" cleanly -- once we've bound we'd skew RootDSE results. + if mode == ModePlain { + result.PlaintextBindTested, result.PlaintextBindAccepted = probePlaintextBindRefusal(result.Address, timeout) + } + + // Establish the LDAP session we'll use for the rest of the probe. + var conn *ldapv3.Conn + if mode == ModeLDAPS { + tlsConn := tls.Client(rawConn, tlsProbeConfig(domain)) + if err := tlsConn.Handshake(); err != nil { + result.Error = "tls-handshake: " + err.Error() + return result + } + result.TLSEstablished = true + state := tlsConn.ConnectionState() + result.TLSVersion = tls.VersionName(state.Version) + result.TLSCipher = tls.CipherSuiteName(state.CipherSuite) + renewDeadline() + conn = ldapv3.NewConn(tlsConn, true) + } else { + conn = ldapv3.NewConn(rawConn, false) + } + conn.Start() + defer conn.Close() + conn.SetTimeout(timeout) + + // Try RootDSE over the native transport first -- works on LDAPS straight + // away, and on LDAP it reveals the supported extensions including + // StartTLS capability before we attempt the upgrade. + renewDeadline() + readRootDSE(conn, &result) + + if mode == ModePlain { + // Detect StartTLS advertisement in supportedExtension. RFC 4511 + // says the RootDSE MAY advertise "1.3.6.1.4.1.1466.20037". + offersStartTLS := stringListContainsFold(result.SupportedExtension, "1.3.6.1.4.1.1466.20037") + if offersStartTLS { + result.StartTLSOffered = true + if err := conn.StartTLS(tlsProbeConfig(domain)); err != nil { + result.Error = "starttls: " + err.Error() + } else { + result.StartTLSUpgraded = true + result.TLSEstablished = true + renewDeadline() + // go-ldap doesn't expose the *tls.ConnectionState directly. + // Fall back to inspecting the underlying conn via TLSConnectionState. + if state, ok := conn.TLSConnectionState(); ok { + result.TLSVersion = tls.VersionName(state.Version) + result.TLSCipher = tls.CipherSuiteName(state.CipherSuite) + } + // Post-TLS SASL refresh: some servers only publish the + // strong mechanisms once the channel is encrypted. We only + // need the mechanisms list -- naming contexts, LDAP + // version and vendor strings don't change. + refreshSASLMechanisms(conn, &result) + } + } else if !result.RootDSERead { + result.Error = "rootdse-unreadable: RootDSE could not be read" + } + } + + // Anonymous bind + search -- we try unconditionally so we can flag + // exposure. + renewDeadline() + anonBindOK := conn.Bind("", "") == nil + result.AnonymousBindAllowed = anonBindOK + if anonBindOK && len(result.NamingContexts) > 0 { + // baseObject search returns 0 or 1 entries -- we only want to + // detect whether an anonymous query can peek at DIT contents. + sr, err := conn.Search(ldapv3.NewSearchRequest( + result.NamingContexts[0], + ldapv3.ScopeBaseObject, + ldapv3.NeverDerefAliases, + 1, int(timeout.Seconds()), false, + "(objectClass=*)", + []string{"1.1"}, // request no attributes + nil, + )) + if err == nil && sr != nil && len(sr.Entries) > 0 { + result.AnonymousSearchAllowed = true + } + } + + // Authenticated bind + base DN read (only when caller provided creds + // AND we are on an encrypted channel -- never ship a password over + // cleartext). + if bindDN != "" && bindPassword != "" && result.TLSEstablished { + result.BindAttempted = true + renewDeadline() + err := conn.Bind(bindDN, bindPassword) + if err == nil { + result.BindOK = true + if baseDN != "" { + result.BaseReadAttempted = true + renewDeadline() + sr, err := conn.Search(ldapv3.NewSearchRequest( + baseDN, + ldapv3.ScopeBaseObject, + ldapv3.NeverDerefAliases, + 1, int(timeout.Seconds()), false, + "(objectClass=*)", + []string{"1.1"}, + nil, + )) + if err != nil { + result.BaseReadError = err.Error() + } else if sr != nil { + result.BaseReadOK = true + result.BaseReadEntries = len(sr.Entries) + } + } + } else { + result.BindError = err.Error() + } + } + + return result +} + +// probePlaintextBindRefusal opens a short-lived, fresh TCP connection and +// attempts a simple bind with a fixed, syntactically-safe DN over cleartext. +// We are not probing credentials -- we want to learn whether the server +// refuses authentication on an unprotected link (RFC 4513 §5.1.2 calls this +// "confidentialityRequired" / resultCode 13). Any response other than +// resultCode 13 means the server will accept cleartext bind attempts. +func probePlaintextBindRefusal(address string, timeout time.Duration) (tested, accepted bool) { + dialer := &net.Dialer{Timeout: timeout} + raw, err := dialer.Dial("tcp", address) + if err != nil { + return false, false + } + defer raw.Close() + _ = raw.SetDeadline(time.Now().Add(timeout)) + + conn := ldapv3.NewConn(raw, false) + conn.Start() + defer conn.Close() + conn.SetTimeout(timeout) + + tested = true + // Fixed probe DN -- caller-supplied domain is not interpolated to avoid + // LDAP DN injection. The server is expected to reject this DN regardless + // of value; we only care whether it returns confidentialityRequired (13) + // or attempts the bind anyway. + err = conn.Bind("cn=checker-probe", "x-not-a-real-password-x") + if err == nil { + return tested, true + } + // resultCode 13 (confidentialityRequired) is the only response that + // means the server actively refused to authenticate over cleartext. + // Anything else (49 invalidCredentials, 32 noSuchObject, …) means the + // server was willing to attempt the bind, which is the insecure + // posture we want to flag. + var lerr *ldapv3.Error + if errors.As(err, &lerr) && lerr.ResultCode == ldapv3.LDAPResultConfidentialityRequired { + return tested, false + } + return tested, true +} + +// readRootDSE performs a single RootDSE lookup and fills the matching +// fields on ep. Failures are not fatal -- many hardened servers refuse +// anonymous RootDSE reads; we just note that we couldn't read it. +func readRootDSE(conn *ldapv3.Conn, ep *EndpointProbe) { + sr, err := conn.Search(ldapv3.NewSearchRequest( + "", + ldapv3.ScopeBaseObject, + ldapv3.NeverDerefAliases, + 1, 5, false, + "(objectClass=*)", + []string{ + "supportedLDAPVersion", + "supportedSASLMechanisms", + "supportedControl", + "supportedExtension", + "namingContexts", + "vendorName", + "vendorVersion", + }, + nil, + )) + if err != nil || sr == nil || len(sr.Entries) == 0 { + return + } + ep.RootDSERead = true + e := sr.Entries[0] + ep.SupportedLDAPVersion = unique(append(ep.SupportedLDAPVersion, e.GetAttributeValues("supportedLDAPVersion")...)) + ep.SupportedSASLMechanisms = unique(append(ep.SupportedSASLMechanisms, e.GetAttributeValues("supportedSASLMechanisms")...)) + ep.SupportedControl = unique(append(ep.SupportedControl, e.GetAttributeValues("supportedControl")...)) + ep.SupportedExtension = unique(append(ep.SupportedExtension, e.GetAttributeValues("supportedExtension")...)) + ep.NamingContexts = unique(append(ep.NamingContexts, e.GetAttributeValues("namingContexts")...)) + if v := e.GetAttributeValue("vendorName"); v != "" { + ep.VendorName = v + } + if v := e.GetAttributeValue("vendorVersion"); v != "" { + ep.VendorVersion = v + } +} + +// refreshSASLMechanisms re-queries the RootDSE after StartTLS to pick up any +// SASL mechanisms the server only advertises over an encrypted channel. +func refreshSASLMechanisms(conn *ldapv3.Conn, ep *EndpointProbe) { + sr, err := conn.Search(ldapv3.NewSearchRequest( + "", + ldapv3.ScopeBaseObject, + ldapv3.NeverDerefAliases, + 1, 5, false, + "(objectClass=*)", + []string{"supportedSASLMechanisms"}, + nil, + )) + if err != nil || sr == nil || len(sr.Entries) == 0 { + return + } + ep.SupportedSASLMechanisms = unique(append(ep.SupportedSASLMechanisms, sr.Entries[0].GetAttributeValues("supportedSASLMechanisms")...)) +} + +func lookupSRV(ctx context.Context, r *net.Resolver, prefix, domain string) ([]SRVRecord, error) { + name := prefix + dns.Fqdn(domain) + _, records, err := r.LookupSRV(ctx, "", "", name) + if err != nil { + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) && dnsErr.IsNotFound { + return nil, nil + } + return nil, err + } + // RFC 2782: single record "." with port 0 means "service explicitly not + // available at this domain". Treat that as "no records" for probing. + if len(records) == 1 && (records[0].Target == "." || records[0].Target == "") && records[0].Port == 0 { + return nil, nil + } + out := make([]SRVRecord, 0, len(records)) + for _, r := range records { + out = append(out, SRVRecord{ + Target: strings.TrimSuffix(r.Target, "."), + Port: r.Port, + Priority: r.Priority, + Weight: r.Weight, + }) + } + return out, nil +} + +// resolveAll resolves A/AAAA for every record across all sets concurrently. +// Each goroutine writes only to its own record, so no lock is needed. +func resolveAll(ctx context.Context, r *net.Resolver, sets ...[]SRVRecord) { + var wg sync.WaitGroup + for _, records := range sets { + for i := range records { + wg.Add(1) + go func(rec *SRVRecord) { + defer wg.Done() + ips, err := r.LookupIPAddr(ctx, rec.Target) + if err != nil { + return + } + for _, ip := range ips { + if v4 := ip.IP.To4(); v4 != nil { + rec.IPv4 = append(rec.IPv4, v4.String()) + } else { + rec.IPv6 = append(rec.IPv6, ip.IP.String()) + } + } + }(&records[i]) + } + } + wg.Wait() +} + +func stringListContainsFold(list []string, want string) bool { + return slices.ContainsFunc(list, func(s string) bool { return strings.EqualFold(s, want) }) +} + +func unique(list []string) []string { + if len(list) <= 1 { + return list + } + seen := make(map[string]struct{}, len(list)) + out := make([]string, 0, len(list)) + for _, s := range list { + if _, ok := seen[s]; ok { + continue + } + seen[s] = struct{}{} + out = append(out, s) + } + return out +} diff --git a/checker/definition.go b/checker/definition.go new file mode 100644 index 0000000..61db31c --- /dev/null +++ b/checker/definition.go @@ -0,0 +1,69 @@ +package checker + +import ( + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Version is reported in CheckerDefinition.Version. Overridden at build time +// by main / plugin. +var Version = "built-in" + +func (p *ldapProvider) Definition() *sdk.CheckerDefinition { + return &sdk.CheckerDefinition{ + ID: "ldap", + Name: "LDAP Directory", + Version: Version, + Availability: sdk.CheckerAvailability{ + ApplyToService: true, + LimitToServices: []string{"abstract.LDAP"}, + }, + HasHTMLReport: true, + ObservationKeys: []sdk.ObservationKey{ObservationKeyLDAP}, + Options: sdk.CheckerOptionsDocumentation{ + RunOpts: []sdk.CheckerOptionDocumentation{ + { + Id: "domain", + Type: "string", + Label: "Domain", + AutoFill: sdk.AutoFillDomainName, + Required: true, + }, + { + Id: "timeout", + Type: "number", + Label: "Per-endpoint timeout (seconds)", + Default: 10, + }, + { + Id: "bind_dn", + Type: "string", + Label: "Bind DN", + Placeholder: "cn=reader,dc=example,dc=com", + Description: "Optional. When set (with bind_password), the checker performs an authenticated bind over TLS and reports whether the directory accepts the provided credentials.", + }, + { + Id: "bind_password", + Type: "string", + Label: "Bind password", + Secret: true, + Description: "Optional. Only used when bind_dn is set. The password is not persisted in the observation payload.", + }, + { + Id: "base_dn", + Type: "string", + Label: "Base DN (read test)", + Placeholder: "dc=example,dc=com", + Description: "Optional. When set, the checker runs a baseObject search on this DN after a successful bind to verify the account has read access. Falls back to an anonymous baseObject search when no bind DN is supplied.", + }, + }, + }, + Rules: Rules(), + Interval: &sdk.CheckIntervalSpec{ + Min: 5 * time.Minute, + Max: 7 * 24 * time.Hour, + Default: 12 * time.Hour, + }, + } +} diff --git a/checker/interactive.go b/checker/interactive.go new file mode 100644 index 0000000..f264f66 --- /dev/null +++ b/checker/interactive.go @@ -0,0 +1,47 @@ +//go:build standalone + +package checker + +import ( + "errors" + "net/http" + "strconv" + "strings" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// RenderForm implements server.Interactive. The option set is the same one +// /evaluate documents, so we reuse it directly (AutoFill hints are ignored +// by the interactive HTML form, where the human types the value in). +func (p *ldapProvider) RenderForm() []sdk.CheckerOptionField { + return p.Definition().Options.RunOpts +} + +// ParseForm implements server.Interactive. Collect handles its own SRV +// and A/AAAA lookups, so the form only needs to forward the user-supplied +// values -- no extra host-side resolution is required here. +func (p *ldapProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) { + domain := strings.TrimSpace(r.FormValue("domain")) + if domain == "" { + return nil, errors.New("domain is required") + } + opts := sdk.CheckerOptions{"domain": domain} + if v := strings.TrimSpace(r.FormValue("timeout")); v != "" { + f, err := strconv.ParseFloat(v, 64) + if err != nil { + return nil, errors.New("timeout must be a number") + } + opts["timeout"] = f + } + if v := strings.TrimSpace(r.FormValue("bind_dn")); v != "" { + opts["bind_dn"] = v + } + if v := r.FormValue("bind_password"); v != "" { + opts["bind_password"] = v + } + if v := strings.TrimSpace(r.FormValue("base_dn")); v != "" { + opts["base_dn"] = v + } + return opts, nil +} diff --git a/checker/provider.go b/checker/provider.go new file mode 100644 index 0000000..3183a84 --- /dev/null +++ b/checker/provider.go @@ -0,0 +1,63 @@ +package checker + +import ( + sdk "git.happydns.org/checker-sdk-go/checker" + tlsct "git.happydns.org/checker-tls/contract" +) + +func Provider() sdk.ObservationProvider { + return &ldapProvider{} +} + +type ldapProvider struct{} + +func (p *ldapProvider) Key() sdk.ObservationKey { + return ObservationKeyLDAP +} + +// DiscoverEntries implements sdk.DiscoveryPublisher. +// +// It publishes TLS endpoint contract entries for every SRV target we found, +// so a downstream TLS checker can verify the certificate chain / SAN / +// expiry on each one without re-doing the SRV lookup. The LDAP checker +// itself only confirms a TLS session can be established -- certificate +// posture lives in the TLS checker. +// +// SNI is set to the bare domain, which is usually the hostname clients +// connect to. On _ldaps._tcp we emit direct-TLS endpoints (STARTTLS=""). +// On _ldap._tcp we emit STARTTLS="ldap" so the TLS checker performs an +// RFC 2830 extended-op upgrade before the handshake. RequireSTARTTLS is +// set to true on 389: a misconfigured server that drops StartTLS must +// show up as a CRIT on the TLS side, not a WARN. +func (p *ldapProvider) DiscoverEntries(data any) ([]sdk.DiscoveryEntry, error) { + d, ok := data.(*LDAPData) + if !ok || d == nil { + return nil, nil + } + + var out []sdk.DiscoveryEntry + emit := func(recs []SRVRecord, starttls string, require bool) error { + for _, r := range recs { + ep := tlsct.TLSEndpoint{ + Host: r.Target, + Port: r.Port, + SNI: d.Domain, + STARTTLS: starttls, + RequireSTARTTLS: require, + } + entry, err := tlsct.NewEntry(ep) + if err != nil { + return err + } + out = append(out, entry) + } + return nil + } + if err := emit(d.SRV.LDAP, "ldap", true); err != nil { + return nil, err + } + if err := emit(d.SRV.LDAPS, "", false); err != nil { + return nil, err + } + return out, nil +} diff --git a/checker/report.go b/checker/report.go new file mode 100644 index 0000000..1d5e194 --- /dev/null +++ b/checker/report.go @@ -0,0 +1,548 @@ +package checker + +import ( + "encoding/json" + "fmt" + "html/template" + "net" + "sort" + "strconv" + "strings" + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +type reportFix struct { + Severity string + Code string + Message string + Fix string + Endpoint string +} + +type reportEndpoint struct { + EndpointProbe + ModeLabel string + + // TLS posture (from a related tls_probes observation, when available). + TLSPosture *reportTLSPosture + + // Rendering helpers. + AnyFail bool + StatusLabel string + StatusClass string +} + +type reportTLSPosture struct { + CheckedAt time.Time + ChainValid *bool + HostnameMatch *bool + NotAfter time.Time +} + +type reportSRVEntry struct { + Prefix string + Target string + Port uint16 + Priority uint16 + Weight uint16 + IPv4 []string + IPv6 []string +} + +type reportData struct { + Domain string + BaseDN string + RunAt string + StatusLabel string + StatusClass string + HasIssues bool + Fixes []reportFix + SRV []reportSRVEntry + FallbackProbed bool + Endpoints []reportEndpoint + HasIPv4 bool + HasIPv6 bool + EncryptedReachable bool + PlainOnlyReachable bool + BindTested bool + HasTLSPosture bool +} + +var reportTpl = template.Must(template.New("ldap").Funcs(template.FuncMap{ + "hasPrefix": strings.HasPrefix, + "deref": func(b *bool) bool { return b != nil && *b }, + "join": func(sep string, list []string) string { return strings.Join(list, sep) }, + "upper": strings.ToUpper, +}).Parse(` + + + + +LDAP Report -- {{.Domain}} + + + + +
+

LDAP -- {{.Domain}}

+ {{.StatusLabel}} +
+ {{if .EncryptedReachable}}encrypted OK{{else}}no encryption{{end}} + {{if .HasIPv4}}IPv4{{end}} + {{if .HasIPv6}}IPv6{{end}} + {{if .BindTested}}bind test{{end}} +
+
Checked {{.RunAt}}{{if .BaseDN}} · base {{.BaseDN}}{{end}}
+
+ +{{if .HasIssues}} +
+

What to fix

+ {{range .Fixes}} +
+
{{.Code}}{{if .Endpoint}} · {{.Endpoint}}{{end}}
+
{{.Message}}
+ {{if .Fix}}
→ {{.Fix}}
{{end}} +
+ {{end}} +
+{{end}} + +
+

DNS / SRV

+ {{if .FallbackProbed}} +

No SRV records published -- fell back to probing the bare domain on default ports 389 / 636.

+ {{else if .SRV}} + + + {{range .SRV}} + + + + + + + + + {{end}} +
RecordTargetPortPrio/WeightIPv4IPv6
{{.Prefix}}{{.Target}}{{.Port}}{{.Priority}}/{{.Weight}}{{range .IPv4}}{{.}} {{end}}{{range .IPv6}}{{.}} {{end}}
+ {{else}} +

No SRV records found.

+ {{end}} +
+ +{{if .Endpoints}} +
+

Endpoints ({{len .Endpoints}})

+ {{range .Endpoints}} + + + {{.ModeLabel}} · {{.Address}} + {{.StatusLabel}} + +
+
+ {{if .SRVPrefix}}
SRV
{{.SRVPrefix}}{{.Target}}:{{.Port}}
{{end}} +
Family
{{if .IsIPv6}}IPv6{{else}}IPv4{{end}}
+
TCP
{{if .TCPConnected}}✓ connected{{else}}✗ failed{{end}}
+ + {{if eq .Mode "ldap"}} +
StartTLS
+ {{if .StartTLSOffered}}✓ offered{{else}}✗ not offered{{end}} + {{if .StartTLSUpgraded}} · upgraded{{else if .StartTLSOffered}} · upgrade failed{{end}} +
+
Cleartext bind
+ {{if not .PlaintextBindTested}}not tested + {{else if .PlaintextBindAccepted}}✗ accepted (insecure) + {{else}}✓ refused (confidentiality required){{end}} +
+ {{end}} + +
TLS
+ {{if .TLSEstablished}}✓ {{.TLSVersion}}{{if .TLSCipher}} — {{.TLSCipher}}{{end}} + {{else}}✗ plaintext{{end}} +
+ +
RootDSE
+ {{if .RootDSERead}}✓ read + {{else}}✗ unreadable{{end}} + {{if .VendorName}} · {{.VendorName}}{{if .VendorVersion}} {{.VendorVersion}}{{end}}{{end}} + {{if .SupportedLDAPVersion}} · LDAPv{{join "," .SupportedLDAPVersion}}{{end}} +
+ + {{if .NamingContexts}} +
Naming contexts
+
{{range .NamingContexts}}{{.}}{{end}}
+
+ {{end}} + + {{if .SupportedSASLMechanisms}} +
SASL
+
+ {{range .SupportedSASLMechanisms}} + {{$u := upper .}} + {{if or (eq $u "PLAIN") (eq $u "LOGIN")}}{{.}} + {{else if or (hasPrefix $u "SCRAM-") (eq $u "EXTERNAL") (eq $u "GSSAPI") (eq $u "GSS-SPNEGO")}}{{.}} + {{else}}{{.}}{{end}} + {{end}} +
+
+ {{end}} + +
Anonymous
+ {{if .AnonymousBindAllowed}}bind allowed + {{else}}✓ bind refused{{end}} + {{if .AnonymousSearchAllowed}} · search allowed (DIT enumerable){{end}} +
+ + {{if .BindAttempted}} +
Bind as DN
+ {{if .BindOK}}✓ succeeded + {{else}}✗ {{.BindError}}{{end}} +
+ {{end}} + {{if .BaseReadAttempted}} +
Base read
+ {{if .BaseReadOK}}✓ {{.BaseReadEntries}} entry/entries + {{else}}✗ {{.BaseReadError}}{{end}} +
+ {{end}} + + {{with .TLSPosture}} +
TLS cert
+ {{if .ChainValid}} + {{if deref .ChainValid}}✓ chain valid{{else}}✗ chain invalid{{end}} + {{end}} + {{if .HostnameMatch}} + · {{if deref .HostnameMatch}}✓ hostname match{{else}}✗ hostname mismatch{{end}} + {{end}} + {{if not .NotAfter.IsZero}} + · expires {{.NotAfter.Format "2006-01-02"}} + {{end}} + {{if not .CheckedAt.IsZero}} +
TLS checked {{.CheckedAt.Format "2006-01-02 15:04 MST"}}
+ {{end}} +
+ {{end}} +
Duration
{{.ElapsedMS}} ms
+ {{if .Error}}
Error
{{.Error}}
{{end}} +
+
+ + {{end}} +
+{{end}} + + + + +`)) + +// GetHTMLReport implements sdk.CheckerHTMLReporter. It folds in related TLS +// observations so the LDAP service page shows cert posture directly, and +// uses ReportContext.States() as the sole source of hint/fix/severity text: +// when no states are threaded through, the page renders a data-only view of +// the raw observation without any derived judgment. +func (p *ldapProvider) GetHTMLReport(rctx sdk.ReportContext) (string, error) { + var d LDAPData + if err := json.Unmarshal(rctx.Data(), &d); err != nil { + return "", fmt.Errorf("unmarshal ldap observation: %w", err) + } + view := buildReportData(&d, rctx.Related(TLSRelatedKey), rctx.States()) + return renderReport(view) +} + +func renderReport(view reportData) (string, error) { + var buf strings.Builder + if err := reportTpl.Execute(&buf, view); err != nil { + return "", fmt.Errorf("render ldap report: %w", err) + } + return buf.String(), nil +} + +func buildReportData(d *LDAPData, related []sdk.RelatedObservation, states []sdk.CheckState) reportData { + tlsByAddr := foldTLSRelated(related) + + // Coverage is a pure raw-view aggregation over endpoint facts (counts and + // booleans, no severity). It feeds the IPv4/IPv6 badges and the + // "encrypted OK" vs "no encryption" hint in the page header. + cov := coverageView(d) + + view := reportData{ + Domain: d.Domain, + BaseDN: d.BaseDN, + RunAt: d.RunAt, + FallbackProbed: d.SRV.FallbackProbed, + HasIPv4: cov.HasIPv4, + HasIPv6: cov.HasIPv6, + EncryptedReachable: cov.EncryptedReachable, + PlainOnlyReachable: cov.PlainOnlyReachable, + BindTested: d.BindTested, + HasTLSPosture: len(tlsByAddr) > 0, + } + + // Hint / fix / severity text is populated *only* from rule output threaded + // through ReportContext.States(). When the host has not piped Evaluate → + // Report, the "What to fix" section is omitted entirely and the page + // falls back to a raw data-only view of the observation. + applyFixes(&view, fixesFromStates(states)) + + // SRV rows. + addSRV := func(prefix string, records []SRVRecord) { + for _, r := range records { + view.SRV = append(view.SRV, reportSRVEntry{ + Prefix: prefix, Target: r.Target, Port: r.Port, + Priority: r.Priority, Weight: r.Weight, + IPv4: r.IPv4, IPv6: r.IPv6, + }) + } + } + addSRV("_ldap._tcp", d.SRV.LDAP) + addSRV("_ldaps._tcp", d.SRV.LDAPS) + + // Endpoint rows. + for _, ep := range d.Endpoints { + re := reportEndpoint{ + EndpointProbe: ep, + ModeLabel: modeLabel(ep.Mode), + } + if meta, hit := tlsByAddr[ep.Address]; hit { + re.TLSPosture = meta + } else if meta, hit := tlsByAddr[net.JoinHostPort(ep.Target, strconv.FormatUint(uint64(ep.Port), 10))]; hit { + re.TLSPosture = meta + } + ok := ep.TCPConnected && ep.TLSEstablished + if ep.Mode == ModePlain { + ok = ok && ep.StartTLSUpgraded + } + re.AnyFail = !ok + if ok { + re.StatusLabel = "OK" + re.StatusClass = "ok" + } else if ep.TCPConnected { + re.StatusLabel = "partial" + re.StatusClass = "warn" + } else { + re.StatusLabel = "unreachable" + re.StatusClass = "fail" + } + view.Endpoints = append(view.Endpoints, re) + } + + return view +} + +// fixesFromStates converts rule-evaluator CheckStates into reportFix entries, +// dropping StatusOK / StatusUnknown (they are not findings to display in the +// "What to fix" list). The fix hint, when present, is read from Meta["fix"] +// using the convention set by issueToState in rules.go. +func fixesFromStates(states []sdk.CheckState) []reportFix { + out := make([]reportFix, 0, len(states)) + for _, st := range states { + if st.Status == sdk.StatusOK || st.Status == sdk.StatusUnknown { + continue + } + fix, _ := st.Meta["fix"].(string) + out = append(out, reportFix{ + Severity: severityFromStatus(st.Status), + Code: st.Code, + Message: st.Message, + Fix: fix, + Endpoint: st.Subject, + }) + } + return out +} + +func severityFromStatus(s sdk.Status) string { + switch s { + case sdk.StatusCrit, sdk.StatusError: + return SeverityCrit + case sdk.StatusWarn: + return SeverityWarn + default: + return SeverityInfo + } +} + +// applyFixes sorts (crit → warn → info) and stamps the page-level status +// banner from the worst severity present. +func applyFixes(view *reportData, fixes []reportFix) { + view.HasIssues = len(fixes) > 0 + sort.SliceStable(fixes, func(i, j int) bool { return sevRank(fixes[i].Severity) < sevRank(fixes[j].Severity) }) + view.Fixes = fixes + + if len(fixes) == 0 { + // No rule output threaded through: render a neutral data-only banner + // (no severity text derived from raw facts). + view.StatusLabel = "report" + view.StatusClass = "muted" + return + } + switch fixes[0].Severity { + case SeverityCrit: + view.StatusLabel = "FAIL" + view.StatusClass = "fail" + case SeverityWarn: + view.StatusLabel = "WARN" + view.StatusClass = "warn" + default: + view.StatusLabel = "INFO" + view.StatusClass = "info" + } +} + +func sevRank(s string) int { + switch s { + case SeverityCrit: + return 0 + case SeverityWarn: + return 1 + default: + return 2 + } +} + +func modeLabel(m LDAPMode) string { + switch m { + case ModePlain: + return "ldap" + case ModeLDAPS: + return "ldaps" + default: + return string(m) + } +} + +// foldTLSRelated builds a per-address posture map from downstream TLS +// observations. This is pure raw-view data (flags and timestamps) for the +// endpoint table; TLS severity text comes in via ReportContext.States() +// from the tls_quality rule, not from this helper. +func foldTLSRelated(related []sdk.RelatedObservation) map[string]*reportTLSPosture { + byAddr := map[string]*reportTLSPosture{} + for _, r := range related { + v := parseTLSRelated(r) + if v == nil { + continue + } + if addr := v.address(); addr != "" { + byAddr[addr] = &reportTLSPosture{ + CheckedAt: r.CollectedAt, + ChainValid: v.ChainValid, + HostnameMatch: v.HostnameMatch, + NotAfter: v.NotAfter, + } + } + } + return byAddr +} + +// coverageView aggregates raw per-endpoint booleans into the header-level +// reachability summary. It is pure data reshaping, no severity, no fix +// strings, and lives here because report.go is its only caller. +func coverageView(data *LDAPData) ReachabilitySpan { + var cov ReachabilitySpan + anyEncrypted := false + anyPlain := false + for _, ep := range data.Endpoints { + if !ep.TCPConnected { + continue + } + if ep.IsIPv6 { + cov.HasIPv6 = true + } else { + cov.HasIPv4 = true + } + if ep.TLSEstablished { + anyEncrypted = true + } else { + anyPlain = true + } + } + cov.EncryptedReachable = anyEncrypted + cov.PlainOnlyReachable = anyPlain && !anyEncrypted + return cov +} diff --git a/checker/rules.go b/checker/rules.go new file mode 100644 index 0000000..2958d0a --- /dev/null +++ b/checker/rules.go @@ -0,0 +1,107 @@ +package checker + +import ( + "context" + "fmt" + "strings" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Rules returns the full list of CheckRules exposed by the LDAP checker. +// Each rule covers one concern (SRV discovery, StartTLS posture, anonymous +// access, …) and produces its CheckStates by scanning raw LDAPData fields +// directly -- there is no shared pre-derived Issues slice in between. +func Rules() []sdk.CheckRule { + return []sdk.CheckRule{ + &srvDiscoveryRule{}, + &endpointReachableRule{}, + &encryptedTransportRule{}, + &startTLSSupportedRule{}, + &ldapsHandshakeRule{}, + &startTLSOnLDAPSRule{}, + &refusesPlainBindRule{}, + &anonymousSearchBlockedRule{}, + &rootDSEReadableRule{}, + &saslMechanismsRule{}, + &protocolVersionRule{}, + &ipv6ReachableRule{}, + &bindCredentialsRule{}, + &baseDNReadRule{}, + &tlsQualityRule{}, + } +} + +// loadLDAPData fetches the LDAP observation. On error, returns a CheckState +// the caller should emit to short-circuit its rule. +func loadLDAPData(ctx context.Context, obs sdk.ObservationGetter) (*LDAPData, *sdk.CheckState) { + var data LDAPData + if err := obs.Get(ctx, ObservationKeyLDAP, &data); err != nil { + return nil, &sdk.CheckState{ + Status: sdk.StatusError, + Message: fmt.Sprintf("failed to load LDAP observation: %v", err), + Code: "ldap.observation_error", + } + } + return &data, nil +} + +// critState builds a StatusCrit state with an optional fix hint encoded in +// Meta["fix"] so report.go's fixesFromStates can surface it. +func critState(code, message, subject, fix string) sdk.CheckState { + return stateWithFix(sdk.StatusCrit, code, message, subject, fix) +} + +func warnState(code, message, subject, fix string) sdk.CheckState { + return stateWithFix(sdk.StatusWarn, code, message, subject, fix) +} + +func infoState(code, message, subject, fix string) sdk.CheckState { + return stateWithFix(sdk.StatusInfo, code, message, subject, fix) +} + +func stateWithFix(status sdk.Status, code, message, subject, fix string) sdk.CheckState { + st := sdk.CheckState{ + Status: status, + Message: message, + Code: code, + Subject: subject, + } + if fix != "" { + st.Meta = map[string]any{"fix": fix} + } + return st +} + +func passState(code, message string) sdk.CheckState { + return sdk.CheckState{ + Status: sdk.StatusOK, + Message: message, + Code: code, + } +} + +func notTestedState(code, message string) sdk.CheckState { + return sdk.CheckState{ + Status: sdk.StatusUnknown, + Message: message, + Code: code, + } +} + +func optString(opts sdk.CheckerOptions, key string) string { + v, _ := opts[key].(string) + return strings.TrimSpace(v) +} + +// ldapsReachable reports whether at least one LDAPS endpoint accepted TCP. +// Used to soften severity of unreachable plain-LDAP endpoints when modern +// clients would use LDAPS anyway. +func ldapsReachable(data *LDAPData) bool { + for _, ep := range data.Endpoints { + if ep.Mode == ModeLDAPS && ep.TCPConnected { + return true + } + } + return false +} diff --git a/checker/rules_bind.go b/checker/rules_bind.go new file mode 100644 index 0000000..712213b --- /dev/null +++ b/checker/rules_bind.go @@ -0,0 +1,129 @@ +package checker + +import ( + "context" + "strconv" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Rules in this file cover the optional bind-credentials workflow (driven +// by the bind_dn / bind_password / base_dn options) plus the cross-checker +// TLS-quality fold. Like the other rules, they read raw EndpointProbe +// fields and downstream observations directly. + +// bindCredentialsRule: optional authenticated bind. Skipped when bind_dn +// isn't supplied, and reported as "not tested" when no encrypted endpoint +// was available to attempt it on. +type bindCredentialsRule struct{} + +func (r *bindCredentialsRule) Name() string { return "ldap.bind_credentials" } +func (r *bindCredentialsRule) Description() string { + return "Verifies the supplied bind credentials are accepted by the directory (only runs when bind_dn is set)." +} + +func (r *bindCredentialsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + if optString(opts, "bind_dn") == "" { + return []sdk.CheckState{notTestedState("ldap.bind_credentials.skipped", "Authenticated bind not tested (no bind_dn supplied).")} + } + data, errSt := loadLDAPData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + var states []sdk.CheckState + attempted := false + for _, ep := range data.Endpoints { + if !ep.BindAttempted { + continue + } + attempted = true + if ep.BindOK { + states = append(states, infoState( + CodeBindOK, + "Bind on "+ep.Address+" succeeded with the provided credentials.", + ep.Address, + "", + )) + } else { + states = append(states, critState( + CodeBindFailed, + "Bind on "+ep.Address+" failed: "+ep.BindError, + ep.Address, + "Verify the bind DN exists and the password is current. On AD, check the account is not locked/expired.", + )) + } + } + if !attempted { + return []sdk.CheckState{notTestedState("ldap.bind_credentials.skipped", "Bind not attempted on any endpoint (no encrypted endpoint reachable).")} + } + if len(states) == 0 { + return []sdk.CheckState{passState("ldap.bind_credentials.ok", "Bind succeeded with the provided credentials.")} + } + return states +} + +// baseDNReadRule: optional baseObject read on the supplied base_dn. +type baseDNReadRule struct{} + +func (r *baseDNReadRule) Name() string { return "ldap.base_dn_read" } +func (r *baseDNReadRule) Description() string { + return "Verifies the bound account can read the supplied base DN (only runs when base_dn is set and bind succeeded)." +} + +func (r *baseDNReadRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + if optString(opts, "base_dn") == "" { + return []sdk.CheckState{notTestedState("ldap.base_dn_read.skipped", "Base DN read not tested (no base_dn supplied).")} + } + data, errSt := loadLDAPData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + var states []sdk.CheckState + attempted := false + for _, ep := range data.Endpoints { + if !ep.BaseReadAttempted { + continue + } + attempted = true + if ep.BaseReadOK { + states = append(states, infoState( + CodeBaseReadOK, + "Base DN read succeeded on "+ep.Address+" (entries="+strconv.Itoa(ep.BaseReadEntries)+").", + ep.Address, + "", + )) + } else { + states = append(states, critState( + CodeBaseReadFailed, + "Bind succeeded but baseObject read on "+data.BaseDN+" failed: "+ep.BaseReadError, + ep.Address, + "Verify the bind DN has read access to the base DN -- typically granted via an ACL entry such as `access to dn.subtree=\"\" by dn.exact=\"\" read`.", + )) + } + } + if !attempted { + return []sdk.CheckState{notTestedState("ldap.base_dn_read.skipped", "Base DN read not attempted (bind did not succeed on any endpoint).")} + } + return states +} + +// tlsQualityRule: folds downstream TLS checker findings onto the LDAP +// service. Consumes a related observation (not LDAPData). +type tlsQualityRule struct{} + +func (r *tlsQualityRule) Name() string { return "ldap.tls_quality" } +func (r *tlsQualityRule) Description() string { + return "Folds the downstream TLS checker findings (certificate chain, hostname match, expiry) onto the LDAP service." +} + +func (r *tlsQualityRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + related, _ := obs.GetRelated(ctx, TLSRelatedKey) + if len(related) == 0 { + return []sdk.CheckState{notTestedState("ldap.tls_quality.skipped", "No related TLS observation available (no TLS checker downstream, or no probe yet).")} + } + states := tlsStatesFromRelated(related) + if len(states) == 0 { + return []sdk.CheckState{passState("ldap.tls_quality.ok", "Downstream TLS checker reports no issues on the LDAP endpoints.")} + } + return states +} diff --git a/checker/rules_posture.go b/checker/rules_posture.go new file mode 100644 index 0000000..331e231 --- /dev/null +++ b/checker/rules_posture.go @@ -0,0 +1,237 @@ +package checker + +import ( + "context" + "strings" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Rules in this file cover directory-posture concerns that read off the +// RootDSE or probe outcomes: cleartext-bind refusal, anonymous search +// exposure, RootDSE readability, SASL mechanism inventory, legacy +// LDAPv2 advertisement. + +// refusesPlainBindRule: server refuses cleartext bind attempts. +type refusesPlainBindRule struct{} + +func (r *refusesPlainBindRule) Name() string { return "ldap.refuses_plain_bind" } +func (r *refusesPlainBindRule) Description() string { + return "Verifies the directory refuses authentication attempts over a cleartext channel." +} + +func (r *refusesPlainBindRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadLDAPData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + var states []sdk.CheckState + for _, ep := range data.Endpoints { + if ep.Mode != ModePlain || !ep.TCPConnected { + continue + } + if ep.PlaintextBindTested && ep.PlaintextBindAccepted { + states = append(states, critState( + CodePlainBindAccepted, + "Cleartext bind attempts are accepted on "+ep.Address+" (server does not reply confidentialityRequired).", + ep.Address, + "Require TLS before authentication: set `security simple_bind=` on OpenLDAP (olcSecurity) or enable `require_tls` in 389-ds/Samba AD to force StartTLS before bind.", + )) + } + } + if len(states) == 0 { + return []sdk.CheckState{passState("ldap.refuses_plain_bind.ok", "Server refuses cleartext bind attempts.")} + } + return states +} + +// anonymousSearchBlockedRule: directory rejects anonymous search of the +// naming context (information-disclosure signal). +type anonymousSearchBlockedRule struct{} + +func (r *anonymousSearchBlockedRule) Name() string { return "ldap.anonymous_search_blocked" } +func (r *anonymousSearchBlockedRule) Description() string { + return "Flags directories that allow anonymous search of the naming context (information disclosure)." +} + +func (r *anonymousSearchBlockedRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadLDAPData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + var states []sdk.CheckState + for _, ep := range data.Endpoints { + if ep.AnonymousSearchAllowed { + states = append(states, warnState( + CodeAnonymousSearchAllowed, + "Anonymous search against naming context succeeds on "+ep.Address+" -- DIT contents may be enumerable without credentials.", + ep.Address, + "Restrict anonymous access: `access to * by anonymous auth by users read` (OpenLDAP) or set `nsslapd-allow-anonymous-access: off` (389-ds).", + )) + } + } + if len(states) == 0 { + return []sdk.CheckState{passState("ldap.anonymous_search_blocked.ok", "Anonymous search against the naming context is blocked.")} + } + return states +} + +// rootDSEReadableRule: RootDSE is readable over TLS and advertises a +// naming context. The unreadable finding fires once per TLS-established +// endpoint; the naming-context finding fires once per endpoint that did +// read the RootDSE. +type rootDSEReadableRule struct{} + +func (r *rootDSEReadableRule) Name() string { return "ldap.rootdse_readable" } +func (r *rootDSEReadableRule) Description() string { + return "Verifies the RootDSE is readable over TLS and advertises naming contexts." +} + +func (r *rootDSEReadableRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadLDAPData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + var states []sdk.CheckState + for _, ep := range data.Endpoints { + if ep.TCPConnected && ep.TLSEstablished && !ep.RootDSERead { + states = append(states, warnState( + CodeRootDSEUnreadable, + "RootDSE is not readable on "+ep.Address+" -- capability discovery is unavailable.", + ep.Address, + "Grant anonymous read on the empty base DN for schema introspection (`access to dn.base=\"\" by * read` on OpenLDAP), or expect reduced visibility of supported mechanisms.", + )) + } + if ep.RootDSERead && len(ep.NamingContexts) == 0 { + states = append(states, infoState( + CodeNoNamingContext, + "RootDSE does not advertise any naming context on "+ep.Address+".", + ep.Address, + "If this server is authoritative, populate the namingContexts attribute so clients can locate the DIT root.", + )) + } + } + if len(states) == 0 { + return []sdk.CheckState{passState("ldap.rootdse_readable.ok", "RootDSE is readable and advertises naming contexts.")} + } + return states +} + +// saslMechanismsRule: supportedSASLMechanisms posture review. +type saslMechanismsRule struct{} + +func (r *saslMechanismsRule) Name() string { return "ldap.sasl_mechanisms" } +func (r *saslMechanismsRule) Description() string { + return "Reviews the supportedSASLMechanisms posture (presence of strong mechanisms, absence of password-equivalent ones)." +} + +func (r *saslMechanismsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadLDAPData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + + sawSASL := false + sawStrong := false + sawPlainOnly := false + for _, ep := range data.Endpoints { + if len(ep.SupportedSASLMechanisms) == 0 { + continue + } + sawSASL = true + hasPlain := false + hasStrong := false + for _, m := range ep.SupportedSASLMechanisms { + u := strings.ToUpper(m) + switch u { + case "PLAIN", "LOGIN": + hasPlain = true + case "EXTERNAL", "GSSAPI", "GSS-SPNEGO": + hasStrong = true + default: + if strings.HasPrefix(u, "SCRAM-") { + hasStrong = true + } + } + } + if hasStrong { + sawStrong = true + } + if hasPlain && !hasStrong { + sawPlainOnly = true + } + } + + var states []sdk.CheckState + if sawSASL { + if !sawStrong { + states = append(states, warnState( + CodeSASLNoStrongMech, + "No strong SASL mechanism (SCRAM-*, EXTERNAL, GSSAPI) is advertised.", + "", + "Enable SCRAM-SHA-256 or GSSAPI where your identity platform allows it.", + )) + } + if sawPlainOnly { + states = append(states, warnState( + CodeSASLPlainOnly, + "Only PLAIN/LOGIN SASL mechanisms are offered.", + "", + "Add SCRAM-SHA-256 so password hashes aren't sent as password-equivalent tokens.", + )) + } + } else if len(data.Endpoints) > 0 { + states = append(states, infoState( + CodeNoSASL, + "No supportedSASLMechanisms advertised by the directory.", + "", + "If SASL is in use, ensure the server publishes supportedSASLMechanisms in the RootDSE. Otherwise only simple binds are available.", + )) + } + + if len(states) == 0 { + return []sdk.CheckState{passState("ldap.sasl_mechanisms.ok", "SASL posture is sound (a strong mechanism is advertised).")} + } + return states +} + +// protocolVersionRule: flags servers that still advertise LDAPv2. +type protocolVersionRule struct{} + +func (r *protocolVersionRule) Name() string { return "ldap.protocol_version" } +func (r *protocolVersionRule) Description() string { + return "Flags servers that still advertise the deprecated LDAPv2 protocol." +} + +// endpointAdvertisesLDAPv2 reports whether an endpoint's RootDSE still lists +// the deprecated LDAPv2 protocol in supportedLDAPVersion. +func endpointAdvertisesLDAPv2(ep EndpointProbe) bool { + for _, v := range ep.SupportedLDAPVersion { + if strings.TrimSpace(v) == "2" { + return true + } + } + return false +} + +func (r *protocolVersionRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadLDAPData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + var states []sdk.CheckState + for _, ep := range data.Endpoints { + if endpointAdvertisesLDAPv2(ep) { + states = append(states, warnState( + CodeLegacyLDAPv2, + "Server still advertises supportedLDAPVersion=2 on "+ep.Address+".", + ep.Address, + "Disable LDAPv2 support -- RFC 3494 deprecated it in 2003. On OpenLDAP, remove `allow bind_v2`.", + )) + } + } + if len(states) == 0 { + return []sdk.CheckState{passState("ldap.protocol_version.ok", "Server does not advertise the deprecated LDAPv2 protocol.")} + } + return states +} diff --git a/checker/rules_transport.go b/checker/rules_transport.go new file mode 100644 index 0000000..805447b --- /dev/null +++ b/checker/rules_transport.go @@ -0,0 +1,279 @@ +package checker + +import ( + "context" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Rules in this file cover transport-level concerns: SRV discovery, TCP +// reachability, encryption availability, TLS handshakes. They read raw +// LDAPData fields directly (no pre-derived Issues slice). + +// srvDiscoveryRule: _ldap._tcp / _ldaps._tcp SRV publishing + resolution. +type srvDiscoveryRule struct{} + +func (r *srvDiscoveryRule) Name() string { return "ldap.has_srv" } +func (r *srvDiscoveryRule) Description() string { + return "Verifies that _ldap._tcp / _ldaps._tcp SRV records are published and resolvable." +} + +func (r *srvDiscoveryRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadLDAPData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + var states []sdk.CheckState + if data.SRV.FallbackProbed { + states = append(states, infoState( + CodeNoSRV, + "No LDAP SRV records published for "+data.Domain+".", + "", + "Consider publishing _ldap._tcp."+data.Domain+" and _ldaps._tcp."+data.Domain+" SRV records to let clients discover the directory automatically.", + )) + } + for prefix, msg := range data.SRV.Errors { + states = append(states, warnState( + CodeSRVServfail, + "DNS lookup failed for "+prefix+data.Domain+": "+msg, + "", + "Check the authoritative DNS servers for this domain.", + )) + } + if len(states) == 0 { + return []sdk.CheckState{passState("ldap.has_srv.ok", "SRV records are published and resolved cleanly.")} + } + return states +} + +// endpointReachableRule: every discovered endpoint accepts a TCP connection. +type endpointReachableRule struct{} + +func (r *endpointReachableRule) Name() string { return "ldap.endpoint_reachable" } +func (r *endpointReachableRule) Description() string { + return "Verifies that every discovered LDAP endpoint accepts a TCP connection." +} + +func (r *endpointReachableRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadLDAPData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + var states []sdk.CheckState + + // Per-endpoint TCP failures. + ldapsUp := ldapsReachable(data) + for _, ep := range data.Endpoints { + if ep.TCPConnected || ep.Error == "" { + continue + } + msg := "Cannot reach " + ep.Address + ": " + ep.Error + "." + if ep.Mode == ModePlain && ldapsUp { + states = append(states, infoState( + CodeTCPUnreachable, msg, ep.Address, + "LDAPS (636) is reachable, so modern clients are unaffected. Only relevant if legacy clients still need plain LDAP on 389.", + )) + } else { + states = append(states, warnState( + CodeTCPUnreachable, msg, ep.Address, + "Verify firewall rules and that the LDAP server is listening on this address.", + )) + } + } + + // Aggregate: no endpoint reachable at all. + if len(data.Endpoints) > 0 { + allDown := true + for _, ep := range data.Endpoints { + if ep.TCPConnected { + allDown = false + break + } + } + if allDown { + states = append(states, critState( + CodeAllEndpointsDown, + "No LDAP endpoint is reachable.", + "", + "Check firewall rules and that the directory is listening on ports 389/636 (or the ports indicated by SRV).", + )) + } + } + + if len(states) == 0 { + return []sdk.CheckState{passState("ldap.endpoint_reachable.ok", "All discovered endpoints are reachable.")} + } + return states +} + +// encryptedTransportRule: at least one endpoint is reachable AND encrypted. +type encryptedTransportRule struct{} + +func (r *encryptedTransportRule) Name() string { return "ldap.has_encrypted_transport" } +func (r *encryptedTransportRule) Description() string { + return "Verifies that at least one reachable endpoint offers an encrypted channel (LDAPS or StartTLS)." +} + +func (r *encryptedTransportRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadLDAPData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + anyReachable := false + anyEncrypted := false + for _, ep := range data.Endpoints { + if ep.TCPConnected { + anyReachable = true + if ep.TLSEstablished { + anyEncrypted = true + } + } + } + if anyReachable && !anyEncrypted { + return []sdk.CheckState{critState( + CodeNoEncryptedEndpoint, + "None of the reachable endpoints established an encrypted channel (no StartTLS, no LDAPS).", + "", + "Enable either LDAPS (port 636) or StartTLS on 389 -- a bind DN should never travel in cleartext.", + )} + } + return []sdk.CheckState{passState("ldap.has_encrypted_transport.ok", "At least one endpoint offers encrypted transport.")} +} + +// startTLSSupportedRule: StartTLS is advertised and succeeds on every +// reachable plain-LDAP endpoint. +type startTLSSupportedRule struct{} + +func (r *startTLSSupportedRule) Name() string { return "ldap.starttls_supported" } +func (r *startTLSSupportedRule) Description() string { + return "Verifies that StartTLS is offered and succeeds on every reachable plain LDAP endpoint." +} + +func (r *startTLSSupportedRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadLDAPData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + var states []sdk.CheckState + for _, ep := range data.Endpoints { + if ep.Mode != ModePlain || !ep.TCPConnected { + continue + } + if !ep.StartTLSOffered { + states = append(states, critState( + CodeStartTLSMissing, + "StartTLS not advertised on "+ep.Address+".", + ep.Address, + "Configure the LDAP server to accept StartTLS (RFC 2830). On OpenLDAP, set olcTLSCertificateFile / olcTLSCertificateKeyFile and ensure the LDAP listener allows encrypted upgrades.", + )) + } else if !ep.StartTLSUpgraded { + states = append(states, critState( + CodeStartTLSFailed, + "StartTLS handshake failed on "+ep.Address+": "+ep.Error+".", + ep.Address, + "Run the TLS checker on this endpoint for cert and cipher details.", + )) + } + } + if len(states) == 0 { + return []sdk.CheckState{passState("ldap.starttls_supported.ok", "StartTLS works on every reachable plain LDAP endpoint.")} + } + return states +} + +// ldapsHandshakeRule: the direct TLS handshake succeeds on every reachable +// LDAPS endpoint. +type ldapsHandshakeRule struct{} + +func (r *ldapsHandshakeRule) Name() string { return "ldap.ldaps_handshake" } +func (r *ldapsHandshakeRule) Description() string { + return "Verifies that the direct TLS handshake succeeds on every LDAPS endpoint." +} + +func (r *ldapsHandshakeRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadLDAPData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + var states []sdk.CheckState + for _, ep := range data.Endpoints { + if ep.Mode == ModeLDAPS && ep.TCPConnected && !ep.TLSEstablished { + states = append(states, critState( + CodeLDAPSHandshakeFailed, + "LDAPS TLS handshake failed on "+ep.Address+": "+ep.Error+".", + ep.Address, + "Check that the LDAPS listener has a valid certificate/key pair and is speaking TLS directly (not LDAP+StartTLS).", + )) + } + } + if len(states) == 0 { + return []sdk.CheckState{passState("ldap.ldaps_handshake.ok", "LDAPS handshake succeeds on every reachable LDAPS endpoint.")} + } + return states +} + +// startTLSOnLDAPSRule: flags LDAPS endpoints that also advertise StartTLS. +type startTLSOnLDAPSRule struct{} + +func (r *startTLSOnLDAPSRule) Name() string { return "ldap.starttls_on_ldaps" } +func (r *startTLSOnLDAPSRule) Description() string { + return "Flags servers that needlessly advertise StartTLS on the implicit-TLS LDAPS port." +} + +func (r *startTLSOnLDAPSRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadLDAPData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + var states []sdk.CheckState + for _, ep := range data.Endpoints { + if ep.Mode == ModeLDAPS && stringListContainsFold(ep.SupportedExtension, "1.3.6.1.4.1.1466.20037") { + states = append(states, infoState( + CodeStartTLSOnLDAPS, + "Server advertises StartTLS on the LDAPS port "+ep.Address+".", + ep.Address, + "This is not actively harmful, but most directories do not need StartTLS exposed on 636. Consider disabling it to reduce attack surface.", + )) + } + } + if len(states) == 0 { + return []sdk.CheckState{passState("ldap.starttls_on_ldaps.ok", "LDAPS endpoints do not also advertise StartTLS.")} + } + return states +} + +// ipv6ReachableRule: at least one endpoint reachable over IPv6. +type ipv6ReachableRule struct{} + +func (r *ipv6ReachableRule) Name() string { return "ldap.ipv6_reachable" } +func (r *ipv6ReachableRule) Description() string { + return "Verifies at least one endpoint is reachable over IPv6." +} + +func (r *ipv6ReachableRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadLDAPData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + hasV4 := false + hasV6 := false + for _, ep := range data.Endpoints { + if !ep.TCPConnected { + continue + } + if ep.IsIPv6 { + hasV6 = true + } else { + hasV4 = true + } + } + if hasV4 && !hasV6 { + return []sdk.CheckState{infoState( + CodeNoIPv6, + "No IPv6 endpoint reachable.", + "", + "Publish AAAA records for the SRV targets.", + )} + } + return []sdk.CheckState{passState("ldap.ipv6_reachable.ok", "At least one endpoint is reachable over IPv6.")} +} diff --git a/checker/tls_related.go b/checker/tls_related.go new file mode 100644 index 0000000..e5eec71 --- /dev/null +++ b/checker/tls_related.go @@ -0,0 +1,174 @@ +package checker + +import ( + "encoding/json" + "net" + "strconv" + "strings" + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// TLSRelatedKey is the observation key we expect a TLS checker to publish +// for the endpoints we discover. Matches the cross-checker convention +// documented in the happyDomain plan. +const TLSRelatedKey sdk.ObservationKey = "tls_probes" + +// tlsProbeView is a permissive view of a TLS checker's payload: we read +// only the fields we need and tolerate missing ones. The TLS checker owns +// the full schema. +type tlsProbeView struct { + Host string `json:"host,omitempty"` + Port uint16 `json:"port,omitempty"` + Endpoint string `json:"endpoint,omitempty"` + TLSVersion string `json:"tls_version,omitempty"` + CipherSuite string `json:"cipher_suite,omitempty"` + HostnameMatch *bool `json:"hostname_match,omitempty"` + ChainValid *bool `json:"chain_valid,omitempty"` + NotAfter time.Time `json:"not_after,omitempty"` + Issues []struct { + Code string `json:"code"` + Severity string `json:"severity"` + Message string `json:"message,omitempty"` + Fix string `json:"fix,omitempty"` + } `json:"issues,omitempty"` +} + +func (v *tlsProbeView) address() string { + if v.Endpoint != "" { + return v.Endpoint + } + if v.Host != "" && v.Port != 0 { + return net.JoinHostPort(v.Host, strconv.Itoa(int(v.Port))) + } + return "" +} + +// parseTLSRelated decodes a RelatedObservation as a TLS probe, gracefully +// returning nil when the payload doesn't look like one. Handles both +// {"probes": {"": }} and a bare shape. +func parseTLSRelated(r sdk.RelatedObservation) *tlsProbeView { + var keyed struct { + Probes map[string]tlsProbeView `json:"probes"` + } + if err := json.Unmarshal(r.Data, &keyed); err == nil && keyed.Probes != nil { + if p, ok := keyed.Probes[r.Ref]; ok { + return &p + } + return nil + } + var v tlsProbeView + if err := json.Unmarshal(r.Data, &v); err != nil { + return nil + } + return &v +} + +// tlsStatesFromRelated converts downstream TLS observations into CheckStates +// so certificate problems land on the LDAP service page. +func tlsStatesFromRelated(related []sdk.RelatedObservation) []sdk.CheckState { + var out []sdk.CheckState + for _, r := range related { + v := parseTLSRelated(r) + if v == nil { + continue + } + addr := v.address() + if len(v.Issues) > 0 { + for _, is := range v.Issues { + sev := strings.ToLower(is.Severity) + switch sev { + case SeverityCrit, SeverityWarn, SeverityInfo: + default: + continue + } + code := is.Code + if code == "" { + code = "tls.unknown" + } + out = append(out, stateWithFix( + severityToStatus(sev), + "ldap.tls."+code, + strings.TrimSpace("TLS on "+addr+": "+is.Message), + addr, + is.Fix, + )) + } + continue + } + // Flag-only payload: synthesize a single summary state. + sev := v.worstSeverity() + if sev == "" { + continue + } + msg := "TLS issue reported on " + addr + switch { + case v.ChainValid != nil && !*v.ChainValid: + msg = "Invalid certificate chain on " + addr + case v.HostnameMatch != nil && !*v.HostnameMatch: + msg = "Certificate does not cover the domain on " + addr + case !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 0: + msg = "Certificate expired on " + addr + " (" + v.NotAfter.Format(time.RFC3339) + ")" + case !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 14*24*time.Hour: + msg = "Certificate expiring soon on " + addr + " (" + v.NotAfter.Format(time.RFC3339) + ")" + } + out = append(out, stateWithFix( + severityToStatus(sev), + "ldap.tls.probe", + msg, + addr, + "See the TLS checker report for details.", + )) + } + return out +} + +// severityToStatus bridges a TLS-related severity string to sdk.Status. The +// severity strings remain stable so JSON payloads from older TLS checkers +// still decode. +func severityToStatus(sev string) sdk.Status { + switch sev { + case SeverityCrit: + return sdk.StatusCrit + case SeverityWarn: + return sdk.StatusWarn + case SeverityInfo: + return sdk.StatusInfo + default: + return sdk.StatusOK + } +} + +func (v *tlsProbeView) worstSeverity() string { + worst := "" + for _, is := range v.Issues { + switch strings.ToLower(is.Severity) { + case SeverityCrit: + return SeverityCrit + case SeverityWarn: + if worst != SeverityCrit { + worst = SeverityWarn + } + case SeverityInfo: + if worst == "" { + worst = SeverityInfo + } + } + } + if v.ChainValid != nil && !*v.ChainValid { + return SeverityCrit + } + if v.HostnameMatch != nil && !*v.HostnameMatch { + return SeverityCrit + } + if !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 0 { + return SeverityCrit + } + if !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 14*24*time.Hour { + if worst != SeverityCrit { + return SeverityWarn + } + } + return worst +} diff --git a/checker/types.go b/checker/types.go new file mode 100644 index 0000000..960cec9 --- /dev/null +++ b/checker/types.go @@ -0,0 +1,182 @@ +// Package checker implements the LDAP server checker for happyDomain. +// +// It probes a domain's LDAP deployment (_ldap._tcp / _ldaps._tcp SRV +// discovery with fallback to default ports 389/636, anonymous bind, +// StartTLS upgrade, RootDSE introspection, plaintext-bind refusal, +// supportedSASLMechanisms) and reports actionable findings. +// +// TLS certificate chain / SAN / expiry / cipher posture is intentionally +// out of scope -- the dedicated TLS checker covers that, fed by the TLS +// endpoints we publish as DiscoveryEntry records. +package checker + +import ( + sdk "git.happydns.org/checker-sdk-go/checker" +) + +const ObservationKeyLDAP sdk.ObservationKey = "ldap" + +// LDAPMode distinguishes plaintext LDAP (with optional StartTLS) from +// implicit-TLS LDAPS endpoints. +type LDAPMode string + +const ( + ModePlain LDAPMode = "ldap" + ModeLDAPS LDAPMode = "ldaps" +) + +// LDAPData is the full observation stored per run. +type LDAPData struct { + Domain string `json:"domain"` + BaseDN string `json:"base_dn,omitempty"` + RunAt string `json:"run_at"` + SRV SRVLookup `json:"srv"` + Endpoints []EndpointProbe `json:"endpoints"` + // BindTested is true when a bind DN was supplied and a bind attempt ran. + BindTested bool `json:"bind_tested,omitempty"` +} + +type SRVLookup struct { + LDAP []SRVRecord `json:"ldap,omitempty"` + LDAPS []SRVRecord `json:"ldaps,omitempty"` + // Errors per-set (keyed by record type like "_ldap._tcp"). + Errors map[string]string `json:"errors,omitempty"` + // FallbackProbed is true when no SRV was published and we probed the + // bare domain on the default ports. + FallbackProbed bool `json:"fallback_probed,omitempty"` +} + +type SRVRecord struct { + Target string `json:"target"` + Port uint16 `json:"port"` + Priority uint16 `json:"priority"` + Weight uint16 `json:"weight"` + // IPv4 and IPv6 addresses resolved for the target (at probe time). + IPv4 []string `json:"ipv4,omitempty"` + IPv6 []string `json:"ipv6,omitempty"` +} + +// EndpointProbe is the result of probing one (mode, host, port, address) tuple. +type EndpointProbe struct { + Mode LDAPMode `json:"mode"` + SRVPrefix string `json:"srv_prefix,omitempty"` + Target string `json:"target"` + Port uint16 `json:"port"` + Address string `json:"address"` + IsIPv6 bool `json:"is_ipv6,omitempty"` + + // What happened. + TCPConnected bool `json:"tcp_connected"` + + // StartTLSOffered is only meaningful on Mode=ldap: whether the server + // accepted the RFC 2830 ExtendedRequest. + StartTLSOffered bool `json:"starttls_offered"` + StartTLSUpgraded bool `json:"starttls_upgraded"` + // TLSEstablished is true whenever the link was encrypted (direct LDAPS + // handshake succeeded OR StartTLS completed). + TLSEstablished bool `json:"tls_established"` + + TLSVersion string `json:"tls_version,omitempty"` + TLSCipher string `json:"tls_cipher,omitempty"` + + // RootDSE / capability fingerprint. + RootDSERead bool `json:"rootdse_read"` + SupportedLDAPVersion []string `json:"supported_ldap_version,omitempty"` + SupportedSASLMechanisms []string `json:"supported_sasl_mechanisms,omitempty"` + SupportedControl []string `json:"supported_control,omitempty"` + SupportedExtension []string `json:"supported_extension,omitempty"` + NamingContexts []string `json:"naming_contexts,omitempty"` + VendorName string `json:"vendor_name,omitempty"` + VendorVersion string `json:"vendor_version,omitempty"` + + // AnonymousBindAllowed is true when an anonymous simple bind to DN="" + // succeeded. Many directories accept this just to expose the RootDSE; + // we flag it only when paired with anonymous read of naming contexts. + AnonymousBindAllowed bool `json:"anonymous_bind_allowed,omitempty"` + + // AnonymousSearchAllowed is true when a subtree search on the first + // naming context with baseObject returned any entry without auth. + // This is the information-disclosure signal. + AnonymousSearchAllowed bool `json:"anonymous_search_allowed,omitempty"` + + // Plaintext-bind posture (only for Mode=ldap, run before TLS upgrade). + // PlaintextBindTested is true when we attempted a simple bind with + // dummy credentials over cleartext to see whether the server refused + // it (RFC 4513 requires refusing auth over insecure channels when + // policy demands it, though in practice most deployments don't). + PlaintextBindTested bool `json:"plaintext_bind_tested,omitempty"` + // PlaintextBindAccepted is true when the server did NOT refuse the + // cleartext bind with "confidentiality required" (resultCode 13). + // A bad-credentials response (49) counts as "accepted the attempt", + // which is the insecure-posture signal we want to flag. + PlaintextBindAccepted bool `json:"plaintext_bind_accepted,omitempty"` + + // Bind test (run only when bind_dn/bind_password options provided). + BindAttempted bool `json:"bind_attempted,omitempty"` + BindOK bool `json:"bind_ok,omitempty"` + BindError string `json:"bind_error,omitempty"` + + // Read access test on base_dn (run only when base_dn provided AND the + // authenticated bind above succeeded, unless base_dn was meant to be + // anonymously readable). + BaseReadAttempted bool `json:"base_read_attempted,omitempty"` + BaseReadOK bool `json:"base_read_ok,omitempty"` + BaseReadEntries int `json:"base_read_entries,omitempty"` + BaseReadError string `json:"base_read_error,omitempty"` + + ElapsedMS int64 `json:"elapsed_ms"` + Error string `json:"error,omitempty"` +} + +type ReachabilitySpan struct { + HasIPv4 bool `json:"has_ipv4"` + HasIPv6 bool `json:"has_ipv6"` + // EncryptedReachable is true when at least one endpoint offered encrypted + // transport (LDAPS or LDAP+StartTLS). + EncryptedReachable bool `json:"encrypted_reachable"` + // PlainOnlyReachable is true when only cleartext endpoints responded. + PlainOnlyReachable bool `json:"plain_only_reachable"` +} + +// Issue is a structured finding attached to the observation so the rule and +// the HTML report can both consume them without re-deriving logic. +type Issue struct { + Code string `json:"code"` + Severity string `json:"severity"` // "info" | "warn" | "crit" + Message string `json:"message"` + Fix string `json:"fix,omitempty"` + Endpoint string `json:"endpoint,omitempty"` +} + +// Severities (string for stable JSON, independent of sdk.Status numeric values). +const ( + SeverityInfo = "info" + SeverityWarn = "warn" + SeverityCrit = "crit" +) + +// Issue codes. +const ( + CodeNoSRV = "ldap.no_srv" + CodeSRVServfail = "ldap.srv.servfail" + CodeTCPUnreachable = "ldap.tcp.unreachable" + CodeAllEndpointsDown = "ldap.all_endpoints_down" + CodeNoEncryptedEndpoint = "ldap.no_encrypted_endpoint" + CodeStartTLSMissing = "ldap.starttls.missing" + CodeStartTLSFailed = "ldap.starttls.handshake_failed" + CodeLDAPSHandshakeFailed = "ldap.ldaps.handshake_failed" + CodePlainBindAccepted = "ldap.plain_bind.accepted" + CodeAnonymousSearchAllowed = "ldap.anon.search_allowed" + CodeRootDSEUnreadable = "ldap.rootdse.unreadable" + CodeNoSASL = "ldap.sasl.none" + CodeSASLPlainOnly = "ldap.sasl.plain_only" + CodeSASLNoStrongMech = "ldap.sasl.no_strong_mech" + CodeLegacyLDAPv2 = "ldap.legacy_v2" + CodeStartTLSOnLDAPS = "ldap.starttls.on_ldaps" + CodeNoIPv6 = "ldap.no_ipv6" + CodeBindFailed = "ldap.bind.failed" + CodeBindOK = "ldap.bind.ok" + CodeBaseReadFailed = "ldap.base_read.failed" + CodeBaseReadOK = "ldap.base_read.ok" + CodeNoNamingContext = "ldap.rootdse.no_naming_context" +) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9c2bbe1 --- /dev/null +++ b/go.mod @@ -0,0 +1,22 @@ +module git.happydns.org/checker-ldap + +go 1.25.0 + +require ( + git.happydns.org/checker-sdk-go v1.4.0 + git.happydns.org/checker-tls v0.6.2 + github.com/go-ldap/ldap/v3 v3.4.8 + github.com/miekg/dns v1.1.72 +) + +require ( + github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect + github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect + github.com/google/uuid v1.6.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/mod v0.33.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/tools v0.42.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9bdc5e9 --- /dev/null +++ b/go.sum @@ -0,0 +1,112 @@ +git.happydns.org/checker-sdk-go v1.4.0 h1:sO8EnF3suhNgYLRsbmCZWJOymH/oNMrOUqj3FEzJArs= +git.happydns.org/checker-sdk-go v1.4.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= +git.happydns.org/checker-tls v0.6.2 h1:8oKia1XlD+tklyqrwzmUgFH1Kw8VLSLLF9suZ7Qr14E= +git.happydns.org/checker-tls v0.6.2/go.mod h1:9tpnxg0iOwS+7If64DRG1jqYonUAgxOBuxwfF5mVkL4= +github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= +github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= +github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= +github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-asn1-ber/asn1-ber v1.5.5/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= +github.com/go-asn1-ber/asn1-ber v1.5.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk= +github.com/go-asn1-ber/asn1-ber v1.5.7/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= +github.com/go-ldap/ldap/v3 v3.4.8 h1:loKJyspcRezt2Q3ZRMq2p/0v8iOurlmeXDPw6fikSvQ= +github.com/go-ldap/ldap/v3 v3.4.8/go.mod h1:qS3Sjlu76eHfHGpUdWkAXQTw4beih+cHsco2jXlIXrk= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= +github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= +github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= +github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= +github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= +github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg= +github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= +github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= +github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= +github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8= +github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= +github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= +github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= +github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= +github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..b47d3c5 --- /dev/null +++ b/main.go @@ -0,0 +1,28 @@ +package main + +import ( + "flag" + "log" + + ldap "git.happydns.org/checker-ldap/checker" + "git.happydns.org/checker-sdk-go/checker/server" +) + +// Version is the standalone binary's version. It defaults to "custom-build" +// and is meant to be overridden by the CI at link time: +// +// go build -ldflags "-X main.Version=1.2.3" . +var Version = "custom-build" + +var listenAddr = flag.String("listen", ":8080", "HTTP listen address") + +func main() { + flag.Parse() + + ldap.Version = Version + + srv := server.New(ldap.Provider()) + if err := srv.ListenAndServe(*listenAddr); err != nil { + log.Fatalf("server error: %v", err) + } +} diff --git a/plugin/plugin.go b/plugin/plugin.go new file mode 100644 index 0000000..1b56f60 --- /dev/null +++ b/plugin/plugin.go @@ -0,0 +1,20 @@ +// Command plugin is the happyDomain plugin entrypoint for the LDAP checker. +// +// It is built as a Go plugin (`go build -buildmode=plugin`) and loaded at +// runtime by happyDomain. +package main + +import ( + ldap "git.happydns.org/checker-ldap/checker" + sdk "git.happydns.org/checker-sdk-go/checker" +) + +var Version = "custom-build" + +// NewCheckerPlugin is the symbol resolved by happyDomain when loading the +// .so file. +func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) { + ldap.Version = Version + prvd := ldap.Provider() + return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil +}