Initial commit

This commit is contained in:
nemunaire 2026-04-26 18:44:22 +07:00
commit b9c522ca5a
21 changed files with 1899 additions and 0 deletions

115
checker/collect.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"`
}