package checker import ( "encoding/json" "fmt" "html/template" "sort" "strings" "time" sdk "git.happydns.org/checker-sdk-go/checker" ) type reportFix struct { Severity string Code string Message string Fix string Endpoint string } type reportEndpoint struct { Mode string ModeLabel string SRVPrefix string Target string Port uint16 Address string IsIPv6 bool TCPConnected bool StartTLSOffered bool StartTLSUpgraded bool TLSEstablished bool TLSVersion string TLSCipher string RootDSERead bool SupportedLDAPVersion []string SupportedSASLMechanisms []string SupportedControl []string SupportedExtension []string NamingContexts []string VendorName string VendorVersion string AnonymousBindAllowed bool AnonymousSearchAllowed bool PlaintextBindTested bool PlaintextBindAccepted bool BindAttempted bool BindOK bool BindError string BaseReadAttempted bool BaseReadOK bool BaseReadEntries int BaseReadError string ElapsedMS int64 Error string // TLS posture (from a related tls_probes observation, when available). TLSPosture *reportTLSPosture // Rendering helpers. AnyFail bool StatusLabel string StatusClass string } type reportTLSPosture struct { CheckedAt time.Time ChainValid *bool HostnameMatch *bool NotAfter time.Time Issues []reportFix } type reportSRVEntry struct { Prefix string Target string Port uint16 Priority uint16 Weight uint16 IPv4 []string IPv6 []string } type reportData struct { Domain string BaseDN string RunAt string StatusLabel string StatusClass string HasIssues bool Fixes []reportFix SRV []reportSRVEntry FallbackProbed bool Endpoints []reportEndpoint HasIPv4 bool HasIPv6 bool EncryptedReachable bool PlainOnlyReachable bool BindTested bool HasTLSPosture bool } var reportTpl = template.Must(template.New("ldap").Funcs(template.FuncMap{ "hasPrefix": strings.HasPrefix, "deref": func(b *bool) bool { return b != nil && *b }, "join": func(sep string, list []string) string { return strings.Join(list, sep) }, "upper": strings.ToUpper, }).Parse(` LDAP Report -- {{.Domain}}

LDAP -- {{.Domain}}

{{.StatusLabel}}
{{if .EncryptedReachable}}encrypted OK{{else}}no encryption{{end}} {{if .HasIPv4}}IPv4{{end}} {{if .HasIPv6}}IPv6{{end}} {{if .BindTested}}bind test{{end}}
Checked {{.RunAt}}{{if .BaseDN}} · base {{.BaseDN}}{{end}}
{{if .HasIssues}}

What to fix

{{range .Fixes}}
{{.Code}}{{if .Endpoint}} · {{.Endpoint}}{{end}}
{{.Message}}
{{if .Fix}}
→ {{.Fix}}
{{end}}
{{end}}
{{end}}

DNS / SRV

{{if .FallbackProbed}}

No SRV records published -- fell back to probing the bare domain on default ports 389 / 636.

{{else if .SRV}} {{range .SRV}} {{end}}
RecordTargetPortPrio/WeightIPv4IPv6
{{.Prefix}} {{.Target}} {{.Port}} {{.Priority}}/{{.Weight}} {{range .IPv4}}{{.}} {{end}} {{range .IPv6}}{{.}} {{end}}
{{else}}

No SRV records found.

{{end}}
{{if .Endpoints}}

Endpoints ({{len .Endpoints}})

{{range .Endpoints}} {{.ModeLabel}} · {{.Address}} {{.StatusLabel}}
{{if .SRVPrefix}}
SRV
{{.SRVPrefix}}{{.Target}}:{{.Port}}
{{end}}
Family
{{if .IsIPv6}}IPv6{{else}}IPv4{{end}}
TCP
{{if .TCPConnected}}✓ connected{{else}}✗ failed{{end}}
{{if eq .Mode "ldap"}}
StartTLS
{{if .StartTLSOffered}}✓ offered{{else}}✗ not offered{{end}} {{if .StartTLSUpgraded}} · upgraded{{else if .StartTLSOffered}} · upgrade failed{{end}}
Cleartext bind
{{if not .PlaintextBindTested}}not tested {{else if .PlaintextBindAccepted}}✗ accepted (insecure) {{else}}✓ refused (confidentiality required){{end}}
{{end}}
TLS
{{if .TLSEstablished}}✓ {{.TLSVersion}}{{if .TLSCipher}} — {{.TLSCipher}}{{end}} {{else}}✗ plaintext{{end}}
RootDSE
{{if .RootDSERead}}✓ read {{else}}✗ unreadable{{end}} {{if .VendorName}} · {{.VendorName}}{{if .VendorVersion}} {{.VendorVersion}}{{end}}{{end}} {{if .SupportedLDAPVersion}} · LDAPv{{join "," .SupportedLDAPVersion}}{{end}}
{{if .NamingContexts}}
Naming contexts
{{range .NamingContexts}}{{.}}{{end}}
{{end}} {{if .SupportedSASLMechanisms}}
SASL
{{range .SupportedSASLMechanisms}} {{$u := upper .}} {{if or (eq $u "PLAIN") (eq $u "LOGIN")}}{{.}} {{else if or (hasPrefix $u "SCRAM-") (eq $u "EXTERNAL") (eq $u "GSSAPI") (eq $u "GSS-SPNEGO")}}{{.}} {{else}}{{.}}{{end}} {{end}}
{{end}}
Anonymous
{{if .AnonymousBindAllowed}}bind allowed {{else}}✓ bind refused{{end}} {{if .AnonymousSearchAllowed}} · search allowed (DIT enumerable){{end}}
{{if .BindAttempted}}
Bind as DN
{{if .BindOK}}✓ succeeded {{else}}✗ {{.BindError}}{{end}}
{{end}} {{if .BaseReadAttempted}}
Base read
{{if .BaseReadOK}}✓ {{.BaseReadEntries}} entry/entries {{else}}✗ {{.BaseReadError}}{{end}}
{{end}} {{with .TLSPosture}}
TLS cert
{{if .ChainValid}} {{if deref .ChainValid}}✓ chain valid{{else}}✗ chain invalid{{end}} {{end}} {{if .HostnameMatch}} · {{if deref .HostnameMatch}}✓ hostname match{{else}}✗ hostname mismatch{{end}} {{end}} {{if not .NotAfter.IsZero}} · expires {{.NotAfter.Format "2006-01-02"}} {{end}} {{if not .CheckedAt.IsZero}}
TLS checked {{.CheckedAt.Format "2006-01-02 15:04 MST"}}
{{end}} {{range .Issues}}
{{.Code}}
{{.Message}}
{{if .Fix}}
→ {{.Fix}}
{{end}}
{{end}}
{{end}}
Duration
{{.ElapsedMS}} ms
{{if .Error}}
Error
{{.Error}}
{{end}}
{{end}}
{{end}} `)) // GetHTMLReport implements sdk.CheckerHTMLReporter. It folds in related TLS // observations so the LDAP service page shows cert posture directly. func (p *ldapProvider) GetHTMLReport(rctx sdk.ReportContext) (string, error) { var d LDAPData if err := json.Unmarshal(rctx.Data(), &d); err != nil { return "", fmt.Errorf("unmarshal ldap observation: %w", err) } view := buildReportData(&d, rctx.Related(TLSRelatedKey)) return renderReport(view) } func renderReport(view reportData) (string, error) { var buf strings.Builder if err := reportTpl.Execute(&buf, view); err != nil { return "", fmt.Errorf("render ldap report: %w", err) } return buf.String(), nil } func buildReportData(d *LDAPData, related []sdk.RelatedObservation) reportData { tlsIssues := tlsIssuesFromRelated(related) tlsByAddr := indexTLSByAddress(related) allIssues := append([]Issue(nil), d.Issues...) allIssues = append(allIssues, tlsIssues...) view := reportData{ Domain: d.Domain, BaseDN: d.BaseDN, RunAt: d.RunAt, FallbackProbed: d.SRV.FallbackProbed, HasIPv4: d.Coverage.HasIPv4, HasIPv6: d.Coverage.HasIPv6, EncryptedReachable: d.Coverage.EncryptedReachable, PlainOnlyReachable: d.Coverage.PlainOnlyReachable, BindTested: d.BindTested, HasIssues: len(allIssues) > 0, HasTLSPosture: len(tlsByAddr) > 0, } // Status banner. worst := "" for _, is := range allIssues { if is.Severity == SeverityCrit { worst = SeverityCrit break } if is.Severity == SeverityWarn { worst = SeverityWarn } else if worst == "" && is.Severity == SeverityInfo { worst = SeverityInfo } } if len(allIssues) == 0 { view.StatusLabel = "OK" view.StatusClass = "ok" } else { switch worst { case SeverityCrit: view.StatusLabel = "FAIL" view.StatusClass = "fail" case SeverityWarn: view.StatusLabel = "WARN" view.StatusClass = "warn" default: view.StatusLabel = "INFO" view.StatusClass = "info" } } // Fix list: sort crit → warn → info, preserving order within each severity. sevRank := func(s string) int { switch s { case SeverityCrit: return 0 case SeverityWarn: return 1 default: return 2 } } sort.SliceStable(allIssues, func(i, j int) bool { return sevRank(allIssues[i].Severity) < sevRank(allIssues[j].Severity) }) for _, is := range allIssues { view.Fixes = append(view.Fixes, reportFix{ Severity: is.Severity, Code: is.Code, Message: is.Message, Fix: is.Fix, Endpoint: is.Endpoint, }) } // SRV rows. addSRV := func(prefix string, records []SRVRecord) { for _, r := range records { view.SRV = append(view.SRV, reportSRVEntry{ Prefix: prefix, Target: r.Target, Port: r.Port, Priority: r.Priority, Weight: r.Weight, IPv4: r.IPv4, IPv6: r.IPv6, }) } } addSRV("_ldap._tcp", d.SRV.LDAP) addSRV("_ldaps._tcp", d.SRV.LDAPS) // Endpoint rows. for _, ep := range d.Endpoints { re := reportEndpoint{ Mode: string(ep.Mode), ModeLabel: modeLabel(ep.Mode), SRVPrefix: ep.SRVPrefix, Target: ep.Target, Port: ep.Port, Address: ep.Address, IsIPv6: ep.IsIPv6, TCPConnected: ep.TCPConnected, StartTLSOffered: ep.StartTLSOffered, StartTLSUpgraded: ep.StartTLSUpgraded, TLSEstablished: ep.TLSEstablished, TLSVersion: ep.TLSVersion, TLSCipher: ep.TLSCipher, RootDSERead: ep.RootDSERead, SupportedLDAPVersion: ep.SupportedLDAPVersion, SupportedSASLMechanisms: ep.SupportedSASLMechanisms, SupportedControl: ep.SupportedControl, SupportedExtension: ep.SupportedExtension, NamingContexts: ep.NamingContexts, VendorName: ep.VendorName, VendorVersion: ep.VendorVersion, AnonymousBindAllowed: ep.AnonymousBindAllowed, AnonymousSearchAllowed: ep.AnonymousSearchAllowed, PlaintextBindTested: ep.PlaintextBindTested, PlaintextBindAccepted: ep.PlaintextBindAccepted, BindAttempted: ep.BindAttempted, BindOK: ep.BindOK, BindError: ep.BindError, BaseReadAttempted: ep.BaseReadAttempted, BaseReadOK: ep.BaseReadOK, BaseReadEntries: ep.BaseReadEntries, BaseReadError: ep.BaseReadError, ElapsedMS: ep.ElapsedMS, Error: ep.Error, } if meta, hit := tlsByAddr[ep.Address]; hit { re.TLSPosture = meta } else if meta, hit := tlsByAddr[endpointKey(ep.Target, ep.Port)]; hit { re.TLSPosture = meta } ok := ep.TCPConnected && ep.TLSEstablished if ep.Mode == ModePlain { ok = ok && ep.StartTLSUpgraded } re.AnyFail = !ok if ok { re.StatusLabel = "OK" re.StatusClass = "ok" } else if ep.TCPConnected { re.StatusLabel = "partial" re.StatusClass = "warn" } else { re.StatusLabel = "unreachable" re.StatusClass = "fail" } view.Endpoints = append(view.Endpoints, re) } return view } func modeLabel(m LDAPMode) string { switch m { case ModePlain: return "ldap" case ModeLDAPS: return "ldaps" default: return string(m) } } // indexTLSByAddress returns a map keyed by "host:port" pointing at a // reportTLSPosture. This lets the template match a related observation to // the right endpoint. func indexTLSByAddress(related []sdk.RelatedObservation) map[string]*reportTLSPosture { out := map[string]*reportTLSPosture{} for _, r := range related { v := parseTLSRelated(r) if v == nil { continue } addr := v.address() if addr == "" { continue } posture := &reportTLSPosture{ CheckedAt: r.CollectedAt, ChainValid: v.ChainValid, HostnameMatch: v.HostnameMatch, NotAfter: v.NotAfter, } for _, is := range v.Issues { sev := strings.ToLower(is.Severity) if sev != SeverityCrit && sev != SeverityWarn && sev != SeverityInfo { continue } posture.Issues = append(posture.Issues, reportFix{ Severity: sev, Code: is.Code, Message: is.Message, Fix: is.Fix, }) } out[addr] = posture } return out }