package checker import ( "encoding/json" "net" "strconv" "strings" "time" sdk "git.happydns.org/checker-sdk-go/checker" ) // TLSRelatedKey is the observation key we expect a TLS checker to publish // for the endpoints we discover. Matches the cross-checker convention // documented in the happyDomain plan. const TLSRelatedKey sdk.ObservationKey = "tls_probes" // tlsProbeView is our local, permissive view of a TLS checker's payload. // We read only the fields we need and tolerate missing ones; the TLS // checker's full schema is owned by that checker. type tlsProbeView struct { Host string `json:"host,omitempty"` Port uint16 `json:"port,omitempty"` Endpoint string `json:"endpoint,omitempty"` TLSVersion string `json:"tls_version,omitempty"` CipherSuite string `json:"cipher_suite,omitempty"` HostnameMatch *bool `json:"hostname_match,omitempty"` ChainValid *bool `json:"chain_valid,omitempty"` NotAfter time.Time `json:"not_after,omitempty"` Issues []struct { Code string `json:"code"` Severity string `json:"severity"` Message string `json:"message,omitempty"` Fix string `json:"fix,omitempty"` } `json:"issues,omitempty"` } // address returns the canonical "host:port" used as our matching key against // XMPP endpoints. Falls back to Endpoint when host/port are unset. func (v *tlsProbeView) address() string { if v.Endpoint != "" { return v.Endpoint } if v.Host != "" && v.Port != 0 { return net.JoinHostPort(v.Host, strconv.Itoa(int(v.Port))) } return "" } // parseTLSRelated decodes a RelatedObservation as a TLS probe, gracefully // returning nil when the payload doesn't look like one. // // Two payload shapes are accepted: // // 1. {"probes": {"": , …}}: the current convention used by // checker-tls. The consumer picks its own probe via r.Ref so one // observation does not leak into another's report. // 2. : a single top-level probe object, kept for back-compat. func parseTLSRelated(r sdk.RelatedObservation) *tlsProbeView { var keyed struct { Probes map[string]tlsProbeView `json:"probes"` } if err := json.Unmarshal(r.Data, &keyed); err == nil && keyed.Probes != nil { if p, ok := keyed.Probes[r.Ref]; ok { return &p } return nil } var v tlsProbeView if err := json.Unmarshal(r.Data, &v); err != nil { return nil } return &v } // tlsIssuesFromRelated converts downstream TLS observations into Issue // entries that slot into our own aggregation. When a TLS checker publishes // its own structured issues we forward them with a code prefix so the // origin is obvious. When it only exposes structured flags, we synthesize // one issue per probe. func tlsIssuesFromRelated(related []sdk.RelatedObservation) []Issue { var out []Issue for _, r := range related { v := parseTLSRelated(r) if v == nil { continue } addr := v.address() if len(v.Issues) > 0 { for _, is := range v.Issues { sev := strings.ToLower(is.Severity) switch sev { case SeverityCrit, SeverityWarn, SeverityInfo: default: continue } code := is.Code if code == "" { code = "tls.unknown" } // Strip a leading "tls." prefix to avoid the double-prefix // "xmpp.tls.tls.*" when the TLS checker already uses that namespace. code = strings.TrimPrefix(code, "tls.") out = append(out, Issue{ Code: "xmpp.tls." + code, Severity: sev, Message: strings.TrimSpace("TLS on " + addr + ": " + is.Message), Fix: is.Fix, Endpoint: addr, }) } continue } // Flag-only payload: synthesize a single summary issue. sev := v.worstSeverity() if sev == "" { continue } msg := "TLS issue reported on " + addr switch { case v.ChainValid != nil && !*v.ChainValid: msg = "Invalid certificate chain on " + addr case v.HostnameMatch != nil && !*v.HostnameMatch: msg = "Certificate does not cover the domain on " + addr case !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 0: msg = "Certificate expired on " + addr + " (" + v.NotAfter.Format(time.RFC3339) + ")" case !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 14*24*time.Hour: msg = "Certificate expiring soon on " + addr + " (" + v.NotAfter.Format(time.RFC3339) + ")" } out = append(out, Issue{ Code: "xmpp.tls.probe", Severity: sev, Message: msg, Fix: "See the TLS checker report for details.", Endpoint: addr, }) } return out } // worstSeverity synthesises a severity from the structured flags on the probe. // It is only called from the flag-only path in tlsIssuesFromRelated (when // v.Issues is empty), so there is no issue list to iterate over. func (v *tlsProbeView) worstSeverity() string { if v.ChainValid != nil && !*v.ChainValid { return SeverityCrit } if v.HostnameMatch != nil && !*v.HostnameMatch { return SeverityCrit } if !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 0 { return SeverityCrit } if !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 14*24*time.Hour { return SeverityWarn } return "" }