Initial commit
This commit is contained in:
commit
30caf67389
18 changed files with 2098 additions and 0 deletions
273
checker/report.go
Normal file
273
checker/report.go
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// GetHTMLReport renders the dangling-records observation as a
|
||||
// self-contained HTML page. The report shows one card per impacted
|
||||
// owner, sorted by descending severity, with the failing pointer and
|
||||
// the human-readable reason behind each trigger.
|
||||
func (p *danglingProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
|
||||
var data DanglingData
|
||||
if raw := ctx.Data(); len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &data); err != nil {
|
||||
return "", fmt.Errorf("parse dangling-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
|
||||
Pointers int
|
||||
OverallText string
|
||||
OverallClass string
|
||||
Top *ownerCard
|
||||
Others []ownerCard
|
||||
CollectErrors []string
|
||||
}
|
||||
|
||||
type ownerCard struct {
|
||||
Owner string
|
||||
Severity string
|
||||
SeverityCSS string
|
||||
Triggers []SignalTrigger
|
||||
}
|
||||
|
||||
func buildReportView(data *DanglingData, states []sdk.CheckState) *reportView {
|
||||
v := &reportView{
|
||||
Zone: data.Zone,
|
||||
ServicesScanned: data.ServicesScanned,
|
||||
Pointers: len(data.Pointers),
|
||||
CollectErrors: data.CollectErrors,
|
||||
}
|
||||
|
||||
cards := cardsFromStates(states)
|
||||
if len(cards) == 0 {
|
||||
// Honour an Error state from the rule so the banner does not
|
||||
// masquerade as OK when the observation could not be loaded.
|
||||
if errState, ok := firstErrorState(states); ok {
|
||||
v.OverallText = errState.Message
|
||||
v.OverallClass = "status-crit"
|
||||
return v
|
||||
}
|
||||
v.OverallText = fmt.Sprintf("No dangling subdomain detected across %d service(s).", data.ServicesScanned)
|
||||
v.OverallClass = "status-ok"
|
||||
return v
|
||||
}
|
||||
|
||||
v.Top = &cards[0]
|
||||
v.Others = cards[1:]
|
||||
v.OverallText, v.OverallClass = overallLabel(cards[0].SeverityCSS)
|
||||
return v
|
||||
}
|
||||
|
||||
// cardsFromStates rebuilds the per-owner cards from the CheckState
|
||||
// slice the host has already produced. We rely on Meta.triggers (set by
|
||||
// danglingRule.Evaluate) so the report and the rule never disagree on
|
||||
// what to show.
|
||||
func cardsFromStates(states []sdk.CheckState) []ownerCard {
|
||||
out := make([]ownerCard, 0, len(states))
|
||||
for _, st := range states {
|
||||
if st.Code == "dangling_clean" || st.Code == "dangling_observation_error" {
|
||||
continue
|
||||
}
|
||||
card := ownerCard{
|
||||
Owner: st.Subject,
|
||||
}
|
||||
if sev, ok := st.Meta["severity"].(string); ok {
|
||||
card.Severity = severityLabel(sev)
|
||||
card.SeverityCSS = sev
|
||||
}
|
||||
// Triggers may have been round-tripped through JSON if the host
|
||||
// crossed an HTTP boundary; handle both shapes.
|
||||
switch v := st.Meta["triggers"].(type) {
|
||||
case []SignalTrigger:
|
||||
card.Triggers = v
|
||||
case []any:
|
||||
skipped := 0
|
||||
for _, item := range v {
|
||||
b, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
var t SignalTrigger
|
||||
if err := json.Unmarshal(b, &t); err != nil {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
card.Triggers = append(card.Triggers, t)
|
||||
}
|
||||
if skipped > 0 {
|
||||
card.Triggers = append(card.Triggers, SignalTrigger{
|
||||
Reason: fmt.Sprintf("%d trigger(s) could not be rendered.", skipped),
|
||||
})
|
||||
}
|
||||
}
|
||||
out = append(out, card)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func severityLabel(css string) string {
|
||||
switch css {
|
||||
case "critical":
|
||||
return "Critical"
|
||||
case "warning":
|
||||
return "Warning"
|
||||
case "info":
|
||||
return "Informational"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func overallLabel(severityCSS string) (text, css string) {
|
||||
switch severityCSS {
|
||||
case "critical":
|
||||
return "Dangling subdomains require urgent attention", "status-crit"
|
||||
case "warning":
|
||||
return "Dangling subdomains should be reviewed", "status-warn"
|
||||
case "info":
|
||||
return "Informational pointer issues found", "status-info"
|
||||
default:
|
||||
return "Dangling subdomains detected", "status-warn"
|
||||
}
|
||||
}
|
||||
|
||||
var reportTmpl = template.Must(template.New("dangling-records-report").Parse(reportTemplate))
|
||||
|
||||
const reportTemplate = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Dangling subdomains — {{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; }
|
||||
.top-fix { border-left: 5px solid var(--crit); background: #fef2f2; padding: 1rem 1.1rem; border-radius: 8px; margin-bottom: 1rem; }
|
||||
.top-fix.severity-warning { border-color: var(--warn); background: #fffbeb; }
|
||||
.top-fix.severity-info { border-color: var(--info); background: #eff6ff; }
|
||||
.other-fix { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: .8rem 1rem; margin-bottom: .55rem; }
|
||||
.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-warning { background: var(--warn); }
|
||||
.sev-critical { background: var(--crit); }
|
||||
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); vertical-align: top; }
|
||||
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>Dangling subdomains</h1>
|
||||
<div class="muted">{{if .Zone}}Zone: <code>{{.Zone}}</code> · {{end}}{{.ServicesScanned}} service(s) scanned · {{.Pointers}} pointer(s) inspected</div>
|
||||
|
||||
<div class="status-banner {{.OverallClass}}" style="margin-top: 1rem;">
|
||||
<div>
|
||||
<div class="label">{{.OverallText}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .Top}}
|
||||
<h2>Fix this first</h2>
|
||||
<div class="top-fix severity-{{.Top.SeverityCSS}}">
|
||||
<h3>
|
||||
<code>{{.Top.Owner}}</code>
|
||||
<span class="sev sev-{{.Top.SeverityCSS}}">{{.Top.Severity}}</span>
|
||||
</h3>
|
||||
{{if .Top.Triggers}}
|
||||
<table>
|
||||
<thead><tr><th>Pointer</th><th>Target</th><th>Why</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Top.Triggers}}
|
||||
<tr>
|
||||
<td><code>{{.Rrtype}}</code></td>
|
||||
<td><code>{{.Target}}</code></td>
|
||||
<td>{{.Reason}}{{if .Detail}} <span class="muted">({{.Detail}})</span>{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Others}}
|
||||
<h2>Other dangling subdomains</h2>
|
||||
{{range .Others}}
|
||||
<div class="other-fix">
|
||||
<h3>
|
||||
<code>{{.Owner}}</code>
|
||||
<span class="sev sev-{{.SeverityCSS}}">{{.Severity}}</span>
|
||||
</h3>
|
||||
{{if .Triggers}}
|
||||
<ul>
|
||||
{{range .Triggers}}
|
||||
<li><code>{{.Rrtype}}</code> → <code>{{.Target}}</code>: {{.Reason}}</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{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>`
|
||||
Loading…
Add table
Add a link
Reference in a new issue