package checker import ( "encoding/json" "fmt" "html/template" "net" "sort" "strconv" "strings" "time" sdk "git.happydns.org/checker-sdk-go/checker" ) type reportFix struct { Severity string Code string Message string Fix string Endpoint string } type reportEndpoint struct { EndpointProbe ModeLabel string // TLS posture (from a related tls_probes observation, when available). TLSPosture *reportTLSPosture // Rendering helpers. AnyFail bool StatusLabel string StatusClass string } type reportTLSPosture struct { CheckedAt time.Time ChainValid *bool HostnameMatch *bool NotAfter time.Time } type reportSRVEntry struct { Prefix string Target string Port uint16 Priority uint16 Weight uint16 IPv4 []string IPv6 []string } type reportData struct { Domain string BaseDN string RunAt string StatusLabel string StatusClass string HasIssues bool Fixes []reportFix SRV []reportSRVEntry FallbackProbed bool Endpoints []reportEndpoint HasIPv4 bool HasIPv6 bool EncryptedReachable bool PlainOnlyReachable bool BindTested bool HasTLSPosture bool } var reportTpl = template.Must(template.New("ldap").Funcs(template.FuncMap{ "hasPrefix": strings.HasPrefix, "deref": func(b *bool) bool { return b != nil && *b }, "join": func(sep string, list []string) string { return strings.Join(list, sep) }, "upper": strings.ToUpper, }).Parse(` 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}}
{{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, and // uses ReportContext.States() as the sole source of hint/fix/severity text: // when no states are threaded through, the page renders a data-only view of // the raw observation without any derived judgment. func (p *ldapProvider) GetHTMLReport(rctx sdk.ReportContext) (string, error) { var d LDAPData if err := json.Unmarshal(rctx.Data(), &d); err != nil { return "", fmt.Errorf("unmarshal ldap observation: %w", err) } view := buildReportData(&d, rctx.Related(TLSRelatedKey), rctx.States()) return renderReport(view) } func renderReport(view reportData) (string, error) { var buf strings.Builder if err := reportTpl.Execute(&buf, view); err != nil { return "", fmt.Errorf("render ldap report: %w", err) } return buf.String(), nil } func buildReportData(d *LDAPData, related []sdk.RelatedObservation, states []sdk.CheckState) reportData { tlsByAddr := foldTLSRelated(related) // Coverage is a pure raw-view aggregation over endpoint facts (counts and // booleans, no severity). It feeds the IPv4/IPv6 badges and the // "encrypted OK" vs "no encryption" hint in the page header. cov := coverageView(d) view := reportData{ Domain: d.Domain, BaseDN: d.BaseDN, RunAt: d.RunAt, FallbackProbed: d.SRV.FallbackProbed, HasIPv4: cov.HasIPv4, HasIPv6: cov.HasIPv6, EncryptedReachable: cov.EncryptedReachable, PlainOnlyReachable: cov.PlainOnlyReachable, BindTested: d.BindTested, HasTLSPosture: len(tlsByAddr) > 0, } // Hint / fix / severity text is populated *only* from rule output threaded // through ReportContext.States(). When the host has not piped Evaluate → // Report, the "What to fix" section is omitted entirely and the page // falls back to a raw data-only view of the observation. applyFixes(&view, fixesFromStates(states)) // SRV rows. addSRV := func(prefix string, records []SRVRecord) { for _, r := range records { view.SRV = append(view.SRV, reportSRVEntry{ Prefix: prefix, Target: r.Target, Port: r.Port, Priority: r.Priority, Weight: r.Weight, IPv4: r.IPv4, IPv6: r.IPv6, }) } } addSRV("_ldap._tcp", d.SRV.LDAP) addSRV("_ldaps._tcp", d.SRV.LDAPS) // Endpoint rows. for _, ep := range d.Endpoints { re := reportEndpoint{ EndpointProbe: ep, ModeLabel: modeLabel(ep.Mode), } if meta, hit := tlsByAddr[ep.Address]; hit { re.TLSPosture = meta } else if meta, hit := tlsByAddr[net.JoinHostPort(ep.Target, strconv.FormatUint(uint64(ep.Port), 10))]; hit { re.TLSPosture = meta } ok := ep.TCPConnected && ep.TLSEstablished if ep.Mode == ModePlain { ok = ok && ep.StartTLSUpgraded } re.AnyFail = !ok if ok { re.StatusLabel = "OK" re.StatusClass = "ok" } else if ep.TCPConnected { re.StatusLabel = "partial" re.StatusClass = "warn" } else { re.StatusLabel = "unreachable" re.StatusClass = "fail" } view.Endpoints = append(view.Endpoints, re) } return view } // fixesFromStates converts rule-evaluator CheckStates into reportFix entries, // dropping StatusOK / StatusUnknown (they are not findings to display in the // "What to fix" list). The fix hint, when present, is read from Meta["fix"] // using the convention set by issueToState in rules.go. func fixesFromStates(states []sdk.CheckState) []reportFix { out := make([]reportFix, 0, len(states)) for _, st := range states { if st.Status == sdk.StatusOK || st.Status == sdk.StatusUnknown { continue } fix, _ := st.Meta["fix"].(string) out = append(out, reportFix{ Severity: severityFromStatus(st.Status), Code: st.Code, Message: st.Message, Fix: fix, Endpoint: st.Subject, }) } return out } func severityFromStatus(s sdk.Status) string { switch s { case sdk.StatusCrit, sdk.StatusError: return SeverityCrit case sdk.StatusWarn: return SeverityWarn default: return SeverityInfo } } // applyFixes sorts (crit → warn → info) and stamps the page-level status // banner from the worst severity present. func applyFixes(view *reportData, fixes []reportFix) { view.HasIssues = len(fixes) > 0 sort.SliceStable(fixes, func(i, j int) bool { return sevRank(fixes[i].Severity) < sevRank(fixes[j].Severity) }) view.Fixes = fixes if len(fixes) == 0 { // No rule output threaded through: render a neutral data-only banner // (no severity text derived from raw facts). view.StatusLabel = "report" view.StatusClass = "muted" return } switch fixes[0].Severity { case SeverityCrit: view.StatusLabel = "FAIL" view.StatusClass = "fail" case SeverityWarn: view.StatusLabel = "WARN" view.StatusClass = "warn" default: view.StatusLabel = "INFO" view.StatusClass = "info" } } func sevRank(s string) int { switch s { case SeverityCrit: return 0 case SeverityWarn: return 1 default: return 2 } } func modeLabel(m LDAPMode) string { switch m { case ModePlain: return "ldap" case ModeLDAPS: return "ldaps" default: return string(m) } } // foldTLSRelated builds a per-address posture map from downstream TLS // observations. This is pure raw-view data (flags and timestamps) for the // endpoint table; TLS severity text comes in via ReportContext.States() // from the tls_quality rule, not from this helper. func foldTLSRelated(related []sdk.RelatedObservation) map[string]*reportTLSPosture { byAddr := map[string]*reportTLSPosture{} for _, r := range related { v := parseTLSRelated(r) if v == nil { continue } if addr := v.address(); addr != "" { byAddr[addr] = &reportTLSPosture{ CheckedAt: r.CollectedAt, ChainValid: v.ChainValid, HostnameMatch: v.HostnameMatch, NotAfter: v.NotAfter, } } } return byAddr } // coverageView aggregates raw per-endpoint booleans into the header-level // reachability summary. It is pure data reshaping, no severity, no fix // strings, and lives here because report.go is its only caller. func coverageView(data *LDAPData) ReachabilitySpan { var cov ReachabilitySpan anyEncrypted := false anyPlain := false for _, ep := range data.Endpoints { if !ep.TCPConnected { continue } if ep.IsIPv6 { cov.HasIPv6 = true } else { cov.HasIPv4 = true } if ep.TLSEstablished { anyEncrypted = true } else { anyPlain = true } } cov.EncryptedReachable = anyEncrypted cov.PlainOnlyReachable = anyPlain && !anyEncrypted return cov }