Initial commit
This commit is contained in:
commit
9b128d22ed
18 changed files with 2364 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
checker-ldap
|
||||
checker-ldap.so
|
||||
14
Dockerfile
Normal file
14
Dockerfile
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
ARG CHECKER_VERSION=custom-build
|
||||
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 go build -tags standalone -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-ldap .
|
||||
|
||||
FROM scratch
|
||||
COPY --from=builder /checker-ldap /checker-ldap
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/checker-ldap"]
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
28
Makefile
Normal 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
31
NOTICE
Normal 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
99
README.md
Normal 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`).
|
||||
747
checker/collect.go
Normal file
747
checker/collect.go
Normal file
|
|
@ -0,0 +1,747 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
ldapv3 "github.com/go-ldap/ldap/v3"
|
||||
"github.com/miekg/dns"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// tlsProbeConfig builds a permissive *tls.Config for probing: hostname
|
||||
// verification is skipped because cert validation is the TLS checker's
|
||||
// job. We only care that a TLS session can be established at all.
|
||||
func tlsProbeConfig(serverName string) *tls.Config {
|
||||
return &tls.Config{
|
||||
ServerName: serverName,
|
||||
InsecureSkipVerify: true, //nolint:gosec -- cert validation is the TLS checker's job
|
||||
MinVersion: tls.VersionTLS10,
|
||||
}
|
||||
}
|
||||
|
||||
// Collect runs the full LDAP probe for a domain.
|
||||
func (p *ldapProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
|
||||
domain, _ := sdk.GetOption[string](opts, "domain")
|
||||
domain = strings.TrimSuffix(domain, ".")
|
||||
if domain == "" {
|
||||
return nil, fmt.Errorf("domain is required")
|
||||
}
|
||||
|
||||
bindDN, _ := sdk.GetOption[string](opts, "bind_dn")
|
||||
bindPassword, _ := sdk.GetOption[string](opts, "bind_password")
|
||||
baseDN, _ := sdk.GetOption[string](opts, "base_dn")
|
||||
|
||||
timeoutSecs := sdk.GetFloatOption(opts, "timeout", 10)
|
||||
if timeoutSecs < 1 {
|
||||
timeoutSecs = 10
|
||||
}
|
||||
perEndpoint := time.Duration(timeoutSecs * float64(time.Second))
|
||||
|
||||
data := &LDAPData{
|
||||
Domain: domain,
|
||||
BaseDN: baseDN,
|
||||
RunAt: time.Now().UTC().Format(time.RFC3339),
|
||||
SRV: SRVLookup{Errors: map[string]string{}},
|
||||
}
|
||||
|
||||
resolver := net.DefaultResolver
|
||||
lookupSets := []struct {
|
||||
prefix string
|
||||
dst *[]SRVRecord
|
||||
}{
|
||||
{"_ldap._tcp.", &data.SRV.LDAP},
|
||||
{"_ldaps._tcp.", &data.SRV.LDAPS},
|
||||
}
|
||||
for _, ls := range lookupSets {
|
||||
records, err := lookupSRV(ctx, resolver, ls.prefix, domain)
|
||||
if err != nil {
|
||||
data.SRV.Errors[ls.prefix] = err.Error()
|
||||
continue
|
||||
}
|
||||
*ls.dst = records
|
||||
}
|
||||
|
||||
totalSRV := len(data.SRV.LDAP) + len(data.SRV.LDAPS)
|
||||
if totalSRV == 0 {
|
||||
data.SRV.FallbackProbed = true
|
||||
data.SRV.LDAP = []SRVRecord{{Target: domain, Port: 389}}
|
||||
data.SRV.LDAPS = []SRVRecord{{Target: domain, Port: 636}}
|
||||
}
|
||||
|
||||
resolveAllInto(ctx, resolver, data.SRV.LDAP)
|
||||
resolveAllInto(ctx, resolver, data.SRV.LDAPS)
|
||||
|
||||
wantBind := bindDN != "" && bindPassword != ""
|
||||
if wantBind {
|
||||
data.BindTested = true
|
||||
}
|
||||
|
||||
probeSet(ctx, data, domain, ModePlain, "_ldap._tcp", data.SRV.LDAP, perEndpoint, bindDN, bindPassword, baseDN)
|
||||
probeSet(ctx, data, domain, ModeLDAPS, "_ldaps._tcp", data.SRV.LDAPS, perEndpoint, bindDN, bindPassword, baseDN)
|
||||
|
||||
computeCoverage(data)
|
||||
data.Issues = deriveIssues(data, wantBind, baseDN != "")
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func probeSet(ctx context.Context, data *LDAPData, domain string, mode LDAPMode, prefix string, records []SRVRecord, timeout time.Duration, bindDN, bindPassword, baseDN string) {
|
||||
for _, rec := range records {
|
||||
addrs := addressesForProbe(rec)
|
||||
if len(addrs) == 0 {
|
||||
ep := EndpointProbe{
|
||||
Mode: mode,
|
||||
SRVPrefix: prefix,
|
||||
Target: rec.Target,
|
||||
Port: rec.Port,
|
||||
Error: "no A/AAAA records for target",
|
||||
}
|
||||
data.Endpoints = append(data.Endpoints, ep)
|
||||
continue
|
||||
}
|
||||
for _, a := range addrs {
|
||||
ep := probeEndpoint(ctx, domain, mode, prefix, rec, a.ip, a.isV6, timeout, bindDN, bindPassword, baseDN)
|
||||
data.Endpoints = append(data.Endpoints, ep)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type probeAddr struct {
|
||||
ip string
|
||||
isV6 bool
|
||||
}
|
||||
|
||||
func addressesForProbe(rec SRVRecord) []probeAddr {
|
||||
var out []probeAddr
|
||||
for _, ip := range rec.IPv4 {
|
||||
out = append(out, probeAddr{ip: ip, isV6: false})
|
||||
}
|
||||
for _, ip := range rec.IPv6 {
|
||||
out = append(out, probeAddr{ip: ip, isV6: true})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// probeEndpoint runs the full probe on a single (host, ip, port) tuple:
|
||||
// TCP connect → optional StartTLS or direct-TLS handshake → RootDSE read →
|
||||
// plaintext-bind posture check (only on LDAP:389 before TLS) → optional
|
||||
// authenticated bind + base-DN read.
|
||||
func probeEndpoint(ctx context.Context, domain string, mode LDAPMode, prefix string, rec SRVRecord, ip string, isV6 bool, timeout time.Duration, bindDN, bindPassword, baseDN string) EndpointProbe {
|
||||
start := time.Now()
|
||||
result := EndpointProbe{
|
||||
Mode: mode,
|
||||
SRVPrefix: prefix,
|
||||
Target: rec.Target,
|
||||
Port: rec.Port,
|
||||
Address: net.JoinHostPort(ip, strconv.Itoa(int(rec.Port))),
|
||||
IsIPv6: isV6,
|
||||
}
|
||||
defer func() { result.ElapsedMS = time.Since(start).Milliseconds() }()
|
||||
|
||||
dialCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
dialer := &net.Dialer{}
|
||||
rawConn, err := dialer.DialContext(dialCtx, "tcp", result.Address)
|
||||
if err != nil {
|
||||
result.Error = "tcp: " + err.Error()
|
||||
return result
|
||||
}
|
||||
result.TCPConnected = true
|
||||
defer rawConn.Close()
|
||||
_ = rawConn.SetDeadline(time.Now().Add(timeout))
|
||||
|
||||
// For the plaintext-bind posture check on Mode=ldap, we first spin up
|
||||
// a separate short-lived connection before upgrading this one to TLS.
|
||||
// A single raw connection can't both "test cleartext bind refusal" and
|
||||
// "then StartTLS" cleanly -- once we've bound we'd skew RootDSE results.
|
||||
if mode == ModePlain {
|
||||
result.PlaintextBindTested, result.PlaintextBindAccepted = probePlaintextBindRefusal(domain, result.Address, timeout)
|
||||
}
|
||||
|
||||
// Establish the LDAP session we'll use for the rest of the probe.
|
||||
var conn *ldapv3.Conn
|
||||
if mode == ModeLDAPS {
|
||||
tlsConn := tls.Client(rawConn, tlsProbeConfig(domain))
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
result.Error = "tls-handshake: " + err.Error()
|
||||
return result
|
||||
}
|
||||
result.TLSEstablished = true
|
||||
state := tlsConn.ConnectionState()
|
||||
result.TLSVersion = tls.VersionName(state.Version)
|
||||
result.TLSCipher = tls.CipherSuiteName(state.CipherSuite)
|
||||
_ = tlsConn.SetDeadline(time.Now().Add(timeout))
|
||||
conn = ldapv3.NewConn(tlsConn, true)
|
||||
} else {
|
||||
conn = ldapv3.NewConn(rawConn, false)
|
||||
}
|
||||
conn.Start()
|
||||
defer conn.Close()
|
||||
conn.SetTimeout(timeout)
|
||||
|
||||
// Try RootDSE over the native transport first -- works on LDAPS straight
|
||||
// away, and on LDAP it reveals the supported extensions including
|
||||
// StartTLS capability before we attempt the upgrade.
|
||||
readRootDSE(conn, &result)
|
||||
|
||||
if mode == ModePlain {
|
||||
// Detect StartTLS advertisement in supportedExtension. RFC 4511
|
||||
// says the RootDSE MAY advertise "1.3.6.1.4.1.1466.20037".
|
||||
offersStartTLS := stringListContains(result.SupportedExtension, "1.3.6.1.4.1.1466.20037")
|
||||
if offersStartTLS {
|
||||
result.StartTLSOffered = true
|
||||
if err := conn.StartTLS(tlsProbeConfig(domain)); err != nil {
|
||||
result.Error = "starttls: " + err.Error()
|
||||
} else {
|
||||
result.StartTLSUpgraded = true
|
||||
result.TLSEstablished = true
|
||||
// go-ldap doesn't expose the *tls.ConnectionState directly.
|
||||
// Fall back to inspecting the underlying conn via TLSConnectionState.
|
||||
if state, ok := conn.TLSConnectionState(); ok {
|
||||
result.TLSVersion = tls.VersionName(state.Version)
|
||||
result.TLSCipher = tls.CipherSuiteName(state.CipherSuite)
|
||||
}
|
||||
// Refresh RootDSE post-TLS: some servers expose more
|
||||
// supported mechanisms after the secure channel is up.
|
||||
readRootDSE(conn, &result)
|
||||
}
|
||||
} else if !result.RootDSERead {
|
||||
result.Error = "rootdse-unreadable: " + firstNonEmpty(result.Error, "RootDSE could not be read")
|
||||
}
|
||||
}
|
||||
|
||||
// Anonymous bind + search -- we try unconditionally so we can flag
|
||||
// exposure.
|
||||
anonBindOK := simpleBindIs(conn, "", "", nil)
|
||||
result.AnonymousBindAllowed = anonBindOK
|
||||
if anonBindOK && len(result.NamingContexts) > 0 {
|
||||
// baseObject search returns 0 or 1 entries -- we only want to
|
||||
// detect whether an anonymous query can peek at DIT contents.
|
||||
sr, err := conn.Search(ldapv3.NewSearchRequest(
|
||||
result.NamingContexts[0],
|
||||
ldapv3.ScopeBaseObject,
|
||||
ldapv3.NeverDerefAliases,
|
||||
1, int(timeout.Seconds()), false,
|
||||
"(objectClass=*)",
|
||||
[]string{"1.1"}, // request no attributes
|
||||
nil,
|
||||
))
|
||||
if err == nil && sr != nil && len(sr.Entries) > 0 {
|
||||
result.AnonymousSearchAllowed = true
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticated bind + base DN read (only when caller provided creds
|
||||
// AND we are on an encrypted channel -- never ship a password over
|
||||
// cleartext).
|
||||
if bindDN != "" && bindPassword != "" && result.TLSEstablished {
|
||||
result.BindAttempted = true
|
||||
err := conn.Bind(bindDN, bindPassword)
|
||||
if err == nil {
|
||||
result.BindOK = true
|
||||
if baseDN != "" {
|
||||
result.BaseReadAttempted = true
|
||||
sr, err := conn.Search(ldapv3.NewSearchRequest(
|
||||
baseDN,
|
||||
ldapv3.ScopeBaseObject,
|
||||
ldapv3.NeverDerefAliases,
|
||||
1, int(timeout.Seconds()), false,
|
||||
"(objectClass=*)",
|
||||
[]string{"1.1"},
|
||||
nil,
|
||||
))
|
||||
if err != nil {
|
||||
result.BaseReadError = err.Error()
|
||||
} else {
|
||||
result.BaseReadOK = true
|
||||
result.BaseReadEntries = len(sr.Entries)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result.BindError = err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// simpleBindIs runs a simple bind and reports whether it succeeded. It is a
|
||||
// thin wrapper so we can distinguish "bind accepted" from "bind rejected"
|
||||
// without tracking specific LDAP result codes.
|
||||
func simpleBindIs(conn *ldapv3.Conn, user, pass string, classifier *ldapClassifier) bool {
|
||||
err := conn.Bind(user, pass)
|
||||
if classifier != nil {
|
||||
classifier.lastErr = err
|
||||
}
|
||||
return err == nil
|
||||
}
|
||||
|
||||
type ldapClassifier struct {
|
||||
lastErr error
|
||||
}
|
||||
|
||||
// probePlaintextBindRefusal opens a short-lived, fresh TCP connection and
|
||||
// attempts a simple bind with a random DN over cleartext. We are not
|
||||
// probing credentials -- we want to learn whether the server refuses
|
||||
// authentication on an unprotected link (RFC 4513 §5.1.2 calls this
|
||||
// "confidentialityRequired" / resultCode 13). Any response other than
|
||||
// resultCode 13 means the server will accept cleartext bind attempts.
|
||||
func probePlaintextBindRefusal(domain, address string, timeout time.Duration) (tested, accepted bool) {
|
||||
dialer := &net.Dialer{Timeout: timeout}
|
||||
raw, err := dialer.Dial("tcp", address)
|
||||
if err != nil {
|
||||
return false, false
|
||||
}
|
||||
defer raw.Close()
|
||||
_ = raw.SetDeadline(time.Now().Add(timeout))
|
||||
|
||||
conn := ldapv3.NewConn(raw, false)
|
||||
conn.Start()
|
||||
defer conn.Close()
|
||||
conn.SetTimeout(timeout)
|
||||
|
||||
tested = true
|
||||
err = conn.Bind("cn=checker-probe,dc="+domain, "x-not-a-real-password-x")
|
||||
if err == nil {
|
||||
// Unlikely but clear: cleartext bind accepted.
|
||||
return tested, true
|
||||
}
|
||||
// Map LDAP result code 13 (confidentiality required) to "refused".
|
||||
var lerr *ldapv3.Error
|
||||
if errors.As(err, &lerr) && lerr.ResultCode == ldapv3.LDAPResultConfidentialityRequired {
|
||||
return tested, false
|
||||
}
|
||||
// resultCode 49 (invalidCredentials), 32 (noSuchObject), … all mean
|
||||
// the server was willing to *try* the bind over cleartext, which is
|
||||
// the warning-worthy posture.
|
||||
return tested, true
|
||||
}
|
||||
|
||||
// readRootDSE performs a single RootDSE lookup and fills the matching
|
||||
// fields on ep. Failures are not fatal -- many hardened servers refuse
|
||||
// anonymous RootDSE reads; we just note that we couldn't read it.
|
||||
func readRootDSE(conn *ldapv3.Conn, ep *EndpointProbe) {
|
||||
sr, err := conn.Search(ldapv3.NewSearchRequest(
|
||||
"",
|
||||
ldapv3.ScopeBaseObject,
|
||||
ldapv3.NeverDerefAliases,
|
||||
1, 5, false,
|
||||
"(objectClass=*)",
|
||||
[]string{
|
||||
"supportedLDAPVersion",
|
||||
"supportedSASLMechanisms",
|
||||
"supportedControl",
|
||||
"supportedExtension",
|
||||
"namingContexts",
|
||||
"vendorName",
|
||||
"vendorVersion",
|
||||
},
|
||||
nil,
|
||||
))
|
||||
if err != nil || sr == nil || len(sr.Entries) == 0 {
|
||||
return
|
||||
}
|
||||
ep.RootDSERead = true
|
||||
e := sr.Entries[0]
|
||||
ep.SupportedLDAPVersion = unique(append(ep.SupportedLDAPVersion, e.GetAttributeValues("supportedLDAPVersion")...))
|
||||
ep.SupportedSASLMechanisms = unique(append(ep.SupportedSASLMechanisms, e.GetAttributeValues("supportedSASLMechanisms")...))
|
||||
ep.SupportedControl = unique(append(ep.SupportedControl, e.GetAttributeValues("supportedControl")...))
|
||||
ep.SupportedExtension = unique(append(ep.SupportedExtension, e.GetAttributeValues("supportedExtension")...))
|
||||
ep.NamingContexts = unique(append(ep.NamingContexts, e.GetAttributeValues("namingContexts")...))
|
||||
if v := e.GetAttributeValue("vendorName"); v != "" {
|
||||
ep.VendorName = v
|
||||
}
|
||||
if v := e.GetAttributeValue("vendorVersion"); v != "" {
|
||||
ep.VendorVersion = v
|
||||
}
|
||||
}
|
||||
|
||||
func lookupSRV(ctx context.Context, r *net.Resolver, prefix, domain string) ([]SRVRecord, error) {
|
||||
name := prefix + dns.Fqdn(domain)
|
||||
_, records, err := r.LookupSRV(ctx, "", "", name)
|
||||
if err != nil {
|
||||
var dnsErr *net.DNSError
|
||||
if errors.As(err, &dnsErr) && dnsErr.IsNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
// RFC 2782: single record "." with port 0 means "service explicitly not
|
||||
// available at this domain". Treat that as "no records" for probing.
|
||||
if len(records) == 1 && (records[0].Target == "." || records[0].Target == "") && records[0].Port == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
out := make([]SRVRecord, 0, len(records))
|
||||
for _, r := range records {
|
||||
out = append(out, SRVRecord{
|
||||
Target: strings.TrimSuffix(r.Target, "."),
|
||||
Port: r.Port,
|
||||
Priority: r.Priority,
|
||||
Weight: r.Weight,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func resolveAllInto(ctx context.Context, r *net.Resolver, records []SRVRecord) {
|
||||
for i := range records {
|
||||
ips, err := r.LookupIPAddr(ctx, records[i].Target)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if v4 := ip.IP.To4(); v4 != nil {
|
||||
records[i].IPv4 = append(records[i].IPv4, v4.String())
|
||||
} else {
|
||||
records[i].IPv6 = append(records[i].IPv6, ip.IP.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func computeCoverage(data *LDAPData) {
|
||||
anyEncrypted := false
|
||||
anyPlain := false
|
||||
for _, ep := range data.Endpoints {
|
||||
if ep.TCPConnected {
|
||||
if ep.IsIPv6 {
|
||||
data.Coverage.HasIPv6 = true
|
||||
} else {
|
||||
data.Coverage.HasIPv4 = true
|
||||
}
|
||||
if ep.TLSEstablished {
|
||||
anyEncrypted = true
|
||||
} else {
|
||||
anyPlain = true
|
||||
}
|
||||
}
|
||||
}
|
||||
data.Coverage.EncryptedReachable = anyEncrypted
|
||||
data.Coverage.PlainOnlyReachable = anyPlain && !anyEncrypted
|
||||
}
|
||||
|
||||
func deriveIssues(data *LDAPData, wantBind, wantBaseRead bool) []Issue {
|
||||
var issues []Issue
|
||||
|
||||
// 1. No SRV published.
|
||||
if data.SRV.FallbackProbed {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeNoSRV,
|
||||
Severity: SeverityInfo,
|
||||
Message: "No LDAP SRV records published for " + data.Domain + ".",
|
||||
Fix: "Consider publishing _ldap._tcp." + data.Domain + " and _ldaps._tcp." + data.Domain + " SRV records to let clients discover the directory automatically.",
|
||||
})
|
||||
}
|
||||
|
||||
// 2. SRV lookup errors.
|
||||
for prefix, msg := range data.SRV.Errors {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeSRVServfail,
|
||||
Severity: SeverityWarn,
|
||||
Message: "DNS lookup failed for " + prefix + data.Domain + ": " + msg,
|
||||
Fix: "Check the authoritative DNS servers for this domain.",
|
||||
})
|
||||
}
|
||||
|
||||
// 3. Endpoint-level issues.
|
||||
allDown := len(data.Endpoints) > 0
|
||||
anyEncrypted := false
|
||||
anyLDAPS := false
|
||||
anyLDAPReachable := false
|
||||
sawSASL := false
|
||||
sawStrongSASL := false
|
||||
sawPlainOnly := false
|
||||
|
||||
for _, ep := range data.Endpoints {
|
||||
if ep.TCPConnected {
|
||||
allDown = false
|
||||
if ep.Mode == ModePlain {
|
||||
anyLDAPReachable = true
|
||||
}
|
||||
if ep.Mode == ModeLDAPS {
|
||||
anyLDAPS = true
|
||||
}
|
||||
if ep.TLSEstablished {
|
||||
anyEncrypted = true
|
||||
}
|
||||
}
|
||||
if !ep.TCPConnected && ep.Error != "" {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeTCPUnreachable,
|
||||
Severity: SeverityWarn,
|
||||
Message: "Cannot reach " + ep.Address + ": " + ep.Error + ".",
|
||||
Fix: "Verify firewall rules and that the LDAP server is listening on this address.",
|
||||
Endpoint: ep.Address,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if ep.Mode == ModePlain && ep.TCPConnected {
|
||||
if !ep.StartTLSOffered {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeStartTLSMissing,
|
||||
Severity: SeverityCrit,
|
||||
Message: "StartTLS not advertised on " + ep.Address + ".",
|
||||
Fix: "Configure the LDAP server to accept StartTLS (RFC 2830). On OpenLDAP, set olcTLSCertificateFile / olcTLSCertificateKeyFile and ensure the LDAP listener allows encrypted upgrades.",
|
||||
Endpoint: ep.Address,
|
||||
})
|
||||
} else if !ep.StartTLSUpgraded {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeStartTLSFailed,
|
||||
Severity: SeverityCrit,
|
||||
Message: "StartTLS handshake failed on " + ep.Address + ": " + ep.Error + ".",
|
||||
Fix: "Run the TLS checker on this endpoint for cert and cipher details.",
|
||||
Endpoint: ep.Address,
|
||||
})
|
||||
}
|
||||
if ep.PlaintextBindTested && ep.PlaintextBindAccepted {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodePlainBindAccepted,
|
||||
Severity: SeverityCrit,
|
||||
Message: "Cleartext bind attempts are accepted on " + ep.Address + " (server does not reply confidentialityRequired).",
|
||||
Fix: "Require TLS before authentication: set `security simple_bind=<n>` on OpenLDAP (olcSecurity) or enable `require_tls` in 389-ds/Samba AD to force StartTLS before bind.",
|
||||
Endpoint: ep.Address,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if ep.Mode == ModeLDAPS && ep.TCPConnected && !ep.TLSEstablished {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeLDAPSHandshakeFailed,
|
||||
Severity: SeverityCrit,
|
||||
Message: "LDAPS TLS handshake failed on " + ep.Address + ": " + ep.Error + ".",
|
||||
Fix: "Check that the LDAPS listener has a valid certificate/key pair and is speaking TLS directly (not LDAP+StartTLS).",
|
||||
Endpoint: ep.Address,
|
||||
})
|
||||
}
|
||||
|
||||
if ep.TCPConnected && ep.TLSEstablished && !ep.RootDSERead {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeRootDSEUnreadable,
|
||||
Severity: SeverityWarn,
|
||||
Message: "RootDSE is not readable on " + ep.Address + " -- capability discovery is unavailable.",
|
||||
Fix: "Grant anonymous read on the empty base DN for schema introspection (`access to dn.base=\"\" by * read` on OpenLDAP), or expect reduced visibility of supported mechanisms.",
|
||||
Endpoint: ep.Address,
|
||||
})
|
||||
}
|
||||
|
||||
// Anonymous exposure.
|
||||
if ep.AnonymousSearchAllowed {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeAnonymousSearchAllowed,
|
||||
Severity: SeverityWarn,
|
||||
Message: "Anonymous search against naming context succeeds on " + ep.Address + " -- DIT contents may be enumerable without credentials.",
|
||||
Fix: "Restrict anonymous access: `access to * by anonymous auth by users read` (OpenLDAP) or set `nsslapd-allow-anonymous-access: off` (389-ds).",
|
||||
Endpoint: ep.Address,
|
||||
})
|
||||
}
|
||||
|
||||
// SASL posture (from RootDSE).
|
||||
if len(ep.SupportedSASLMechanisms) > 0 {
|
||||
sawSASL = true
|
||||
hasPlain := false
|
||||
hasStrong := false
|
||||
for _, m := range ep.SupportedSASLMechanisms {
|
||||
u := strings.ToUpper(m)
|
||||
switch u {
|
||||
case "PLAIN", "LOGIN":
|
||||
hasPlain = true
|
||||
case "EXTERNAL", "GSSAPI", "GSS-SPNEGO":
|
||||
hasStrong = true
|
||||
default:
|
||||
if strings.HasPrefix(u, "SCRAM-") {
|
||||
hasStrong = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if hasPlain && !hasStrong {
|
||||
sawPlainOnly = true
|
||||
}
|
||||
if hasStrong {
|
||||
sawStrongSASL = true
|
||||
}
|
||||
}
|
||||
|
||||
// Protocol version.
|
||||
hasV3 := false
|
||||
hasV2 := false
|
||||
for _, v := range ep.SupportedLDAPVersion {
|
||||
switch strings.TrimSpace(v) {
|
||||
case "3":
|
||||
hasV3 = true
|
||||
case "2":
|
||||
hasV2 = true
|
||||
}
|
||||
}
|
||||
_ = hasV3
|
||||
if hasV2 {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeLegacyLDAPv2,
|
||||
Severity: SeverityWarn,
|
||||
Message: "Server still advertises supportedLDAPVersion=2 on " + ep.Address + ".",
|
||||
Fix: "Disable LDAPv2 support -- RFC 3494 deprecated it in 2003. On OpenLDAP, remove `allow bind_v2`.",
|
||||
Endpoint: ep.Address,
|
||||
})
|
||||
}
|
||||
|
||||
// Naming context exposure check -- missing it is usually benign, but
|
||||
// on authoritative directories the absence means you cannot route
|
||||
// queries. Report as info.
|
||||
if ep.RootDSERead && len(ep.NamingContexts) == 0 {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeNoNamingContext,
|
||||
Severity: SeverityInfo,
|
||||
Message: "RootDSE does not advertise any naming context on " + ep.Address + ".",
|
||||
Fix: "If this server is authoritative, populate the namingContexts attribute so clients can locate the DIT root.",
|
||||
Endpoint: ep.Address,
|
||||
})
|
||||
}
|
||||
|
||||
// StartTLS offered on LDAPS port -- not wrong per se (some servers
|
||||
// support both), but usually a configuration smell.
|
||||
if ep.Mode == ModeLDAPS && stringListContains(ep.SupportedExtension, "1.3.6.1.4.1.1466.20037") {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeStartTLSOnLDAPS,
|
||||
Severity: SeverityInfo,
|
||||
Message: "Server advertises StartTLS on the LDAPS port " + ep.Address + ".",
|
||||
Fix: "This is not actively harmful, but most directories do not need StartTLS exposed on 636. Consider disabling it to reduce attack surface.",
|
||||
Endpoint: ep.Address,
|
||||
})
|
||||
}
|
||||
|
||||
// Authenticated bind result.
|
||||
if ep.BindAttempted {
|
||||
if ep.BindOK {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeBindOK,
|
||||
Severity: SeverityInfo,
|
||||
Message: "Bind as " + ep.Address + " succeeded with the provided credentials.",
|
||||
Endpoint: ep.Address,
|
||||
})
|
||||
if ep.BaseReadAttempted && !ep.BaseReadOK {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeBaseReadFailed,
|
||||
Severity: SeverityCrit,
|
||||
Message: "Bind succeeded but baseObject read on " + data.BaseDN + " failed: " + ep.BaseReadError,
|
||||
Fix: "Verify the bind DN has read access to the base DN -- typically granted via an ACL entry such as `access to dn.subtree=\"<base>\" by dn.exact=\"<bind>\" read`.",
|
||||
Endpoint: ep.Address,
|
||||
})
|
||||
}
|
||||
if ep.BaseReadAttempted && ep.BaseReadOK {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeBaseReadOK,
|
||||
Severity: SeverityInfo,
|
||||
Message: "Base DN read succeeded on " + ep.Address + " (entries=" + strconv.Itoa(ep.BaseReadEntries) + ").",
|
||||
Endpoint: ep.Address,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeBindFailed,
|
||||
Severity: SeverityCrit,
|
||||
Message: "Bind on " + ep.Address + " failed: " + ep.BindError,
|
||||
Fix: "Verify the bind DN exists and the password is current. On AD, check the account is not locked/expired.",
|
||||
Endpoint: ep.Address,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate-level derivations.
|
||||
if allDown {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeAllEndpointsDown,
|
||||
Severity: SeverityCrit,
|
||||
Message: "No LDAP endpoint is reachable.",
|
||||
Fix: "Check firewall rules and that the directory is listening on ports 389/636 (or the ports indicated by SRV).",
|
||||
})
|
||||
} else if !anyEncrypted {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeNoEncryptedEndpoint,
|
||||
Severity: SeverityCrit,
|
||||
Message: "None of the reachable endpoints established an encrypted channel (no StartTLS, no LDAPS).",
|
||||
Fix: "Enable either LDAPS (port 636) or StartTLS on 389 -- a bind DN should never travel in cleartext.",
|
||||
})
|
||||
}
|
||||
if anyLDAPReachable && !anyLDAPS {
|
||||
// Not always a misconfig (some sites run StartTLS-only), info only.
|
||||
// No dedicated issue -- informational.
|
||||
}
|
||||
|
||||
if sawSASL {
|
||||
if !sawStrongSASL {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeSASLNoStrongMech,
|
||||
Severity: SeverityWarn,
|
||||
Message: "No strong SASL mechanism (SCRAM-*, EXTERNAL, GSSAPI) is advertised.",
|
||||
Fix: "Enable SCRAM-SHA-256 or GSSAPI where your identity platform allows it.",
|
||||
})
|
||||
}
|
||||
if sawPlainOnly {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeSASLPlainOnly,
|
||||
Severity: SeverityWarn,
|
||||
Message: "Only PLAIN/LOGIN SASL mechanisms are offered.",
|
||||
Fix: "Add SCRAM-SHA-256 so password hashes aren't sent as password-equivalent tokens.",
|
||||
})
|
||||
}
|
||||
} else if len(data.Endpoints) > 0 {
|
||||
// We didn't see supportedSASLMechanisms at all -- either the server
|
||||
// doesn't advertise them or we couldn't read RootDSE.
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeNoSASL,
|
||||
Severity: SeverityInfo,
|
||||
Message: "No supportedSASLMechanisms advertised by the directory.",
|
||||
Fix: "If SASL is in use, ensure the server publishes supportedSASLMechanisms in the RootDSE. Otherwise only simple binds are available.",
|
||||
})
|
||||
}
|
||||
|
||||
if data.Coverage.HasIPv4 && !data.Coverage.HasIPv6 {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeNoIPv6,
|
||||
Severity: SeverityInfo,
|
||||
Message: "No IPv6 endpoint reachable.",
|
||||
Fix: "Publish AAAA records for the SRV targets.",
|
||||
})
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
func stringListContains(list []string, want string) bool {
|
||||
for _, s := range list {
|
||||
if strings.EqualFold(s, want) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func unique(list []string) []string {
|
||||
seen := make(map[string]struct{}, len(list))
|
||||
out := make([]string, 0, len(list))
|
||||
for _, s := range list {
|
||||
if _, ok := seen[s]; ok {
|
||||
continue
|
||||
}
|
||||
seen[s] = struct{}{}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstNonEmpty(a, b string) string {
|
||||
if a != "" {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
69
checker/definition.go
Normal file
69
checker/definition.go
Normal 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 Definition() *sdk.CheckerDefinition {
|
||||
return &sdk.CheckerDefinition{
|
||||
ID: "ldap",
|
||||
Name: "LDAP Directory",
|
||||
Version: Version,
|
||||
Availability: sdk.CheckerAvailability{
|
||||
ApplyToService: true,
|
||||
LimitToServices: []string{"abstract.LDAP"},
|
||||
},
|
||||
HasHTMLReport: true,
|
||||
ObservationKeys: []sdk.ObservationKey{ObservationKeyLDAP},
|
||||
Options: sdk.CheckerOptionsDocumentation{
|
||||
RunOpts: []sdk.CheckerOptionDocumentation{
|
||||
{
|
||||
Id: "domain",
|
||||
Type: "string",
|
||||
Label: "Domain",
|
||||
AutoFill: sdk.AutoFillDomainName,
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Id: "timeout",
|
||||
Type: "number",
|
||||
Label: "Per-endpoint timeout (seconds)",
|
||||
Default: 10,
|
||||
},
|
||||
{
|
||||
Id: "bind_dn",
|
||||
Type: "string",
|
||||
Label: "Bind DN",
|
||||
Placeholder: "cn=reader,dc=example,dc=com",
|
||||
Description: "Optional. When set (with bind_password), the checker performs an authenticated bind over TLS and reports whether the directory accepts the provided credentials.",
|
||||
},
|
||||
{
|
||||
Id: "bind_password",
|
||||
Type: "string",
|
||||
Label: "Bind password",
|
||||
Secret: true,
|
||||
Description: "Optional. Only used when bind_dn is set. The password is not persisted in the observation payload.",
|
||||
},
|
||||
{
|
||||
Id: "base_dn",
|
||||
Type: "string",
|
||||
Label: "Base DN (read test)",
|
||||
Placeholder: "dc=example,dc=com",
|
||||
Description: "Optional. When set, the checker runs a baseObject search on this DN after a successful bind to verify the account has read access. Falls back to an anonymous baseObject search when no bind DN is supplied.",
|
||||
},
|
||||
},
|
||||
},
|
||||
Rules: []sdk.CheckRule{Rule()},
|
||||
Interval: &sdk.CheckIntervalSpec{
|
||||
Min: 5 * time.Minute,
|
||||
Max: 7 * 24 * time.Hour,
|
||||
Default: 12 * time.Hour,
|
||||
},
|
||||
}
|
||||
}
|
||||
82
checker/interactive.go
Normal file
82
checker/interactive.go
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
//go:build standalone
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// RenderForm implements server.Interactive. It exposes the same option
|
||||
// set as /evaluate, minus the AutoFill hint on `domain` (the human is the
|
||||
// one filling it in) and with a sensible default timeout.
|
||||
func (p *ldapProvider) RenderForm() []sdk.CheckerOptionField {
|
||||
return []sdk.CheckerOptionField{
|
||||
{
|
||||
Id: "domain",
|
||||
Type: "string",
|
||||
Label: "Domain",
|
||||
Placeholder: "example.com",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Id: "timeout",
|
||||
Type: "number",
|
||||
Label: "Per-endpoint timeout (seconds)",
|
||||
Default: 10,
|
||||
},
|
||||
{
|
||||
Id: "bind_dn",
|
||||
Type: "string",
|
||||
Label: "Bind DN",
|
||||
Placeholder: "cn=reader,dc=example,dc=com",
|
||||
Description: "Optional. When set (with bind_password), the checker performs an authenticated bind over TLS and reports whether the directory accepts the provided credentials.",
|
||||
},
|
||||
{
|
||||
Id: "bind_password",
|
||||
Type: "string",
|
||||
Label: "Bind password",
|
||||
Secret: true,
|
||||
Description: "Optional. Only used when bind_dn is set. The password is not persisted in the observation payload.",
|
||||
},
|
||||
{
|
||||
Id: "base_dn",
|
||||
Type: "string",
|
||||
Label: "Base DN (read test)",
|
||||
Placeholder: "dc=example,dc=com",
|
||||
Description: "Optional. When set, the checker runs a baseObject search on this DN after a successful bind to verify the account has read access.",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ParseForm implements server.Interactive. Collect handles its own SRV
|
||||
// and A/AAAA lookups, so the form only needs to forward the user-supplied
|
||||
// values -- no extra host-side resolution is required here.
|
||||
func (p *ldapProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
|
||||
domain := strings.TrimSpace(r.FormValue("domain"))
|
||||
if domain == "" {
|
||||
return nil, errors.New("domain is required")
|
||||
}
|
||||
opts := sdk.CheckerOptions{"domain": domain}
|
||||
if v := strings.TrimSpace(r.FormValue("timeout")); v != "" {
|
||||
f, err := strconv.ParseFloat(v, 64)
|
||||
if err != nil {
|
||||
return nil, errors.New("timeout must be a number")
|
||||
}
|
||||
opts["timeout"] = f
|
||||
}
|
||||
if v := strings.TrimSpace(r.FormValue("bind_dn")); v != "" {
|
||||
opts["bind_dn"] = v
|
||||
}
|
||||
if v := r.FormValue("bind_password"); v != "" {
|
||||
opts["bind_password"] = v
|
||||
}
|
||||
if v := strings.TrimSpace(r.FormValue("base_dn")); v != "" {
|
||||
opts["base_dn"] = v
|
||||
}
|
||||
return opts, nil
|
||||
}
|
||||
68
checker/provider.go
Normal file
68
checker/provider.go
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
tlsct "git.happydns.org/checker-tls/contract"
|
||||
)
|
||||
|
||||
func Provider() sdk.ObservationProvider {
|
||||
return &ldapProvider{}
|
||||
}
|
||||
|
||||
type ldapProvider struct{}
|
||||
|
||||
func (p *ldapProvider) Key() sdk.ObservationKey {
|
||||
return ObservationKeyLDAP
|
||||
}
|
||||
|
||||
// Definition implements sdk.CheckerDefinitionProvider.
|
||||
func (p *ldapProvider) Definition() *sdk.CheckerDefinition {
|
||||
return Definition()
|
||||
}
|
||||
|
||||
// DiscoverEntries implements sdk.DiscoveryPublisher.
|
||||
//
|
||||
// It publishes TLS endpoint contract entries for every SRV target we found,
|
||||
// so a downstream TLS checker can verify the certificate chain / SAN /
|
||||
// expiry on each one without re-doing the SRV lookup. The LDAP checker
|
||||
// itself only confirms a TLS session can be established -- certificate
|
||||
// posture lives in the TLS checker.
|
||||
//
|
||||
// SNI is set to the bare domain, which is usually the hostname clients
|
||||
// connect to. On _ldaps._tcp we emit direct-TLS endpoints (STARTTLS="").
|
||||
// On _ldap._tcp we emit STARTTLS="ldap" so the TLS checker performs an
|
||||
// RFC 2830 extended-op upgrade before the handshake. RequireSTARTTLS is
|
||||
// set to true on 389: a misconfigured server that drops StartTLS must
|
||||
// show up as a CRIT on the TLS side, not a WARN.
|
||||
func (p *ldapProvider) DiscoverEntries(data any) ([]sdk.DiscoveryEntry, error) {
|
||||
d, ok := data.(*LDAPData)
|
||||
if !ok || d == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var out []sdk.DiscoveryEntry
|
||||
emit := func(recs []SRVRecord, starttls string, require bool) error {
|
||||
for _, r := range recs {
|
||||
ep := tlsct.TLSEndpoint{
|
||||
Host: r.Target,
|
||||
Port: r.Port,
|
||||
SNI: d.Domain,
|
||||
STARTTLS: starttls,
|
||||
RequireSTARTTLS: require,
|
||||
}
|
||||
entry, err := tlsct.NewEntry(ep)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out = append(out, entry)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := emit(d.SRV.LDAP, "ldap", true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := emit(d.SRV.LDAPS, "", false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
577
checker/report.go
Normal file
577
checker/report.go
Normal file
|
|
@ -0,0 +1,577 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
type reportFix struct {
|
||||
Severity string
|
||||
Code string
|
||||
Message string
|
||||
Fix string
|
||||
Endpoint string
|
||||
}
|
||||
|
||||
type reportEndpoint struct {
|
||||
Mode string
|
||||
ModeLabel string
|
||||
SRVPrefix string
|
||||
Target string
|
||||
Port uint16
|
||||
Address string
|
||||
IsIPv6 bool
|
||||
TCPConnected bool
|
||||
StartTLSOffered bool
|
||||
StartTLSUpgraded bool
|
||||
TLSEstablished bool
|
||||
TLSVersion string
|
||||
TLSCipher string
|
||||
RootDSERead bool
|
||||
SupportedLDAPVersion []string
|
||||
SupportedSASLMechanisms []string
|
||||
SupportedControl []string
|
||||
SupportedExtension []string
|
||||
NamingContexts []string
|
||||
VendorName string
|
||||
VendorVersion string
|
||||
AnonymousBindAllowed bool
|
||||
AnonymousSearchAllowed bool
|
||||
PlaintextBindTested bool
|
||||
PlaintextBindAccepted bool
|
||||
BindAttempted bool
|
||||
BindOK bool
|
||||
BindError string
|
||||
BaseReadAttempted bool
|
||||
BaseReadOK bool
|
||||
BaseReadEntries int
|
||||
BaseReadError string
|
||||
ElapsedMS int64
|
||||
Error string
|
||||
|
||||
// TLS posture (from a related tls_probes observation, when available).
|
||||
TLSPosture *reportTLSPosture
|
||||
|
||||
// Rendering helpers.
|
||||
AnyFail bool
|
||||
StatusLabel string
|
||||
StatusClass string
|
||||
}
|
||||
|
||||
type reportTLSPosture struct {
|
||||
CheckedAt time.Time
|
||||
ChainValid *bool
|
||||
HostnameMatch *bool
|
||||
NotAfter time.Time
|
||||
Issues []reportFix
|
||||
}
|
||||
|
||||
type reportSRVEntry struct {
|
||||
Prefix string
|
||||
Target string
|
||||
Port uint16
|
||||
Priority uint16
|
||||
Weight uint16
|
||||
IPv4 []string
|
||||
IPv6 []string
|
||||
}
|
||||
|
||||
type reportData struct {
|
||||
Domain string
|
||||
BaseDN string
|
||||
RunAt string
|
||||
StatusLabel string
|
||||
StatusClass string
|
||||
HasIssues bool
|
||||
Fixes []reportFix
|
||||
SRV []reportSRVEntry
|
||||
FallbackProbed bool
|
||||
Endpoints []reportEndpoint
|
||||
HasIPv4 bool
|
||||
HasIPv6 bool
|
||||
EncryptedReachable bool
|
||||
PlainOnlyReachable bool
|
||||
BindTested bool
|
||||
HasTLSPosture bool
|
||||
}
|
||||
|
||||
var reportTpl = template.Must(template.New("ldap").Funcs(template.FuncMap{
|
||||
"hasPrefix": strings.HasPrefix,
|
||||
"deref": func(b *bool) bool { return b != nil && *b },
|
||||
"join": func(sep string, list []string) string { return strings.Join(list, sep) },
|
||||
"upper": strings.ToUpper,
|
||||
}).Parse(`<!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">→ {{.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> → <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">✓ connected</span>{{else}}<span class="check-fail">✗ failed</span>{{end}}</dd>
|
||||
|
||||
{{if eq .Mode "ldap"}}
|
||||
<dt>StartTLS</dt><dd>
|
||||
{{if .StartTLSOffered}}<span class="check-ok">✓ offered</span>{{else}}<span class="check-fail">✗ not offered</span>{{end}}
|
||||
{{if .StartTLSUpgraded}} · <span class="check-ok">upgraded</span>{{else if .StartTLSOffered}} · <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">✗ accepted (insecure)</span>
|
||||
{{else}}<span class="check-ok">✓ refused (confidentiality required)</span>{{end}}
|
||||
</dd>
|
||||
{{end}}
|
||||
|
||||
<dt>TLS</dt><dd>
|
||||
{{if .TLSEstablished}}<span class="check-ok">✓ {{.TLSVersion}}{{if .TLSCipher}} — {{.TLSCipher}}{{end}}</span>
|
||||
{{else}}<span class="check-fail">✗ plaintext</span>{{end}}
|
||||
</dd>
|
||||
|
||||
<dt>RootDSE</dt><dd>
|
||||
{{if .RootDSERead}}<span class="check-ok">✓ read</span>
|
||||
{{else}}<span class="check-warn">✗ unreadable</span>{{end}}
|
||||
{{if .VendorName}} · <code>{{.VendorName}}{{if .VendorVersion}} {{.VendorVersion}}{{end}}</code>{{end}}
|
||||
{{if .SupportedLDAPVersion}} · 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">✓ bind refused</span>{{end}}
|
||||
{{if .AnonymousSearchAllowed}} · <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">✓ succeeded</span>
|
||||
{{else}}<span class="check-fail">✗ {{.BindError}}</span>{{end}}
|
||||
</dd>
|
||||
{{end}}
|
||||
{{if .BaseReadAttempted}}
|
||||
<dt>Base read</dt><dd>
|
||||
{{if .BaseReadOK}}<span class="check-ok">✓ {{.BaseReadEntries}} entry/entries</span>
|
||||
{{else}}<span class="check-fail">✗ {{.BaseReadError}}</span>{{end}}
|
||||
</dd>
|
||||
{{end}}
|
||||
|
||||
{{with .TLSPosture}}
|
||||
<dt>TLS cert</dt><dd>
|
||||
{{if .ChainValid}}
|
||||
{{if deref .ChainValid}}<span class="check-ok">✓ chain valid</span>{{else}}<span class="check-fail">✗ chain invalid</span>{{end}}
|
||||
{{end}}
|
||||
{{if .HostnameMatch}}
|
||||
· {{if deref .HostnameMatch}}<span class="check-ok">✓ hostname match</span>{{else}}<span class="check-fail">✗ hostname mismatch</span>{{end}}
|
||||
{{end}}
|
||||
{{if not .NotAfter.IsZero}}
|
||||
· 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}}
|
||||
{{range .Issues}}
|
||||
<div class="fix {{.Severity}}" style="margin-top:.3rem">
|
||||
<div class="code">{{.Code}}</div>
|
||||
<div class="msg">{{.Message}}</div>
|
||||
{{if .Fix}}<div class="how">→ {{.Fix}}</div>{{end}}
|
||||
</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.
|
||||
func (p *ldapProvider) GetHTMLReport(rctx sdk.ReportContext) (string, error) {
|
||||
var d LDAPData
|
||||
if err := json.Unmarshal(rctx.Data(), &d); err != nil {
|
||||
return "", fmt.Errorf("unmarshal ldap observation: %w", err)
|
||||
}
|
||||
view := buildReportData(&d, rctx.Related(TLSRelatedKey))
|
||||
return renderReport(view)
|
||||
}
|
||||
|
||||
func renderReport(view reportData) (string, error) {
|
||||
var buf strings.Builder
|
||||
if err := reportTpl.Execute(&buf, view); err != nil {
|
||||
return "", fmt.Errorf("render ldap report: %w", err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
func buildReportData(d *LDAPData, related []sdk.RelatedObservation) reportData {
|
||||
tlsIssues := tlsIssuesFromRelated(related)
|
||||
tlsByAddr := indexTLSByAddress(related)
|
||||
|
||||
allIssues := append([]Issue(nil), d.Issues...)
|
||||
allIssues = append(allIssues, tlsIssues...)
|
||||
|
||||
view := reportData{
|
||||
Domain: d.Domain,
|
||||
BaseDN: d.BaseDN,
|
||||
RunAt: d.RunAt,
|
||||
FallbackProbed: d.SRV.FallbackProbed,
|
||||
HasIPv4: d.Coverage.HasIPv4,
|
||||
HasIPv6: d.Coverage.HasIPv6,
|
||||
EncryptedReachable: d.Coverage.EncryptedReachable,
|
||||
PlainOnlyReachable: d.Coverage.PlainOnlyReachable,
|
||||
BindTested: d.BindTested,
|
||||
HasIssues: len(allIssues) > 0,
|
||||
HasTLSPosture: len(tlsByAddr) > 0,
|
||||
}
|
||||
|
||||
// Status banner.
|
||||
worst := ""
|
||||
for _, is := range allIssues {
|
||||
if is.Severity == SeverityCrit {
|
||||
worst = SeverityCrit
|
||||
break
|
||||
}
|
||||
if is.Severity == SeverityWarn {
|
||||
worst = SeverityWarn
|
||||
} else if worst == "" && is.Severity == SeverityInfo {
|
||||
worst = SeverityInfo
|
||||
}
|
||||
}
|
||||
if len(allIssues) == 0 {
|
||||
view.StatusLabel = "OK"
|
||||
view.StatusClass = "ok"
|
||||
} else {
|
||||
switch worst {
|
||||
case SeverityCrit:
|
||||
view.StatusLabel = "FAIL"
|
||||
view.StatusClass = "fail"
|
||||
case SeverityWarn:
|
||||
view.StatusLabel = "WARN"
|
||||
view.StatusClass = "warn"
|
||||
default:
|
||||
view.StatusLabel = "INFO"
|
||||
view.StatusClass = "info"
|
||||
}
|
||||
}
|
||||
|
||||
// Fix list: sort crit → warn → info, preserving order within each severity.
|
||||
sevRank := func(s string) int {
|
||||
switch s {
|
||||
case SeverityCrit:
|
||||
return 0
|
||||
case SeverityWarn:
|
||||
return 1
|
||||
default:
|
||||
return 2
|
||||
}
|
||||
}
|
||||
sort.SliceStable(allIssues, func(i, j int) bool { return sevRank(allIssues[i].Severity) < sevRank(allIssues[j].Severity) })
|
||||
for _, is := range allIssues {
|
||||
view.Fixes = append(view.Fixes, reportFix{
|
||||
Severity: is.Severity,
|
||||
Code: is.Code,
|
||||
Message: is.Message,
|
||||
Fix: is.Fix,
|
||||
Endpoint: is.Endpoint,
|
||||
})
|
||||
}
|
||||
|
||||
// SRV rows.
|
||||
addSRV := func(prefix string, records []SRVRecord) {
|
||||
for _, r := range records {
|
||||
view.SRV = append(view.SRV, reportSRVEntry{
|
||||
Prefix: prefix, Target: r.Target, Port: r.Port,
|
||||
Priority: r.Priority, Weight: r.Weight,
|
||||
IPv4: r.IPv4, IPv6: r.IPv6,
|
||||
})
|
||||
}
|
||||
}
|
||||
addSRV("_ldap._tcp", d.SRV.LDAP)
|
||||
addSRV("_ldaps._tcp", d.SRV.LDAPS)
|
||||
|
||||
// Endpoint rows.
|
||||
for _, ep := range d.Endpoints {
|
||||
re := reportEndpoint{
|
||||
Mode: string(ep.Mode),
|
||||
ModeLabel: modeLabel(ep.Mode),
|
||||
SRVPrefix: ep.SRVPrefix,
|
||||
Target: ep.Target,
|
||||
Port: ep.Port,
|
||||
Address: ep.Address,
|
||||
IsIPv6: ep.IsIPv6,
|
||||
TCPConnected: ep.TCPConnected,
|
||||
StartTLSOffered: ep.StartTLSOffered,
|
||||
StartTLSUpgraded: ep.StartTLSUpgraded,
|
||||
TLSEstablished: ep.TLSEstablished,
|
||||
TLSVersion: ep.TLSVersion,
|
||||
TLSCipher: ep.TLSCipher,
|
||||
RootDSERead: ep.RootDSERead,
|
||||
SupportedLDAPVersion: ep.SupportedLDAPVersion,
|
||||
SupportedSASLMechanisms: ep.SupportedSASLMechanisms,
|
||||
SupportedControl: ep.SupportedControl,
|
||||
SupportedExtension: ep.SupportedExtension,
|
||||
NamingContexts: ep.NamingContexts,
|
||||
VendorName: ep.VendorName,
|
||||
VendorVersion: ep.VendorVersion,
|
||||
AnonymousBindAllowed: ep.AnonymousBindAllowed,
|
||||
AnonymousSearchAllowed: ep.AnonymousSearchAllowed,
|
||||
PlaintextBindTested: ep.PlaintextBindTested,
|
||||
PlaintextBindAccepted: ep.PlaintextBindAccepted,
|
||||
BindAttempted: ep.BindAttempted,
|
||||
BindOK: ep.BindOK,
|
||||
BindError: ep.BindError,
|
||||
BaseReadAttempted: ep.BaseReadAttempted,
|
||||
BaseReadOK: ep.BaseReadOK,
|
||||
BaseReadEntries: ep.BaseReadEntries,
|
||||
BaseReadError: ep.BaseReadError,
|
||||
ElapsedMS: ep.ElapsedMS,
|
||||
Error: ep.Error,
|
||||
}
|
||||
if meta, hit := tlsByAddr[ep.Address]; hit {
|
||||
re.TLSPosture = meta
|
||||
} else if meta, hit := tlsByAddr[endpointKey(ep.Target, ep.Port)]; hit {
|
||||
re.TLSPosture = meta
|
||||
}
|
||||
ok := ep.TCPConnected && ep.TLSEstablished
|
||||
if ep.Mode == ModePlain {
|
||||
ok = ok && ep.StartTLSUpgraded
|
||||
}
|
||||
re.AnyFail = !ok
|
||||
if ok {
|
||||
re.StatusLabel = "OK"
|
||||
re.StatusClass = "ok"
|
||||
} else if ep.TCPConnected {
|
||||
re.StatusLabel = "partial"
|
||||
re.StatusClass = "warn"
|
||||
} else {
|
||||
re.StatusLabel = "unreachable"
|
||||
re.StatusClass = "fail"
|
||||
}
|
||||
view.Endpoints = append(view.Endpoints, re)
|
||||
}
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
func modeLabel(m LDAPMode) string {
|
||||
switch m {
|
||||
case ModePlain:
|
||||
return "ldap"
|
||||
case ModeLDAPS:
|
||||
return "ldaps"
|
||||
default:
|
||||
return string(m)
|
||||
}
|
||||
}
|
||||
|
||||
// indexTLSByAddress returns a map keyed by "host:port" pointing at a
|
||||
// reportTLSPosture. This lets the template match a related observation to
|
||||
// the right endpoint.
|
||||
func indexTLSByAddress(related []sdk.RelatedObservation) map[string]*reportTLSPosture {
|
||||
out := map[string]*reportTLSPosture{}
|
||||
for _, r := range related {
|
||||
v := parseTLSRelated(r)
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
addr := v.address()
|
||||
if addr == "" {
|
||||
continue
|
||||
}
|
||||
posture := &reportTLSPosture{
|
||||
CheckedAt: r.CollectedAt,
|
||||
ChainValid: v.ChainValid,
|
||||
HostnameMatch: v.HostnameMatch,
|
||||
NotAfter: v.NotAfter,
|
||||
}
|
||||
for _, is := range v.Issues {
|
||||
sev := strings.ToLower(is.Severity)
|
||||
if sev != SeverityCrit && sev != SeverityWarn && sev != SeverityInfo {
|
||||
continue
|
||||
}
|
||||
posture.Issues = append(posture.Issues, reportFix{
|
||||
Severity: sev,
|
||||
Code: is.Code,
|
||||
Message: is.Message,
|
||||
Fix: is.Fix,
|
||||
})
|
||||
}
|
||||
out[addr] = posture
|
||||
}
|
||||
return out
|
||||
}
|
||||
99
checker/rule.go
Normal file
99
checker/rule.go
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
func Rule() sdk.CheckRule {
|
||||
return &ldapRule{}
|
||||
}
|
||||
|
||||
type ldapRule struct{}
|
||||
|
||||
func (r *ldapRule) Name() string {
|
||||
return "ldap_server"
|
||||
}
|
||||
|
||||
func (r *ldapRule) Description() string {
|
||||
return "Checks discovery, transport security (StartTLS / LDAPS), RootDSE, SASL posture and (optionally) bind credentials of an LDAP directory."
|
||||
}
|
||||
|
||||
func (r *ldapRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
var data LDAPData
|
||||
if err := obs.Get(ctx, ObservationKeyLDAP, &data); err != nil {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusError,
|
||||
Message: fmt.Sprintf("failed to load LDAP observation: %v", err),
|
||||
Code: "ldap.observation_error",
|
||||
}}
|
||||
}
|
||||
|
||||
issues := append([]Issue(nil), data.Issues...)
|
||||
|
||||
// Fold related TLS observations (from a downstream TLS checker, if any)
|
||||
// into the LDAP issue list so cert/chain problems show up on the LDAP
|
||||
// service page without requiring a separate glance at the TLS checker.
|
||||
related, _ := obs.GetRelated(ctx, TLSRelatedKey)
|
||||
issues = append(issues, tlsIssuesFromRelated(related)...)
|
||||
|
||||
withIssue := make(map[string]bool, len(issues))
|
||||
out := make([]sdk.CheckState, 0, len(issues)+len(data.Endpoints))
|
||||
|
||||
for _, is := range issues {
|
||||
st := sdk.CheckState{
|
||||
Status: severityToStatus(is.Severity),
|
||||
Message: is.Message,
|
||||
Code: is.Code,
|
||||
Subject: is.Endpoint,
|
||||
}
|
||||
if is.Fix != "" {
|
||||
st.Meta = map[string]any{"fix": is.Fix}
|
||||
}
|
||||
out = append(out, st)
|
||||
if is.Endpoint != "" {
|
||||
withIssue[is.Endpoint] = true
|
||||
}
|
||||
}
|
||||
|
||||
for _, ep := range data.Endpoints {
|
||||
if withIssue[ep.Address] || !ep.TCPConnected {
|
||||
continue
|
||||
}
|
||||
out = append(out, sdk.CheckState{
|
||||
Status: sdk.StatusOK,
|
||||
Message: fmt.Sprintf("%s endpoint operational (TLS=%v)", ep.Mode, ep.TLSEstablished),
|
||||
Code: "ldap.ok",
|
||||
Subject: ep.Address,
|
||||
Meta: map[string]any{
|
||||
"mode": string(ep.Mode),
|
||||
"tls_established": ep.TLSEstablished,
|
||||
"rootdse_read": ep.RootDSERead,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if len(out) == 0 {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusInfo,
|
||||
Message: "no LDAP endpoint discovered",
|
||||
Code: "ldap.nothing_to_evaluate",
|
||||
}}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func severityToStatus(sev string) sdk.Status {
|
||||
switch sev {
|
||||
case SeverityCrit:
|
||||
return sdk.StatusCrit
|
||||
case SeverityWarn:
|
||||
return sdk.StatusWarn
|
||||
case SeverityInfo:
|
||||
return sdk.StatusInfo
|
||||
default:
|
||||
return sdk.StatusOK
|
||||
}
|
||||
}
|
||||
162
checker/tls_related.go
Normal file
162
checker/tls_related.go
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// TLSRelatedKey is the observation key we expect a TLS checker to publish
|
||||
// for the endpoints we discover. Matches the cross-checker convention
|
||||
// documented in the happyDomain plan.
|
||||
const TLSRelatedKey sdk.ObservationKey = "tls_probes"
|
||||
|
||||
// tlsProbeView is a permissive view of a TLS checker's payload: we read
|
||||
// only the fields we need and tolerate missing ones. The TLS checker owns
|
||||
// the full schema.
|
||||
type tlsProbeView struct {
|
||||
Host string `json:"host,omitempty"`
|
||||
Port uint16 `json:"port,omitempty"`
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
TLSVersion string `json:"tls_version,omitempty"`
|
||||
CipherSuite string `json:"cipher_suite,omitempty"`
|
||||
HostnameMatch *bool `json:"hostname_match,omitempty"`
|
||||
ChainValid *bool `json:"chain_valid,omitempty"`
|
||||
NotAfter time.Time `json:"not_after,omitempty"`
|
||||
Issues []struct {
|
||||
Code string `json:"code"`
|
||||
Severity string `json:"severity"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Fix string `json:"fix,omitempty"`
|
||||
} `json:"issues,omitempty"`
|
||||
}
|
||||
|
||||
func (v *tlsProbeView) address() string {
|
||||
if v.Endpoint != "" {
|
||||
return v.Endpoint
|
||||
}
|
||||
if v.Host != "" && v.Port != 0 {
|
||||
return net.JoinHostPort(v.Host, strconv.Itoa(int(v.Port)))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// parseTLSRelated decodes a RelatedObservation as a TLS probe, gracefully
|
||||
// returning nil when the payload doesn't look like one. Handles both
|
||||
// {"probes": {"<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
|
||||
}
|
||||
|
||||
// tlsIssuesFromRelated converts downstream TLS observations into Issue
|
||||
// entries so certificate problems land on the LDAP service page.
|
||||
func tlsIssuesFromRelated(related []sdk.RelatedObservation) []Issue {
|
||||
var out []Issue
|
||||
for _, r := range related {
|
||||
v := parseTLSRelated(r)
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
addr := v.address()
|
||||
if len(v.Issues) > 0 {
|
||||
for _, is := range v.Issues {
|
||||
sev := strings.ToLower(is.Severity)
|
||||
switch sev {
|
||||
case SeverityCrit, SeverityWarn, SeverityInfo:
|
||||
default:
|
||||
continue
|
||||
}
|
||||
code := is.Code
|
||||
if code == "" {
|
||||
code = "tls.unknown"
|
||||
}
|
||||
out = append(out, Issue{
|
||||
Code: "ldap.tls." + code,
|
||||
Severity: sev,
|
||||
Message: strings.TrimSpace("TLS on " + addr + ": " + is.Message),
|
||||
Fix: is.Fix,
|
||||
Endpoint: addr,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Flag-only payload: synthesize a single summary issue.
|
||||
sev := v.worstSeverity()
|
||||
if sev == "" {
|
||||
continue
|
||||
}
|
||||
msg := "TLS issue reported on " + addr
|
||||
switch {
|
||||
case v.ChainValid != nil && !*v.ChainValid:
|
||||
msg = "Invalid certificate chain on " + addr
|
||||
case v.HostnameMatch != nil && !*v.HostnameMatch:
|
||||
msg = "Certificate does not cover the domain on " + addr
|
||||
case !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 0:
|
||||
msg = "Certificate expired on " + addr + " (" + v.NotAfter.Format(time.RFC3339) + ")"
|
||||
case !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 14*24*time.Hour:
|
||||
msg = "Certificate expiring soon on " + addr + " (" + v.NotAfter.Format(time.RFC3339) + ")"
|
||||
}
|
||||
out = append(out, Issue{
|
||||
Code: "ldap.tls.probe",
|
||||
Severity: sev,
|
||||
Message: msg,
|
||||
Fix: "See the TLS checker report for details.",
|
||||
Endpoint: addr,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (v *tlsProbeView) worstSeverity() string {
|
||||
worst := ""
|
||||
for _, is := range v.Issues {
|
||||
switch strings.ToLower(is.Severity) {
|
||||
case SeverityCrit:
|
||||
return SeverityCrit
|
||||
case SeverityWarn:
|
||||
if worst != SeverityCrit {
|
||||
worst = SeverityWarn
|
||||
}
|
||||
case SeverityInfo:
|
||||
if worst == "" {
|
||||
worst = SeverityInfo
|
||||
}
|
||||
}
|
||||
}
|
||||
if v.ChainValid != nil && !*v.ChainValid {
|
||||
return SeverityCrit
|
||||
}
|
||||
if v.HostnameMatch != nil && !*v.HostnameMatch {
|
||||
return SeverityCrit
|
||||
}
|
||||
if !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 0 {
|
||||
return SeverityCrit
|
||||
}
|
||||
if !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 14*24*time.Hour {
|
||||
if worst != SeverityCrit {
|
||||
return SeverityWarn
|
||||
}
|
||||
}
|
||||
return worst
|
||||
}
|
||||
|
||||
func endpointKey(host string, port uint16) string {
|
||||
return net.JoinHostPort(host, strconv.FormatUint(uint64(port), 10))
|
||||
}
|
||||
184
checker/types.go
Normal file
184
checker/types.go
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
// Package checker implements the LDAP server checker for happyDomain.
|
||||
//
|
||||
// It probes a domain's LDAP deployment (_ldap._tcp / _ldaps._tcp SRV
|
||||
// discovery with fallback to default ports 389/636, anonymous bind,
|
||||
// StartTLS upgrade, RootDSE introspection, plaintext-bind refusal,
|
||||
// supportedSASLMechanisms) and reports actionable findings.
|
||||
//
|
||||
// TLS certificate chain / SAN / expiry / cipher posture is intentionally
|
||||
// out of scope -- the dedicated TLS checker covers that, fed by the TLS
|
||||
// endpoints we publish as DiscoveryEntry records.
|
||||
package checker
|
||||
|
||||
import (
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
const ObservationKeyLDAP sdk.ObservationKey = "ldap"
|
||||
|
||||
// LDAPMode distinguishes plaintext LDAP (with optional StartTLS) from
|
||||
// implicit-TLS LDAPS endpoints.
|
||||
type LDAPMode string
|
||||
|
||||
const (
|
||||
ModePlain LDAPMode = "ldap"
|
||||
ModeLDAPS LDAPMode = "ldaps"
|
||||
)
|
||||
|
||||
// LDAPData is the full observation stored per run.
|
||||
type LDAPData struct {
|
||||
Domain string `json:"domain"`
|
||||
BaseDN string `json:"base_dn,omitempty"`
|
||||
RunAt string `json:"run_at"`
|
||||
SRV SRVLookup `json:"srv"`
|
||||
Endpoints []EndpointProbe `json:"endpoints"`
|
||||
Coverage ReachabilitySpan `json:"coverage"`
|
||||
// BindTested is true when a bind DN was supplied and a bind attempt ran.
|
||||
BindTested bool `json:"bind_tested,omitempty"`
|
||||
Issues []Issue `json:"issues"`
|
||||
}
|
||||
|
||||
type SRVLookup struct {
|
||||
LDAP []SRVRecord `json:"ldap,omitempty"`
|
||||
LDAPS []SRVRecord `json:"ldaps,omitempty"`
|
||||
// Errors per-set (keyed by record type like "_ldap._tcp").
|
||||
Errors map[string]string `json:"errors,omitempty"`
|
||||
// FallbackProbed is true when no SRV was published and we probed the
|
||||
// bare domain on the default ports.
|
||||
FallbackProbed bool `json:"fallback_probed,omitempty"`
|
||||
}
|
||||
|
||||
type SRVRecord struct {
|
||||
Target string `json:"target"`
|
||||
Port uint16 `json:"port"`
|
||||
Priority uint16 `json:"priority"`
|
||||
Weight uint16 `json:"weight"`
|
||||
// IPv4 and IPv6 addresses resolved for the target (at probe time).
|
||||
IPv4 []string `json:"ipv4,omitempty"`
|
||||
IPv6 []string `json:"ipv6,omitempty"`
|
||||
}
|
||||
|
||||
// EndpointProbe is the result of probing one (mode, host, port, address) tuple.
|
||||
type EndpointProbe struct {
|
||||
Mode LDAPMode `json:"mode"`
|
||||
SRVPrefix string `json:"srv_prefix,omitempty"`
|
||||
Target string `json:"target"`
|
||||
Port uint16 `json:"port"`
|
||||
Address string `json:"address"`
|
||||
IsIPv6 bool `json:"is_ipv6,omitempty"`
|
||||
|
||||
// What happened.
|
||||
TCPConnected bool `json:"tcp_connected"`
|
||||
|
||||
// StartTLSOffered is only meaningful on Mode=ldap: whether the server
|
||||
// accepted the RFC 2830 ExtendedRequest.
|
||||
StartTLSOffered bool `json:"starttls_offered"`
|
||||
StartTLSUpgraded bool `json:"starttls_upgraded"`
|
||||
// TLSEstablished is true whenever the link was encrypted (direct LDAPS
|
||||
// handshake succeeded OR StartTLS completed).
|
||||
TLSEstablished bool `json:"tls_established"`
|
||||
|
||||
TLSVersion string `json:"tls_version,omitempty"`
|
||||
TLSCipher string `json:"tls_cipher,omitempty"`
|
||||
|
||||
// RootDSE / capability fingerprint.
|
||||
RootDSERead bool `json:"rootdse_read"`
|
||||
SupportedLDAPVersion []string `json:"supported_ldap_version,omitempty"`
|
||||
SupportedSASLMechanisms []string `json:"supported_sasl_mechanisms,omitempty"`
|
||||
SupportedControl []string `json:"supported_control,omitempty"`
|
||||
SupportedExtension []string `json:"supported_extension,omitempty"`
|
||||
NamingContexts []string `json:"naming_contexts,omitempty"`
|
||||
VendorName string `json:"vendor_name,omitempty"`
|
||||
VendorVersion string `json:"vendor_version,omitempty"`
|
||||
|
||||
// AnonymousBindAllowed is true when an anonymous simple bind to DN=""
|
||||
// succeeded. Many directories accept this just to expose the RootDSE;
|
||||
// we flag it only when paired with anonymous read of naming contexts.
|
||||
AnonymousBindAllowed bool `json:"anonymous_bind_allowed,omitempty"`
|
||||
|
||||
// AnonymousSearchAllowed is true when a subtree search on the first
|
||||
// naming context with baseObject returned any entry without auth.
|
||||
// This is the information-disclosure signal.
|
||||
AnonymousSearchAllowed bool `json:"anonymous_search_allowed,omitempty"`
|
||||
|
||||
// Plaintext-bind posture (only for Mode=ldap, run before TLS upgrade).
|
||||
// PlaintextBindTested is true when we attempted a simple bind with
|
||||
// dummy credentials over cleartext to see whether the server refused
|
||||
// it (RFC 4513 requires refusing auth over insecure channels when
|
||||
// policy demands it, though in practice most deployments don't).
|
||||
PlaintextBindTested bool `json:"plaintext_bind_tested,omitempty"`
|
||||
// PlaintextBindAccepted is true when the server did NOT refuse the
|
||||
// cleartext bind with "confidentiality required" (resultCode 13).
|
||||
// A bad-credentials response (49) counts as "accepted the attempt",
|
||||
// which is the insecure-posture signal we want to flag.
|
||||
PlaintextBindAccepted bool `json:"plaintext_bind_accepted,omitempty"`
|
||||
|
||||
// Bind test (run only when bind_dn/bind_password options provided).
|
||||
BindAttempted bool `json:"bind_attempted,omitempty"`
|
||||
BindOK bool `json:"bind_ok,omitempty"`
|
||||
BindError string `json:"bind_error,omitempty"`
|
||||
|
||||
// Read access test on base_dn (run only when base_dn provided AND the
|
||||
// authenticated bind above succeeded, unless base_dn was meant to be
|
||||
// anonymously readable).
|
||||
BaseReadAttempted bool `json:"base_read_attempted,omitempty"`
|
||||
BaseReadOK bool `json:"base_read_ok,omitempty"`
|
||||
BaseReadEntries int `json:"base_read_entries,omitempty"`
|
||||
BaseReadError string `json:"base_read_error,omitempty"`
|
||||
|
||||
ElapsedMS int64 `json:"elapsed_ms"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type ReachabilitySpan struct {
|
||||
HasIPv4 bool `json:"has_ipv4"`
|
||||
HasIPv6 bool `json:"has_ipv6"`
|
||||
// EncryptedReachable is true when at least one endpoint offered encrypted
|
||||
// transport (LDAPS or LDAP+StartTLS).
|
||||
EncryptedReachable bool `json:"encrypted_reachable"`
|
||||
// PlainOnlyReachable is true when only cleartext endpoints responded.
|
||||
PlainOnlyReachable bool `json:"plain_only_reachable"`
|
||||
}
|
||||
|
||||
// Issue is a structured finding attached to the observation so the rule and
|
||||
// the HTML report can both consume them without re-deriving logic.
|
||||
type Issue struct {
|
||||
Code string `json:"code"`
|
||||
Severity string `json:"severity"` // "info" | "warn" | "crit"
|
||||
Message string `json:"message"`
|
||||
Fix string `json:"fix,omitempty"`
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
}
|
||||
|
||||
// Severities (string for stable JSON, independent of sdk.Status numeric values).
|
||||
const (
|
||||
SeverityInfo = "info"
|
||||
SeverityWarn = "warn"
|
||||
SeverityCrit = "crit"
|
||||
)
|
||||
|
||||
// Issue codes.
|
||||
const (
|
||||
CodeNoSRV = "ldap.no_srv"
|
||||
CodeSRVServfail = "ldap.srv.servfail"
|
||||
CodeTCPUnreachable = "ldap.tcp.unreachable"
|
||||
CodeAllEndpointsDown = "ldap.all_endpoints_down"
|
||||
CodeNoEncryptedEndpoint = "ldap.no_encrypted_endpoint"
|
||||
CodeStartTLSMissing = "ldap.starttls.missing"
|
||||
CodeStartTLSFailed = "ldap.starttls.handshake_failed"
|
||||
CodeLDAPSHandshakeFailed = "ldap.ldaps.handshake_failed"
|
||||
CodePlainBindAccepted = "ldap.plain_bind.accepted"
|
||||
CodeAnonymousSearchAllowed = "ldap.anon.search_allowed"
|
||||
CodeRootDSEUnreadable = "ldap.rootdse.unreadable"
|
||||
CodeNoSASL = "ldap.sasl.none"
|
||||
CodeSASLPlainOnly = "ldap.sasl.plain_only"
|
||||
CodeSASLNoStrongMech = "ldap.sasl.no_strong_mech"
|
||||
CodeLegacyLDAPv2 = "ldap.legacy_v2"
|
||||
CodeStartTLSOnLDAPS = "ldap.starttls.on_ldaps"
|
||||
CodeNoIPv6 = "ldap.no_ipv6"
|
||||
CodeBindFailed = "ldap.bind.failed"
|
||||
CodeBindOK = "ldap.bind.ok"
|
||||
CodeBaseReadFailed = "ldap.base_read.failed"
|
||||
CodeBaseReadOK = "ldap.base_read.ok"
|
||||
CodeNoNamingContext = "ldap.rootdse.no_naming_context"
|
||||
)
|
||||
22
go.mod
Normal file
22
go.mod
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
module git.happydns.org/checker-ldap
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
git.happydns.org/checker-sdk-go v1.3.0
|
||||
git.happydns.org/checker-tls v0.3.0
|
||||
github.com/go-ldap/ldap/v3 v3.4.8
|
||||
github.com/miekg/dns v1.1.72
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
)
|
||||
112
go.sum
Normal file
112
go.sum
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
git.happydns.org/checker-sdk-go v1.3.0 h1:FG2kIhlJCzI0m35EhxSgn4UWc9M4ha6aZTeoChu4l7A=
|
||||
git.happydns.org/checker-sdk-go v1.3.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
|
||||
git.happydns.org/checker-tls v0.3.0 h1:hg9fNtT5l/0UJAuUakOwCuT1MFiyp4lDFaZKpgdzPoo=
|
||||
git.happydns.org/checker-tls v0.3.0/go.mod h1:0ZSG0CTP007SHBPE7qInESVIOcW+xgucHUhHgj6MeZ8=
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
|
||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
|
||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.5/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.7/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-ldap/ldap/v3 v3.4.8 h1:loKJyspcRezt2Q3ZRMq2p/0v8iOurlmeXDPw6fikSvQ=
|
||||
github.com/go-ldap/ldap/v3 v3.4.8/go.mod h1:qS3Sjlu76eHfHGpUdWkAXQTw4beih+cHsco2jXlIXrk=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
|
||||
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
|
||||
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
||||
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
28
main.go
Normal file
28
main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
19
plugin/plugin.go
Normal file
19
plugin/plugin.go
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
// Command plugin is the happyDomain plugin entrypoint for the LDAP checker.
|
||||
//
|
||||
// It is built as a Go plugin (`go build -buildmode=plugin`) and loaded at
|
||||
// runtime by happyDomain.
|
||||
package main
|
||||
|
||||
import (
|
||||
ldap "git.happydns.org/checker-ldap/checker"
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
var Version = "custom-build"
|
||||
|
||||
// NewCheckerPlugin is the symbol resolved by happyDomain when loading the
|
||||
// .so file.
|
||||
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
|
||||
ldap.Version = Version
|
||||
return ldap.Definition(), ldap.Provider(), nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue