package dav import ( "encoding/json" "fmt" "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 happydomain3/docs/checker-discovery-endpoint.md. const TLSRelatedKey sdk.ObservationKey = "tls_probes" // tlsProbeView is a permissive decode of a TLS probe payload. We intentionally // only read 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"` // Alternative shape used by the reference checker-tls payload sketched // in the docs: cert.{notAfter, sanMatch, chainValid, daysRemaining}. Cert *struct { NotAfter time.Time `json:"notAfter,omitempty"` SANMatch *bool `json:"sanMatch,omitempty"` ChainValid *bool `json:"chainValid,omitempty"` DaysRemaining *int `json:"daysRemaining,omitempty"` SubjectCN string `json:"subjectCN,omitempty"` IssuerCN string `json:"issuerCN,omitempty"` } `json:"cert,omitempty"` Rules []struct { Code string `json:"code,omitempty"` Status string `json:"status,omitempty"` Message string `json:"message,omitempty"` } `json:"rules,omitempty"` Issues []struct { Code string `json:"code,omitempty"` Severity string `json:"severity,omitempty"` Message string `json:"message,omitempty"` Fix string `json:"fix,omitempty"` } `json:"issues,omitempty"` } // address returns the canonical "host:port" string used to match a probe // against one of our discovered endpoints. 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 "" } // certExpiry normalises the two schema shapes into a single (t, ok) pair so // callers don't have to know which one the TLS checker emits. func (v *tlsProbeView) certExpiry() (time.Time, bool) { if !v.NotAfter.IsZero() { return v.NotAfter, true } if v.Cert != nil && !v.Cert.NotAfter.IsZero() { return v.Cert.NotAfter, true } return time.Time{}, false } func (v *tlsProbeView) hostnameOK() (bool, bool) { if v.HostnameMatch != nil { return *v.HostnameMatch, true } if v.Cert != nil && v.Cert.SANMatch != nil { return *v.Cert.SANMatch, true } return false, false } func (v *tlsProbeView) chainOK() (bool, bool) { if v.ChainValid != nil { return *v.ChainValid, true } if v.Cert != nil && v.Cert.ChainValid != nil { return *v.Cert.ChainValid, true } return false, false } // parseTLSRelated decodes a RelatedObservation as a TLS probe, returning nil // when the payload doesn't look like one. // // Two payload shapes are accepted: // // 1. {"probes": {"": , …}}: the current convention used by // checker-tls. Each consumer picks its own probe via r.Ref; the value // is the DiscoveryEntry.Ref that the producer originally emitted, // preserved by the host along the lineage chain. // 2. : a single top-level probe object, kept for back-compat with // callers that pre-date the keyed map and with unit-test fixtures. 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 } // TLSSummary is what the HTML report renders for each probed endpoint. type TLSSummary struct { Address string TLSVersion string Status string // "ok", "warn", "fail", "info" Detail string NotAfter time.Time DaysRemaining int } // tlsCallout captures a cross-checker issue we want to foreground in the // "Action items" section of the HTML report. type tlsCallout struct { Severity string // "warn" or "crit" Title string Body string } // foldTLSRelated walks the TLS probes and returns (1) a per-endpoint summary // for rendering, (2) callouts for the top of the report when there's anything // actionable. Callers pass both through the reportData pipeline. func foldTLSRelated(related []sdk.RelatedObservation) (summaries []TLSSummary, callouts []tlsCallout) { for _, r := range related { v := parseTLSRelated(r) if v == nil { continue } sum := buildTLSSummary(v) summaries = append(summaries, sum) callouts = append(callouts, buildTLSCallouts(v, sum.Address)...) } return summaries, callouts } func buildTLSSummary(v *tlsProbeView) TLSSummary { s := TLSSummary{Address: v.address(), TLSVersion: v.TLSVersion, Status: "ok"} if t, ok := v.certExpiry(); ok { s.NotAfter = t days := int(time.Until(t) / (24 * time.Hour)) if v.Cert != nil && v.Cert.DaysRemaining != nil { days = *v.Cert.DaysRemaining } s.DaysRemaining = days switch { case days < 0: s.Status = "fail" s.Detail = fmt.Sprintf("certificate expired %d day(s) ago", -days) case days < 14: s.Status = "warn" s.Detail = fmt.Sprintf("certificate expires in %d day(s)", days) default: s.Detail = fmt.Sprintf("certificate valid for %d day(s)", days) } } if ok, has := v.hostnameOK(); has && !ok { s.Status = "fail" s.Detail = "certificate does not cover the endpoint hostname" } if ok, has := v.chainOK(); has && !ok { s.Status = "fail" s.Detail = "certificate chain validation failed" } // Explicit issues from the TLS checker outrank our inferred status. for _, iss := range v.Issues { sev := strings.ToLower(iss.Severity) switch sev { case "crit": s.Status = "fail" case "warn": if s.Status != "fail" { s.Status = "warn" } } if iss.Message != "" { s.Detail = iss.Message } } return s } func buildTLSCallouts(v *tlsProbeView, addr string) []tlsCallout { var out []tlsCallout // Structured issues from the TLS checker are the preferred source. for _, iss := range v.Issues { sev := strings.ToLower(iss.Severity) if sev != "crit" && sev != "warn" { continue } callout := tlsCallout{ Severity: sev, Title: fmt.Sprintf("TLS on %s: %s", addr, strings.TrimSpace(iss.Message)), } if callout.Title == "TLS on "+addr+": " { callout.Title = "TLS issue on " + addr } if iss.Fix != "" { callout.Body = iss.Fix } else { callout.Body = "See the TLS checker report for details." } out = append(out, callout) } if len(out) > 0 { return out } // Fallback: synthesize callouts from structured flags. if t, ok := v.certExpiry(); ok { days := int(time.Until(t) / (24 * time.Hour)) if v.Cert != nil && v.Cert.DaysRemaining != nil { days = *v.Cert.DaysRemaining } switch { case days < 0: out = append(out, tlsCallout{ Severity: "crit", Title: fmt.Sprintf("Certificate on %s has expired", addr), Body: fmt.Sprintf("Renew it. Clients will refuse to connect. Expired %d day(s) ago (valid until %s).", -days, t.Format(time.RFC3339)), }) case days < 14: out = append(out, tlsCallout{ Severity: "warn", Title: fmt.Sprintf("Certificate on %s expires in %d day(s)", addr, days), Body: fmt.Sprintf("Schedule a renewal. Currently valid until %s.", t.Format(time.RFC3339)), }) } } if ok, has := v.chainOK(); has && !ok { out = append(out, tlsCallout{ Severity: "crit", Title: fmt.Sprintf("Broken certificate chain on %s", addr), Body: "The TLS checker could not validate the chain. Ensure the server sends the full intermediate chain.", }) } if ok, has := v.hostnameOK(); has && !ok { out = append(out, tlsCallout{ Severity: "crit", Title: fmt.Sprintf("Certificate does not cover %s", addr), Body: "Add the hostname to the certificate's SANs or point the service at a cert that covers it.", }) } return out }