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