package checker import ( "bytes" "encoding/json" "fmt" "html/template" "sort" 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 := indexProbes(ctx.Related(tls.ObservationKeyTLSProbes)) rows := make([]reportRow, 0, len(data.Targets)) 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" } rows = append(rows, reportRow{ Owner: t.Owner, Host: t.Host, Port: t.Port, Proto: t.Proto, STARTTLS: t.STARTTLS, RecordCount: len(t.Records), StatusLabel: status, StatusClass: cls, Leaf: leaf, }) } view := reportView{ CollectedAt: data.CollectedAt.Format("2006-01-02 15:04 MST"), TargetCount: len(data.Targets), Diagnoses: diagnose(data, probes), Rows: rows, CSS: template.CSS(reportCSS), } var b bytes.Buffer if err := reportTemplate.Execute(&b, view); err != nil { return "", fmt.Errorf("render DANE report: %w", err) } return b.String(), nil } // reportView is the rendering payload passed to reportTemplate. Pre-computing // the per-row status label/class and leaf string keeps the template free of // branching beyond simple range/if. type reportView struct { CollectedAt string TargetCount int Diagnoses []diagnosis Rows []reportRow CSS template.CSS } type reportRow struct { Owner string Host string Port uint16 Proto string STARTTLS string RecordCount int StatusLabel string StatusClass string Leaf string } // 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. 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 !probeUsable(probe): 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 summarizeMatches(t, probe).matched == 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 } // proposedTLSA renders a ready-to-paste replacement RR computed from the // live chain. The (usage, selector, matching) triplet is taken from the // user's first existing record so the suggestion stays consistent with // their published profile (e.g. a deployment standardised on usage 2 keeps // usage 2). When no record is published yet, fall back to the DANE-EE + // SPKI + SHA-256 triplet most Let's Encrypt deployers settle on. func proposedTLSA(t TargetResult, p *tls.TLSProbe) string { if p == nil || len(p.Chain) == 0 { return "" } tmpl := TLSARecord{Usage: UsageDANEEE, Selector: SelectorSPKI, MatchingType: MatchingSHA256} if len(t.Records) > 0 { r := t.Records[0] tmpl.Usage = r.Usage tmpl.Selector = r.Selector tmpl.MatchingType = r.MatchingType // Suggesting Full (matching type 0) inline as a zone fragment is // not useful: collapse to SHA-256 of the same selector, which is // what operators publish in practice. if tmpl.MatchingType == MatchingFull { tmpl.MatchingType = MatchingSHA256 } } slot := p.Chain[0] if (tmpl.Usage == UsagePKIXTA || tmpl.Usage == UsageDANETA) && len(p.Chain) > 1 { slot = p.Chain[1] } hex, err := recordCandidate(tmpl, slot) if err != nil || hex == "" { return "" } return fmt.Sprintf("%s IN TLSA %d %d %d %s", t.Owner, tmpl.Usage, tmpl.Selector, tmpl.MatchingType, hex) } // 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 !probeUsable(p) { return "Handshake failed", "crit" } if len(t.Records) == 0 { return "No records", "info" } matched := summarizeMatches(t, p).matched if matched == 0 { return "No match", "crit" } return fmt.Sprintf("%d/%d match", matched, len(t.Records)), "ok" } var reportTemplate = template.Must(template.New("dane").Parse(` DANE report

DANE / TLSA

Collected {{.CollectedAt}} · {{.TargetCount}} endpoint(s).

{{with .Diagnoses}}

Action required

{{range .}}

{{.Title}}

{{.Detail}}

{{with .Fix}}
{{.}}
{{end}}
{{end}}
{{end}}

Endpoints

{{range .Rows}} {{end}}
EndpointStatusRecordsObserved leaf
{{.Owner}}
{{.Proto}} → {{.Host}}:{{.Port}}{{with .STARTTLS}} · STARTTLS {{.}}{{end}}
{{.StatusLabel}} {{.RecordCount}} {{.Leaf}}
`)) const reportCSS = `body{font-family:system-ui,sans-serif;margin:0;background:#fafbfc;color:#1b1f23;} main{max-width:980px;margin:0 auto;padding:1.5rem;} h1{margin:0 0 .25rem 0;} .meta{color:#586069;margin:0 0 1.5rem 0;} section{margin-bottom:2rem;} h2{border-bottom:1px solid #e1e4e8;padding-bottom:.25rem;} .finding{border-left:4px solid;padding:.75rem 1rem;margin:.75rem 0;background:#fff;border-radius:4px;} .finding h3{margin:0 0 .25rem 0;font-size:1rem;} .finding.sev-crit{border-color:#d73a49;} .finding.sev-warn{border-color:#dbab09;} .finding.sev-info{border-color:#0366d6;} .fix{background:#1b1f23;color:#fafbfc;padding:.5rem .75rem;border-radius:4px;overflow-x:auto;font-size:.85rem;} table{width:100%;border-collapse:collapse;background:#fff;} th,td{padding:.5rem .75rem;border-bottom:1px solid #e1e4e8;text-align:left;vertical-align:top;} tr.status-crit td:nth-child(2){color:#d73a49;font-weight:600;} tr.status-ok td:nth-child(2){color:#22863a;font-weight:600;} tr.status-unknown td:nth-child(2){color:#586069;} code{font-size:.85rem;} small{color:#586069;}`