package checker import ( "bytes" "encoding/json" "fmt" "html/template" "strings" sdk "git.happydns.org/checker-sdk-go/checker" ) // GetHTMLReport renders an HTML document summarizing the last alias run. // Critical findings are surfaced in a dedicated top section with fix hints; // the chain is visualized as a stepped list; the full findings list sits // below as a detailed table. 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) buf := &bytes.Buffer{} if err := reportTmpl.Execute(buf, view); err != nil { return "", err } return buf.String(), nil } // topFailureCodes lists the findings that deserve a dedicated "fix this first" // card at the top of the report. Order matters: it drives the visual order. var topFailureCodes = []string{ "alias_cname_at_apex", "alias_coexisting_rrset", "alias_loop", "alias_chain_too_long", "alias_target_unresolvable", "alias_rcode", "alias_cname_not_signed", "alias_multiple_records", } // reportView is the template payload. We pre-compute everything the template // needs so the template itself stays dumb. 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 TopFailures []topFailure OtherFindings []AliasFinding RawJSON string } type chainStep struct { Index int Owner string Kind string Target string TTL uint32 Server string IsLast bool CSSKind string } type topFailure struct { Code string Title string Severity string Messages []string Hint string Subject string } func buildReportView(data *AliasData) *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, } v.FinalAddresses = append(v.FinalAddresses, data.FinalA...) v.FinalAddresses = append(v.FinalAddresses, data.FinalAAAA...) // Overall status = worst severity among findings. worst := "" for _, f := range data.Findings { switch f.Severity { case SeverityCrit: worst = "crit" case SeverityWarn: if worst != "crit" { worst = "warn" } case SeverityInfo: if worst == "" { worst = "info" } } } switch worst { case "crit": v.OverallStatus = "crit" v.OverallStatusText = "Critical issues detected" v.OverallClass = "status-crit" case "warn": v.OverallStatus = "warn" v.OverallStatusText = "Warnings detected" v.OverallClass = "status-warn" case "info": v.OverallStatus = "info" v.OverallStatusText = "Informational notes" v.OverallClass = "status-info" default: v.OverallStatus = "ok" v.OverallStatusText = "Alias chain healthy" v.OverallClass = "status-ok" } // Chain steps. 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) } // Bucket findings: top failures (grouped by code) vs. the rest. topIndex := map[string]int{} for i, c := range topFailureCodes { topIndex[c] = i } topMap := map[string]*topFailure{} for _, f := range data.Findings { if _, isTop := topIndex[f.Code]; isTop { tf, ok := topMap[f.Code] if !ok { tf = &topFailure{ Code: f.Code, Title: titleFor(f.Code), Severity: string(f.Severity), Hint: f.Hint, Subject: f.Subject, } topMap[f.Code] = tf } tf.Messages = append(tf.Messages, f.Message) if tf.Hint == "" { tf.Hint = f.Hint } // Escalate severity to the worst among grouped findings. if severityRank(f.Severity) > severityRank(Severity(tf.Severity)) { tf.Severity = string(f.Severity) } continue } v.OtherFindings = append(v.OtherFindings, f) } for _, code := range topFailureCodes { if tf, ok := topMap[code]; ok { v.TopFailures = append(v.TopFailures, *tf) } } if raw, err := json.MarshalIndent(data, "", " "); err == nil { v.RawJSON = string(raw) } return v } func severityRank(s Severity) int { switch s { case SeverityCrit: return 3 case SeverityWarn: return 2 case SeverityInfo: return 1 } return 0 } func titleFor(code string) string { switch code { case "alias_cname_at_apex": return "CNAME at zone apex" case "alias_coexisting_rrset": return "CNAME coexists with other records" case "alias_loop": return "Alias chain loops" case "alias_chain_too_long": return "Alias chain too long" case "alias_target_unresolvable": return "Target does not resolve" case "alias_rcode": return "Alias lookup error" case "alias_cname_not_signed": return "CNAME not DNSSEC-signed" case "alias_multiple_records": return "Multiple CNAME records at the same name" } return strings.ReplaceAll(code, "_", " ") } var reportTmpl = template.Must(template.New("alias-report").Parse(reportTemplate)) // reportTemplate is the single-file HTML report. Styles are inlined so the // report embeds cleanly 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}}
SeverityCodeSubjectMessage
{{.Severity}} {{.Code}} {{.Subject}} {{.Message}}{{if .Hint}}
{{.Hint}}{{end}}
{{end}} {{if .RawJSON}}

Raw observation

Show raw JSON
{{.RawJSON}}
{{end}} `