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 = `
Alias chain report — {{.Owner}}
{{.OverallStatusText}}
for {{.Owner}}
{{if .FinalTarget}}final: {{.FinalTarget}}{{end}}
Apex
{{if .Apex}}{{.Apex}}{{else}}—{{end}}
Final target
{{if .FinalTarget}}{{.FinalTarget}}{{else}}—{{end}}
Final addresses
{{if .FinalAddresses}}{{range .FinalAddresses}}{{.}}
{{end}}{{else}}none{{end}}
DNSSEC
{{if .ZoneSigned}}signed zone{{else}}unsigned{{end}}
{{if .ZoneSigned}}{{if .CNAMESigned}}CNAME signed{{else}}CNAME unsigned{{end}}{{end}}
Apex flattening (ALIAS/ANAME)
{{if .ApexFlattening}}detected{{else}}not detected{{end}}
{{if .TopFailures}}
Fix these first
{{range .TopFailures}}
{{.Title}} {{.Severity}}
{{range .Messages}}- {{.}}
{{end}}
{{if .Hint}}
How to fix{{.Hint}}
{{end}}
{{end}}
{{end}}
{{if .ChainSteps}}
Resolution chain
{{range .ChainSteps}}
#{{.Index}}
{{.Kind}}
{{.Owner}}
{{if .Target}}→{{.Target}}{{end}}
{{if .TTL}}TTL {{.TTL}}s{{end}}
{{if .Server}} · {{.Server}}{{end}}
{{end}}
{{end}}
{{if .DNAMEs}}
DNAME substitutions
| Owner | Target | TTL | Server |
{{range .DNAMEs}}
{{.Owner}} |
{{.Target}} |
{{.TTL}} |
{{.Server}} |
{{end}}
{{end}}
{{if .Coexisting}}
Records coexisting with CNAME
| Type | TTL |
{{range .Coexisting}}
{{.Type}} | {{.TTL}} |
{{end}}
{{end}}
{{if .OtherFindings}}
Additional findings
| Severity | Rule | Subject | Message |
{{range .OtherFindings}}
| {{.Severity}} |
{{.RuleName}} |
{{.Subject}} |
{{.Message}}{{if .Hint}} {{.Hint}}{{end}} |
{{end}}
{{end}}
`