package checker import ( "bytes" "encoding/json" "fmt" "html" "sort" "strings" sdk "git.happydns.org/checker-sdk-go/checker" tls "git.happydns.org/checker-tls/checker" ) // GetHTMLReport implements sdk.CheckerHTMLReporter. The report opens with a // diagnosis-first section that lists the most common DANE failure modes // actually detected on the user's targets, each with a one-shot remediation // snippet; a per-target table follows for reference. func (p *daneProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) { var data DANEData if err := json.Unmarshal(ctx.Data(), &data); err != nil { return "", fmt.Errorf("decode DANE data: %w", err) } probes := map[string]*tls.TLSProbe{} for _, ro := range ctx.Related(tls.ObservationKeyTLSProbes) { for k, v := range parseTLSProbeMap(ro.Data) { probes[k] = &v } } var b bytes.Buffer fmt.Fprint(&b, `DANE report`) fmt.Fprint(&b, reportCSS) fmt.Fprint(&b, `
`) fmt.Fprintf(&b, `

DANE / TLSA

Collected %s · %d endpoint(s).

`, html.EscapeString(data.CollectedAt.Format("2006-01-02 15:04 MST")), len(data.Targets)) diag := diagnose(data, probes) if len(diag) > 0 { fmt.Fprint(&b, `

Action required

`) for _, d := range diag { fmt.Fprintf(&b, `

%s

%s

`, html.EscapeString(d.Severity), html.EscapeString(d.Title), html.EscapeString(d.Detail)) if d.Fix != "" { fmt.Fprintf(&b, `
%s
`, html.EscapeString(d.Fix)) } fmt.Fprint(&b, `
`) } fmt.Fprint(&b, `
`) } fmt.Fprint(&b, `

Endpoints

`) for _, t := range data.Targets { probe := probes[t.Ref] status, cls := targetStatus(t, probe) leaf := "—" if probe != nil && len(probe.Chain) > 0 { leaf = probe.Chain[0].Subject } else if probe != nil && probe.Error != "" { leaf = "handshake error" } fmt.Fprintf(&b, ``, html.EscapeString(cls), html.EscapeString(t.Owner), html.EscapeString(t.Proto), html.EscapeString(t.Host), t.Port, starttlsLabel(t.STARTTLS), html.EscapeString(status), len(t.Records), html.EscapeString(leaf), ) } fmt.Fprint(&b, `
EndpointStatusRecordsObserved leaf
%s
%s → %s:%d%s
%s%d%s
`) fmt.Fprint(&b, `
`) return b.String(), nil } // diagnosis is a single actionable hint surfaced at the top of the report. type diagnosis struct { Severity string // crit | warn | info Title string Detail string Fix string // ready-to-apply snippet (shell or zone fragment) } // diagnose scans every target and produces the minimum set of high-signal // cards users need to act on. Priority ordering (most-common first): // // 1. no_match: TLSA records do not cover the live cert (post-rotation miss). // 2. handshake_failed: endpoint unreachable or TLS broken, DANE can't be // validated at all. // 3. pkix_chain_invalid: usage 0/1 published but public chain is broken. // 4. usage_3_matches_issuer: DANE-EE selector matches an intermediate // the record is probably miscategorized (usage 2 was intended). // 5. no_probe_yet: quiet informational to avoid false alarms on first run. // // countMatched returns the number of TLSA records in t that match probe's chain. func countMatched(t TargetResult, p *tls.TLSProbe) int { if p == nil { return 0 } n := 0 for _, r := range t.Records { if ok, _ := matchRecord(r, p); ok { n++ } } return n } func diagnose(data DANEData, probes map[string]*tls.TLSProbe) []diagnosis { var out []diagnosis for _, t := range data.Targets { probe := probes[t.Ref] switch { case probe == nil: out = append(out, diagnosis{ Severity: SeverityInfo, Title: fmt.Sprintf("Waiting for first TLS probe on %s:%d", t.Host, t.Port), Detail: "checker-tls has not yet probed this endpoint. This is normal immediately after publishing a new TLSA record; status will clear on the next cycle.", }) case probe.Error != "" || len(probe.Chain) == 0: out = append(out, diagnosis{ Severity: SeverityCrit, Title: fmt.Sprintf("Cannot reach %s:%d to validate DANE", t.Host, t.Port), Detail: "TLS handshake failed — DANE publishes hashes for a certificate nobody can see. Either the service is down, the port is blocked, or STARTTLS negotiation is broken.", Fix: handshakeFix(t), }) default: if countMatched(t, probe) == 0 && len(t.Records) > 0 { out = append(out, diagnosis{ Severity: SeverityCrit, Title: fmt.Sprintf("No TLSA record matches the live certificate on %s:%d", t.Host, t.Port), Detail: "This is the most common DANE outage cause: the certificate was rotated without rolling over the TLSA RRset, and validating resolvers are now rejecting the connection. Publish a TLSA record for the new certificate before removing the old one.", Fix: proposedTLSA(t, probe), }) } if hasPKIXUsage(t) && (probe.ChainValid == nil || !*probe.ChainValid) { out = append(out, diagnosis{ Severity: SeverityCrit, Title: fmt.Sprintf("Usage 0/1 needs a publicly-trusted chain on %s:%d", t.Host, t.Port), Detail: "TLSA usages 0 (PKIX-TA) and 1 (PKIX-EE) require the certificate chain to validate against system roots. Either re-issue through a publicly-trusted CA or switch to usage 2 / 3, which skip PKIX.", }) } if warn := suspiciousUsage(t, probe); warn != "" { out = append(out, diagnosis{ Severity: SeverityWarn, Title: fmt.Sprintf("Suspicious TLSA usage on %s:%d", t.Host, t.Port), Detail: warn, }) } } } // Stable: crit first, then warn, then info; preserving encounter order // within each group keeps the table and the cards aligned. sort.SliceStable(out, func(i, j int) bool { return sevRank(out[i].Severity) < sevRank(out[j].Severity) }) return out } func sevRank(s string) int { switch s { case SeverityCrit: return 0 case SeverityWarn: return 1 default: return 2 } } // hasPKIXUsage reports whether any TLSA record at this target demands PKIX // validation (usage 0 or 1). func hasPKIXUsage(t TargetResult) bool { for _, r := range t.Records { if r.Usage == UsagePKIXTA || r.Usage == UsagePKIXEE { return true } } return false } // suspiciousUsage returns a human-readable hint when a record hash matches a // chain slot that contradicts its declared usage (e.g. usage 3 whose hash // actually matches the intermediate), almost always a publisher error. func suspiciousUsage(t TargetResult, p *tls.TLSProbe) string { if len(p.Chain) < 2 { return "" } for _, r := range t.Records { if r.Usage != UsageDANEEE && r.Usage != UsagePKIXEE { continue } // Compare against non-leaf certs; any match there means the user // published the wrong usage. for _, c := range p.Chain[1:] { cand, err := recordCandidate(r, c) if err != nil { continue } if strings.EqualFold(cand, r.Certificate) { return "A record declared with usage 1/3 (end-entity) actually matches an intermediate certificate. It should probably use usage 0 or 2 (trust-anchor) instead." } } } return "" } // proposedTLSA renders a ready-to-paste replacement RR using the most common // DANE-EE + SPKI + SHA-256 triplet computed from the live leaf. This is the // profile Let's Encrypt users are pushed towards because it survives any // cert rotation that keeps the same key pair. func proposedTLSA(t TargetResult, p *tls.TLSProbe) string { if p == nil || len(p.Chain) == 0 { return "" } return fmt.Sprintf("%s IN TLSA 3 1 1 %s", t.Owner, p.Chain[0].SPKISHA256) } // handshakeFix proposes a STARTTLS-aware first step when the probe failed. func handshakeFix(t TargetResult) string { if t.STARTTLS != "" { return fmt.Sprintf("openssl s_client -connect %s:%d -starttls %s -servername %s", t.Host, t.Port, t.STARTTLS, t.Host) } return fmt.Sprintf("openssl s_client -connect %s:%d -servername %s", t.Host, t.Port, t.Host) } func targetStatus(t TargetResult, p *tls.TLSProbe) (label, class string) { if p == nil { return "Waiting for probe", "unknown" } if p.Error != "" || len(p.Chain) == 0 { return "Handshake failed", "crit" } if len(t.Records) == 0 { return "No records", "info" } matched := countMatched(t, p) if matched == 0 { return "No match", "crit" } return fmt.Sprintf("%d/%d match", matched, len(t.Records)), "ok" } func starttlsLabel(s string) string { if s == "" { return "" } return " · STARTTLS " + html.EscapeString(s) } const reportCSS = ``