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}}
Owner
{{.Owner}}
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}}

{{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

{{range .DNAMEs}} {{end}}
OwnerTargetTTLServer
{{.Owner}} {{.Target}} {{.TTL}} {{.Server}}
{{end}} {{if .Coexisting}}

Records coexisting with CNAME

{{range .Coexisting}} {{end}}
TypeTTL
{{.Type}}{{.TTL}}
{{end}} {{if .OtherFindings}}

Additional findings

{{range .OtherFindings}} {{end}}
SeverityRuleSubjectMessage
{{.Severity}} {{.RuleName}} {{.Subject}} {{.Message}}{{if .Hint}}
{{.Hint}}{{end}}
{{end}} `