Initial commit
This commit is contained in:
commit
53626dd36a
29 changed files with 3940 additions and 0 deletions
246
checker/collect.go
Normal file
246
checker/collect.go
Normal 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
223
checker/collect_test.go
Normal 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
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)
|
||||
}
|
||||
73
checker/provider_test.go
Normal file
73
checker/provider_test.go
Normal 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
1000
checker/report.go
Normal file
File diff suppressed because it is too large
Load diff
170
checker/report_test.go
Normal file
170
checker/report_test.go
Normal 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
75
checker/rule.go
Normal 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
96
checker/rule_test.go
Normal 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
214
checker/rules_common.go
Normal 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
|
||||
}
|
||||
122
checker/rules_common_test.go
Normal file
122
checker/rules_common_test.go
Normal 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
164
checker/rules_status.go
Normal 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
|
||||
}
|
||||
189
checker/rules_status_test.go
Normal file
189
checker/rules_status_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
37
checker/testhelpers_test.go
Normal file
37
checker/testhelpers_test.go
Normal 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
86
checker/types.go
Normal 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"`
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue