Initial commit
This commit is contained in:
commit
7c6f8ab8a7
21 changed files with 1891 additions and 0 deletions
115
checker/collect.go
Normal file
115
checker/collect.go
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ParseGrokOutput decodes the JSON produced by `dnsviz grok` into a typed
|
||||
// map of zone analyses. The returned Order slice lists zone FQDNs sorted
|
||||
// from the most-specific (queried name) up to the root, matching DNSViz's
|
||||
// natural chain order.
|
||||
func ParseGrokOutput(raw []byte) (map[string]ZoneAnalysis, []string, error) {
|
||||
var top map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &top); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
out := make(map[string]ZoneAnalysis, len(top))
|
||||
for k, v := range top {
|
||||
out[k] = decodeZone(v)
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(out))
|
||||
for k := range out {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
return labelDepth(keys[i]) > labelDepth(keys[j])
|
||||
})
|
||||
return out, keys, nil
|
||||
}
|
||||
|
||||
func decodeZone(raw json.RawMessage) ZoneAnalysis {
|
||||
var z ZoneAnalysis
|
||||
var m map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
var s string
|
||||
if json.Unmarshal(raw, &s) == nil {
|
||||
z.Status = s
|
||||
}
|
||||
return z
|
||||
}
|
||||
|
||||
if v, ok := m["status"]; ok {
|
||||
_ = json.Unmarshal(v, &z.Status)
|
||||
}
|
||||
z.Errors = decodeFindings(m["errors"])
|
||||
z.Warnings = decodeFindings(m["warnings"])
|
||||
|
||||
delete(m, "status")
|
||||
delete(m, "errors")
|
||||
delete(m, "warnings")
|
||||
if len(m) > 0 {
|
||||
z.Extra = make(map[string]any, len(m))
|
||||
for k, v := range m {
|
||||
var any any
|
||||
_ = json.Unmarshal(v, &any)
|
||||
z.Extra[k] = any
|
||||
}
|
||||
}
|
||||
return z
|
||||
}
|
||||
|
||||
func decodeFindings(raw json.RawMessage) []Finding {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
var arr []map[string]any
|
||||
if err := json.Unmarshal(raw, &arr); err != nil {
|
||||
var strs []string
|
||||
if json.Unmarshal(raw, &strs) == nil {
|
||||
out := make([]Finding, 0, len(strs))
|
||||
for _, s := range strs {
|
||||
out = append(out, Finding{Description: s})
|
||||
}
|
||||
return out
|
||||
}
|
||||
return nil
|
||||
}
|
||||
out := make([]Finding, 0, len(arr))
|
||||
for _, item := range arr {
|
||||
f := Finding{}
|
||||
if s, ok := item["code"].(string); ok {
|
||||
f.Code = s
|
||||
}
|
||||
if s, ok := item["description"].(string); ok && s != "" {
|
||||
f.Description = s
|
||||
} else if s, ok := item["message"].(string); ok && s != "" {
|
||||
f.Description = s
|
||||
} else {
|
||||
b, _ := json.Marshal(item)
|
||||
f.Description = string(b)
|
||||
}
|
||||
if servers, ok := item["servers"].([]any); ok {
|
||||
for _, srv := range servers {
|
||||
if s, ok := srv.(string); ok {
|
||||
f.Servers = append(f.Servers, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func labelDepth(zone string) int {
|
||||
z := strings.TrimSuffix(zone, ".")
|
||||
if z == "" {
|
||||
return 0
|
||||
}
|
||||
return strings.Count(z, ".") + 1
|
||||
}
|
||||
52
checker/definition.go
Normal file
52
checker/definition.go
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Version is overridden at link time: -ldflags "-X ...Version=1.2.3".
|
||||
var Version = "built-in"
|
||||
|
||||
func (p *dnsvizProvider) Definition() *sdk.CheckerDefinition {
|
||||
def := &sdk.CheckerDefinition{
|
||||
ID: "dnsviz",
|
||||
Name: "DNSSEC (DNSViz)",
|
||||
Version: Version,
|
||||
Availability: sdk.CheckerAvailability{
|
||||
ApplyToDomain: true,
|
||||
},
|
||||
ObservationKeys: []sdk.ObservationKey{ObservationKeyDNSViz},
|
||||
Options: sdk.CheckerOptionsDocumentation{
|
||||
AdminOpts: []sdk.CheckerOptionDocumentation{
|
||||
{
|
||||
Id: "probeTimeoutSeconds",
|
||||
Type: "uint",
|
||||
Label: "Probe timeout (s)",
|
||||
Description: "Hard timeout for the `dnsviz probe` invocation. The recursive walk can take a while on slow zones.",
|
||||
Default: float64(120),
|
||||
},
|
||||
},
|
||||
DomainOpts: []sdk.CheckerOptionDocumentation{
|
||||
{
|
||||
Id: "domain_name",
|
||||
Label: "Domain name",
|
||||
AutoFill: sdk.AutoFillDomainName,
|
||||
},
|
||||
},
|
||||
},
|
||||
Rules: Rules(),
|
||||
HasHTMLReport: true,
|
||||
HasMetrics: true,
|
||||
Interval: &sdk.CheckIntervalSpec{
|
||||
Min: 15 * time.Minute,
|
||||
Max: 7 * 24 * time.Hour,
|
||||
Default: 6 * time.Hour,
|
||||
},
|
||||
}
|
||||
def.BuildRulesInfo()
|
||||
return def
|
||||
}
|
||||
39
checker/interactive.go
Normal file
39
checker/interactive.go
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build standalone
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// RenderForm exposes a minimal /check form when running standalone.
|
||||
func (p *dnsvizProvider) RenderForm() []sdk.CheckerOptionField {
|
||||
return []sdk.CheckerOptionField{
|
||||
{
|
||||
Id: "domain_name",
|
||||
Type: "string",
|
||||
Label: "Domain name",
|
||||
Placeholder: "example.com",
|
||||
Required: true,
|
||||
Description: "Fully-qualified domain name to analyse with DNSViz.",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ParseForm builds the CheckerOptions from the human-facing /check form.
|
||||
func (p *dnsvizProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
|
||||
domain := strings.TrimSpace(r.FormValue("domain_name"))
|
||||
if domain == "" {
|
||||
return nil, errors.New("domain name is required")
|
||||
}
|
||||
opts := sdk.CheckerOptions{
|
||||
"domain_name": strings.TrimSuffix(domain, "."),
|
||||
}
|
||||
return opts, nil
|
||||
}
|
||||
31
checker/provider.go
Normal file
31
checker/provider.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// CollectFn is the function signature for the DNSViz data collection step.
|
||||
// The checker package is decoupled from the subprocess invocation so it can
|
||||
// be imported without GPL obligations. Implementations live in the binary or
|
||||
// plugin layer (see internal/collect).
|
||||
type CollectFn func(ctx context.Context, opts sdk.CheckerOptions) (any, error)
|
||||
|
||||
// Provider returns a new DNSViz observation provider backed by the given
|
||||
// collect function.
|
||||
func Provider(collect CollectFn) sdk.ObservationProvider {
|
||||
return &dnsvizProvider{collect: collect}
|
||||
}
|
||||
|
||||
type dnsvizProvider struct{ collect CollectFn }
|
||||
|
||||
func (p *dnsvizProvider) Key() sdk.ObservationKey {
|
||||
return ObservationKeyDNSViz
|
||||
}
|
||||
|
||||
func (p *dnsvizProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
|
||||
return p.collect(ctx, opts)
|
||||
}
|
||||
329
checker/report.go
Normal file
329
checker/report.go
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// GetHTMLReport produces a self-contained HTML page that:
|
||||
//
|
||||
// - Banners the overall DNSViz status of the queried domain.
|
||||
// - Lists "Fix these first": the curated common-failure matches and any
|
||||
// critical state, with the human-readable hint pulled from CheckState.Meta.
|
||||
// - Renders one block per zone in the chain (root → … → leaf), with the
|
||||
// zone's status, errors and warnings, so a recursive DNSSEC failure
|
||||
// can be located at the exact level it broke.
|
||||
// - Falls back to a raw-JSON dump when no rule states were threaded in.
|
||||
func (p *dnsvizProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
|
||||
var data DNSVizData
|
||||
if raw := ctx.Data(); len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &data); err != nil {
|
||||
return "", fmt.Errorf("decoding DNSViz data: %w", err)
|
||||
}
|
||||
}
|
||||
states := ctx.States()
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(`<!doctype html><html lang="en"><head><meta charset="utf-8">`)
|
||||
b.WriteString(`<title>DNSSEC report — ` + html.EscapeString(data.Domain) + `</title>`)
|
||||
b.WriteString(`<style>` + reportCSS + `</style></head><body>`)
|
||||
|
||||
fmt.Fprintf(&b, `<header><h1>DNSSEC analysis</h1><p class="domain">%s</p></header>`,
|
||||
html.EscapeString(emptyAsUnknown(data.Domain)))
|
||||
|
||||
if len(states) == 0 && len(data.Zones) == 0 {
|
||||
b.WriteString(`<p class="empty">No DNSViz data and no rule states. The check probably failed before producing any output.</p>`)
|
||||
b.WriteString(`</body></html>`)
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
writeOverallBanner(&b, &data, states)
|
||||
writeFixFirst(&b, states)
|
||||
writeChain(&b, &data)
|
||||
writeAllStates(&b, states)
|
||||
writeRawSection(&b, &data)
|
||||
|
||||
b.WriteString(`</body></html>`)
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// ExtractMetrics turns the rule output into time-series points so a
|
||||
// happyDomain dashboard can show DNSSEC drift over time.
|
||||
func (p *dnsvizProvider) ExtractMetrics(ctx sdk.ReportContext, collectedAt time.Time) ([]sdk.CheckMetric, error) {
|
||||
var data DNSVizData
|
||||
if raw := ctx.Data(); len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
metrics := []sdk.CheckMetric{
|
||||
{
|
||||
Name: "dnsviz.zones.count",
|
||||
Value: float64(len(data.Zones)),
|
||||
Timestamp: collectedAt,
|
||||
},
|
||||
}
|
||||
|
||||
var totalErrors, totalWarnings int
|
||||
for _, z := range data.Zones {
|
||||
totalErrors += len(z.Errors)
|
||||
totalWarnings += len(z.Warnings)
|
||||
}
|
||||
metrics = append(metrics,
|
||||
sdk.CheckMetric{Name: "dnsviz.errors.count", Value: float64(totalErrors), Timestamp: collectedAt},
|
||||
sdk.CheckMetric{Name: "dnsviz.warnings.count", Value: float64(totalWarnings), Timestamp: collectedAt},
|
||||
)
|
||||
|
||||
byStatus := map[sdk.Status]int{}
|
||||
for _, s := range ctx.States() {
|
||||
byStatus[s.Status]++
|
||||
}
|
||||
for status, n := range byStatus {
|
||||
metrics = append(metrics, sdk.CheckMetric{
|
||||
Name: "dnsviz.findings.count",
|
||||
Value: float64(n),
|
||||
Labels: map[string]string{"status": status.String()},
|
||||
Timestamp: collectedAt,
|
||||
})
|
||||
}
|
||||
return metrics, nil
|
||||
}
|
||||
|
||||
// ── HTML rendering helpers ───────────────────────────────────────────────
|
||||
|
||||
const reportCSS = `
|
||||
*,*::before,*::after{box-sizing:border-box}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;margin:0;padding:1.5rem;background:#fafafa;color:#222;line-height:1.45}
|
||||
header h1{margin:0 0 .25rem;font-size:1.6rem}
|
||||
header .domain{margin:0 0 1rem;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:#555}
|
||||
.banner{display:inline-block;padding:.6rem 1rem;border-radius:.4rem;color:#fff;font-weight:600;margin:0 0 1.5rem}
|
||||
.banner small{display:block;font-weight:400;opacity:.85;font-size:.85rem;margin-top:.25rem}
|
||||
.s-OK{background:#2e7d32}.s-INFO{background:#0277bd}.s-WARN{background:#ef6c00}.s-CRIT{background:#c62828}.s-ERROR{background:#6a1b9a}.s-UNKNOWN{background:#555}
|
||||
.section{background:#fff;border:1px solid #e0e0e0;border-radius:.4rem;padding:1rem 1.25rem;margin:0 0 1.5rem;box-shadow:0 1px 2px rgba(0,0,0,.04)}
|
||||
.section h2{margin:0 0 .75rem;font-size:1.15rem}
|
||||
.zone{border-left:4px solid #ccc;padding:.5rem .75rem;margin:.5rem 0;background:#fafafa;border-radius:0 .3rem .3rem 0}
|
||||
.zone.s-OK{border-color:#2e7d32}.zone.s-INFO{border-color:#0277bd}.zone.s-WARN{border-color:#ef6c00}.zone.s-CRIT{border-color:#c62828}.zone.s-UNKNOWN{border-color:#777}
|
||||
.zone h3{margin:0 0 .25rem;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:1rem}
|
||||
.zone .status{font-size:.8rem;text-transform:uppercase;letter-spacing:.05em;color:#fff;padding:.1rem .4rem;border-radius:.2rem;margin-left:.5rem}
|
||||
.findings{margin:.5rem 0 0;padding:0;list-style:none}
|
||||
.findings li{padding:.35rem .5rem;border-radius:.25rem;margin:.2rem 0;font-size:.9rem}
|
||||
.findings li.err{background:#ffebee;color:#b71c1c}
|
||||
.findings li.warn{background:#fff3e0;color:#bf360c}
|
||||
.findings li code{background:rgba(0,0,0,.06);padding:0 .2rem;border-radius:.15rem;font-size:.8rem}
|
||||
table{border-collapse:collapse;width:100%;font-size:.9rem}
|
||||
th,td{border:1px solid #e0e0e0;padding:.35rem .5rem;text-align:left;vertical-align:top}
|
||||
th{background:#f5f5f5;font-weight:600}
|
||||
.fix-card{border-left:4px solid #c62828;background:#fff;padding:.75rem 1rem;border-radius:0 .3rem .3rem 0;margin:.5rem 0}
|
||||
.fix-card.warn{border-color:#ef6c00}
|
||||
.fix-card h4{margin:0 0 .25rem;font-size:1rem}
|
||||
.fix-card .where{color:#666;font-size:.85rem;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
|
||||
.fix-card .hint{margin:.4rem 0 0}
|
||||
details>summary{cursor:pointer;color:#555}
|
||||
pre{background:#f5f5f5;padding:.75rem;border-radius:.3rem;overflow-x:auto;font-size:.8rem;line-height:1.4}
|
||||
.empty{padding:2rem;text-align:center;color:#777}
|
||||
`
|
||||
|
||||
func writeOverallBanner(b *strings.Builder, data *DNSVizData, states []sdk.CheckState) {
|
||||
leaf := data.Domain + "."
|
||||
z, ok := data.Zones[leaf]
|
||||
if !ok {
|
||||
zones := orderedZones(data)
|
||||
if len(zones) > 0 {
|
||||
leaf = zones[0]
|
||||
z = data.Zones[leaf]
|
||||
}
|
||||
}
|
||||
st := statusFromGrok(z.Status)
|
||||
if w := worstStatus(states); w > st {
|
||||
st = w
|
||||
}
|
||||
fmt.Fprintf(b,
|
||||
`<div class="banner s-%s">Overall: %s<small>DNSViz status of %s: %s</small></div>`,
|
||||
st.String(), st.String(),
|
||||
html.EscapeString(strings.TrimSuffix(leaf, ".")),
|
||||
html.EscapeString(emptyAsUnknown(z.Status)),
|
||||
)
|
||||
}
|
||||
|
||||
func writeFixFirst(b *strings.Builder, states []sdk.CheckState) {
|
||||
type item struct {
|
||||
state sdk.CheckState
|
||||
}
|
||||
var items []item
|
||||
for _, s := range states {
|
||||
if s.Status < sdk.StatusWarn {
|
||||
continue
|
||||
}
|
||||
items = append(items, item{state: s})
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return
|
||||
}
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
if items[i].state.Status != items[j].state.Status {
|
||||
return items[i].state.Status > items[j].state.Status
|
||||
}
|
||||
return items[i].state.Subject < items[j].state.Subject
|
||||
})
|
||||
|
||||
b.WriteString(`<section class="section"><h2>Fix these first</h2>`)
|
||||
for _, it := range items {
|
||||
s := it.state
|
||||
title, hint := titleAndHint(s)
|
||||
klass := "fix-card"
|
||||
if s.Status == sdk.StatusWarn {
|
||||
klass = "fix-card warn"
|
||||
}
|
||||
fmt.Fprintf(b, `<div class="%s"><h4>%s</h4><div class="where">at <code>%s</code>, rule <code>%s</code></div>`,
|
||||
klass,
|
||||
html.EscapeString(title),
|
||||
html.EscapeString(s.Subject),
|
||||
html.EscapeString(s.Code),
|
||||
)
|
||||
if hint != "" {
|
||||
fmt.Fprintf(b, `<p class="hint">%s</p>`, html.EscapeString(hint))
|
||||
} else if s.Message != "" {
|
||||
fmt.Fprintf(b, `<p class="hint">%s</p>`, html.EscapeString(s.Message))
|
||||
}
|
||||
b.WriteString(`</div>`)
|
||||
}
|
||||
b.WriteString(`</section>`)
|
||||
}
|
||||
|
||||
func titleAndHint(s sdk.CheckState) (title, hint string) {
|
||||
if s.Meta != nil {
|
||||
if v, ok := s.Meta["title"].(string); ok {
|
||||
title = v
|
||||
}
|
||||
if v, ok := s.Meta["hint"].(string); ok {
|
||||
hint = v
|
||||
}
|
||||
}
|
||||
if title == "" {
|
||||
title = s.Message
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func writeChain(b *strings.Builder, data *DNSVizData) {
|
||||
zones := orderedZones(data)
|
||||
if len(zones) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
b.WriteString(`<section class="section"><h2>Per-zone analysis (root → leaf)</h2>`)
|
||||
// Render root first, leaf last for a chain narrative.
|
||||
for i := len(zones) - 1; i >= 0; i-- {
|
||||
name := zones[i]
|
||||
z := data.Zones[name]
|
||||
st := statusFromGrok(z.Status)
|
||||
fmt.Fprintf(b, `<div class="zone s-%s"><h3>%s<span class="status s-%s">%s</span></h3>`,
|
||||
st.String(),
|
||||
html.EscapeString(name),
|
||||
st.String(),
|
||||
html.EscapeString(emptyAsUnknown(z.Status)),
|
||||
)
|
||||
if len(z.Errors) > 0 {
|
||||
b.WriteString(`<ul class="findings">`)
|
||||
for _, f := range z.Errors {
|
||||
writeFindingLI(b, f, "err")
|
||||
}
|
||||
b.WriteString(`</ul>`)
|
||||
}
|
||||
if len(z.Warnings) > 0 {
|
||||
b.WriteString(`<ul class="findings">`)
|
||||
for _, f := range z.Warnings {
|
||||
writeFindingLI(b, f, "warn")
|
||||
}
|
||||
b.WriteString(`</ul>`)
|
||||
}
|
||||
if len(z.Errors) == 0 && len(z.Warnings) == 0 {
|
||||
b.WriteString(`<p style="margin:.4rem 0;color:#2e7d32">No DNSViz finding at this level.</p>`)
|
||||
}
|
||||
b.WriteString(`</div>`)
|
||||
}
|
||||
b.WriteString(`</section>`)
|
||||
}
|
||||
|
||||
func writeFindingLI(b *strings.Builder, f Finding, klass string) {
|
||||
fmt.Fprintf(b, `<li class="%s">`, klass)
|
||||
if f.Code != "" {
|
||||
fmt.Fprintf(b, `<code>%s</code> `, html.EscapeString(f.Code))
|
||||
}
|
||||
b.WriteString(html.EscapeString(f.Description))
|
||||
if len(f.Servers) > 0 {
|
||||
fmt.Fprintf(b, ` <small>(%s)</small>`, html.EscapeString(strings.Join(f.Servers, ", ")))
|
||||
}
|
||||
b.WriteString(`</li>`)
|
||||
}
|
||||
|
||||
func writeAllStates(b *strings.Builder, states []sdk.CheckState) {
|
||||
if len(states) == 0 {
|
||||
return
|
||||
}
|
||||
sorted := append([]sdk.CheckState(nil), states...)
|
||||
sort.SliceStable(sorted, func(i, j int) bool {
|
||||
if sorted[i].Status != sorted[j].Status {
|
||||
return sorted[i].Status > sorted[j].Status
|
||||
}
|
||||
if sorted[i].Subject != sorted[j].Subject {
|
||||
return sorted[i].Subject < sorted[j].Subject
|
||||
}
|
||||
return sorted[i].Code < sorted[j].Code
|
||||
})
|
||||
b.WriteString(`<section class="section"><h2>All rule states</h2><table><thead><tr><th>Status</th><th>Subject</th><th>Code</th><th>Message</th></tr></thead><tbody>`)
|
||||
for _, s := range sorted {
|
||||
fmt.Fprintf(b, `<tr><td><span class="status s-%s">%s</span></td><td><code>%s</code></td><td><code>%s</code></td><td>%s</td></tr>`,
|
||||
s.Status.String(), s.Status.String(),
|
||||
html.EscapeString(s.Subject),
|
||||
html.EscapeString(s.Code),
|
||||
html.EscapeString(s.Message),
|
||||
)
|
||||
}
|
||||
b.WriteString(`</tbody></table></section>`)
|
||||
}
|
||||
|
||||
func writeRawSection(b *strings.Builder, data *DNSVizData) {
|
||||
if len(data.Raw) == 0 {
|
||||
return
|
||||
}
|
||||
b.WriteString(`<section class="section"><details><summary>Raw <code>dnsviz grok</code> output</summary><pre>`)
|
||||
b.WriteString(html.EscapeString(string(data.Raw)))
|
||||
b.WriteString(`</pre></details>`)
|
||||
if data.ProbeStderr != "" {
|
||||
b.WriteString(`<details><summary>dnsviz probe stderr</summary><pre>`)
|
||||
b.WriteString(html.EscapeString(data.ProbeStderr))
|
||||
b.WriteString(`</pre></details>`)
|
||||
}
|
||||
if data.GrokStderr != "" {
|
||||
b.WriteString(`<details><summary>dnsviz grok stderr</summary><pre>`)
|
||||
b.WriteString(html.EscapeString(data.GrokStderr))
|
||||
b.WriteString(`</pre></details>`)
|
||||
}
|
||||
b.WriteString(`</section>`)
|
||||
}
|
||||
|
||||
// worstStatus returns the highest-severity status in states, using the same
|
||||
// ordering writeFixFirst relies on (Crit > Error > Warn > Info > OK > Unknown).
|
||||
// Returns StatusOK when states is empty.
|
||||
func worstStatus(states []sdk.CheckState) sdk.Status {
|
||||
if len(states) == 0 {
|
||||
return sdk.StatusOK
|
||||
}
|
||||
worst := states[0].Status
|
||||
for _, s := range states[1:] {
|
||||
if s.Status > worst {
|
||||
worst = s.Status
|
||||
}
|
||||
}
|
||||
return worst
|
||||
}
|
||||
78
checker/rule.go
Normal file
78
checker/rule.go
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Rules returns the full rule set evaluated against a DNSVizData observation.
|
||||
//
|
||||
// Each rule maps to a single concern so the UI can show a clean checklist.
|
||||
// Most rules iterate over zones in the chain and emit one CheckState per
|
||||
// zone; Subject is the zone FQDN, so a fault at the TLD never gets
|
||||
// silently merged with a fault at the leaf.
|
||||
func Rules() []sdk.CheckRule {
|
||||
return []sdk.CheckRule{
|
||||
&overallStatusRule{},
|
||||
&perZoneStatusRule{},
|
||||
&zoneErrorsRule{},
|
||||
&zoneWarningsRule{},
|
||||
&commonFailuresRule{},
|
||||
}
|
||||
}
|
||||
|
||||
func loadData(ctx context.Context, obs sdk.ObservationGetter, code string) (*DNSVizData, []sdk.CheckState) {
|
||||
var data DNSVizData
|
||||
if err := obs.Get(ctx, ObservationKeyDNSViz, &data); err != nil {
|
||||
return nil, []sdk.CheckState{{
|
||||
Status: sdk.StatusError,
|
||||
Code: code,
|
||||
Message: fmt.Sprintf("Failed to load DNSViz observation: %v", err),
|
||||
}}
|
||||
}
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
// orderedZones returns zone keys in the report-friendly order (queried name
|
||||
// first, root last), preferring DNSVizData.Order when populated.
|
||||
func orderedZones(data *DNSVizData) []string {
|
||||
if len(data.Order) > 0 {
|
||||
return data.Order
|
||||
}
|
||||
keys := make([]string, 0, len(data.Zones))
|
||||
for k := range data.Zones {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
return labelDepth(keys[i]) > labelDepth(keys[j])
|
||||
})
|
||||
return keys
|
||||
}
|
||||
|
||||
// statusFromGrok turns a DNSViz status string into our SDK Status.
|
||||
func statusFromGrok(s string) sdk.Status {
|
||||
switch strings.ToUpper(strings.TrimSpace(s)) {
|
||||
case "SECURE":
|
||||
return sdk.StatusOK
|
||||
case "INSECURE":
|
||||
// "INSECURE" means "no DNSSEC and no parent DS": informational, not
|
||||
// a failure. Rules elsewhere can still flag a missing chain.
|
||||
return sdk.StatusInfo
|
||||
case "BOGUS":
|
||||
return sdk.StatusCrit
|
||||
case "INDETERMINATE":
|
||||
return sdk.StatusWarn
|
||||
case "NON_EXISTENT":
|
||||
return sdk.StatusInfo
|
||||
case "":
|
||||
return sdk.StatusUnknown
|
||||
default:
|
||||
return sdk.StatusUnknown
|
||||
}
|
||||
}
|
||||
209
checker/rules_common.go
Normal file
209
checker/rules_common.go
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// commonFailuresRule pattern-matches DNSViz finding descriptions and codes
|
||||
// against a curated list of "user-facing" failure scenarios so the report
|
||||
// can put plain-language explanations and remediation hints next to them.
|
||||
//
|
||||
// The matching is intentionally permissive: DNSViz wording shifts between
|
||||
// versions, so we look for substrings rather than exact codes.
|
||||
type commonFailuresRule struct{}
|
||||
|
||||
func (r *commonFailuresRule) Name() string { return "dnsviz_common_failures" }
|
||||
func (r *commonFailuresRule) Description() string {
|
||||
return "Highlights well-known DNSSEC failure scenarios (broken chain, expired signatures, missing/extra DS, algorithm mismatch, …) with remediation hints."
|
||||
}
|
||||
|
||||
// CommonFailure is the catalog entry for a recognized scenario. It is
|
||||
// exported so the report layer can pull the human explanation/hint out of
|
||||
// CheckState.Meta and render the curated "fix this first" sections.
|
||||
type CommonFailure struct {
|
||||
ID string // Stable code emitted in CheckState.Code.
|
||||
Title string // Short headline for the report block.
|
||||
Hint string // What the user should typically do.
|
||||
Patterns []string // Substrings (lowercased) matched against the finding's code+description.
|
||||
Severity sdk.Status
|
||||
}
|
||||
|
||||
// commonFailures is the curated catalog. Order matters: the first matching
|
||||
// entry wins, so put more specific scenarios above the generic ones.
|
||||
var commonFailures = []CommonFailure{
|
||||
{
|
||||
ID: "dnssec_chain_broken_no_ds",
|
||||
Title: "Parent has no DS record for this zone",
|
||||
Hint: "Publish the DS record(s) generated from your KSK at the registrar/parent. Without DS, validators see the zone as INSECURE even when DNSKEYs are present.",
|
||||
Patterns: []string{
|
||||
"no ds records",
|
||||
"missing ds",
|
||||
"no ds record was found",
|
||||
"ds_records_missing",
|
||||
},
|
||||
Severity: sdk.StatusCrit,
|
||||
},
|
||||
{
|
||||
ID: "dnssec_ds_digest_mismatch",
|
||||
Title: "DS at parent does not match any DNSKEY at the child",
|
||||
Hint: "The DS digest at the parent does not match any DNSKEY served by the child. Re-export the DS record from your current KSK and update it at the registrar; remove stale DS entries.",
|
||||
Patterns: []string{
|
||||
"ds does not match",
|
||||
"no matching dnskey",
|
||||
"ds_does_not_match",
|
||||
"no dnskey matching",
|
||||
},
|
||||
Severity: sdk.StatusCrit,
|
||||
},
|
||||
{
|
||||
ID: "dnssec_rrsig_expired",
|
||||
Title: "RRSIG signature has expired",
|
||||
Hint: "At least one RRSIG is past its expiration. Resign the zone (most signers do this automatically; investigate why the cron/automation didn't run).",
|
||||
Patterns: []string{
|
||||
"signature has expired",
|
||||
"rrsig_expired",
|
||||
"signature_expired",
|
||||
"expired",
|
||||
},
|
||||
Severity: sdk.StatusCrit,
|
||||
},
|
||||
{
|
||||
ID: "dnssec_rrsig_not_yet_valid",
|
||||
Title: "RRSIG signature is not yet valid",
|
||||
Hint: "An RRSIG inception time is in the future. Check that the signing host's clock is synchronized (NTP) and that the signer didn't generate signatures with a future inception.",
|
||||
Patterns: []string{
|
||||
"signature is not yet valid",
|
||||
"signature_not_yet_valid",
|
||||
"inception in the future",
|
||||
},
|
||||
Severity: sdk.StatusCrit,
|
||||
},
|
||||
{
|
||||
ID: "dnssec_signature_invalid",
|
||||
Title: "Cryptographic signature is invalid",
|
||||
Hint: "A validator could not verify the signature with the published DNSKEY. The zone may have been resigned with a key that was not published, or the served DNSKEY set is inconsistent across servers.",
|
||||
Patterns: []string{
|
||||
"signature_invalid",
|
||||
"signature is invalid",
|
||||
"bad signature",
|
||||
},
|
||||
Severity: sdk.StatusCrit,
|
||||
},
|
||||
{
|
||||
ID: "dnssec_algorithm_mismatch",
|
||||
Title: "Algorithm declared in DS not present in DNSKEY (or vice versa)",
|
||||
Hint: "RFC 4035 §2.2 requires that for every algorithm a DS uses, the child must publish at least one DNSKEY with the same algorithm. Either add the missing DNSKEY/DS or retire the orphan.",
|
||||
Patterns: []string{
|
||||
"algorithm_missing",
|
||||
"algorithm not signed",
|
||||
"missing rrsig for algorithm",
|
||||
"algorithm mismatch",
|
||||
},
|
||||
Severity: sdk.StatusCrit,
|
||||
},
|
||||
{
|
||||
ID: "dnssec_deprecated_algorithm",
|
||||
Title: "Deprecated DNSSEC algorithm in use",
|
||||
Hint: "Algorithms 5 (RSASHA1) and 7 (RSASHA1-NSEC3) are deprecated. Roll the KSK/ZSK to algorithm 13 (ECDSAP256SHA256) or 8 (RSASHA256) and update the DS at the parent.",
|
||||
Patterns: []string{
|
||||
"deprecated algorithm",
|
||||
"weak algorithm",
|
||||
"sha1",
|
||||
"rsasha1",
|
||||
},
|
||||
Severity: sdk.StatusWarn,
|
||||
},
|
||||
{
|
||||
ID: "dnssec_no_dnskey",
|
||||
Title: "No DNSKEY served at the apex",
|
||||
Hint: "The zone declares a DS at the parent but serves no DNSKEY at the apex. Validators see this as BOGUS. Republish the DNSKEY set or remove the DS at the parent.",
|
||||
Patterns: []string{
|
||||
"no dnskey",
|
||||
"dnskey_missing",
|
||||
},
|
||||
Severity: sdk.StatusCrit,
|
||||
},
|
||||
{
|
||||
ID: "dnssec_servfail",
|
||||
Title: "An authoritative server returned SERVFAIL on DNSSEC queries",
|
||||
Hint: "At least one server on the path returned SERVFAIL. Often caused by a server that doesn't have the keys it should sign with, or by EDNS/UDP fragmentation. Verify the server can answer DNSKEY/RRSIG over both UDP and TCP.",
|
||||
Patterns: []string{
|
||||
"servfail",
|
||||
"server failure",
|
||||
},
|
||||
Severity: sdk.StatusCrit,
|
||||
},
|
||||
{
|
||||
ID: "dnssec_inconsistent_responses",
|
||||
Title: "Authoritative servers disagree",
|
||||
Hint: "Different authoritative servers serve different DNSKEY/RRSIG/NSEC contents. Confirm that the secondary servers have completed AXFR/IXFR and are serving the same zone version.",
|
||||
Patterns: []string{
|
||||
"inconsistent",
|
||||
"disagree",
|
||||
},
|
||||
Severity: sdk.StatusWarn,
|
||||
},
|
||||
}
|
||||
|
||||
func (r *commonFailuresRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errState := loadData(ctx, obs, "dnsviz_common_failures")
|
||||
if errState != nil {
|
||||
return errState
|
||||
}
|
||||
var out []sdk.CheckState
|
||||
matched := map[string]struct{}{}
|
||||
for _, name := range orderedZones(data) {
|
||||
z := data.Zones[name]
|
||||
for _, f := range append(append([]Finding(nil), z.Errors...), z.Warnings...) {
|
||||
haystack := strings.ToLower(f.Code + " " + f.Description)
|
||||
for _, c := range commonFailures {
|
||||
if !matchesAny(haystack, c.Patterns) {
|
||||
continue
|
||||
}
|
||||
key := name + "|" + c.ID
|
||||
if _, seen := matched[key]; seen {
|
||||
continue
|
||||
}
|
||||
matched[key] = struct{}{}
|
||||
out = append(out, sdk.CheckState{
|
||||
Status: c.Severity,
|
||||
Code: c.ID,
|
||||
Subject: name,
|
||||
Message: c.Title + ": " + c.Hint,
|
||||
Meta: map[string]any{
|
||||
"title": c.Title,
|
||||
"hint": c.Hint,
|
||||
"original_code": f.Code,
|
||||
"original_description": f.Description,
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusOK,
|
||||
Code: "dnsviz_common_failures",
|
||||
Message: "No well-known DNSSEC failure scenario detected by the heuristics.",
|
||||
}}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func matchesAny(haystack string, needles []string) bool {
|
||||
for _, n := range needles {
|
||||
if n == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(haystack, n) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
185
checker/rules_status.go
Normal file
185
checker/rules_status.go
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// overallStatusRule reports on the leaf zone's DNSViz status. A SECURE leaf
|
||||
// means the entire chain validates from the root; BOGUS means at least one
|
||||
// link of the chain is broken.
|
||||
type overallStatusRule struct{}
|
||||
|
||||
func (r *overallStatusRule) Name() string { return "dnsviz_overall_status" }
|
||||
func (r *overallStatusRule) Description() string {
|
||||
return "Reports the DNSViz status of the queried domain (SECURE, INSECURE, BOGUS, INDETERMINATE)."
|
||||
}
|
||||
func (r *overallStatusRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errState := loadData(ctx, obs, "dnsviz_overall_status")
|
||||
if errState != nil {
|
||||
return errState
|
||||
}
|
||||
leaf := data.Domain + "."
|
||||
z, ok := data.Zones[leaf]
|
||||
if !ok {
|
||||
// Fall back to the most-specific zone DNSViz reported.
|
||||
zones := orderedZones(data)
|
||||
if len(zones) == 0 {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusUnknown,
|
||||
Code: "dnsviz_overall_status",
|
||||
Message: "DNSViz returned no zones for this domain",
|
||||
}}
|
||||
}
|
||||
leaf = zones[0]
|
||||
z = data.Zones[leaf]
|
||||
}
|
||||
st := sdk.CheckState{
|
||||
Code: "dnsviz_overall_status",
|
||||
Subject: leaf,
|
||||
Status: statusFromGrok(z.Status),
|
||||
Message: fmt.Sprintf("DNSViz status: %s", emptyAsUnknown(z.Status)),
|
||||
Meta: map[string]any{
|
||||
"status": z.Status,
|
||||
"errors": len(z.Errors),
|
||||
"warnings": len(z.Warnings),
|
||||
},
|
||||
}
|
||||
return []sdk.CheckState{st}
|
||||
}
|
||||
|
||||
// perZoneStatusRule emits one CheckState per zone in the chain. This is what
|
||||
// powers the "every authoritative/parent in a dedicated block" requirement
|
||||
// of the report: each entry has Subject set to the zone name.
|
||||
type perZoneStatusRule struct{}
|
||||
|
||||
func (r *perZoneStatusRule) Name() string { return "dnsviz_per_zone_status" }
|
||||
func (r *perZoneStatusRule) Description() string {
|
||||
return "Reports the DNSViz status of every zone in the chain (root, TLD, intermediates, leaf)."
|
||||
}
|
||||
func (r *perZoneStatusRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errState := loadData(ctx, obs, "dnsviz_per_zone_status")
|
||||
if errState != nil {
|
||||
return errState
|
||||
}
|
||||
zones := orderedZones(data)
|
||||
if len(zones) == 0 {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusUnknown,
|
||||
Code: "dnsviz_per_zone_status",
|
||||
Message: "DNSViz returned no zones for this domain",
|
||||
}}
|
||||
}
|
||||
out := make([]sdk.CheckState, 0, len(zones))
|
||||
for _, name := range zones {
|
||||
z := data.Zones[name]
|
||||
out = append(out, sdk.CheckState{
|
||||
Code: "dnsviz_per_zone_status",
|
||||
Subject: name,
|
||||
Status: statusFromGrok(z.Status),
|
||||
Message: fmt.Sprintf("%s: errors=%d warnings=%d", emptyAsUnknown(z.Status), len(z.Errors), len(z.Warnings)),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// zoneErrorsRule turns every DNSViz "error" entry into a Crit CheckState.
|
||||
// One state per (zone, finding) pair, so the UI can show a precise list.
|
||||
type zoneErrorsRule struct{}
|
||||
|
||||
func (r *zoneErrorsRule) Name() string { return "dnsviz_zone_errors" }
|
||||
func (r *zoneErrorsRule) Description() string {
|
||||
return "Surfaces every error reported by DNSViz, scoped to the zone where it was found."
|
||||
}
|
||||
func (r *zoneErrorsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errState := loadData(ctx, obs, "dnsviz_zone_errors")
|
||||
if errState != nil {
|
||||
return errState
|
||||
}
|
||||
var out []sdk.CheckState
|
||||
for _, name := range orderedZones(data) {
|
||||
for _, f := range data.Zones[name].Errors {
|
||||
out = append(out, sdk.CheckState{
|
||||
Status: sdk.StatusCrit,
|
||||
Code: nonEmpty(f.Code, "dnsviz_zone_errors"),
|
||||
Subject: name,
|
||||
Message: f.Description,
|
||||
Meta: findingMeta(f),
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusOK,
|
||||
Code: "dnsviz_zone_errors",
|
||||
Message: "DNSViz reported no errors in any zone",
|
||||
}}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// zoneWarningsRule mirrors zoneErrorsRule for warnings (StatusWarn).
|
||||
type zoneWarningsRule struct{}
|
||||
|
||||
func (r *zoneWarningsRule) Name() string { return "dnsviz_zone_warnings" }
|
||||
func (r *zoneWarningsRule) Description() string {
|
||||
return "Surfaces every warning reported by DNSViz, scoped to the zone where it was found."
|
||||
}
|
||||
func (r *zoneWarningsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errState := loadData(ctx, obs, "dnsviz_zone_warnings")
|
||||
if errState != nil {
|
||||
return errState
|
||||
}
|
||||
var out []sdk.CheckState
|
||||
for _, name := range orderedZones(data) {
|
||||
for _, f := range data.Zones[name].Warnings {
|
||||
out = append(out, sdk.CheckState{
|
||||
Status: sdk.StatusWarn,
|
||||
Code: nonEmpty(f.Code, "dnsviz_zone_warnings"),
|
||||
Subject: name,
|
||||
Message: f.Description,
|
||||
Meta: findingMeta(f),
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusOK,
|
||||
Code: "dnsviz_zone_warnings",
|
||||
Message: "DNSViz reported no warnings in any zone",
|
||||
}}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func emptyAsUnknown(s string) string {
|
||||
if s == "" {
|
||||
return "UNKNOWN"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func nonEmpty(a, b string) string {
|
||||
if a != "" {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func findingMeta(f Finding) map[string]any {
|
||||
m := map[string]any{}
|
||||
if f.Code != "" {
|
||||
m["code"] = f.Code
|
||||
}
|
||||
if len(f.Servers) > 0 {
|
||||
m["servers"] = f.Servers
|
||||
}
|
||||
if len(m) == 0 {
|
||||
return nil
|
||||
}
|
||||
return m
|
||||
}
|
||||
71
checker/types.go
Normal file
71
checker/types.go
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package checker implements a happyDomain checker that wraps DNSViz
|
||||
// (https://github.com/dnsviz/dnsviz). It runs `dnsviz probe` followed by
|
||||
// `dnsviz grok` against a domain, stores the structured analysis as the
|
||||
// observation, and turns the per-zone errors/warnings into CheckStates.
|
||||
//
|
||||
// The container ships dnsviz alongside this binary, so the checker has no
|
||||
// external dependency at runtime besides the network.
|
||||
package checker
|
||||
|
||||
import (
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// ObservationKeyDNSViz is the observation key for DNSViz analysis output.
|
||||
const ObservationKeyDNSViz sdk.ObservationKey = "dnsviz"
|
||||
|
||||
// DNSVizData is what Collect stores. It carries the full grok output
|
||||
// (parsed into a permissive structure) plus the raw bytes for the report.
|
||||
//
|
||||
// DNSViz emits a single top-level object whose keys are zone FQDNs (with
|
||||
// trailing dot), one per level of the chain. Inside each zone object the
|
||||
// shape is permissive: many fields are conditional, so we keep most of them
|
||||
// as map[string]any and only pluck out what the rules need.
|
||||
type DNSVizData struct {
|
||||
// Domain is the queried FQDN, with trailing dot stripped.
|
||||
Domain string `json:"domain"`
|
||||
|
||||
// Zones is the per-zone analysis, keyed by zone FQDN (with trailing dot
|
||||
// preserved, matching DNSViz's output).
|
||||
Zones map[string]ZoneAnalysis `json:"zones"`
|
||||
|
||||
// Order is Zones' keys, sorted from the queried name up to the root.
|
||||
// We surface it explicitly so the report can render in a stable order
|
||||
// without having to re-sort on every render.
|
||||
Order []string `json:"order,omitempty"`
|
||||
|
||||
// Raw is the unmodified `dnsviz grok` JSON. Kept around so the report
|
||||
// can fall back on it for fields the typed view does not capture.
|
||||
Raw []byte `json:"raw,omitempty"`
|
||||
|
||||
// ProbeStderr / GrokStderr capture the diagnostics dnsviz prints to
|
||||
// stderr. Useful when collection succeeds but the analysis is partial.
|
||||
ProbeStderr string `json:"probe_stderr,omitempty"`
|
||||
GrokStderr string `json:"grok_stderr,omitempty"`
|
||||
}
|
||||
|
||||
// ZoneAnalysis is a permissive view over one zone's grok block.
|
||||
//
|
||||
// DNSViz uses a small set of statuses ("SECURE", "BOGUS", "INSECURE",
|
||||
// "INDETERMINATE", "NON_EXISTENT") and groups problems into "errors" and
|
||||
// "warnings" arrays. Each finding has a "description" and may carry a
|
||||
// "code" plus a list of servers it was observed on. We expose those as a
|
||||
// stable Finding type and keep everything else under Extra for the report.
|
||||
type ZoneAnalysis struct {
|
||||
Status string `json:"status,omitempty"`
|
||||
Errors []Finding `json:"errors,omitempty"`
|
||||
Warnings []Finding `json:"warnings,omitempty"`
|
||||
Extra map[string]any `json:"extra,omitempty"`
|
||||
}
|
||||
|
||||
// Finding mirrors the shape DNSViz uses for entries in errors/warnings.
|
||||
// Producers occasionally use slightly different field names across versions
|
||||
// of dnsviz; we accept both `description`/`message` for the human text and
|
||||
// fall back to a generic stringification at parse time.
|
||||
type Finding struct {
|
||||
Code string `json:"code,omitempty"`
|
||||
Description string `json:"description"`
|
||||
Servers []string `json:"servers,omitempty"`
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue