Initial commit

This commit is contained in:
nemunaire 2026-04-26 18:44:22 +07:00
commit 53626dd36a
29 changed files with 3940 additions and 0 deletions

246
checker/collect.go Normal file
View file

@ -0,0 +1,246 @@
// SPDX-License-Identifier: MIT
package checker
import (
"encoding/json"
"fmt"
"sort"
"strings"
)
// ParseGrokOutput decodes the JSON produced by `dnsviz grok` into a typed
// map of zone analyses. Order lists zones from most-specific to root.
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 {
// Skip non-zone keys some dnsviz versions emit (e.g. "_meta").
if k == "" || strings.HasPrefix(k, "_") {
continue
}
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 node any
if err := json.Unmarshal(raw, &node); err != nil {
return z
}
m, ok := node.(map[string]any)
if !ok {
if s, ok := node.(string); ok {
z.Status = s
}
return z
}
if s, ok := m["status"].(string); ok {
z.DNSStatus = s
}
// DNSSEC chain status lives under delegation.status.
if del, ok := m["delegation"].(map[string]any); ok {
if s, ok := del["status"].(string); ok {
z.Status = s
}
}
// Root has no parent and therefore no delegation block. dnsviz signals
// trust-anchor validation through the RRSIG covering the apex DNSKEY
// rrset (queries.<zone>/IN/DNSKEY.answer[*].rrsig[*].status). With
// `dnsviz grok -t …` and a matching anchor, that RRSIG becomes VALID
// and we lift the zone to SECURE; an INVALID/EXPIRED RRSIG drags it
// to BOGUS. Without a trust anchor, this leaves Status empty and we
// fall back to the DNS rcode below.
if z.Status == "" {
z.Status = inferApexDNSKEYStatus(m["queries"])
}
if z.Status == "" {
z.Status = z.DNSStatus
}
z.Errors, z.Warnings = collectFindings(m, "")
return z
}
func collectFindings(node any, path string) (errs, warns []Finding) {
switch v := node.(type) {
case map[string]any:
for k, val := range v {
sub := joinPath(path, k)
switch k {
case "errors":
errs = append(errs, asFindings(val, path)...)
continue
case "warnings":
warns = append(warns, asFindings(val, path)...)
continue
}
e, w := collectFindings(val, sub)
errs = append(errs, e...)
warns = append(warns, w...)
}
case []any:
for i, item := range v {
sub := fmt.Sprintf("%s[%d]", path, i)
e, w := collectFindings(item, sub)
errs = append(errs, e...)
warns = append(warns, w...)
}
}
return
}
func joinPath(parent, key string) string {
if parent == "" {
return key
}
return parent + "/" + key
}
// asFindings turns a value attached to an "errors"/"warnings" key into a
// slice of Finding. DNSViz uses a few shapes here across versions:
// - []object{description, code, servers}
// - []string (rare, very old grok)
// - object keyed by code -> entry (newer grok flattens findings by code)
func asFindings(raw any, path string) []Finding {
switch v := raw.(type) {
case []any:
out := make([]Finding, 0, len(v))
for _, item := range v {
out = append(out, makeFinding(item, "", path))
}
return out
case map[string]any:
out := make([]Finding, 0, len(v))
keys := make([]string, 0, len(v))
for k := range v {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
out = append(out, makeFinding(v[k], k, path))
}
return out
case []string:
out := make([]Finding, 0, len(v))
for _, s := range v {
out = append(out, Finding{Description: s, Path: path})
}
return out
}
return nil
}
func makeFinding(item any, codeHint, path string) Finding {
f := Finding{Path: path, Code: codeHint}
switch v := item.(type) {
case string:
f.Description = v
case map[string]any:
if s, ok := v["code"].(string); ok && s != "" {
f.Code = s
}
if s, ok := v["description"].(string); ok && s != "" {
f.Description = s
} else if s, ok := v["message"].(string); ok && s != "" {
f.Description = s
}
if servers, ok := v["servers"].([]any); ok {
for _, s := range servers {
if str, ok := s.(string); ok {
f.Servers = append(f.Servers, str)
}
}
}
// If we couldn't extract a human description, keep the raw structure
// in Extra rather than synthesising a JSON blob into the description
// field (which would then be rendered as ugly text in the report).
if f.Description == "" {
f.Extra = v
}
default:
// Unknown shape: stash the raw value so the report can still surface
// it from a debug section, but don't pollute Description.
f.Extra = map[string]any{"value": item}
}
return f
}
// inferApexDNSKEYStatus returns "SECURE", "BOGUS", or "" based on the
// status of RRSIGs covering the zone's apex DNSKEY rrset. dnsviz attaches
// a per-RRSIG status whenever a key reaches it (either through DS from
// the parent or through a configured trust anchor at this zone). For
// the root, this is the only place where trust-anchor validation
// surfaces in the grok output.
//
// queries is the value at zone["queries"], a map keyed by
// "<zone>/IN/<RRTYPE>". We pick the DNSKEY query and look at every
// RRSIG inside its answer.
func inferApexDNSKEYStatus(queries any) string {
q, ok := queries.(map[string]any)
if !ok {
return ""
}
var dnskeyQ map[string]any
for k, v := range q {
if !strings.HasSuffix(k, "/IN/DNSKEY") {
continue
}
if m, ok := v.(map[string]any); ok {
dnskeyQ = m
break
}
}
if dnskeyQ == nil {
return ""
}
answers, _ := dnskeyQ["answer"].([]any)
sawValid := false
for _, a := range answers {
am, _ := a.(map[string]any)
if am == nil {
continue
}
rrsigs, _ := am["rrsig"].([]any)
for _, rs := range rrsigs {
rm, _ := rs.(map[string]any)
if rm == nil {
continue
}
s, _ := rm["status"].(string)
switch strings.ToUpper(s) {
case "INVALID", "BOGUS", "EXPIRED", "PREMATURE":
return "BOGUS"
case "VALID", "SECURE":
sawValid = true
}
}
}
if sawValid {
return "SECURE"
}
return ""
}
func labelDepth(zone string) int {
z := strings.TrimSuffix(zone, ".")
if z == "" {
return 0
}
return strings.Count(z, ".") + 1
}

223
checker/collect_test.go Normal file
View file

@ -0,0 +1,223 @@
// SPDX-License-Identifier: MIT
package checker
import (
"reflect"
"sort"
"strings"
"testing"
)
func TestLabelDepth(t *testing.T) {
cases := map[string]int{
"": 0,
".": 0,
"com.": 1,
"example.com.": 2,
"www.example.com": 3,
"a.b.c.d.e.": 5,
}
for in, want := range cases {
if got := labelDepth(in); got != want {
t.Errorf("labelDepth(%q) = %d, want %d", in, got, want)
}
}
}
func TestJoinPath(t *testing.T) {
cases := []struct {
parent, key, want string
}{
{"", "errors", "errors"},
{"delegation", "errors", "delegation/errors"},
{"queries/example.com.", "answer", "queries/example.com./answer"},
}
for _, c := range cases {
if got := joinPath(c.parent, c.key); got != c.want {
t.Errorf("joinPath(%q,%q) = %q, want %q", c.parent, c.key, got, c.want)
}
}
}
func TestParseGrokOutput_OrderAndShape(t *testing.T) {
raw := []byte(`{
"example.com.": {
"status": "NOERROR",
"delegation": {"status": "SECURE"},
"queries": {"example.com./A": {"errors": [{"code": "X", "description": "boom"}]}}
},
"com.": {"delegation": {"status": "SECURE"}},
".": {"delegation": {"status": "SECURE"}},
"_meta": {"ignored": true}
}`)
zones, order, err := ParseGrokOutput(raw)
if err != nil {
t.Fatalf("ParseGrokOutput: %v", err)
}
if _, ok := zones["_meta"]; ok {
t.Errorf("expected _meta-prefixed key to be skipped, got it in zones")
}
if len(zones) != 3 {
t.Errorf("expected 3 zones, got %d (%v)", len(zones), zones)
}
// Order: most-specific first (example.com.), root last.
if !reflect.DeepEqual(order, []string{"example.com.", "com.", "."}) {
t.Errorf("unexpected order: %v", order)
}
if zones["example.com."].Status != "SECURE" {
t.Errorf("expected delegation.status to win for example.com., got %q", zones["example.com."].Status)
}
if zones["example.com."].DNSStatus != "NOERROR" {
t.Errorf("expected DNSStatus=NOERROR, got %q", zones["example.com."].DNSStatus)
}
if len(zones["example.com."].Errors) != 1 {
t.Fatalf("expected 1 error, got %v", zones["example.com."].Errors)
}
if zones["example.com."].Errors[0].Code != "X" {
t.Errorf("expected code=X, got %q", zones["example.com."].Errors[0].Code)
}
}
func TestParseGrokOutput_InvalidJSON(t *testing.T) {
if _, _, err := ParseGrokOutput([]byte("not json")); err == nil {
t.Fatal("expected error for invalid JSON")
}
}
func TestParseGrokOutput_StringZone(t *testing.T) {
// Old grok: a zone may collapse into a bare string status.
raw := []byte(`{"missing.example.": "NON_EXISTENT"}`)
zones, _, err := ParseGrokOutput(raw)
if err != nil {
t.Fatalf("ParseGrokOutput: %v", err)
}
if zones["missing.example."].Status != "NON_EXISTENT" {
t.Errorf("got %q, want NON_EXISTENT", zones["missing.example."].Status)
}
}
func TestDecodeZone_StatusFallbacks(t *testing.T) {
// Only top-level status; no delegation block. Status must fall back to it.
raw := []byte(`{"status": "NOERROR"}`)
z := decodeZone(raw)
if z.DNSStatus != "NOERROR" || z.Status != "NOERROR" {
t.Errorf("expected Status and DNSStatus = NOERROR, got %+v", z)
}
}
func TestCollectFindings_Nested(t *testing.T) {
raw := []byte(`{
"delegation": {
"errors": [{"code": "DS", "description": "missing"}]
},
"queries": {
"example.com./A": {
"answer": [
{"warnings": [{"code": "W1", "description": "smelly"}]}
]
}
}
}`)
z := decodeZone(raw)
if len(z.Errors) != 1 || z.Errors[0].Path != "delegation" {
t.Errorf("expected one error tagged delegation, got %+v", z.Errors)
}
if len(z.Warnings) != 1 {
t.Fatalf("expected one warning, got %+v", z.Warnings)
}
w := z.Warnings[0]
if !strings.HasPrefix(w.Path, "queries/example.com./A/answer[") {
t.Errorf("unexpected warning path: %q", w.Path)
}
if w.Code != "W1" || w.Description != "smelly" {
t.Errorf("unexpected warning content: %+v", w)
}
}
func TestAsFindings_VariantShapes(t *testing.T) {
// Object-keyed-by-code variant.
out := asFindings(map[string]any{
"CODE_B": map[string]any{"description": "second"},
"CODE_A": map[string]any{"description": "first"},
}, "p")
if len(out) != 2 {
t.Fatalf("expected 2 findings, got %v", out)
}
// Sorted by key for stability.
if out[0].Code != "CODE_A" || out[1].Code != "CODE_B" {
t.Errorf("findings not sorted by key: %+v", out)
}
for _, f := range out {
if f.Path != "p" {
t.Errorf("expected path=p, got %q", f.Path)
}
}
// []string variant (rare but supported via direct call).
strs := asFindings([]string{"raw1", "raw2"}, "p")
if len(strs) != 2 || strs[0].Description != "raw1" {
t.Errorf("string-list shape mishandled: %+v", strs)
}
// Unsupported scalar shape returns nil.
if asFindings(42, "p") != nil {
t.Errorf("expected nil for non-list non-map non-string-slice")
}
}
func TestMakeFinding_FallbackAndServers(t *testing.T) {
// description missing, message present.
f := makeFinding(map[string]any{
"message": "use-message",
"servers": []any{"ns1.example.", "ns2.example.", 42 /*ignored*/},
}, "fallback_code", "p")
if f.Description != "use-message" {
t.Errorf("wanted message fallback, got %q", f.Description)
}
if !reflect.DeepEqual(f.Servers, []string{"ns1.example.", "ns2.example."}) {
t.Errorf("non-string server entries should be skipped, got %v", f.Servers)
}
if f.Code != "fallback_code" {
t.Errorf("expected codeHint to be used when item has no code, got %q", f.Code)
}
// neither description nor message: keep the raw payload in Extra
// instead of synthesising a JSON blob into Description (which would
// then render as ugly text in the report).
f2 := makeFinding(map[string]any{"weird": 1}, "", "p")
if f2.Description != "" {
t.Errorf("expected empty Description when no human text available, got %q", f2.Description)
}
if f2.Extra == nil || f2.Extra["weird"] != 1 {
t.Errorf("expected raw payload in Extra, got %+v", f2.Extra)
}
// Plain string item.
f3 := makeFinding("just a string", "h", "p")
if f3.Description != "just a string" || f3.Code != "h" {
t.Errorf("string item mishandled: %+v", f3)
}
// Item explicit code overrides codeHint.
f4 := makeFinding(map[string]any{"code": "REAL", "description": "d"}, "hint", "p")
if f4.Code != "REAL" {
t.Errorf("expected explicit code to win, got %q", f4.Code)
}
}
func TestParseGrokOutput_OrderStable(t *testing.T) {
// Same-depth zones should still produce a deterministic slice (keys order
// in Go maps is randomized) - just checks the zones each appear once.
raw := []byte(`{"a.": {}, "b.": {}}`)
_, order, err := ParseGrokOutput(raw)
if err != nil {
t.Fatal(err)
}
cp := append([]string(nil), order...)
sort.Strings(cp)
if !reflect.DeepEqual(cp, []string{"a.", "b."}) {
t.Errorf("missing zones in order: %v", order)
}
}

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)
}

73
checker/provider_test.go Normal file
View file

@ -0,0 +1,73 @@
// SPDX-License-Identifier: MIT
package checker
import (
"context"
"errors"
"testing"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func TestProvider_DelegatesCollect(t *testing.T) {
called := false
want := errors.New("sentinel")
p := Provider(func(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
called = true
return "value", want
})
got, err := p.Collect(context.Background(), sdk.CheckerOptions{})
if !called {
t.Fatal("collect fn not called")
}
if got != "value" || err != want {
t.Errorf("unexpected return: %v, %v", got, err)
}
if p.Key() != ObservationKeyDNSViz {
t.Errorf("Key=%q, want %q", p.Key(), ObservationKeyDNSViz)
}
}
func TestDefinition(t *testing.T) {
p := Provider(func(_ context.Context, _ sdk.CheckerOptions) (any, error) { return nil, nil })
dp, ok := p.(sdk.CheckerDefinitionProvider)
if !ok {
t.Fatal("provider does not implement CheckerDefinitionProvider")
}
def := dp.Definition()
if def.ID != "dnsviz" {
t.Errorf("ID=%q", def.ID)
}
if !def.HasHTMLReport || !def.HasMetrics {
t.Error("expected HasHTMLReport and HasMetrics to be true")
}
if !def.Availability.ApplyToDomain {
t.Error("expected ApplyToDomain")
}
if def.Interval == nil || def.Interval.Default <= 0 {
t.Errorf("interval not set: %+v", def.Interval)
}
if len(def.Rules) == 0 || len(def.RulesInfo) != len(def.Rules) {
t.Errorf("rules vs rulesInfo: %d / %d", len(def.Rules), len(def.RulesInfo))
}
// At least one rule per published name.
for _, ri := range def.RulesInfo {
if ri.Name == "" || ri.Description == "" {
t.Errorf("missing name/description in RulesInfo: %+v", ri)
}
}
if len(def.ObservationKeys) == 0 || def.ObservationKeys[0] != ObservationKeyDNSViz {
t.Errorf("observation keys: %v", def.ObservationKeys)
}
// Sanity: the domain-level option declares the auto-fill we rely on.
hasDomain := false
for _, o := range def.Options.DomainOpts {
if o.Id == "domain_name" && o.AutoFill == sdk.AutoFillDomainName {
hasDomain = true
}
}
if !hasDomain {
t.Error("expected domain_name option with AutoFillDomainName")
}
}

1000
checker/report.go Normal file

File diff suppressed because it is too large Load diff

170
checker/report_test.go Normal file
View file

@ -0,0 +1,170 @@
// SPDX-License-Identifier: MIT
package checker
import (
"encoding/json"
"strings"
"testing"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func TestWorstStatus(t *testing.T) {
if got := worstStatus(nil); got != sdk.StatusOK {
t.Errorf("nil states: got %v, want OK", got)
}
got := worstStatus([]sdk.CheckState{
{Status: sdk.StatusOK},
{Status: sdk.StatusWarn},
{Status: sdk.StatusCrit},
{Status: sdk.StatusInfo},
})
if got != sdk.StatusCrit {
t.Errorf("got %v, want Crit", got)
}
}
func TestTitleAndHint(t *testing.T) {
title, hint := titleAndHint(sdk.CheckState{
Message: "fallback",
Meta: map[string]any{"title": "T", "hint": "H"},
})
if title != "T" || hint != "H" {
t.Errorf("got (%q,%q), want (T,H)", title, hint)
}
// Falls back to message when no title in meta.
title, _ = titleAndHint(sdk.CheckState{Message: "fb"})
if title != "fb" {
t.Errorf("expected fallback to Message, got %q", title)
}
}
func TestGetHTMLReport_EmptyContext(t *testing.T) {
p := &dnsvizProvider{}
out, err := p.GetHTMLReport(sdk.StaticReportContext(nil))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, "No DNSViz data and no rule states") {
t.Errorf("expected empty banner, got: %s", out)
}
}
func TestGetHTMLReport_FullDocument(t *testing.T) {
data := &DNSVizData{
Domain: "example.com",
Order: []string{"example.com.", "com.", "."},
Zones: map[string]ZoneAnalysis{
"example.com.": {
Status: "BOGUS",
Errors: []Finding{{Code: "RRSIG_EXPIRED", Description: "signature has expired", Servers: []string{"ns1"}}},
},
"com.": {Status: "SECURE"},
".": {Status: "SECURE"},
},
Raw: []byte(`{"example.com.": {"status": "BOGUS"}}`),
ProbeStderr: "probe-warning",
GrokStderr: "grok-warning",
}
rawJSON, _ := json.Marshal(data)
states := []sdk.CheckState{
{Status: sdk.StatusCrit, Code: "dnssec_rrsig_expired", Subject: "example.com.", Message: "Signature expired",
Meta: map[string]any{"title": "Signature expired", "hint": "Re-sign the zone."}},
{Status: sdk.StatusOK, Code: "dnsviz_overall_status", Message: "ok"},
}
p := &dnsvizProvider{}
out, err := p.GetHTMLReport(sdk.NewReportContext(rawJSON, nil, states))
if err != nil {
t.Fatal(err)
}
wantContains := []string{
"<title>DNSSEC report: example.com</title>",
`class="banner s-CRIT"`,
"Fix these first",
"Re-sign the zone.",
"DNS hierarchy",
"RRSIG_EXPIRED",
"All rule states",
"probe-warning",
"grok-warning",
}
for _, sub := range wantContains {
if !strings.Contains(out, sub) {
t.Errorf("HTML missing %q", sub)
}
}
// Ensure XSS-prone strings are escaped.
xssData := &DNSVizData{
Domain: `<script>alert(1)</script>`,
Order: []string{"x."},
Zones: map[string]ZoneAnalysis{"x.": {Status: "SECURE"}},
}
rawXSS, _ := json.Marshal(xssData)
xssOut, _ := p.GetHTMLReport(sdk.StaticReportContext(rawXSS))
if strings.Contains(xssOut, "<script>alert(1)</script>") {
t.Errorf("unescaped <script> in report: %s", xssOut)
}
}
func TestGetHTMLReport_BadJSON(t *testing.T) {
p := &dnsvizProvider{}
_, err := p.GetHTMLReport(sdk.StaticReportContext(json.RawMessage("not json")))
if err == nil {
t.Fatal("expected error for malformed data")
}
}
func TestExtractMetrics(t *testing.T) {
data := &DNSVizData{
Zones: map[string]ZoneAnalysis{
"example.com.": {Errors: []Finding{{}, {}}, Warnings: []Finding{{}}},
"com.": {},
},
}
rawJSON, _ := json.Marshal(data)
states := []sdk.CheckState{
{Status: sdk.StatusOK},
{Status: sdk.StatusCrit},
{Status: sdk.StatusCrit},
}
p := &dnsvizProvider{}
now := time.Now()
metrics, err := p.ExtractMetrics(sdk.NewReportContext(rawJSON, nil, states), now)
if err != nil {
t.Fatal(err)
}
byName := map[string]float64{}
statusCounts := map[string]float64{}
for _, m := range metrics {
if !m.Timestamp.Equal(now) {
t.Errorf("metric %q has wrong timestamp", m.Name)
}
if m.Name == "dnsviz.findings.count" {
statusCounts[m.Labels["status"]] = m.Value
continue
}
byName[m.Name] = m.Value
}
if byName["dnsviz.zones.count"] != 2 {
t.Errorf("zones.count = %v, want 2", byName["dnsviz.zones.count"])
}
if byName["dnsviz.errors.count"] != 2 {
t.Errorf("errors.count = %v, want 2", byName["dnsviz.errors.count"])
}
if byName["dnsviz.warnings.count"] != 1 {
t.Errorf("warnings.count = %v, want 1", byName["dnsviz.warnings.count"])
}
if statusCounts["CRIT"] != 2 || statusCounts["OK"] != 1 {
t.Errorf("findings counts: %v", statusCounts)
}
}
func TestExtractMetrics_BadJSON(t *testing.T) {
p := &dnsvizProvider{}
_, err := p.ExtractMetrics(sdk.StaticReportContext(json.RawMessage("not json")), time.Now())
if err == nil {
t.Fatal("expected error")
}
}

75
checker/rule.go Normal file
View file

@ -0,0 +1,75 @@
// 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.
// Subject is the zone FQDN so a fault at the TLD is never silently merged with a leaf fault.
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
}
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
}
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 "NOERROR":
// DNS-level OK with no DNSSEC chain status reported. The zone
// resolves but isn't signed (or grok didn't classify it).
return sdk.StatusInfo
case "":
return sdk.StatusUnknown
default:
return sdk.StatusUnknown
}
}

96
checker/rule_test.go Normal file
View file

@ -0,0 +1,96 @@
// SPDX-License-Identifier: MIT
package checker
import (
"context"
"errors"
"reflect"
"testing"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func TestStatusFromGrok(t *testing.T) {
cases := map[string]sdk.Status{
"SECURE": sdk.StatusOK,
" secure ": sdk.StatusOK,
"INSECURE": sdk.StatusInfo,
"BOGUS": sdk.StatusCrit,
"INDETERMINATE": sdk.StatusWarn,
"NON_EXISTENT": sdk.StatusInfo,
"NOERROR": sdk.StatusInfo,
"": sdk.StatusUnknown,
"WHATEVER": sdk.StatusUnknown,
}
for in, want := range cases {
if got := statusFromGrok(in); got != want {
t.Errorf("statusFromGrok(%q) = %v, want %v", in, got, want)
}
}
}
func TestOrderedZones_PrefersOrder(t *testing.T) {
d := &DNSVizData{
Order: []string{"keep.", "this."},
Zones: map[string]ZoneAnalysis{"keep.": {}, "this.": {}, "other.": {}},
}
if got := orderedZones(d); !reflect.DeepEqual(got, []string{"keep.", "this."}) {
t.Errorf("expected explicit Order to be returned, got %v", got)
}
}
func TestOrderedZones_SortsByDepth(t *testing.T) {
d := &DNSVizData{
Zones: map[string]ZoneAnalysis{
".": {},
"com.": {},
"example.com.": {},
},
}
got := orderedZones(d)
want := []string{"example.com.", "com.", "."}
if !reflect.DeepEqual(got, want) {
t.Errorf("orderedZones=%v, want %v", got, want)
}
}
func TestLoadData_ReturnsErrorState(t *testing.T) {
obs := stubObs{err: errors.New("boom")}
data, errState := loadData(context.Background(), obs, "code_x")
if data != nil {
t.Errorf("expected nil data on error, got %+v", data)
}
if len(errState) != 1 || errState[0].Status != sdk.StatusError || errState[0].Code != "code_x" {
t.Errorf("unexpected error state: %+v", errState)
}
}
func TestLoadData_Success(t *testing.T) {
d := &DNSVizData{Domain: "example.com", Zones: map[string]ZoneAnalysis{"example.com.": {Status: "SECURE"}}}
obs := stubObs{value: d}
got, errState := loadData(context.Background(), obs, "code_y")
if errState != nil {
t.Fatalf("unexpected error state: %+v", errState)
}
if got == nil || got.Domain != "example.com" {
t.Errorf("unexpected data: %+v", got)
}
}
func TestRules_Wired(t *testing.T) {
rs := Rules()
if len(rs) == 0 {
t.Fatal("expected at least one rule")
}
seen := map[string]bool{}
for _, r := range rs {
if r.Name() == "" || r.Description() == "" {
t.Errorf("rule has empty Name/Description: %T", r)
}
if seen[r.Name()] {
t.Errorf("duplicate rule name: %s", r.Name())
}
seen[r.Name()] = true
}
}

214
checker/rules_common.go Normal file
View file

@ -0,0 +1,214 @@
// SPDX-License-Identifier: MIT
package checker
import (
"context"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// commonFailuresRule uses substring matching rather than exact codes because
// DNSViz wording shifts between versions.
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 exported so the report layer can read Title/Hint from CheckState.Meta.
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",
"signature expired",
"rrsig 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",
"algorithm_deprecated",
"weak algorithm",
"rsasha1",
"rsa/sha-1",
"rsa-sha1",
"algorithm 5 ",
"algorithm 7 ",
},
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{}{}
matchFinding := func(name string, f Finding) {
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 {
return
}
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,
},
})
return
}
}
for _, name := range orderedZones(data) {
z := data.Zones[name]
for _, f := range z.Errors {
matchFinding(name, f)
}
for _, f := range z.Warnings {
matchFinding(name, f)
}
}
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
}

View file

@ -0,0 +1,122 @@
// SPDX-License-Identifier: MIT
package checker
import (
"context"
"strings"
"testing"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func TestMatchesAny(t *testing.T) {
if !matchesAny("foo signature has expired bar", []string{"signature has expired"}) {
t.Error("expected substring match")
}
if matchesAny("clean", []string{"missing"}) {
t.Error("did not expect a match")
}
if matchesAny("anything", []string{""}) {
t.Error("empty needle must not match")
}
if matchesAny("anything", nil) {
t.Error("nil needles must not match")
}
}
func TestCommonFailuresRule_Match(t *testing.T) {
d := &DNSVizData{
Order: []string{"example.com."},
Zones: map[string]ZoneAnalysis{
"example.com.": {
Errors: []Finding{
{Code: "X", Description: "An RRSIG signature has expired"},
},
},
},
}
r := &commonFailuresRule{}
states := r.Evaluate(context.Background(), stubObs{value: d}, nil)
if len(states) != 1 {
t.Fatalf("expected 1 match, got %+v", states)
}
if states[0].Code != "dnssec_rrsig_expired" {
t.Errorf("expected curated id, got %q", states[0].Code)
}
if states[0].Status != sdk.StatusCrit {
t.Errorf("expected severity Crit, got %v", states[0].Status)
}
if _, ok := states[0].Meta["hint"].(string); !ok {
t.Errorf("expected hint in Meta, got %+v", states[0].Meta)
}
}
func TestCommonFailuresRule_NoMatchEmitsOK(t *testing.T) {
d := &DNSVizData{
Order: []string{"example.com."},
Zones: map[string]ZoneAnalysis{
"example.com.": {Errors: []Finding{{Description: "totally unrelated message"}}},
},
}
r := &commonFailuresRule{}
states := r.Evaluate(context.Background(), stubObs{value: d}, nil)
if len(states) != 1 || states[0].Status != sdk.StatusOK {
t.Errorf("expected single OK summary, got %+v", states)
}
}
func TestCommonFailuresRule_DedupePerZone(t *testing.T) {
// Same scenario surfaced multiple times in a single zone should only emit
// one curated state for that (zone, scenario) pair.
d := &DNSVizData{
Order: []string{"example.com."},
Zones: map[string]ZoneAnalysis{
"example.com.": {
Errors: []Finding{
{Description: "no DS records found"},
{Description: "missing DS at the parent"},
},
},
},
}
r := &commonFailuresRule{}
states := r.Evaluate(context.Background(), stubObs{value: d}, nil)
if len(states) != 1 {
t.Fatalf("expected dedup to a single state, got %+v", states)
}
if states[0].Code != "dnssec_chain_broken_no_ds" {
t.Errorf("unexpected curated code: %q", states[0].Code)
}
}
func TestCommonFailuresRule_FirstMatchWins(t *testing.T) {
// "rrsig_expired" is listed before generic "expired" patterns; verify a
// finding mentioning multiple patterns yields exactly one curated entry.
d := &DNSVizData{
Order: []string{"x."},
Zones: map[string]ZoneAnalysis{
"x.": {Errors: []Finding{{Description: "signature has expired and DS does not match"}}},
},
}
r := &commonFailuresRule{}
states := r.Evaluate(context.Background(), stubObs{value: d}, nil)
if len(states) != 1 {
t.Fatalf("expected single curated entry, got %d: %+v", len(states), states)
}
if !strings.HasPrefix(states[0].Code, "dnssec_") {
t.Errorf("unexpected code %q", states[0].Code)
}
}
func TestCommonFailures_PatternsLowercase(t *testing.T) {
// All catalog patterns are lowercased for substring matching against a
// lowercased haystack. A non-lowercase pattern would silently never match.
for _, c := range commonFailures {
for _, p := range c.Patterns {
if p != strings.ToLower(p) {
t.Errorf("pattern %q in %q is not lowercase", p, c.ID)
}
}
}
}

164
checker/rules_status.go Normal file
View file

@ -0,0 +1,164 @@
// SPDX-License-Identifier: MIT
package checker
import (
"context"
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
)
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}
}
// Subject is set to the zone name so each delegation level gets its own report block.
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
}
// 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
}
return zoneFindingStates(data, "dnsviz_zone_errors", sdk.StatusCrit, "errors", func(z ZoneAnalysis) []Finding { return z.Errors })
}
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
}
return zoneFindingStates(data, "dnsviz_zone_warnings", sdk.StatusWarn, "warnings", func(z ZoneAnalysis) []Finding { return z.Warnings })
}
// zoneFindingStates emits a single OK state when nothing matches so the rule outcome is always observable.
func zoneFindingStates(data *DNSVizData, ruleCode string, status sdk.Status, kindLabel string, pick func(ZoneAnalysis) []Finding) []sdk.CheckState {
var out []sdk.CheckState
for _, name := range orderedZones(data) {
for _, f := range pick(data.Zones[name]) {
out = append(out, sdk.CheckState{
Status: status,
Code: nonEmpty(f.Code, ruleCode),
Subject: name,
Message: f.Description,
Meta: findingMeta(f),
})
}
}
if len(out) == 0 {
return []sdk.CheckState{{
Status: sdk.StatusOK,
Code: ruleCode,
Message: fmt.Sprintf("DNSViz reported no %s in any zone", kindLabel),
}}
}
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
}

View file

@ -0,0 +1,189 @@
// SPDX-License-Identifier: MIT
package checker
import (
"context"
"errors"
"strings"
"testing"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func sampleData() *DNSVizData {
return &DNSVizData{
Domain: "example.com",
Order: []string{"example.com.", "com.", "."},
Zones: map[string]ZoneAnalysis{
"example.com.": {
Status: "BOGUS",
Errors: []Finding{{Code: "RRSIG_EXPIRED", Description: "signature has expired"}},
},
"com.": {Status: "SECURE"},
".": {Status: "SECURE"},
},
}
}
func TestOverallStatusRule(t *testing.T) {
r := &overallStatusRule{}
states := r.Evaluate(context.Background(), stubObs{value: sampleData()}, nil)
if len(states) != 1 {
t.Fatalf("expected 1 state, got %d", len(states))
}
if states[0].Status != sdk.StatusCrit {
t.Errorf("expected StatusCrit for BOGUS leaf, got %v", states[0].Status)
}
if states[0].Subject != "example.com." {
t.Errorf("subject: got %q", states[0].Subject)
}
}
func TestOverallStatusRule_FallbackToFirstZone(t *testing.T) {
d := &DNSVizData{
Domain: "missing",
Order: []string{"other.zone."},
Zones: map[string]ZoneAnalysis{"other.zone.": {Status: "SECURE"}},
}
r := &overallStatusRule{}
states := r.Evaluate(context.Background(), stubObs{value: d}, nil)
if states[0].Subject != "other.zone." {
t.Errorf("expected fallback to first zone, got %q", states[0].Subject)
}
if states[0].Status != sdk.StatusOK {
t.Errorf("expected SECURE -> OK, got %v", states[0].Status)
}
}
func TestOverallStatusRule_NoZones(t *testing.T) {
r := &overallStatusRule{}
states := r.Evaluate(context.Background(), stubObs{value: &DNSVizData{Domain: "x"}}, nil)
if states[0].Status != sdk.StatusUnknown {
t.Errorf("expected Unknown for empty zones, got %v", states[0].Status)
}
}
func TestOverallStatusRule_LoadError(t *testing.T) {
r := &overallStatusRule{}
states := r.Evaluate(context.Background(), stubObs{err: errors.New("nope")}, nil)
if len(states) != 1 || states[0].Status != sdk.StatusError {
t.Errorf("expected one error state, got %+v", states)
}
}
func TestPerZoneStatusRule(t *testing.T) {
r := &perZoneStatusRule{}
states := r.Evaluate(context.Background(), stubObs{value: sampleData()}, nil)
if len(states) != 3 {
t.Fatalf("expected 3 per-zone states, got %d", len(states))
}
subjects := make([]string, len(states))
for i, s := range states {
subjects[i] = s.Subject
}
want := []string{"example.com.", "com.", "."}
for i := range want {
if subjects[i] != want[i] {
t.Errorf("subjects[%d]=%q, want %q", i, subjects[i], want[i])
}
}
}
func TestPerZoneStatusRule_NoZones(t *testing.T) {
r := &perZoneStatusRule{}
states := r.Evaluate(context.Background(), stubObs{value: &DNSVizData{}}, nil)
if len(states) != 1 || states[0].Status != sdk.StatusUnknown {
t.Errorf("expected single Unknown state, got %+v", states)
}
}
func TestZoneErrorsRule(t *testing.T) {
r := &zoneErrorsRule{}
states := r.Evaluate(context.Background(), stubObs{value: sampleData()}, nil)
if len(states) != 1 {
t.Fatalf("expected 1 error state, got %d", len(states))
}
if states[0].Status != sdk.StatusCrit {
t.Errorf("expected Crit, got %v", states[0].Status)
}
if states[0].Code != "RRSIG_EXPIRED" {
t.Errorf("expected the finding code to be used, got %q", states[0].Code)
}
}
func TestZoneErrorsRule_NoFindings(t *testing.T) {
d := &DNSVizData{Order: []string{"a."}, Zones: map[string]ZoneAnalysis{"a.": {Status: "SECURE"}}}
r := &zoneErrorsRule{}
states := r.Evaluate(context.Background(), stubObs{value: d}, nil)
if len(states) != 1 || states[0].Status != sdk.StatusOK {
t.Errorf("expected OK summary state, got %+v", states)
}
}
func TestZoneWarningsRule(t *testing.T) {
d := &DNSVizData{
Order: []string{"a."},
Zones: map[string]ZoneAnalysis{"a.": {Warnings: []Finding{{Description: "soft"}}}},
}
r := &zoneWarningsRule{}
states := r.Evaluate(context.Background(), stubObs{value: d}, nil)
if len(states) != 1 || states[0].Status != sdk.StatusWarn {
t.Errorf("expected Warn state, got %+v", states)
}
// Code falls back to the rule code when finding has none.
if states[0].Code != "dnsviz_zone_warnings" {
t.Errorf("expected fallback code, got %q", states[0].Code)
}
}
func TestEmptyAsUnknown(t *testing.T) {
if emptyAsUnknown("") != "UNKNOWN" {
t.Error("empty should map to UNKNOWN")
}
if emptyAsUnknown("X") != "X" {
t.Error("non-empty should pass through")
}
}
func TestNonEmpty(t *testing.T) {
if nonEmpty("a", "b") != "a" || nonEmpty("", "b") != "b" {
t.Error("nonEmpty did not pick non-empty")
}
}
func TestFindingMeta(t *testing.T) {
if findingMeta(Finding{}) != nil {
t.Error("expected nil for empty finding")
}
m := findingMeta(Finding{Code: "C", Servers: []string{"a"}})
if m["code"] != "C" {
t.Errorf("missing code in meta: %v", m)
}
srvs, _ := m["servers"].([]string)
if len(srvs) != 1 || srvs[0] != "a" {
t.Errorf("missing servers in meta: %v", m)
}
}
func TestZoneErrorsRule_ConcatPerZone(t *testing.T) {
d := &DNSVizData{
Order: []string{"leaf.", "tld."},
Zones: map[string]ZoneAnalysis{
"leaf.": {Errors: []Finding{{Description: "leaf-err"}}},
"tld.": {Errors: []Finding{{Description: "tld-err"}}},
},
}
r := &zoneErrorsRule{}
states := r.Evaluate(context.Background(), stubObs{value: d}, nil)
if len(states) != 2 {
t.Fatalf("expected 2 states, got %d", len(states))
}
// Subjects should both appear, leaf first per Order.
if states[0].Subject != "leaf." || states[1].Subject != "tld." {
t.Errorf("subjects out of order: %q,%q", states[0].Subject, states[1].Subject)
}
if !strings.Contains(states[0].Message, "leaf-err") {
t.Errorf("leaf message lost: %q", states[0].Message)
}
}

View file

@ -0,0 +1,37 @@
// SPDX-License-Identifier: MIT
package checker
import (
"context"
"encoding/json"
"errors"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// stubObs is a minimal ObservationGetter for rule tests. It JSON round-trips
// the stored value into the destination so it exercises the same code path
// rules see in production.
type stubObs struct {
value any
err error
}
func (s stubObs) Get(_ context.Context, _ sdk.ObservationKey, dest any) error {
if s.err != nil {
return s.err
}
if s.value == nil {
return errors.New("no value")
}
raw, err := json.Marshal(s.value)
if err != nil {
return err
}
return json.Unmarshal(raw, dest)
}
func (s stubObs) GetRelated(_ context.Context, _ sdk.ObservationKey) ([]sdk.RelatedObservation, error) {
return nil, nil
}

86
checker/types.go Normal file
View file

@ -0,0 +1,86 @@
// 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 output puts the DNSSEC chain status at delegation.status (one of
// "SECURE", "BOGUS", "INSECURE", "INDETERMINATE") while the top-level
// "status" field carries the DNS rcode for the zone apex (e.g. "NOERROR").
// Errors and warnings are not surfaced as a flat per-zone array; instead
// they appear as nested "errors"/"warnings" arrays attached to the record
// where the problem was found (DS, DNSKEY, RRSIG, NSEC proof, query
// response, server, …). We walk the whole zone subtree to collect them.
type ZoneAnalysis struct {
// Status is the DNSSEC chain status taken from delegation.status when
// available, falling back to the top-level "status" field otherwise.
Status string `json:"status,omitempty"`
// DNSStatus is the raw top-level "status" field (DNS rcode such as
// "NOERROR"). Kept for the report so we can distinguish "DNS resolved
// fine" from "DNSSEC chain validates".
DNSStatus string `json:"dns_status,omitempty"`
Errors []Finding `json:"errors,omitempty"`
Warnings []Finding `json:"warnings,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"`
// Path is a slash-separated pointer to the JSON node where the finding
// was attached (e.g. "delegation/ds[0]" or
// "queries/example.com./IN/A/answer[0]/rrsig[0]"). Useful in the
// report so a generic "signature_invalid" can be located precisely.
Path string `json:"path,omitempty"`
// Extra holds the raw finding payload when no human description could
// be extracted. Surfaced by the report as a debug fallback.
Extra map[string]any `json:"extra,omitempty"`
}