checker-ldap/checker/rules_transport.go

279 lines
9.2 KiB
Go

package checker
import (
"context"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Rules in this file cover transport-level concerns: SRV discovery, TCP
// reachability, encryption availability, TLS handshakes. They read raw
// LDAPData fields directly (no pre-derived Issues slice).
// srvDiscoveryRule: _ldap._tcp / _ldaps._tcp SRV publishing + resolution.
type srvDiscoveryRule struct{}
func (r *srvDiscoveryRule) Name() string { return "ldap.has_srv" }
func (r *srvDiscoveryRule) Description() string {
return "Verifies that _ldap._tcp / _ldaps._tcp SRV records are published and resolvable."
}
func (r *srvDiscoveryRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadLDAPData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
var states []sdk.CheckState
if data.SRV.FallbackProbed {
states = append(states, infoState(
CodeNoSRV,
"No LDAP SRV records published for "+data.Domain+".",
"",
"Consider publishing _ldap._tcp."+data.Domain+" and _ldaps._tcp."+data.Domain+" SRV records to let clients discover the directory automatically.",
))
}
for prefix, msg := range data.SRV.Errors {
states = append(states, warnState(
CodeSRVServfail,
"DNS lookup failed for "+prefix+data.Domain+": "+msg,
"",
"Check the authoritative DNS servers for this domain.",
))
}
if len(states) == 0 {
return []sdk.CheckState{passState("ldap.has_srv.ok", "SRV records are published and resolved cleanly.")}
}
return states
}
// endpointReachableRule: every discovered endpoint accepts a TCP connection.
type endpointReachableRule struct{}
func (r *endpointReachableRule) Name() string { return "ldap.endpoint_reachable" }
func (r *endpointReachableRule) Description() string {
return "Verifies that every discovered LDAP endpoint accepts a TCP connection."
}
func (r *endpointReachableRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadLDAPData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
var states []sdk.CheckState
// Per-endpoint TCP failures.
ldapsUp := ldapsReachable(data)
for _, ep := range data.Endpoints {
if ep.TCPConnected || ep.Error == "" {
continue
}
msg := "Cannot reach " + ep.Address + ": " + ep.Error + "."
if ep.Mode == ModePlain && ldapsUp {
states = append(states, infoState(
CodeTCPUnreachable, msg, ep.Address,
"LDAPS (636) is reachable, so modern clients are unaffected. Only relevant if legacy clients still need plain LDAP on 389.",
))
} else {
states = append(states, warnState(
CodeTCPUnreachable, msg, ep.Address,
"Verify firewall rules and that the LDAP server is listening on this address.",
))
}
}
// Aggregate: no endpoint reachable at all.
if len(data.Endpoints) > 0 {
allDown := true
for _, ep := range data.Endpoints {
if ep.TCPConnected {
allDown = false
break
}
}
if allDown {
states = append(states, critState(
CodeAllEndpointsDown,
"No LDAP endpoint is reachable.",
"",
"Check firewall rules and that the directory is listening on ports 389/636 (or the ports indicated by SRV).",
))
}
}
if len(states) == 0 {
return []sdk.CheckState{passState("ldap.endpoint_reachable.ok", "All discovered endpoints are reachable.")}
}
return states
}
// encryptedTransportRule: at least one endpoint is reachable AND encrypted.
type encryptedTransportRule struct{}
func (r *encryptedTransportRule) Name() string { return "ldap.has_encrypted_transport" }
func (r *encryptedTransportRule) Description() string {
return "Verifies that at least one reachable endpoint offers an encrypted channel (LDAPS or StartTLS)."
}
func (r *encryptedTransportRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadLDAPData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
anyReachable := false
anyEncrypted := false
for _, ep := range data.Endpoints {
if ep.TCPConnected {
anyReachable = true
if ep.TLSEstablished {
anyEncrypted = true
}
}
}
if anyReachable && !anyEncrypted {
return []sdk.CheckState{critState(
CodeNoEncryptedEndpoint,
"None of the reachable endpoints established an encrypted channel (no StartTLS, no LDAPS).",
"",
"Enable either LDAPS (port 636) or StartTLS on 389 -- a bind DN should never travel in cleartext.",
)}
}
return []sdk.CheckState{passState("ldap.has_encrypted_transport.ok", "At least one endpoint offers encrypted transport.")}
}
// startTLSSupportedRule: StartTLS is advertised and succeeds on every
// reachable plain-LDAP endpoint.
type startTLSSupportedRule struct{}
func (r *startTLSSupportedRule) Name() string { return "ldap.starttls_supported" }
func (r *startTLSSupportedRule) Description() string {
return "Verifies that StartTLS is offered and succeeds on every reachable plain LDAP endpoint."
}
func (r *startTLSSupportedRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadLDAPData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
var states []sdk.CheckState
for _, ep := range data.Endpoints {
if ep.Mode != ModePlain || !ep.TCPConnected {
continue
}
if !ep.StartTLSOffered {
states = append(states, critState(
CodeStartTLSMissing,
"StartTLS not advertised on "+ep.Address+".",
ep.Address,
"Configure the LDAP server to accept StartTLS (RFC 2830). On OpenLDAP, set olcTLSCertificateFile / olcTLSCertificateKeyFile and ensure the LDAP listener allows encrypted upgrades.",
))
} else if !ep.StartTLSUpgraded {
states = append(states, critState(
CodeStartTLSFailed,
"StartTLS handshake failed on "+ep.Address+": "+ep.Error+".",
ep.Address,
"Run the TLS checker on this endpoint for cert and cipher details.",
))
}
}
if len(states) == 0 {
return []sdk.CheckState{passState("ldap.starttls_supported.ok", "StartTLS works on every reachable plain LDAP endpoint.")}
}
return states
}
// ldapsHandshakeRule: the direct TLS handshake succeeds on every reachable
// LDAPS endpoint.
type ldapsHandshakeRule struct{}
func (r *ldapsHandshakeRule) Name() string { return "ldap.ldaps_handshake" }
func (r *ldapsHandshakeRule) Description() string {
return "Verifies that the direct TLS handshake succeeds on every LDAPS endpoint."
}
func (r *ldapsHandshakeRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadLDAPData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
var states []sdk.CheckState
for _, ep := range data.Endpoints {
if ep.Mode == ModeLDAPS && ep.TCPConnected && !ep.TLSEstablished {
states = append(states, critState(
CodeLDAPSHandshakeFailed,
"LDAPS TLS handshake failed on "+ep.Address+": "+ep.Error+".",
ep.Address,
"Check that the LDAPS listener has a valid certificate/key pair and is speaking TLS directly (not LDAP+StartTLS).",
))
}
}
if len(states) == 0 {
return []sdk.CheckState{passState("ldap.ldaps_handshake.ok", "LDAPS handshake succeeds on every reachable LDAPS endpoint.")}
}
return states
}
// startTLSOnLDAPSRule: flags LDAPS endpoints that also advertise StartTLS.
type startTLSOnLDAPSRule struct{}
func (r *startTLSOnLDAPSRule) Name() string { return "ldap.starttls_on_ldaps" }
func (r *startTLSOnLDAPSRule) Description() string {
return "Flags servers that needlessly advertise StartTLS on the implicit-TLS LDAPS port."
}
func (r *startTLSOnLDAPSRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadLDAPData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
var states []sdk.CheckState
for _, ep := range data.Endpoints {
if ep.Mode == ModeLDAPS && stringListContainsFold(ep.SupportedExtension, "1.3.6.1.4.1.1466.20037") {
states = append(states, infoState(
CodeStartTLSOnLDAPS,
"Server advertises StartTLS on the LDAPS port "+ep.Address+".",
ep.Address,
"This is not actively harmful, but most directories do not need StartTLS exposed on 636. Consider disabling it to reduce attack surface.",
))
}
}
if len(states) == 0 {
return []sdk.CheckState{passState("ldap.starttls_on_ldaps.ok", "LDAPS endpoints do not also advertise StartTLS.")}
}
return states
}
// ipv6ReachableRule: at least one endpoint reachable over IPv6.
type ipv6ReachableRule struct{}
func (r *ipv6ReachableRule) Name() string { return "ldap.ipv6_reachable" }
func (r *ipv6ReachableRule) Description() string {
return "Verifies at least one endpoint is reachable over IPv6."
}
func (r *ipv6ReachableRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadLDAPData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
hasV4 := false
hasV6 := false
for _, ep := range data.Endpoints {
if !ep.TCPConnected {
continue
}
if ep.IsIPv6 {
hasV6 = true
} else {
hasV4 = true
}
}
if hasV4 && !hasV6 {
return []sdk.CheckState{infoState(
CodeNoIPv6,
"No IPv6 endpoint reachable.",
"",
"Publish AAAA records for the SRV targets.",
)}
}
return []sdk.CheckState{passState("ldap.ipv6_reachable.ok", "At least one endpoint is reachable over IPv6.")}
}