458 lines
14 KiB
Go
458 lines
14 KiB
Go
package checker
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html/template"
|
|
"strings"
|
|
|
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
|
)
|
|
|
|
// GetHTMLReport degrades gracefully when ctx.States() is empty (older hosts,
|
|
// ad-hoc reporters): the data sections still render, the verdict ones do not.
|
|
func (p *aliasProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
|
|
var data AliasData
|
|
if raw := ctx.Data(); len(raw) > 0 {
|
|
if err := json.Unmarshal(raw, &data); err != nil {
|
|
return "", fmt.Errorf("parse alias data: %w", err)
|
|
}
|
|
}
|
|
|
|
view := buildReportView(&data, ctx.States())
|
|
|
|
buf := &bytes.Buffer{}
|
|
if err := reportTmpl.Execute(buf, view); err != nil {
|
|
return "", err
|
|
}
|
|
return buf.String(), nil
|
|
}
|
|
|
|
// topFailureRules drives the visual order of the "fix these first" cards.
|
|
var topFailureRules = []string{
|
|
"cname_at_apex",
|
|
"cname_coexistence",
|
|
"chain_loop",
|
|
"chain_length",
|
|
"target_resolvable",
|
|
"chain_rcode",
|
|
"cname_dnssec",
|
|
"multiple_records",
|
|
}
|
|
|
|
type reportView struct {
|
|
Owner string
|
|
Apex string
|
|
FinalTarget string
|
|
FinalAddresses []string
|
|
OverallStatus string
|
|
OverallStatusText string
|
|
OverallClass string
|
|
ChainSteps []chainStep
|
|
DNAMEs []ChainHop
|
|
Coexisting []CoexistingRRset
|
|
ApexFlattening bool
|
|
ZoneSigned bool
|
|
CNAMESigned bool
|
|
HasStates bool
|
|
TopFailures []topFailure
|
|
OtherFindings []otherFinding
|
|
}
|
|
|
|
type chainStep struct {
|
|
Index int
|
|
Owner string
|
|
Kind string
|
|
Target string
|
|
TTL uint32
|
|
Server string
|
|
IsLast bool
|
|
CSSKind string
|
|
}
|
|
|
|
type topFailure struct {
|
|
RuleName string
|
|
Title string
|
|
Severity string
|
|
Messages []string
|
|
Hint string
|
|
Subject string
|
|
}
|
|
|
|
type otherFinding struct {
|
|
Severity string
|
|
RuleName string
|
|
Subject string
|
|
Message string
|
|
Hint string
|
|
}
|
|
|
|
func buildReportView(data *AliasData, states []sdk.CheckState) *reportView {
|
|
v := &reportView{
|
|
Owner: data.Owner,
|
|
Apex: data.Apex,
|
|
FinalTarget: data.FinalTarget,
|
|
DNAMEs: data.DNAMESubstitutions,
|
|
Coexisting: data.Coexisting,
|
|
ApexFlattening: data.ApexFlattening,
|
|
ZoneSigned: data.ZoneSigned,
|
|
CNAMESigned: data.CNAMESigned,
|
|
HasStates: len(states) > 0,
|
|
}
|
|
|
|
v.FinalAddresses = append(v.FinalAddresses, data.FinalA...)
|
|
v.FinalAddresses = append(v.FinalAddresses, data.FinalAAAA...)
|
|
|
|
for i, h := range data.Chain {
|
|
step := chainStep{
|
|
Index: i + 1,
|
|
Owner: h.Owner,
|
|
Kind: string(h.Kind),
|
|
Target: h.Target,
|
|
TTL: h.TTL,
|
|
Server: h.Server,
|
|
IsLast: i == len(data.Chain)-1,
|
|
}
|
|
switch h.Kind {
|
|
case KindCNAME:
|
|
step.CSSKind = "kind-cname"
|
|
case KindDNAME:
|
|
step.CSSKind = "kind-dname"
|
|
case KindALIAS:
|
|
step.CSSKind = "kind-alias"
|
|
case KindTarget:
|
|
step.CSSKind = "kind-target"
|
|
}
|
|
v.ChainSteps = append(v.ChainSteps, step)
|
|
}
|
|
|
|
if v.HasStates {
|
|
worst := worstStatus(states)
|
|
v.OverallStatus, v.OverallStatusText, v.OverallClass = statusLabel(worst)
|
|
|
|
topIndex := map[string]int{}
|
|
for i, r := range topFailureRules {
|
|
topIndex[r] = i
|
|
}
|
|
topMap := map[string]*topFailure{}
|
|
for _, s := range states {
|
|
if s.Status == sdk.StatusOK || s.Status == sdk.StatusUnknown {
|
|
continue
|
|
}
|
|
if _, isTop := topIndex[s.RuleName]; isTop && (s.Status == sdk.StatusWarn || s.Status == sdk.StatusCrit) {
|
|
tf, ok := topMap[s.RuleName]
|
|
if !ok {
|
|
tf = &topFailure{
|
|
RuleName: s.RuleName,
|
|
Title: titleFor(s.RuleName),
|
|
Severity: severityClass(s.Status),
|
|
Hint: hintOf(s),
|
|
Subject: s.Subject,
|
|
}
|
|
topMap[s.RuleName] = tf
|
|
}
|
|
tf.Messages = append(tf.Messages, s.Message)
|
|
if tf.Hint == "" {
|
|
tf.Hint = hintOf(s)
|
|
}
|
|
if statusRank(s.Status) > severityRankClass(tf.Severity) {
|
|
tf.Severity = severityClass(s.Status)
|
|
}
|
|
continue
|
|
}
|
|
v.OtherFindings = append(v.OtherFindings, otherFinding{
|
|
Severity: severityClass(s.Status),
|
|
RuleName: s.RuleName,
|
|
Subject: s.Subject,
|
|
Message: s.Message,
|
|
Hint: hintOf(s),
|
|
})
|
|
}
|
|
for _, ruleName := range topFailureRules {
|
|
if tf, ok := topMap[ruleName]; ok {
|
|
v.TopFailures = append(v.TopFailures, *tf)
|
|
}
|
|
}
|
|
} else {
|
|
v.OverallStatus = "unknown"
|
|
v.OverallStatusText = "Rule output not provided"
|
|
v.OverallClass = "status-info"
|
|
}
|
|
|
|
return v
|
|
}
|
|
|
|
func worstStatus(states []sdk.CheckState) sdk.Status {
|
|
worst := sdk.StatusOK
|
|
for _, s := range states {
|
|
if statusRank(s.Status) > statusRank(worst) {
|
|
worst = s.Status
|
|
}
|
|
}
|
|
return worst
|
|
}
|
|
|
|
func statusLabel(s sdk.Status) (status, text, class string) {
|
|
switch s {
|
|
case sdk.StatusCrit:
|
|
return "crit", "Critical issues detected", "status-crit"
|
|
case sdk.StatusWarn:
|
|
return "warn", "Warnings detected", "status-warn"
|
|
case sdk.StatusInfo:
|
|
return "info", "Informational notes", "status-info"
|
|
default:
|
|
return "ok", "Alias chain healthy", "status-ok"
|
|
}
|
|
}
|
|
|
|
func severityClass(s sdk.Status) string {
|
|
switch s {
|
|
case sdk.StatusCrit:
|
|
return "crit"
|
|
case sdk.StatusWarn:
|
|
return "warn"
|
|
case sdk.StatusInfo:
|
|
return "info"
|
|
case sdk.StatusError:
|
|
return "crit"
|
|
default:
|
|
return "ok"
|
|
}
|
|
}
|
|
|
|
func statusRank(s sdk.Status) int {
|
|
switch s {
|
|
case sdk.StatusCrit, sdk.StatusError:
|
|
return 4
|
|
case sdk.StatusWarn:
|
|
return 3
|
|
case sdk.StatusInfo:
|
|
return 2
|
|
case sdk.StatusOK:
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func severityRankClass(c string) int {
|
|
switch c {
|
|
case "crit":
|
|
return 4
|
|
case "warn":
|
|
return 3
|
|
case "info":
|
|
return 2
|
|
case "ok":
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func hintOf(s sdk.CheckState) string {
|
|
if s.Meta == nil {
|
|
return ""
|
|
}
|
|
h, _ := s.Meta[hintKey].(string)
|
|
return h
|
|
}
|
|
|
|
func titleFor(rule string) string {
|
|
switch rule {
|
|
case "cname_at_apex":
|
|
return "CNAME at zone apex"
|
|
case "cname_coexistence":
|
|
return "CNAME coexists with other records"
|
|
case "chain_loop":
|
|
return "Alias chain loops"
|
|
case "chain_length":
|
|
return "Alias chain too long"
|
|
case "target_resolvable":
|
|
return "Target does not resolve"
|
|
case "chain_rcode":
|
|
return "Alias lookup error"
|
|
case "cname_dnssec":
|
|
return "CNAME not DNSSEC-signed"
|
|
case "multiple_records":
|
|
return "Multiple CNAME records at the same name"
|
|
}
|
|
return strings.ReplaceAll(rule, "_", " ")
|
|
}
|
|
|
|
var reportTmpl = template.Must(template.New("alias-report").Parse(reportTemplate))
|
|
|
|
// Inlined styles so the report embeds in an iframe with no asset dependencies.
|
|
const reportTemplate = `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>Alias chain report — {{.Owner}}</title>
|
|
<style>
|
|
:root {
|
|
--ok: #1e9e5d;
|
|
--info: #3b82f6;
|
|
--warn: #d97706;
|
|
--crit: #dc2626;
|
|
--bg: #f7f7f8;
|
|
--card: #ffffff;
|
|
--border: #e5e7eb;
|
|
--text: #111827;
|
|
--muted: #6b7280;
|
|
}
|
|
body { margin: 0; padding: 1.2rem; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: var(--text); background: var(--bg); line-height: 1.45; }
|
|
h1 { font-size: 1.4rem; margin: 0 0 .3rem 0; }
|
|
h2 { font-size: 1.05rem; margin: 1.5rem 0 .6rem 0; border-bottom: 1px solid var(--border); padding-bottom: .25rem; }
|
|
h3 { font-size: .95rem; margin: 0 0 .35rem 0; }
|
|
.muted { color: var(--muted); }
|
|
.status-banner { display: flex; align-items: center; justify-content: space-between; padding: .8rem 1rem; border-radius: 8px; color: #fff; margin-bottom: 1rem; }
|
|
.status-ok { background: var(--ok); }
|
|
.status-info { background: var(--info); }
|
|
.status-warn { background: var(--warn); }
|
|
.status-crit { background: var(--crit); }
|
|
.status-banner .label { font-weight: 600; font-size: 1rem; }
|
|
.status-banner .sub { opacity: .9; font-size: .85rem; }
|
|
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: .75rem; margin-bottom: 1rem; }
|
|
.card { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: .8rem 1rem; }
|
|
.card .k { color: var(--muted); font-size: .8rem; text-transform: uppercase; letter-spacing: .03em; }
|
|
.card .v { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .95rem; word-break: break-all; }
|
|
.top-failure { border-left: 4px solid var(--crit); background: #fef2f2; padding: .8rem 1rem; border-radius: 6px; margin-bottom: .6rem; }
|
|
.top-failure.severity-warn { border-color: var(--warn); background: #fffbeb; }
|
|
.top-failure.severity-info { border-color: var(--info); background: #eff6ff; }
|
|
.top-failure h3 { margin-bottom: .25rem; }
|
|
.top-failure ul { margin: .25rem 0 .35rem 1.1rem; padding: 0; font-size: .9rem; }
|
|
.top-failure .fix { background: rgba(0,0,0,.04); padding: .45rem .6rem; border-radius: 4px; font-size: .9rem; }
|
|
.top-failure .fix strong { display: block; color: var(--text); margin-bottom: .15rem; }
|
|
.chain { display: flex; flex-direction: column; gap: .4rem; }
|
|
.hop { display: flex; align-items: center; gap: .6rem; background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: .5rem .8rem; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9rem; }
|
|
.hop .idx { color: var(--muted); font-variant-numeric: tabular-nums; }
|
|
.hop .kind { padding: .1rem .45rem; border-radius: 4px; font-size: .72rem; font-weight: 600; color: #fff; }
|
|
.kind-cname { background: #3b82f6; }
|
|
.kind-dname { background: #8b5cf6; }
|
|
.kind-alias { background: #14b8a6; }
|
|
.kind-target { background: #1e9e5d; }
|
|
.hop .arrow { color: var(--muted); }
|
|
.hop .meta { color: var(--muted); font-size: .78rem; margin-left: auto; }
|
|
table { width: 100%; border-collapse: collapse; font-size: .88rem; background: var(--card); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
|
|
th, td { text-align: left; padding: .45rem .7rem; border-bottom: 1px solid var(--border); }
|
|
th { background: #f3f4f6; font-weight: 600; font-size: .78rem; text-transform: uppercase; letter-spacing: .03em; color: var(--muted); }
|
|
tr:last-child td { border-bottom: none; }
|
|
.sev { display: inline-block; padding: .08rem .4rem; border-radius: 4px; font-size: .72rem; font-weight: 600; color: #fff; text-transform: uppercase; }
|
|
.sev-info { background: var(--info); }
|
|
.sev-warn { background: var(--warn); }
|
|
.sev-crit { background: var(--crit); }
|
|
details { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: .5rem .8rem; }
|
|
details pre { max-height: 360px; overflow: auto; font-size: .8rem; }
|
|
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
.badge { display: inline-block; background: #e5e7eb; padding: .05rem .4rem; border-radius: 4px; font-size: .75rem; color: var(--text); }
|
|
.badge.on { background: #dcfce7; color: #14532d; }
|
|
.badge.off { background: #fee2e2; color: #7f1d1d; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="status-banner {{.OverallClass}}">
|
|
<div>
|
|
<div class="label">{{.OverallStatusText}}</div>
|
|
<div class="sub">for <code>{{.Owner}}</code></div>
|
|
</div>
|
|
<div class="sub">
|
|
{{if .FinalTarget}}final: <code>{{.FinalTarget}}</code>{{end}}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid">
|
|
<div class="card"><div class="k">Owner</div><div class="v">{{.Owner}}</div></div>
|
|
<div class="card"><div class="k">Apex</div><div class="v">{{if .Apex}}{{.Apex}}{{else}}—{{end}}</div></div>
|
|
<div class="card"><div class="k">Final target</div><div class="v">{{if .FinalTarget}}{{.FinalTarget}}{{else}}—{{end}}</div></div>
|
|
<div class="card"><div class="k">Final addresses</div>
|
|
<div class="v">{{if .FinalAddresses}}{{range .FinalAddresses}}{{.}}<br>{{end}}{{else}}<span class="muted">none</span>{{end}}</div>
|
|
</div>
|
|
<div class="card"><div class="k">DNSSEC</div>
|
|
<div class="v">
|
|
{{if .ZoneSigned}}<span class="badge on">signed zone</span>{{else}}<span class="badge off">unsigned</span>{{end}}
|
|
{{if .ZoneSigned}}{{if .CNAMESigned}}<span class="badge on">CNAME signed</span>{{else}}<span class="badge off">CNAME unsigned</span>{{end}}{{end}}
|
|
</div>
|
|
</div>
|
|
<div class="card"><div class="k">Apex flattening (ALIAS/ANAME)</div>
|
|
<div class="v">{{if .ApexFlattening}}<span class="badge on">detected</span>{{else}}<span class="muted">not detected</span>{{end}}</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{if .TopFailures}}
|
|
<h2>Fix these first</h2>
|
|
{{range .TopFailures}}
|
|
<div class="top-failure severity-{{.Severity}}">
|
|
<h3>{{.Title}} <span class="sev sev-{{.Severity}}">{{.Severity}}</span></h3>
|
|
<ul>
|
|
{{range .Messages}}<li>{{.}}</li>{{end}}
|
|
</ul>
|
|
{{if .Hint}}<div class="fix"><strong>How to fix</strong>{{.Hint}}</div>{{end}}
|
|
</div>
|
|
{{end}}
|
|
{{end}}
|
|
|
|
{{if .ChainSteps}}
|
|
<h2>Resolution chain</h2>
|
|
<div class="chain">
|
|
{{range .ChainSteps}}
|
|
<div class="hop">
|
|
<span class="idx">#{{.Index}}</span>
|
|
<span class="kind {{.CSSKind}}">{{.Kind}}</span>
|
|
<code>{{.Owner}}</code>
|
|
{{if .Target}}<span class="arrow">→</span><code>{{.Target}}</code>{{end}}
|
|
<span class="meta">
|
|
{{if .TTL}}TTL {{.TTL}}s{{end}}
|
|
{{if .Server}} · {{.Server}}{{end}}
|
|
</span>
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
{{end}}
|
|
|
|
{{if .DNAMEs}}
|
|
<h2>DNAME substitutions</h2>
|
|
<table>
|
|
<thead><tr><th>Owner</th><th>Target</th><th>TTL</th><th>Server</th></tr></thead>
|
|
<tbody>
|
|
{{range .DNAMEs}}
|
|
<tr>
|
|
<td><code>{{.Owner}}</code></td>
|
|
<td><code>{{.Target}}</code></td>
|
|
<td>{{.TTL}}</td>
|
|
<td><code>{{.Server}}</code></td>
|
|
</tr>
|
|
{{end}}
|
|
</tbody>
|
|
</table>
|
|
{{end}}
|
|
|
|
{{if .Coexisting}}
|
|
<h2>Records coexisting with CNAME</h2>
|
|
<table>
|
|
<thead><tr><th>Type</th><th>TTL</th></tr></thead>
|
|
<tbody>
|
|
{{range .Coexisting}}
|
|
<tr><td><code>{{.Type}}</code></td><td>{{.TTL}}</td></tr>
|
|
{{end}}
|
|
</tbody>
|
|
</table>
|
|
{{end}}
|
|
|
|
{{if .OtherFindings}}
|
|
<h2>Additional findings</h2>
|
|
<table>
|
|
<thead><tr><th>Severity</th><th>Rule</th><th>Subject</th><th>Message</th></tr></thead>
|
|
<tbody>
|
|
{{range .OtherFindings}}
|
|
<tr>
|
|
<td><span class="sev sev-{{.Severity}}">{{.Severity}}</span></td>
|
|
<td><code>{{.RuleName}}</code></td>
|
|
<td><code>{{.Subject}}</code></td>
|
|
<td>{{.Message}}{{if .Hint}}<br><span class="muted">{{.Hint}}</span>{{end}}</td>
|
|
</tr>
|
|
{{end}}
|
|
</tbody>
|
|
</table>
|
|
{{end}}
|
|
|
|
</body>
|
|
</html>`
|