package checker import ( "context" "fmt" "sort" sdk "git.happydns.org/checker-sdk-go/checker" ) // Rule returns the rule that aggregates per-endpoint TLS probe outcomes into // a single status for this checker run. func Rule() sdk.CheckRule { return &tlsRule{} } type tlsRule struct{} func (r *tlsRule) Name() string { return "tls_posture" } func (r *tlsRule) Description() string { return "Summarises TLS handshake, certificate validity, hostname match and expiry across all probed endpoints" } func (r *tlsRule) ValidateOptions(opts sdk.CheckerOptions) error { return nil } func (r *tlsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { var data TLSData if err := obs.Get(ctx, ObservationKeyTLSProbes, &data); err != nil { return []sdk.CheckState{{ Status: sdk.StatusError, Message: fmt.Sprintf("Failed to read tls_probes: %v", err), Code: "tls_observation_error", }} } // Steady state when no producer has published entries for this target // yet (or when the last producer run cleared them). Report Unknown so // we don't flap red during the eventual-consistency window between a // fresh enrollment and the first producer cycle. if len(data.Probes) == 0 { return []sdk.CheckState{{ Status: sdk.StatusUnknown, Message: "No TLS endpoints have been discovered for this target yet", Code: "tls_no_endpoints", }} } refs := make([]string, 0, len(data.Probes)) for ref := range data.Probes { refs = append(refs, ref) } sort.Strings(refs) out := make([]sdk.CheckState, 0, len(refs)) for _, ref := range refs { p := data.Probes[ref] out = append(out, evaluateProbe(p)) } return out } // evaluateProbe distills a single TLSProbe into a CheckState. Subject is the // probed endpoint so the host can correlate states across runs and surface // them per-target in the UI. Message describes the finding only -- the UI // renders Subject separately. func evaluateProbe(p TLSProbe) sdk.CheckState { subject := fmt.Sprintf("%s://%s", p.Type, p.Endpoint) meta := map[string]any{ "type": p.Type, "host": p.Host, "port": p.Port, "sni": p.SNI, "issues": len(p.Issues), } if p.TLSVersion != "" { meta["tls_version"] = p.TLSVersion } if !p.NotAfter.IsZero() { meta["not_after"] = p.NotAfter } worst, critMsg, warnMsg := summarize(p.Issues) switch worst { case SeverityCrit: return sdk.CheckState{ Status: sdk.StatusCrit, Message: critMsg, Code: "tls_critical", Subject: subject, Meta: meta, } case SeverityWarn: return sdk.CheckState{ Status: sdk.StatusWarn, Message: warnMsg, Code: "tls_warning", Subject: subject, Meta: meta, } default: msg := "TLS endpoint OK" if p.TLSVersion != "" { msg = fmt.Sprintf("TLS endpoint OK (%s)", p.TLSVersion) } return sdk.CheckState{ Status: sdk.StatusOK, Message: msg, Code: "tls_ok", Subject: subject, Meta: meta, } } } // summarize walks the issues once and returns (worst severity, first // critical message, first warning message). Picking the messages during the // same pass avoids a second iteration in the caller. func summarize(issues []Issue) (worst, firstCrit, firstWarn string) { for _, is := range issues { msg := is.Message if msg == "" { msg = is.Code } switch is.Severity { case SeverityCrit: worst = SeverityCrit if firstCrit == "" { firstCrit = msg } case SeverityWarn: if worst == "" || worst == SeverityInfo { worst = SeverityWarn } if firstWarn == "" { firstWarn = msg } case SeverityInfo: if worst == "" { worst = SeverityInfo } } } return }