Initial commit
This commit is contained in:
commit
a6dbcef0f9
26 changed files with 2993 additions and 0 deletions
292
checker/report.go
Normal file
292
checker/report.go
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
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(`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>DANE report</title>
|
||||
<style>{{.CSS}}</style>
|
||||
</head>
|
||||
<body><main>
|
||||
<h1>DANE / TLSA</h1>
|
||||
<p class="meta">Collected {{.CollectedAt}} · {{.TargetCount}} endpoint(s).</p>
|
||||
{{with .Diagnoses}}<section class="diagnosis">
|
||||
<h2>Action required</h2>
|
||||
{{range .}}<article class="finding sev-{{.Severity}}">
|
||||
<h3>{{.Title}}</h3>
|
||||
<p>{{.Detail}}</p>
|
||||
{{with .Fix}}<pre class="fix">{{.}}</pre>{{end}}
|
||||
</article>
|
||||
{{end}}</section>
|
||||
{{end}}<section class="targets">
|
||||
<h2>Endpoints</h2>
|
||||
<table>
|
||||
<thead><tr><th>Endpoint</th><th>Status</th><th>Records</th><th>Observed leaf</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Rows}}<tr class="status-{{.StatusClass}}">
|
||||
<td><code>{{.Owner}}</code><br><small>{{.Proto}} → {{.Host}}:{{.Port}}{{with .STARTTLS}} · STARTTLS {{.}}{{end}}</small></td>
|
||||
<td>{{.StatusLabel}}</td>
|
||||
<td>{{.RecordCount}}</td>
|
||||
<td>{{.Leaf}}</td>
|
||||
</tr>
|
||||
{{end}}</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</main></body></html>`))
|
||||
|
||||
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;}`
|
||||
Loading…
Add table
Add a link
Reference in a new issue