Compare commits

...

3 commits

Author SHA1 Message Date
4543e9b0cf Move status inference out of observation layer into rules
All checks were successful
continuous-integration/drone/push Build is passing
The prober (collect.go) was calling inferApexDNSKEYStatus during
zone parsing, effectively making a SECURE/BOGUS judgement inside the
collection phase rather than the evaluation phase.  The DNS-rcode
fallback (z.Status = z.DNSStatus) was also applied at parse time.
2026-05-16 21:51:44 +08:00
5591df021b Add CI/CD pipeline
All checks were successful
continuous-integration/drone/push Build is passing
2026-05-10 18:58:34 +08:00
77dfb82313 fix: return ErrNoCollector instead of panicking on nil CollectFn
Hosts that register the provider only for its definition (externalizable
checker) construct it with a nil CollectFn. If the local ObservationContext
ends up calling Collect, the previous code dereferenced a nil function
value and crashed the goroutine. Surface a typed error so rules degrade
to a clean StatusError state.
2026-04-30 09:23:26 +07:00
9 changed files with 328 additions and 83 deletions

22
.drone-manifest.yml Normal file
View file

@ -0,0 +1,22 @@
image: happydomain/checker-dnsviz:{{#if build.tag}}{{trimPrefix "v" build.tag}}{{else}}latest{{/if}}
{{#if build.tags}}
tags:
{{#each build.tags}}
- {{this}}
{{/each}}
{{/if}}
manifests:
- image: happydomain/checker-dnsviz:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-amd64
platform:
architecture: amd64
os: linux
- image: happydomain/checker-dnsviz:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm64
platform:
architecture: arm64
os: linux
variant: v8
- image: happydomain/checker-dnsviz:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm
platform:
architecture: arm
os: linux
variant: v7

187
.drone.yml Normal file
View file

@ -0,0 +1,187 @@
---
kind: pipeline
type: docker
name: build-amd64
platform:
os: linux
arch: amd64
steps:
- name: checker build
image: golang:1-alpine
commands:
- apk add --no-cache git make
- make
environment:
CHECKER_VERSION: "${DRONE_BRANCH}-${DRONE_COMMIT}"
CGO_ENABLED: 0
when:
event:
exclude:
- tag
- name: checker build tag
image: golang:1-alpine
commands:
- apk add --no-cache git make
- make
environment:
CHECKER_VERSION: "${DRONE_SEMVER}"
CGO_ENABLED: 0
when:
event:
- tag
- name: publish on Docker Hub
image: plugins/docker
settings:
repo: happydomain/checker-dnsviz
auto_tag: true
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
dockerfile: Dockerfile
build_args:
- CHECKER_VERSION=${DRONE_BRANCH}-${DRONE_COMMIT}
username:
from_secret: docker_username
password:
from_secret: docker_password
when:
event:
exclude:
- tag
- name: publish on Docker Hub (tag)
image: plugins/docker
settings:
repo: happydomain/checker-dnsviz
auto_tag: true
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
dockerfile: Dockerfile
build_args:
- CHECKER_VERSION=${DRONE_SEMVER}
username:
from_secret: docker_username
password:
from_secret: docker_password
when:
event:
- tag
trigger:
branch:
exclude:
- renovate/*
event:
- cron
- push
- tag
---
kind: pipeline
type: docker
name: build-arm64
platform:
os: linux
arch: arm64
steps:
- name: checker build
image: golang:1-alpine
commands:
- apk add --no-cache git make
- make
environment:
CHECKER_VERSION: "${DRONE_BRANCH}-${DRONE_COMMIT}"
CGO_ENABLED: 0
when:
event:
exclude:
- tag
- name: checker build tag
image: golang:1-alpine
commands:
- apk add --no-cache git make
- make
environment:
CHECKER_VERSION: "${DRONE_SEMVER}"
CGO_ENABLED: 0
when:
event:
- tag
- name: publish on Docker Hub
image: plugins/docker
settings:
repo: happydomain/checker-dnsviz
auto_tag: true
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
dockerfile: Dockerfile
build_args:
- CHECKER_VERSION=${DRONE_BRANCH}-${DRONE_COMMIT}
username:
from_secret: docker_username
password:
from_secret: docker_password
when:
event:
exclude:
- tag
- name: publish on Docker Hub (tag)
image: plugins/docker
settings:
repo: happydomain/checker-dnsviz
auto_tag: true
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
dockerfile: Dockerfile
build_args:
- CHECKER_VERSION=${DRONE_SEMVER}
username:
from_secret: docker_username
password:
from_secret: docker_password
when:
event:
- tag
trigger:
event:
- cron
- push
- tag
---
kind: pipeline
name: docker-manifest
platform:
os: linux
arch: arm64
steps:
- name: publish on Docker Hub
image: plugins/manifest
settings:
auto_tag: true
ignore_missing: true
spec: .drone-manifest.yml
username:
from_secret: docker_username
password:
from_secret: docker_password
trigger:
branch:
exclude:
- renovate/*
event:
- cron
- push
- tag
depends_on:
- build-amd64
- build-arm64

View file

@ -59,18 +59,12 @@ func decodeZone(raw json.RawMessage) ZoneAnalysis {
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
// Store raw queries so rules can infer the zone status from RRSIG
// validation when no delegation block is present (e.g. root zone).
// Status inference and DNS-rcode fallback are the responsibility of the
// evaluation layer (see effectiveStatus in rule.go).
if q, ok := m["queries"].(map[string]any); ok {
z.Queries = q
}
z.Errors, z.Warnings = collectFindings(m, "")
@ -181,62 +175,6 @@ func makeFinding(item any, codeHint, path string) Finding {
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 == "" {

View file

@ -99,11 +99,19 @@ func TestParseGrokOutput_StringZone(t *testing.T) {
}
func TestDecodeZone_StatusFallbacks(t *testing.T) {
// Only top-level status; no delegation block. Status must fall back to it.
// Only top-level status; no delegation block.
// The observation layer stores DNSStatus and leaves Status empty.
// effectiveStatus (rule layer) is responsible for the DNS-rcode fallback.
raw := []byte(`{"status": "NOERROR"}`)
z := decodeZone(raw)
if z.DNSStatus != "NOERROR" || z.Status != "NOERROR" {
t.Errorf("expected Status and DNSStatus = NOERROR, got %+v", z)
if z.DNSStatus != "NOERROR" {
t.Errorf("expected DNSStatus = NOERROR, got %q", z.DNSStatus)
}
if z.Status != "" {
t.Errorf("expected Status empty (no delegation block), got %q", z.Status)
}
if got := effectiveStatus(z); got != "NOERROR" {
t.Errorf("effectiveStatus: expected NOERROR fallback, got %q", got)
}
}

View file

@ -4,10 +4,16 @@ package checker
import (
"context"
"errors"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// ErrNoCollector is returned by Collect when the provider was constructed
// without a CollectFn (e.g. when registered host-side as an externalizable-only
// provider). Callers should route the observation to an external checker.
var ErrNoCollector = errors.New("dnsviz: provider has no local collector; use an external 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
@ -27,5 +33,8 @@ func (p *dnsvizProvider) Key() sdk.ObservationKey {
}
func (p *dnsvizProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
if p.collect == nil {
return nil, ErrNoCollector
}
return p.collect(ctx, opts)
}

View file

@ -147,14 +147,15 @@ func buildBanner(data *DNSVizData, states []sdk.CheckState) *bannerView {
z = data.Zones[leaf]
}
}
st := statusFromGrok(z.Status)
eff := effectiveStatus(z)
st := statusFromGrok(eff)
if w := worstStatus(states); w > st {
st = w
}
return &bannerView{
Status: st.String(),
Leaf: strings.TrimSuffix(leaf, "."),
LeafSt: emptyAsUnknown(z.Status),
LeafSt: emptyAsUnknown(eff),
}
}
@ -381,7 +382,7 @@ func renderChain(data *DNSVizData) string {
}
func writeZoneBlock(b *strings.Builder, name string, idx, total int, z ZoneAnalysis, raw map[string]any) {
st := statusFromGrok(z.Status)
st := statusFromGrok(effectiveStatus(z))
level := zoneLevelLabel(idx, total)
// Default-open zones with problems so the user sees them without
@ -399,8 +400,9 @@ func writeZoneBlock(b *strings.Builder, name string, idx, total int, z ZoneAnaly
if level != "" {
fmt.Fprintf(b, `<span class="level">%s</span>`, html.EscapeString(level))
}
fmt.Fprintf(b, `<span class="badge s-%s">%s</span>`, st.String(), html.EscapeString(emptyAsUnknown(z.Status)))
if z.DNSStatus != "" && !strings.EqualFold(z.DNSStatus, z.Status) {
eff := effectiveStatus(z)
fmt.Fprintf(b, `<span class="badge s-%s">%s</span>`, st.String(), html.EscapeString(emptyAsUnknown(eff)))
if z.DNSStatus != "" && !strings.EqualFold(z.DNSStatus, eff) {
fmt.Fprintf(b, `<span class="badge ghost">DNS: %s</span>`, html.EscapeString(z.DNSStatus))
}
if n := len(z.Errors); n > 0 {

View file

@ -49,6 +49,75 @@ func orderedZones(data *DNSVizData) []string {
return keys
}
// effectiveStatus returns the DNSSEC status for a zone, applying the
// inference chain that the observation layer deliberately leaves to rules:
// 1. delegation.status (z.Status) — the authoritative answer when present.
// 2. RRSIG-based inference — for zones without a delegation block (root),
// dnsviz surfaces trust-anchor validation only through per-RRSIG statuses.
// 3. DNS rcode fallback (z.DNSStatus) — when the zone is unsigned or grok
// did not classify it at all.
func effectiveStatus(z ZoneAnalysis) string {
if z.Status != "" {
return z.Status
}
if s := inferApexDNSKEYStatus(z.Queries); s != "" {
return s
}
return z.DNSStatus
}
// 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 map[string]any) string {
var dnskeyQ map[string]any
for k, v := range queries {
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 statusFromGrok(s string) sdk.Status {
switch strings.ToUpper(strings.TrimSpace(s)) {
case "SECURE":

View file

@ -35,13 +35,14 @@ func (r *overallStatusRule) Evaluate(ctx context.Context, obs sdk.ObservationGet
leaf = zones[0]
z = data.Zones[leaf]
}
eff := effectiveStatus(z)
st := sdk.CheckState{
Code: "dnsviz_overall_status",
Subject: leaf,
Status: statusFromGrok(z.Status),
Message: fmt.Sprintf("DNSViz status: %s", emptyAsUnknown(z.Status)),
Status: statusFromGrok(eff),
Message: fmt.Sprintf("DNSViz status: %s", emptyAsUnknown(eff)),
Meta: map[string]any{
"status": z.Status,
"status": eff,
"errors": len(z.Errors),
"warnings": len(z.Warnings),
},
@ -72,11 +73,12 @@ func (r *perZoneStatusRule) Evaluate(ctx context.Context, obs sdk.ObservationGet
out := make([]sdk.CheckState, 0, len(zones))
for _, name := range zones {
z := data.Zones[name]
eff := effectiveStatus(z)
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)),
Status: statusFromGrok(eff),
Message: fmt.Sprintf("%s: errors=%d warnings=%d", emptyAsUnknown(eff), len(z.Errors), len(z.Warnings)),
})
}
return out

View file

@ -56,8 +56,11 @@ type DNSVizData struct {
// 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 is the DNSSEC chain status reported directly under
// delegation.status in the grok output. Empty when no delegation block
// is present (e.g. the root zone). Rules call effectiveStatus(z) rather
// than reading this field directly so that the RRSIG-based inference and
// the DNS-rcode fallback are applied consistently.
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
@ -65,6 +68,11 @@ type ZoneAnalysis struct {
DNSStatus string `json:"dns_status,omitempty"`
Errors []Finding `json:"errors,omitempty"`
Warnings []Finding `json:"warnings,omitempty"`
// Queries holds the per-zone "queries" subtree from the grok output so
// that rules can infer the zone status from RRSIG validation when no
// delegation block is present (e.g. root zone, see inferApexDNSKEYStatus
// in rule.go).
Queries map[string]any `json:"queries,omitempty"`
}
// Finding mirrors the shape DNSViz uses for entries in errors/warnings.