From 9b128d22ed560cad408394730de8ec88313f0760 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 | 14 + LICENSE | 21 ++ Makefile | 28 ++ NOTICE | 31 ++ README.md | 99 ++++++ checker/collect.go | 747 +++++++++++++++++++++++++++++++++++++++++ checker/definition.go | 69 ++++ checker/interactive.go | 82 +++++ checker/provider.go | 68 ++++ checker/report.go | 577 +++++++++++++++++++++++++++++++ checker/rule.go | 99 ++++++ checker/tls_related.go | 162 +++++++++ checker/types.go | 184 ++++++++++ go.mod | 22 ++ go.sum | 112 ++++++ main.go | 28 ++ plugin/plugin.go | 19 ++ 18 files changed, 2364 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/rule.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..f4cdb2f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +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 +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..aeb21cd --- /dev/null +++ b/checker/collect.go @@ -0,0 +1,747 @@ +package checker + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "net" + "strconv" + "strings" + "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 + lookupSets := []struct { + prefix string + dst *[]SRVRecord + }{ + {"_ldap._tcp.", &data.SRV.LDAP}, + {"_ldaps._tcp.", &data.SRV.LDAPS}, + } + for _, ls := range lookupSets { + records, err := lookupSRV(ctx, resolver, ls.prefix, domain) + if err != nil { + data.SRV.Errors[ls.prefix] = err.Error() + continue + } + *ls.dst = records + } + + 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}} + } + + resolveAllInto(ctx, resolver, data.SRV.LDAP) + resolveAllInto(ctx, resolver, data.SRV.LDAPS) + + wantBind := bindDN != "" && bindPassword != "" + if wantBind { + data.BindTested = true + } + + probeSet(ctx, data, domain, ModePlain, "_ldap._tcp", data.SRV.LDAP, perEndpoint, bindDN, bindPassword, baseDN) + probeSet(ctx, data, domain, ModeLDAPS, "_ldaps._tcp", data.SRV.LDAPS, perEndpoint, bindDN, bindPassword, baseDN) + + computeCoverage(data) + data.Issues = deriveIssues(data, wantBind, baseDN != "") + + return data, nil +} + +func probeSet(ctx context.Context, data *LDAPData, domain string, mode LDAPMode, prefix string, records []SRVRecord, timeout time.Duration, bindDN, bindPassword, baseDN string) { + for _, rec := range records { + addrs := addressesForProbe(rec) + if len(addrs) == 0 { + ep := EndpointProbe{ + Mode: mode, + SRVPrefix: prefix, + Target: rec.Target, + Port: rec.Port, + Error: "no A/AAAA records for target", + } + data.Endpoints = append(data.Endpoints, ep) + continue + } + for _, a := range addrs { + ep := probeEndpoint(ctx, domain, mode, prefix, rec, a.ip, a.isV6, timeout, bindDN, bindPassword, baseDN) + data.Endpoints = append(data.Endpoints, ep) + } + } +} + +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() + _ = rawConn.SetDeadline(time.Now().Add(timeout)) + + // 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(domain, 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) + _ = tlsConn.SetDeadline(time.Now().Add(timeout)) + 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. + 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 := stringListContains(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 + // 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) + } + // Refresh RootDSE post-TLS: some servers expose more + // supported mechanisms after the secure channel is up. + readRootDSE(conn, &result) + } + } else if !result.RootDSERead { + result.Error = "rootdse-unreadable: " + firstNonEmpty(result.Error, "RootDSE could not be read") + } + } + + // Anonymous bind + search -- we try unconditionally so we can flag + // exposure. + anonBindOK := simpleBindIs(conn, "", "", 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 + err := conn.Bind(bindDN, bindPassword) + if err == nil { + result.BindOK = true + if baseDN != "" { + result.BaseReadAttempted = true + 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 { + result.BaseReadOK = true + result.BaseReadEntries = len(sr.Entries) + } + } + } else { + result.BindError = err.Error() + } + } + + return result +} + +// simpleBindIs runs a simple bind and reports whether it succeeded. It is a +// thin wrapper so we can distinguish "bind accepted" from "bind rejected" +// without tracking specific LDAP result codes. +func simpleBindIs(conn *ldapv3.Conn, user, pass string, classifier *ldapClassifier) bool { + err := conn.Bind(user, pass) + if classifier != nil { + classifier.lastErr = err + } + return err == nil +} + +type ldapClassifier struct { + lastErr error +} + +// probePlaintextBindRefusal opens a short-lived, fresh TCP connection and +// attempts a simple bind with a random 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(domain, 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 + err = conn.Bind("cn=checker-probe,dc="+domain, "x-not-a-real-password-x") + if err == nil { + // Unlikely but clear: cleartext bind accepted. + return tested, true + } + // Map LDAP result code 13 (confidentiality required) to "refused". + var lerr *ldapv3.Error + if errors.As(err, &lerr) && lerr.ResultCode == ldapv3.LDAPResultConfidentialityRequired { + return tested, false + } + // resultCode 49 (invalidCredentials), 32 (noSuchObject), … all mean + // the server was willing to *try* the bind over cleartext, which is + // the warning-worthy posture. + 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 + } +} + +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 +} + +func resolveAllInto(ctx context.Context, r *net.Resolver, records []SRVRecord) { + for i := range records { + ips, err := r.LookupIPAddr(ctx, records[i].Target) + if err != nil { + continue + } + for _, ip := range ips { + if v4 := ip.IP.To4(); v4 != nil { + records[i].IPv4 = append(records[i].IPv4, v4.String()) + } else { + records[i].IPv6 = append(records[i].IPv6, ip.IP.String()) + } + } + } +} + +func computeCoverage(data *LDAPData) { + anyEncrypted := false + anyPlain := false + for _, ep := range data.Endpoints { + if ep.TCPConnected { + if ep.IsIPv6 { + data.Coverage.HasIPv6 = true + } else { + data.Coverage.HasIPv4 = true + } + if ep.TLSEstablished { + anyEncrypted = true + } else { + anyPlain = true + } + } + } + data.Coverage.EncryptedReachable = anyEncrypted + data.Coverage.PlainOnlyReachable = anyPlain && !anyEncrypted +} + +func deriveIssues(data *LDAPData, wantBind, wantBaseRead bool) []Issue { + var issues []Issue + + // 1. No SRV published. + if data.SRV.FallbackProbed { + issues = append(issues, Issue{ + Code: CodeNoSRV, + Severity: SeverityInfo, + Message: "No LDAP SRV records published for " + data.Domain + ".", + Fix: "Consider publishing _ldap._tcp." + data.Domain + " and _ldaps._tcp." + data.Domain + " SRV records to let clients discover the directory automatically.", + }) + } + + // 2. SRV lookup errors. + for prefix, msg := range data.SRV.Errors { + issues = append(issues, Issue{ + Code: CodeSRVServfail, + Severity: SeverityWarn, + Message: "DNS lookup failed for " + prefix + data.Domain + ": " + msg, + Fix: "Check the authoritative DNS servers for this domain.", + }) + } + + // 3. Endpoint-level issues. + allDown := len(data.Endpoints) > 0 + anyEncrypted := false + anyLDAPS := false + anyLDAPReachable := false + sawSASL := false + sawStrongSASL := false + sawPlainOnly := false + + for _, ep := range data.Endpoints { + if ep.TCPConnected { + allDown = false + if ep.Mode == ModePlain { + anyLDAPReachable = true + } + if ep.Mode == ModeLDAPS { + anyLDAPS = true + } + if ep.TLSEstablished { + anyEncrypted = true + } + } + if !ep.TCPConnected && ep.Error != "" { + issues = append(issues, Issue{ + Code: CodeTCPUnreachable, + Severity: SeverityWarn, + Message: "Cannot reach " + ep.Address + ": " + ep.Error + ".", + Fix: "Verify firewall rules and that the LDAP server is listening on this address.", + Endpoint: ep.Address, + }) + continue + } + + if ep.Mode == ModePlain && ep.TCPConnected { + if !ep.StartTLSOffered { + issues = append(issues, Issue{ + Code: CodeStartTLSMissing, + Severity: SeverityCrit, + Message: "StartTLS not advertised on " + ep.Address + ".", + Fix: "Configure the LDAP server to accept StartTLS (RFC 2830). On OpenLDAP, set olcTLSCertificateFile / olcTLSCertificateKeyFile and ensure the LDAP listener allows encrypted upgrades.", + Endpoint: ep.Address, + }) + } else if !ep.StartTLSUpgraded { + issues = append(issues, Issue{ + Code: CodeStartTLSFailed, + Severity: SeverityCrit, + Message: "StartTLS handshake failed on " + ep.Address + ": " + ep.Error + ".", + Fix: "Run the TLS checker on this endpoint for cert and cipher details.", + Endpoint: ep.Address, + }) + } + if ep.PlaintextBindTested && ep.PlaintextBindAccepted { + issues = append(issues, Issue{ + Code: CodePlainBindAccepted, + Severity: SeverityCrit, + Message: "Cleartext bind attempts are accepted on " + ep.Address + " (server does not reply confidentialityRequired).", + Fix: "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.", + Endpoint: ep.Address, + }) + } + } + + if ep.Mode == ModeLDAPS && ep.TCPConnected && !ep.TLSEstablished { + issues = append(issues, Issue{ + Code: CodeLDAPSHandshakeFailed, + Severity: SeverityCrit, + Message: "LDAPS TLS handshake failed on " + ep.Address + ": " + ep.Error + ".", + Fix: "Check that the LDAPS listener has a valid certificate/key pair and is speaking TLS directly (not LDAP+StartTLS).", + Endpoint: ep.Address, + }) + } + + if ep.TCPConnected && ep.TLSEstablished && !ep.RootDSERead { + issues = append(issues, Issue{ + Code: CodeRootDSEUnreadable, + Severity: SeverityWarn, + Message: "RootDSE is not readable on " + ep.Address + " -- capability discovery is unavailable.", + Fix: "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.", + Endpoint: ep.Address, + }) + } + + // Anonymous exposure. + if ep.AnonymousSearchAllowed { + issues = append(issues, Issue{ + Code: CodeAnonymousSearchAllowed, + Severity: SeverityWarn, + Message: "Anonymous search against naming context succeeds on " + ep.Address + " -- DIT contents may be enumerable without credentials.", + Fix: "Restrict anonymous access: `access to * by anonymous auth by users read` (OpenLDAP) or set `nsslapd-allow-anonymous-access: off` (389-ds).", + Endpoint: ep.Address, + }) + } + + // SASL posture (from RootDSE). + if len(ep.SupportedSASLMechanisms) > 0 { + 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 hasPlain && !hasStrong { + sawPlainOnly = true + } + if hasStrong { + sawStrongSASL = true + } + } + + // Protocol version. + hasV3 := false + hasV2 := false + for _, v := range ep.SupportedLDAPVersion { + switch strings.TrimSpace(v) { + case "3": + hasV3 = true + case "2": + hasV2 = true + } + } + _ = hasV3 + if hasV2 { + issues = append(issues, Issue{ + Code: CodeLegacyLDAPv2, + Severity: SeverityWarn, + Message: "Server still advertises supportedLDAPVersion=2 on " + ep.Address + ".", + Fix: "Disable LDAPv2 support -- RFC 3494 deprecated it in 2003. On OpenLDAP, remove `allow bind_v2`.", + Endpoint: ep.Address, + }) + } + + // Naming context exposure check -- missing it is usually benign, but + // on authoritative directories the absence means you cannot route + // queries. Report as info. + if ep.RootDSERead && len(ep.NamingContexts) == 0 { + issues = append(issues, Issue{ + Code: CodeNoNamingContext, + Severity: SeverityInfo, + Message: "RootDSE does not advertise any naming context on " + ep.Address + ".", + Fix: "If this server is authoritative, populate the namingContexts attribute so clients can locate the DIT root.", + Endpoint: ep.Address, + }) + } + + // StartTLS offered on LDAPS port -- not wrong per se (some servers + // support both), but usually a configuration smell. + if ep.Mode == ModeLDAPS && stringListContains(ep.SupportedExtension, "1.3.6.1.4.1.1466.20037") { + issues = append(issues, Issue{ + Code: CodeStartTLSOnLDAPS, + Severity: SeverityInfo, + Message: "Server advertises StartTLS on the LDAPS port " + ep.Address + ".", + Fix: "This is not actively harmful, but most directories do not need StartTLS exposed on 636. Consider disabling it to reduce attack surface.", + Endpoint: ep.Address, + }) + } + + // Authenticated bind result. + if ep.BindAttempted { + if ep.BindOK { + issues = append(issues, Issue{ + Code: CodeBindOK, + Severity: SeverityInfo, + Message: "Bind as " + ep.Address + " succeeded with the provided credentials.", + Endpoint: ep.Address, + }) + if ep.BaseReadAttempted && !ep.BaseReadOK { + issues = append(issues, Issue{ + Code: CodeBaseReadFailed, + Severity: SeverityCrit, + Message: "Bind succeeded but baseObject read on " + data.BaseDN + " failed: " + ep.BaseReadError, + Fix: "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`.", + Endpoint: ep.Address, + }) + } + if ep.BaseReadAttempted && ep.BaseReadOK { + issues = append(issues, Issue{ + Code: CodeBaseReadOK, + Severity: SeverityInfo, + Message: "Base DN read succeeded on " + ep.Address + " (entries=" + strconv.Itoa(ep.BaseReadEntries) + ").", + Endpoint: ep.Address, + }) + } + } else { + issues = append(issues, Issue{ + Code: CodeBindFailed, + Severity: SeverityCrit, + Message: "Bind on " + ep.Address + " failed: " + ep.BindError, + Fix: "Verify the bind DN exists and the password is current. On AD, check the account is not locked/expired.", + Endpoint: ep.Address, + }) + } + } + } + + // Aggregate-level derivations. + if allDown { + issues = append(issues, Issue{ + Code: CodeAllEndpointsDown, + Severity: SeverityCrit, + Message: "No LDAP endpoint is reachable.", + Fix: "Check firewall rules and that the directory is listening on ports 389/636 (or the ports indicated by SRV).", + }) + } else if !anyEncrypted { + issues = append(issues, Issue{ + Code: CodeNoEncryptedEndpoint, + Severity: SeverityCrit, + Message: "None of the reachable endpoints established an encrypted channel (no StartTLS, no LDAPS).", + Fix: "Enable either LDAPS (port 636) or StartTLS on 389 -- a bind DN should never travel in cleartext.", + }) + } + if anyLDAPReachable && !anyLDAPS { + // Not always a misconfig (some sites run StartTLS-only), info only. + // No dedicated issue -- informational. + } + + if sawSASL { + if !sawStrongSASL { + issues = append(issues, Issue{ + Code: CodeSASLNoStrongMech, + Severity: SeverityWarn, + Message: "No strong SASL mechanism (SCRAM-*, EXTERNAL, GSSAPI) is advertised.", + Fix: "Enable SCRAM-SHA-256 or GSSAPI where your identity platform allows it.", + }) + } + if sawPlainOnly { + issues = append(issues, Issue{ + Code: CodeSASLPlainOnly, + Severity: SeverityWarn, + Message: "Only PLAIN/LOGIN SASL mechanisms are offered.", + Fix: "Add SCRAM-SHA-256 so password hashes aren't sent as password-equivalent tokens.", + }) + } + } else if len(data.Endpoints) > 0 { + // We didn't see supportedSASLMechanisms at all -- either the server + // doesn't advertise them or we couldn't read RootDSE. + issues = append(issues, Issue{ + Code: CodeNoSASL, + Severity: SeverityInfo, + Message: "No supportedSASLMechanisms advertised by the directory.", + Fix: "If SASL is in use, ensure the server publishes supportedSASLMechanisms in the RootDSE. Otherwise only simple binds are available.", + }) + } + + if data.Coverage.HasIPv4 && !data.Coverage.HasIPv6 { + issues = append(issues, Issue{ + Code: CodeNoIPv6, + Severity: SeverityInfo, + Message: "No IPv6 endpoint reachable.", + Fix: "Publish AAAA records for the SRV targets.", + }) + } + + return issues +} + +func stringListContains(list []string, want string) bool { + for _, s := range list { + if strings.EqualFold(s, want) { + return true + } + } + return false +} + +func unique(list []string) []string { + 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 +} + +func firstNonEmpty(a, b string) string { + if a != "" { + return a + } + return b +} diff --git a/checker/definition.go b/checker/definition.go new file mode 100644 index 0000000..49da8e3 --- /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 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: []sdk.CheckRule{Rule()}, + 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..17a3554 --- /dev/null +++ b/checker/interactive.go @@ -0,0 +1,82 @@ +//go:build standalone + +package checker + +import ( + "errors" + "net/http" + "strconv" + "strings" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// RenderForm implements server.Interactive. It exposes the same option +// set as /evaluate, minus the AutoFill hint on `domain` (the human is the +// one filling it in) and with a sensible default timeout. +func (p *ldapProvider) RenderForm() []sdk.CheckerOptionField { + return []sdk.CheckerOptionField{ + { + Id: "domain", + Type: "string", + Label: "Domain", + Placeholder: "example.com", + 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.", + }, + } +} + +// 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..43b17c2 --- /dev/null +++ b/checker/provider.go @@ -0,0 +1,68 @@ +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 +} + +// Definition implements sdk.CheckerDefinitionProvider. +func (p *ldapProvider) Definition() *sdk.CheckerDefinition { + return Definition() +} + +// 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..4d758c8 --- /dev/null +++ b/checker/report.go @@ -0,0 +1,577 @@ +package checker + +import ( + "encoding/json" + "fmt" + "html/template" + "sort" + "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 { + Mode string + ModeLabel string + SRVPrefix string + Target string + Port uint16 + Address string + IsIPv6 bool + TCPConnected bool + StartTLSOffered bool + StartTLSUpgraded bool + TLSEstablished bool + TLSVersion string + TLSCipher string + RootDSERead bool + SupportedLDAPVersion []string + SupportedSASLMechanisms []string + SupportedControl []string + SupportedExtension []string + NamingContexts []string + VendorName string + VendorVersion string + AnonymousBindAllowed bool + AnonymousSearchAllowed bool + PlaintextBindTested bool + PlaintextBindAccepted bool + BindAttempted bool + BindOK bool + BindError string + BaseReadAttempted bool + BaseReadOK bool + BaseReadEntries int + BaseReadError string + ElapsedMS int64 + Error 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 + Issues []reportFix +} + +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}} + {{range .Issues}} +
+
{{.Code}}
+
{{.Message}}
+ {{if .Fix}}
→ {{.Fix}}
{{end}} +
+ {{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. +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)) + 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) reportData { + tlsIssues := tlsIssuesFromRelated(related) + tlsByAddr := indexTLSByAddress(related) + + allIssues := append([]Issue(nil), d.Issues...) + allIssues = append(allIssues, tlsIssues...) + + view := reportData{ + Domain: d.Domain, + BaseDN: d.BaseDN, + RunAt: d.RunAt, + FallbackProbed: d.SRV.FallbackProbed, + HasIPv4: d.Coverage.HasIPv4, + HasIPv6: d.Coverage.HasIPv6, + EncryptedReachable: d.Coverage.EncryptedReachable, + PlainOnlyReachable: d.Coverage.PlainOnlyReachable, + BindTested: d.BindTested, + HasIssues: len(allIssues) > 0, + HasTLSPosture: len(tlsByAddr) > 0, + } + + // Status banner. + worst := "" + for _, is := range allIssues { + if is.Severity == SeverityCrit { + worst = SeverityCrit + break + } + if is.Severity == SeverityWarn { + worst = SeverityWarn + } else if worst == "" && is.Severity == SeverityInfo { + worst = SeverityInfo + } + } + if len(allIssues) == 0 { + view.StatusLabel = "OK" + view.StatusClass = "ok" + } else { + switch worst { + case SeverityCrit: + view.StatusLabel = "FAIL" + view.StatusClass = "fail" + case SeverityWarn: + view.StatusLabel = "WARN" + view.StatusClass = "warn" + default: + view.StatusLabel = "INFO" + view.StatusClass = "info" + } + } + + // Fix list: sort crit → warn → info, preserving order within each severity. + sevRank := func(s string) int { + switch s { + case SeverityCrit: + return 0 + case SeverityWarn: + return 1 + default: + return 2 + } + } + sort.SliceStable(allIssues, func(i, j int) bool { return sevRank(allIssues[i].Severity) < sevRank(allIssues[j].Severity) }) + for _, is := range allIssues { + view.Fixes = append(view.Fixes, reportFix{ + Severity: is.Severity, + Code: is.Code, + Message: is.Message, + Fix: is.Fix, + Endpoint: is.Endpoint, + }) + } + + // 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{ + Mode: string(ep.Mode), + ModeLabel: modeLabel(ep.Mode), + SRVPrefix: ep.SRVPrefix, + Target: ep.Target, + Port: ep.Port, + Address: ep.Address, + IsIPv6: ep.IsIPv6, + TCPConnected: ep.TCPConnected, + StartTLSOffered: ep.StartTLSOffered, + StartTLSUpgraded: ep.StartTLSUpgraded, + TLSEstablished: ep.TLSEstablished, + TLSVersion: ep.TLSVersion, + TLSCipher: ep.TLSCipher, + RootDSERead: ep.RootDSERead, + SupportedLDAPVersion: ep.SupportedLDAPVersion, + SupportedSASLMechanisms: ep.SupportedSASLMechanisms, + SupportedControl: ep.SupportedControl, + SupportedExtension: ep.SupportedExtension, + NamingContexts: ep.NamingContexts, + VendorName: ep.VendorName, + VendorVersion: ep.VendorVersion, + AnonymousBindAllowed: ep.AnonymousBindAllowed, + AnonymousSearchAllowed: ep.AnonymousSearchAllowed, + PlaintextBindTested: ep.PlaintextBindTested, + PlaintextBindAccepted: ep.PlaintextBindAccepted, + BindAttempted: ep.BindAttempted, + BindOK: ep.BindOK, + BindError: ep.BindError, + BaseReadAttempted: ep.BaseReadAttempted, + BaseReadOK: ep.BaseReadOK, + BaseReadEntries: ep.BaseReadEntries, + BaseReadError: ep.BaseReadError, + ElapsedMS: ep.ElapsedMS, + Error: ep.Error, + } + if meta, hit := tlsByAddr[ep.Address]; hit { + re.TLSPosture = meta + } else if meta, hit := tlsByAddr[endpointKey(ep.Target, ep.Port)]; 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 +} + +func modeLabel(m LDAPMode) string { + switch m { + case ModePlain: + return "ldap" + case ModeLDAPS: + return "ldaps" + default: + return string(m) + } +} + +// indexTLSByAddress returns a map keyed by "host:port" pointing at a +// reportTLSPosture. This lets the template match a related observation to +// the right endpoint. +func indexTLSByAddress(related []sdk.RelatedObservation) map[string]*reportTLSPosture { + out := map[string]*reportTLSPosture{} + for _, r := range related { + v := parseTLSRelated(r) + if v == nil { + continue + } + addr := v.address() + if addr == "" { + continue + } + posture := &reportTLSPosture{ + CheckedAt: r.CollectedAt, + ChainValid: v.ChainValid, + HostnameMatch: v.HostnameMatch, + NotAfter: v.NotAfter, + } + for _, is := range v.Issues { + sev := strings.ToLower(is.Severity) + if sev != SeverityCrit && sev != SeverityWarn && sev != SeverityInfo { + continue + } + posture.Issues = append(posture.Issues, reportFix{ + Severity: sev, + Code: is.Code, + Message: is.Message, + Fix: is.Fix, + }) + } + out[addr] = posture + } + return out +} diff --git a/checker/rule.go b/checker/rule.go new file mode 100644 index 0000000..73f9c83 --- /dev/null +++ b/checker/rule.go @@ -0,0 +1,99 @@ +package checker + +import ( + "context" + "fmt" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +func Rule() sdk.CheckRule { + return &ldapRule{} +} + +type ldapRule struct{} + +func (r *ldapRule) Name() string { + return "ldap_server" +} + +func (r *ldapRule) Description() string { + return "Checks discovery, transport security (StartTLS / LDAPS), RootDSE, SASL posture and (optionally) bind credentials of an LDAP directory." +} + +func (r *ldapRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + var data LDAPData + if err := obs.Get(ctx, ObservationKeyLDAP, &data); err != nil { + return []sdk.CheckState{{ + Status: sdk.StatusError, + Message: fmt.Sprintf("failed to load LDAP observation: %v", err), + Code: "ldap.observation_error", + }} + } + + issues := append([]Issue(nil), data.Issues...) + + // Fold related TLS observations (from a downstream TLS checker, if any) + // into the LDAP issue list so cert/chain problems show up on the LDAP + // service page without requiring a separate glance at the TLS checker. + related, _ := obs.GetRelated(ctx, TLSRelatedKey) + issues = append(issues, tlsIssuesFromRelated(related)...) + + withIssue := make(map[string]bool, len(issues)) + out := make([]sdk.CheckState, 0, len(issues)+len(data.Endpoints)) + + for _, is := range issues { + st := sdk.CheckState{ + Status: severityToStatus(is.Severity), + Message: is.Message, + Code: is.Code, + Subject: is.Endpoint, + } + if is.Fix != "" { + st.Meta = map[string]any{"fix": is.Fix} + } + out = append(out, st) + if is.Endpoint != "" { + withIssue[is.Endpoint] = true + } + } + + for _, ep := range data.Endpoints { + if withIssue[ep.Address] || !ep.TCPConnected { + continue + } + out = append(out, sdk.CheckState{ + Status: sdk.StatusOK, + Message: fmt.Sprintf("%s endpoint operational (TLS=%v)", ep.Mode, ep.TLSEstablished), + Code: "ldap.ok", + Subject: ep.Address, + Meta: map[string]any{ + "mode": string(ep.Mode), + "tls_established": ep.TLSEstablished, + "rootdse_read": ep.RootDSERead, + }, + }) + } + + if len(out) == 0 { + return []sdk.CheckState{{ + Status: sdk.StatusInfo, + Message: "no LDAP endpoint discovered", + Code: "ldap.nothing_to_evaluate", + }} + } + return out +} + +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 + } +} diff --git a/checker/tls_related.go b/checker/tls_related.go new file mode 100644 index 0000000..69dcbbd --- /dev/null +++ b/checker/tls_related.go @@ -0,0 +1,162 @@ +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 +} + +// tlsIssuesFromRelated converts downstream TLS observations into Issue +// entries so certificate problems land on the LDAP service page. +func tlsIssuesFromRelated(related []sdk.RelatedObservation) []Issue { + var out []Issue + 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, Issue{ + Code: "ldap.tls." + code, + Severity: sev, + Message: strings.TrimSpace("TLS on " + addr + ": " + is.Message), + Fix: is.Fix, + Endpoint: addr, + }) + } + continue + } + // Flag-only payload: synthesize a single summary issue. + 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, Issue{ + Code: "ldap.tls.probe", + Severity: sev, + Message: msg, + Fix: "See the TLS checker report for details.", + Endpoint: addr, + }) + } + return out +} + +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 +} + +func endpointKey(host string, port uint16) string { + return net.JoinHostPort(host, strconv.FormatUint(uint64(port), 10)) +} diff --git a/checker/types.go b/checker/types.go new file mode 100644 index 0000000..9e00250 --- /dev/null +++ b/checker/types.go @@ -0,0 +1,184 @@ +// 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"` + Coverage ReachabilitySpan `json:"coverage"` + // BindTested is true when a bind DN was supplied and a bind attempt ran. + BindTested bool `json:"bind_tested,omitempty"` + Issues []Issue `json:"issues"` +} + +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..aee46d7 --- /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.3.0 + git.happydns.org/checker-tls v0.3.0 + 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..3083f09 --- /dev/null +++ b/go.sum @@ -0,0 +1,112 @@ +git.happydns.org/checker-sdk-go v1.3.0 h1:FG2kIhlJCzI0m35EhxSgn4UWc9M4ha6aZTeoChu4l7A= +git.happydns.org/checker-sdk-go v1.3.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= +git.happydns.org/checker-tls v0.3.0 h1:hg9fNtT5l/0UJAuUakOwCuT1MFiyp4lDFaZKpgdzPoo= +git.happydns.org/checker-tls v0.3.0/go.mod h1:0ZSG0CTP007SHBPE7qInESVIOcW+xgucHUhHgj6MeZ8= +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..027b56d --- /dev/null +++ b/plugin/plugin.go @@ -0,0 +1,19 @@ +// 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 + return ldap.Definition(), ldap.Provider(), nil +}