Initial commit

This commit is contained in:
nemunaire 2026-04-24 12:56:46 +07:00
commit beca2fd7eb
21 changed files with 2698 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
checker-ldap
checker-ldap.so

15
Dockerfile Normal file
View file

@ -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"]

21
LICENSE Normal file
View file

@ -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.

28
Makefile Normal file
View file

@ -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

31
NOTICE Normal file
View file

@ -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

99
README.md Normal file
View file

@ -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`).

485
checker/collect.go Normal file
View file

@ -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
}

69
checker/definition.go Normal file
View file

@ -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,
},
}
}

47
checker/interactive.go Normal file
View file

@ -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
}

63
checker/provider.go Normal file
View file

@ -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
}

548
checker/report.go Normal file
View file

@ -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(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LDAP Report -- {{.Domain}}</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
:root {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
line-height: 1.5;
color: #1f2937;
background: #f3f4f6;
}
body { margin: 0; padding: 1rem; }
code { font-family: ui-monospace, monospace; font-size: .9em; }
h1 { margin: 0 0 .4rem; font-size: 1.15rem; font-weight: 700; }
h2 { font-size: 1rem; font-weight: 700; margin: 0 0 .6rem; }
h3 { font-size: .9rem; font-weight: 600; margin: 0 0 .4rem; }
.hd, .section, details {
background: #fff;
box-shadow: 0 1px 3px rgba(0,0,0,.08);
}
.hd { border-radius: 10px; padding: 1rem 1.25rem; margin-bottom: .75rem; }
.section { border-radius: 8px; padding: .85rem 1rem; margin-bottom: .6rem; }
details { border-radius: 8px; margin-bottom: .45rem; overflow: hidden; }
.badge {
display: inline-flex; align-items: center;
padding: .2em .65em; border-radius: 9999px;
font-size: .78rem; font-weight: 700; letter-spacing: .02em;
}
.ok { background: #d1fae5; color: #065f46; }
.warn { background: #fef3c7; color: #92400e; }
.fail { background: #fee2e2; color: #991b1b; }
.muted { background: #e5e7eb; color: #374151; }
.info { background: #dbeafe; color: #1e40af; }
.meta { color: #6b7280; font-size: .82rem; margin-top: .35rem; }
summary {
display: flex; align-items: center; gap: .5rem;
padding: .65rem 1rem; cursor: pointer; user-select: none; list-style: none;
}
summary::-webkit-details-marker { display: none; }
summary::before { content: "▶"; font-size: .65rem; color: #9ca3af; transition: transform .15s; flex-shrink: 0; }
details[open] > summary::before { transform: rotate(90deg); }
.conn-addr { font-weight: 600; flex: 1; font-size: .9rem; font-family: ui-monospace, monospace; }
.details-body { padding: .6rem 1rem .85rem; border-top: 1px solid #f3f4f6; }
table { border-collapse: collapse; width: 100%; font-size: .85rem; }
th, td { text-align: left; padding: .3rem .5rem; border-bottom: 1px solid #f3f4f6; vertical-align: top; }
th { font-weight: 600; color: #6b7280; }
.fix {
border-left: 3px solid #dc2626;
padding: .5rem .75rem; margin-bottom: .5rem;
background: #fef2f2; border-radius: 0 6px 6px 0;
}
.fix.warn { border-color: #f59e0b; background: #fffbeb; }
.fix.info { border-color: #3b82f6; background: #eff6ff; }
.fix .code { font-family: ui-monospace, monospace; font-size: .75rem; color: #6b7280; }
.fix .msg { font-weight: 600; margin: .1rem 0 .2rem; }
.fix .how { font-size: .88rem; }
.fix .ep { font-size: .78rem; color: #6b7280; font-family: ui-monospace, monospace; }
.chiprow { display: flex; flex-wrap: wrap; gap: .25rem; }
.chip {
display: inline-block; padding: .12em .5em;
background: #e0e7ff; color: #3730a3;
border-radius: 4px; font-size: .78rem; font-family: ui-monospace, monospace;
}
.chip.plain { background: #fee2e2; color: #991b1b; }
.chip.scram { background: #d1fae5; color: #065f46; }
.chip.strong { background: #d1fae5; color: #065f46; }
.kv { display: grid; grid-template-columns: auto 1fr; gap: .3rem 1rem; font-size: .86rem; }
.kv dt { color: #6b7280; }
.kv dd { margin: 0; }
.note { color: #6b7280; font-size: .85rem; }
.footer { color: #6b7280; font-size: .78rem; text-align: center; margin-top: 1rem; padding-bottom: 2rem; }
.check-ok { color: #059669; }
.check-fail { color: #dc2626; }
.check-warn { color: #b45309; }
</style>
</head>
<body>
<div class="hd">
<h1>LDAP -- <code>{{.Domain}}</code></h1>
<span class="badge {{.StatusClass}}">{{.StatusLabel}}</span>
<div class="meta">
{{if .EncryptedReachable}}<span class="badge ok" style="margin-right:.25rem">encrypted OK</span>{{else}}<span class="badge fail" style="margin-right:.25rem">no encryption</span>{{end}}
{{if .HasIPv4}}<span class="badge muted">IPv4</span>{{end}}
{{if .HasIPv6}}<span class="badge muted">IPv6</span>{{end}}
{{if .BindTested}}<span class="badge info" style="margin-left:.25rem">bind test</span>{{end}}
</div>
<div class="meta">Checked {{.RunAt}}{{if .BaseDN}} · base <code>{{.BaseDN}}</code>{{end}}</div>
</div>
{{if .HasIssues}}
<div class="section">
<h2>What to fix</h2>
{{range .Fixes}}
<div class="fix {{.Severity}}">
<div class="code">{{.Code}}{{if .Endpoint}} · {{.Endpoint}}{{end}}</div>
<div class="msg">{{.Message}}</div>
{{if .Fix}}<div class="how">&rarr; {{.Fix}}</div>{{end}}
</div>
{{end}}
</div>
{{end}}
<div class="section">
<h2>DNS / SRV</h2>
{{if .FallbackProbed}}
<p class="note">No SRV records published -- fell back to probing the bare domain on default ports 389 / 636.</p>
{{else if .SRV}}
<table>
<tr><th>Record</th><th>Target</th><th>Port</th><th>Prio/Weight</th><th>IPv4</th><th>IPv6</th></tr>
{{range .SRV}}
<tr>
<td><code>{{.Prefix}}</code></td>
<td><code>{{.Target}}</code></td>
<td>{{.Port}}</td>
<td>{{.Priority}}/{{.Weight}}</td>
<td>{{range .IPv4}}<code>{{.}}</code> {{end}}</td>
<td>{{range .IPv6}}<code>{{.}}</code> {{end}}</td>
</tr>
{{end}}
</table>
{{else}}
<p class="note">No SRV records found.</p>
{{end}}
</div>
{{if .Endpoints}}
<div class="section">
<h2>Endpoints ({{len .Endpoints}})</h2>
{{range .Endpoints}}
<details{{if .AnyFail}} open{{end}}>
<summary>
<span class="conn-addr">{{.ModeLabel}} · {{.Address}}</span>
<span class="badge {{.StatusClass}}">{{.StatusLabel}}</span>
</summary>
<div class="details-body">
<dl class="kv">
{{if .SRVPrefix}}<dt>SRV</dt><dd><code>{{.SRVPrefix}}</code> &rarr; <code>{{.Target}}:{{.Port}}</code></dd>{{end}}
<dt>Family</dt><dd>{{if .IsIPv6}}IPv6{{else}}IPv4{{end}}</dd>
<dt>TCP</dt><dd>{{if .TCPConnected}}<span class="check-ok">&#10003; connected</span>{{else}}<span class="check-fail">&#10007; failed</span>{{end}}</dd>
{{if eq .Mode "ldap"}}
<dt>StartTLS</dt><dd>
{{if .StartTLSOffered}}<span class="check-ok">&#10003; offered</span>{{else}}<span class="check-fail">&#10007; not offered</span>{{end}}
{{if .StartTLSUpgraded}} &middot; <span class="check-ok">upgraded</span>{{else if .StartTLSOffered}} &middot; <span class="check-fail">upgrade failed</span>{{end}}
</dd>
<dt>Cleartext bind</dt><dd>
{{if not .PlaintextBindTested}}<span class="note">not tested</span>
{{else if .PlaintextBindAccepted}}<span class="check-fail">&#10007; accepted (insecure)</span>
{{else}}<span class="check-ok">&#10003; refused (confidentiality required)</span>{{end}}
</dd>
{{end}}
<dt>TLS</dt><dd>
{{if .TLSEstablished}}<span class="check-ok">&#10003; {{.TLSVersion}}{{if .TLSCipher}} &mdash; {{.TLSCipher}}{{end}}</span>
{{else}}<span class="check-fail">&#10007; plaintext</span>{{end}}
</dd>
<dt>RootDSE</dt><dd>
{{if .RootDSERead}}<span class="check-ok">&#10003; read</span>
{{else}}<span class="check-warn">&#10007; unreadable</span>{{end}}
{{if .VendorName}} &middot; <code>{{.VendorName}}{{if .VendorVersion}} {{.VendorVersion}}{{end}}</code>{{end}}
{{if .SupportedLDAPVersion}} &middot; LDAPv{{join "," .SupportedLDAPVersion}}{{end}}
</dd>
{{if .NamingContexts}}
<dt>Naming contexts</dt><dd>
<div class="chiprow">{{range .NamingContexts}}<span class="chip"><code>{{.}}</code></span>{{end}}</div>
</dd>
{{end}}
{{if .SupportedSASLMechanisms}}
<dt>SASL</dt><dd>
<div class="chiprow">
{{range .SupportedSASLMechanisms}}
{{$u := upper .}}
{{if or (eq $u "PLAIN") (eq $u "LOGIN")}}<span class="chip plain">{{.}}</span>
{{else if or (hasPrefix $u "SCRAM-") (eq $u "EXTERNAL") (eq $u "GSSAPI") (eq $u "GSS-SPNEGO")}}<span class="chip strong">{{.}}</span>
{{else}}<span class="chip">{{.}}</span>{{end}}
{{end}}
</div>
</dd>
{{end}}
<dt>Anonymous</dt><dd>
{{if .AnonymousBindAllowed}}<span class="check-warn">bind allowed</span>
{{else}}<span class="check-ok">&#10003; bind refused</span>{{end}}
{{if .AnonymousSearchAllowed}} &middot; <span class="check-fail">search allowed (DIT enumerable)</span>{{end}}
</dd>
{{if .BindAttempted}}
<dt>Bind as DN</dt><dd>
{{if .BindOK}}<span class="check-ok">&#10003; succeeded</span>
{{else}}<span class="check-fail">&#10007; {{.BindError}}</span>{{end}}
</dd>
{{end}}
{{if .BaseReadAttempted}}
<dt>Base read</dt><dd>
{{if .BaseReadOK}}<span class="check-ok">&#10003; {{.BaseReadEntries}} entry/entries</span>
{{else}}<span class="check-fail">&#10007; {{.BaseReadError}}</span>{{end}}
</dd>
{{end}}
{{with .TLSPosture}}
<dt>TLS cert</dt><dd>
{{if .ChainValid}}
{{if deref .ChainValid}}<span class="check-ok">&#10003; chain valid</span>{{else}}<span class="check-fail">&#10007; chain invalid</span>{{end}}
{{end}}
{{if .HostnameMatch}}
&middot; {{if deref .HostnameMatch}}<span class="check-ok">&#10003; hostname match</span>{{else}}<span class="check-fail">&#10007; hostname mismatch</span>{{end}}
{{end}}
{{if not .NotAfter.IsZero}}
&middot; expires <code>{{.NotAfter.Format "2006-01-02"}}</code>
{{end}}
{{if not .CheckedAt.IsZero}}
<div class="note">TLS checked {{.CheckedAt.Format "2006-01-02 15:04 MST"}}</div>
{{end}}
</dd>
{{end}}
<dt>Duration</dt><dd>{{.ElapsedMS}} ms</dd>
{{if .Error}}<dt>Error</dt><dd><span class="check-fail">{{.Error}}</span></dd>{{end}}
</dl>
</div>
</details>
{{end}}
</div>
{{end}}
<p class="footer">{{if .HasTLSPosture}}Certificate posture above comes from the TLS checker, which probed the same endpoints after we discovered them.{{else}}For certificate chain, SAN match, expiry, and cipher posture, run the TLS checker on the same ports.{{end}}</p>
</body>
</html>`))
// 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
}

107
checker/rules.go Normal file
View file

@ -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
}

129
checker/rules_bind.go Normal file
View file

@ -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=\"<base>\" by dn.exact=\"<bind>\" 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
}

237
checker/rules_posture.go Normal file
View file

@ -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=<n>` 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
}

279
checker/rules_transport.go Normal file
View file

@ -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.")}
}

174
checker/tls_related.go Normal file
View file

@ -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": {"<ref>": <probe>}} and a bare <probe> 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
}

182
checker/types.go Normal file
View file

@ -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"
)

22
go.mod Normal file
View file

@ -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
)

112
go.sum Normal file
View file

@ -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=

28
main.go Normal file
View file

@ -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)
}
}

20
plugin/plugin.go Normal file
View file

@ -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
}