diff --git a/.drone-manifest.yml b/.drone-manifest.yml deleted file mode 100644 index e9b7e97..0000000 --- a/.drone-manifest.yml +++ /dev/null @@ -1,22 +0,0 @@ -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 diff --git a/.drone.yml b/.drone.yml deleted file mode 100644 index e609ffb..0000000 --- a/.drone.yml +++ /dev/null @@ -1,187 +0,0 @@ ---- -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 diff --git a/checker/collect.go b/checker/collect.go index 8640947..a6713b7 100644 --- a/checker/collect.go +++ b/checker/collect.go @@ -59,12 +59,18 @@ func decodeZone(raw json.RawMessage) ZoneAnalysis { z.Status = s } } - // 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 + // Root has no parent and therefore no delegation block. dnsviz signals + // trust-anchor validation through the RRSIG covering the apex DNSKEY + // rrset (queries./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, "") @@ -175,6 +181,62 @@ 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 +// "/IN/". 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 == "" { diff --git a/checker/collect_test.go b/checker/collect_test.go index c356a14..e92ee2d 100644 --- a/checker/collect_test.go +++ b/checker/collect_test.go @@ -99,19 +99,11 @@ func TestParseGrokOutput_StringZone(t *testing.T) { } func TestDecodeZone_StatusFallbacks(t *testing.T) { - // 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. + // Only top-level status; no delegation block. Status must fall back to it. raw := []byte(`{"status": "NOERROR"}`) z := decodeZone(raw) - 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) + if z.DNSStatus != "NOERROR" || z.Status != "NOERROR" { + t.Errorf("expected Status and DNSStatus = NOERROR, got %+v", z) } } diff --git a/checker/provider.go b/checker/provider.go index a7d9825..ad7e3b3 100644 --- a/checker/provider.go +++ b/checker/provider.go @@ -4,16 +4,10 @@ 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 @@ -33,8 +27,5 @@ 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) } diff --git a/checker/report.go b/checker/report.go index 8752491..f02299b 100644 --- a/checker/report.go +++ b/checker/report.go @@ -147,15 +147,14 @@ func buildBanner(data *DNSVizData, states []sdk.CheckState) *bannerView { z = data.Zones[leaf] } } - eff := effectiveStatus(z) - st := statusFromGrok(eff) + st := statusFromGrok(z.Status) if w := worstStatus(states); w > st { st = w } return &bannerView{ Status: st.String(), Leaf: strings.TrimSuffix(leaf, "."), - LeafSt: emptyAsUnknown(eff), + LeafSt: emptyAsUnknown(z.Status), } } @@ -382,7 +381,7 @@ func renderChain(data *DNSVizData) string { } func writeZoneBlock(b *strings.Builder, name string, idx, total int, z ZoneAnalysis, raw map[string]any) { - st := statusFromGrok(effectiveStatus(z)) + st := statusFromGrok(z.Status) level := zoneLevelLabel(idx, total) // Default-open zones with problems so the user sees them without @@ -400,9 +399,8 @@ func writeZoneBlock(b *strings.Builder, name string, idx, total int, z ZoneAnaly if level != "" { fmt.Fprintf(b, `%s`, html.EscapeString(level)) } - eff := effectiveStatus(z) - fmt.Fprintf(b, `%s`, st.String(), html.EscapeString(emptyAsUnknown(eff))) - if z.DNSStatus != "" && !strings.EqualFold(z.DNSStatus, eff) { + fmt.Fprintf(b, `%s`, st.String(), html.EscapeString(emptyAsUnknown(z.Status))) + if z.DNSStatus != "" && !strings.EqualFold(z.DNSStatus, z.Status) { fmt.Fprintf(b, `DNS: %s`, html.EscapeString(z.DNSStatus)) } if n := len(z.Errors); n > 0 { diff --git a/checker/rule.go b/checker/rule.go index 1c4cdfa..ef868ad 100644 --- a/checker/rule.go +++ b/checker/rule.go @@ -49,75 +49,6 @@ 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 -// "/IN/". 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": diff --git a/checker/rules_status.go b/checker/rules_status.go index 1fc48cb..bcf79da 100644 --- a/checker/rules_status.go +++ b/checker/rules_status.go @@ -35,14 +35,13 @@ 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(eff), - Message: fmt.Sprintf("DNSViz status: %s", emptyAsUnknown(eff)), + Status: statusFromGrok(z.Status), + Message: fmt.Sprintf("DNSViz status: %s", emptyAsUnknown(z.Status)), Meta: map[string]any{ - "status": eff, + "status": z.Status, "errors": len(z.Errors), "warnings": len(z.Warnings), }, @@ -73,12 +72,11 @@ 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(eff), - Message: fmt.Sprintf("%s: errors=%d warnings=%d", emptyAsUnknown(eff), len(z.Errors), len(z.Warnings)), + Status: statusFromGrok(z.Status), + Message: fmt.Sprintf("%s: errors=%d warnings=%d", emptyAsUnknown(z.Status), len(z.Errors), len(z.Warnings)), }) } return out diff --git a/checker/types.go b/checker/types.go index 7950eac..e0f4669 100644 --- a/checker/types.go +++ b/checker/types.go @@ -56,11 +56,8 @@ 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 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 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 @@ -68,11 +65,6 @@ 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.