All checks were successful
continuous-integration/drone/push Build is passing
346 lines
12 KiB
Go
346 lines
12 KiB
Go
package checker
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html/template"
|
|
|
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
|
)
|
|
|
|
// GetHTMLReport renders the legacy-records observation as a self-contained
|
|
// HTML page suitable for iframe embedding.
|
|
//
|
|
// Cards are built from ctx.States(): each finding state carries the full
|
|
// metadata (reason, replacement, how-to-fix, locations) in CheckState.Meta.
|
|
// The rule already emits states in descending severity order, so the first
|
|
// card is always the top priority without re-sorting here.
|
|
func (p *legacyProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
|
|
var data LegacyData
|
|
if raw := ctx.Data(); len(raw) > 0 {
|
|
if err := json.Unmarshal(raw, &data); err != nil {
|
|
return "", fmt.Errorf("parse legacy-records 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
|
|
}
|
|
|
|
type reportView struct {
|
|
Zone string
|
|
ServicesScanned int
|
|
Total int
|
|
OverallStatus string
|
|
OverallText string
|
|
OverallClass string
|
|
Top *findingCard
|
|
Others []findingCard
|
|
CollectErrors []string
|
|
}
|
|
|
|
type findingCard struct {
|
|
TypeName string
|
|
Reason string
|
|
Replacement string
|
|
HowToFix string
|
|
Severity string
|
|
SeverityCSS string
|
|
Count int
|
|
Locations []FindingLocation
|
|
}
|
|
|
|
func buildReportView(data *LegacyData, states []sdk.CheckState) *reportView {
|
|
v := &reportView{
|
|
Zone: data.Zone,
|
|
ServicesScanned: data.ServicesScanned,
|
|
Total: len(data.Findings),
|
|
CollectErrors: data.CollectErrors,
|
|
}
|
|
|
|
if len(states) == 0 {
|
|
// No rule output yet: data-only rendering with a neutral headline.
|
|
v.OverallStatus = "ok"
|
|
v.OverallText = fmt.Sprintf("Data collected — %d service(s) scanned.", data.ServicesScanned)
|
|
v.OverallClass = "status-ok"
|
|
return v
|
|
}
|
|
|
|
var cards []findingCard
|
|
for _, st := range states {
|
|
c, ok := cardFromState(st)
|
|
if !ok {
|
|
continue
|
|
}
|
|
cards = append(cards, c)
|
|
}
|
|
|
|
if len(cards) > 0 {
|
|
v.Top = &cards[0]
|
|
v.Others = cards[1:]
|
|
worst := worstFindingStatus(states)
|
|
v.OverallStatus, v.OverallText, v.OverallClass = overallFromStatus(worst)
|
|
} else if errState, ok := firstErrorState(states); ok {
|
|
v.OverallStatus = "error"
|
|
v.OverallText = errState.Message
|
|
v.OverallClass = "status-crit"
|
|
} else {
|
|
v.OverallStatus = "ok"
|
|
v.OverallClass = "status-ok"
|
|
v.OverallText = fmt.Sprintf("No legacy record types found across %d service(s).", data.ServicesScanned)
|
|
for _, st := range states {
|
|
if st.Code == "legacy_records_clean" {
|
|
v.OverallText = st.Message
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return v
|
|
}
|
|
|
|
// cardFromState builds a findingCard from a finding CheckState. States that
|
|
// carry no "locations" metadata (clean / error / skip states) return ok=false.
|
|
func cardFromState(st sdk.CheckState) (findingCard, bool) {
|
|
if st.Meta == nil {
|
|
return findingCard{}, false
|
|
}
|
|
rawLocs, ok := st.Meta["locations"]
|
|
if !ok {
|
|
return findingCard{}, false
|
|
}
|
|
locations := decodeLocations(rawLocs)
|
|
|
|
typeName, _ := st.Meta["type"].(string)
|
|
if typeName == "" {
|
|
typeName = st.Subject
|
|
}
|
|
reason, _ := st.Meta["reason"].(string)
|
|
replacement, _ := st.Meta["replacement"].(string)
|
|
howToFix, _ := st.Meta["how_to_fix"].(string)
|
|
sevLabel, sevCSS := severityFromStatus(st.Status)
|
|
|
|
return findingCard{
|
|
TypeName: typeName,
|
|
Reason: reason,
|
|
Replacement: replacement,
|
|
HowToFix: howToFix,
|
|
Severity: sevLabel,
|
|
SeverityCSS: sevCSS,
|
|
Count: len(locations),
|
|
Locations: locations,
|
|
}, true
|
|
}
|
|
|
|
// decodeLocations handles the JSON round-trip: Meta values are stored as any
|
|
// but pass through json.Marshal/Unmarshal when transmitted, so []FindingLocation
|
|
// arrives as []interface{} of map[string]interface{}. Re-marshaling restores
|
|
// the typed slice.
|
|
func decodeLocations(v any) []FindingLocation {
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var locs []FindingLocation
|
|
if err := json.Unmarshal(b, &locs); err != nil {
|
|
return nil
|
|
}
|
|
return locs
|
|
}
|
|
|
|
func severityFromStatus(s sdk.Status) (label, css string) {
|
|
switch s {
|
|
case sdk.StatusCrit, sdk.StatusError:
|
|
return "Critical", "crit"
|
|
case sdk.StatusWarn:
|
|
return "Warning", "warn"
|
|
default:
|
|
return "Informational", "info"
|
|
}
|
|
}
|
|
|
|
func worstFindingStatus(states []sdk.CheckState) sdk.Status {
|
|
worst := sdk.StatusInfo
|
|
for _, st := range states {
|
|
switch st.Status {
|
|
case sdk.StatusCrit, sdk.StatusError:
|
|
return sdk.StatusCrit
|
|
case sdk.StatusWarn:
|
|
worst = sdk.StatusWarn
|
|
}
|
|
}
|
|
return worst
|
|
}
|
|
|
|
func overallFromStatus(s sdk.Status) (status, text, css string) {
|
|
switch s {
|
|
case sdk.StatusCrit, sdk.StatusError:
|
|
return "crit", "Legacy records require urgent migration", "status-crit"
|
|
case sdk.StatusWarn:
|
|
return "warn", "Legacy records should be migrated", "status-warn"
|
|
default:
|
|
return "info", "Only informational legacy records found", "status-info"
|
|
}
|
|
}
|
|
|
|
func firstErrorState(states []sdk.CheckState) (sdk.CheckState, bool) {
|
|
for i := range states {
|
|
if states[i].Status == sdk.StatusError {
|
|
return states[i], true
|
|
}
|
|
}
|
|
return sdk.CheckState{}, false
|
|
}
|
|
|
|
var reportTmpl = template.Must(template.New("legacy-records-report").Funcs(template.FuncMap{
|
|
"display": func(s string) string {
|
|
if s == "" || s == "@" {
|
|
return "@"
|
|
}
|
|
return s
|
|
},
|
|
}).Parse(reportTemplate))
|
|
|
|
const reportTemplate = `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>Legacy DNS records — {{if .Zone}}{{.Zone}}{{else}}zone report{{end}}</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); font-size: .85rem; }
|
|
.status-banner { display: flex; align-items: center; justify-content: space-between; padding: .9rem 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(200px, 1fr)); gap: .75rem; margin-bottom: 1rem; }
|
|
.stat { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: .8rem 1rem; }
|
|
.stat .k { color: var(--muted); font-size: .75rem; text-transform: uppercase; letter-spacing: .03em; }
|
|
.stat .v { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 1.05rem; }
|
|
.top-fix { border-left: 5px solid var(--crit); background: #fef2f2; padding: 1rem 1.1rem; border-radius: 8px; margin-bottom: 1rem; }
|
|
.top-fix.severity-warn { border-color: var(--warn); background: #fffbeb; }
|
|
.top-fix.severity-info { border-color: var(--info); background: #eff6ff; }
|
|
.top-fix h3 { display: flex; align-items: center; gap: .5rem; }
|
|
.top-fix h3 .type { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 1rem; }
|
|
.top-fix .why { color: var(--muted); font-size: .9rem; margin: .1rem 0 .6rem 0; }
|
|
.top-fix .fix { background: rgba(0,0,0,.04); padding: .55rem .7rem; border-radius: 6px; font-size: .9rem; }
|
|
.top-fix .fix strong { display: block; margin-bottom: .2rem; color: var(--text); }
|
|
.top-fix .locs { margin: .55rem 0 0 0; font-size: .85rem; }
|
|
.top-fix .locs code { background: rgba(0,0,0,.05); padding: .05rem .35rem; border-radius: 4px; }
|
|
.other-fix { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: .8rem 1rem; margin-bottom: .55rem; }
|
|
.other-fix h3 { display: flex; align-items: center; gap: .5rem; }
|
|
.other-fix h3 .type { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .95rem; }
|
|
.other-fix .why { color: var(--muted); font-size: .85rem; margin: .15rem 0 .5rem 0; }
|
|
.other-fix .fix { background: #f3f4f6; padding: .45rem .6rem; border-radius: 4px; font-size: .85rem; }
|
|
.other-fix .locs { font-size: .82rem; color: var(--muted); margin-top: .4rem; }
|
|
.sev { display: inline-block; padding: .1rem .45rem; border-radius: 4px; font-size: .72rem; font-weight: 600; color: #fff; text-transform: uppercase; letter-spacing: .04em; }
|
|
.sev-info { background: var(--info); }
|
|
.sev-warn { background: var(--warn); }
|
|
.sev-crit { background: var(--crit); }
|
|
.pill { display: inline-block; background: rgba(0,0,0,.06); padding: .1rem .5rem; border-radius: 999px; font-size: .75rem; }
|
|
table { width: 100%; border-collapse: collapse; font-size: .85rem; background: var(--card); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; margin-top: .35rem; }
|
|
th, td { text-align: left; padding: .4rem .65rem; border-bottom: 1px solid var(--border); }
|
|
th { background: #f3f4f6; font-weight: 600; font-size: .72rem; text-transform: uppercase; letter-spacing: .03em; color: var(--muted); }
|
|
tr:last-child td { border-bottom: none; }
|
|
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
details.errors { background: #fffbeb; border: 1px solid #fde68a; border-radius: 8px; padding: .55rem .8rem; margin-top: 1rem; }
|
|
details.errors summary { cursor: pointer; font-weight: 600; }
|
|
details.errors li { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .82rem; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>Legacy DNS records</h1>
|
|
<div class="muted">{{if .Zone}}Zone: <code>{{.Zone}}</code> · {{end}}{{.ServicesScanned}} service(s) scanned · {{.Total}} legacy record(s) found</div>
|
|
|
|
<div class="status-banner {{.OverallClass}}" style="margin-top: 1rem;">
|
|
<div>
|
|
<div class="label">{{.OverallText}}</div>
|
|
<div class="sub">{{if .Top}}Most severe: <code>{{.Top.TypeName}}</code> ({{.Top.Severity}}){{else}}No legacy records detected{{end}}</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{if .Top}}
|
|
<h2>Fix this first</h2>
|
|
<div class="top-fix severity-{{.Top.SeverityCSS}}">
|
|
<h3>
|
|
<span class="type">{{.Top.TypeName}}</span>
|
|
<span class="sev sev-{{.Top.SeverityCSS}}">{{.Top.Severity}}</span>
|
|
<span class="pill">{{.Top.Count}} occurrence{{if ne .Top.Count 1}}s{{end}}</span>
|
|
</h3>
|
|
<div class="why">{{.Top.Reason}}{{if .Top.Replacement}} · use <code>{{.Top.Replacement}}</code> instead{{end}}</div>
|
|
<div class="fix">
|
|
<strong>How to fix</strong>
|
|
{{.Top.HowToFix}}
|
|
</div>
|
|
{{if .Top.Locations}}
|
|
<table>
|
|
<thead><tr><th>Subdomain</th><th>Owner</th><th>Service</th></tr></thead>
|
|
<tbody>
|
|
{{range .Top.Locations}}
|
|
<tr>
|
|
<td><code>{{display .Subdomain}}</code></td>
|
|
<td>{{if .Name}}<code>{{.Name}}</code>{{else}}<span class="muted">—</span>{{end}}</td>
|
|
<td>{{if .ServiceType}}<code>{{.ServiceType}}</code>{{else}}<span class="muted">—</span>{{end}}</td>
|
|
</tr>
|
|
{{end}}
|
|
</tbody>
|
|
</table>
|
|
{{end}}
|
|
</div>
|
|
{{end}}
|
|
|
|
{{if .Others}}
|
|
<h2>Other legacy records</h2>
|
|
{{range .Others}}
|
|
<div class="other-fix">
|
|
<h3>
|
|
<span class="type">{{.TypeName}}</span>
|
|
<span class="sev sev-{{.SeverityCSS}}">{{.Severity}}</span>
|
|
<span class="pill">{{.Count}} occurrence{{if ne .Count 1}}s{{end}}</span>
|
|
</h3>
|
|
<div class="why">{{.Reason}}{{if .Replacement}} · use <code>{{.Replacement}}</code> instead{{end}}</div>
|
|
<div class="fix"><strong>How to fix:</strong> {{.HowToFix}}</div>
|
|
{{if .Locations}}
|
|
<div class="locs">
|
|
Owners:
|
|
{{range $i, $l := .Locations}}{{if $i}}, {{end}}<code>{{display $l.Subdomain}}</code>{{end}}
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
{{end}}
|
|
{{end}}
|
|
|
|
{{if .CollectErrors}}
|
|
<details class="errors">
|
|
<summary>{{len .CollectErrors}} service(s) skipped during scan</summary>
|
|
<ul>
|
|
{{range .CollectErrors}}<li>{{.}}</li>{{end}}
|
|
</ul>
|
|
</details>
|
|
{{end}}
|
|
|
|
</body>
|
|
</html>`
|