Initial commit

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

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
checker-dnsviz
checker-dnsviz.so

37
Dockerfile Normal file
View file

@ -0,0 +1,37 @@
# -- Build the Go checker binary ------------------------------------------
FROM golang:1.25-alpine AS builder
ARG CHECKER_VERSION=custom-build
WORKDIR /src
COPY go.mod go.sum* ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -tags standalone -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-dnsviz .
# -- Runtime image: dnsviz (Python) + checker binary ----------------------
#
# DNSViz is a Python tool. We base on alpine:3.20 and install dnsviz from
# its pip distribution along with the C deps it needs (libcrypto, m2crypto,
# pygraphviz is *not* installed: we only need probe/grok which output JSON).
FROM alpine:3.20
RUN apk add --no-cache \
python3 \
py3-pip \
py3-cryptography \
py3-dnspython \
py3-pygraphviz \
graphviz \
ca-certificates \
dnssec-root \
&& pip3 install --no-cache-dir --break-system-packages dnsviz \
&& adduser -D -u 65534 -H -s /sbin/nologin checker || true
COPY --from=builder /checker-dnsviz /usr/local/bin/checker-dnsviz
USER 65534:65534
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD ["/usr/local/bin/checker-dnsviz", "-healthcheck"]
ENTRYPOINT ["/usr/local/bin/checker-dnsviz"]

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 The happyDomain Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the “Software”), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

28
Makefile Normal file
View file

@ -0,0 +1,28 @@
CHECKER_NAME := checker-dnsviz
CHECKER_IMAGE := happydomain/$(CHECKER_NAME)
CHECKER_VERSION ?= custom-build
CHECKER_SOURCES := main.go $(wildcard checker/*.go)
GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION)
.PHONY: all plugin docker test clean
all: $(CHECKER_NAME)
$(CHECKER_NAME): $(CHECKER_SOURCES)
go build -tags standalone -ldflags "$(GO_LDFLAGS)" -o $@ .
plugin: $(CHECKER_NAME).so
$(CHECKER_NAME).so: $(CHECKER_SOURCES) $(wildcard plugin/*.go)
go build -buildmode=plugin -ldflags "$(GO_LDFLAGS)" -o $@ ./plugin/
docker:
docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) .
test:
go test -tags standalone ./...
clean:
rm -f $(CHECKER_NAME) $(CHECKER_NAME).so

26
NOTICE Normal file
View file

@ -0,0 +1,26 @@
checker-dnsviz
Copyright (c) 2026 The happyDomain Authors
This product is licensed under the MIT License (see LICENSE).
-------------------------------------------------------------------------------
Third-party notices
-------------------------------------------------------------------------------
This product includes software developed as part of the checker-sdk-go
project (https://git.happydns.org/happyDomain/checker-sdk-go), licensed
under the Apache License, Version 2.0:
checker-sdk-go
Copyright 2020-2026 The happyDomain Authors
This product includes software developed as part of the happyDomain
project (https://happydomain.org).
Portions of this code were originally written for the happyDomain
server (licensed under AGPL-3.0 and a commercial license) and are
made available there under the Apache License, Version 2.0 to enable
a permissively licensed ecosystem of checker plugins.
You may obtain a copy of the Apache License 2.0 at:
http://www.apache.org/licenses/LICENSE-2.0

121
README.md Normal file
View file

@ -0,0 +1,121 @@
# checker-dnsviz
DNSSEC checker for [happyDomain](https://www.happydomain.org/), implemented as
a thin wrapper around [DNSViz](https://github.com/dnsviz/dnsviz).
The container ships `dnsviz` (Python) alongside the Go binary that exposes the
standard happyDomain checker HTTP API (`/health`, `/definition`, `/collect`,
`/evaluate`, `/report`).
## How it works
For each check run, the Go binary invokes:
```
dnsviz probe -A . <ancestors…> <domain> | dnsviz grok -t <root-trust-anchor>
```
The queried name **and every ancestor up to the root** are passed to
`dnsviz probe`, in root → leaf order. `dnsviz` only emits a full analysis
(DNSKEY set, DS at parent, queries) for names listed on the command line;
ancestors stumbled upon implicitly are kept as "stub" entries that grok
ignores. Listing them explicitly is what makes every link of the chain
appear in the report.
`dnsviz grok -t <file>` is given a BIND-format DNSKEY trust anchor for the
root zone. Without it, the root has no parent to chain against and stays
classified as `NOERROR` (DNS rcode) instead of `SECURE` (DNSSEC).
The output is then parsed: per-zone errors and warnings are walked out of
the nested record tree (`delegation.errors`, `dnskey[i].errors`,
`queries/.../rrsig[j].errors`, …) and turned into individual `CheckState`
entries tagged with the JSON path where they were found. A curated
catalog of common DNSSEC failure scenarios (broken chain, expired RRSIG,
DS digest mismatch, deprecated algorithm, …) is matched against the
findings to generate a "Fix these first" section in the HTML report with
plain-language remediation hints.
The HTML report renders one block per zone in the chain (root → TLD →
intermediates → leaf), each with its delegation/DS records, DNSKEY set,
authoritative servers, and per-query analysis (RRsets, RRSIG validity,
NSEC proofs) so a recursive DNSSEC failure can be located at the exact
level, and the exact record, where it broke.
## Scope
This checker is intentionally limited to what DNSViz reports. NSEC/NSEC3
zone-walk hardening and NSEC3PARAM iteration policy (RFC 9276) are
delivered by a separate `checker-dnssec` module.
## Usage
### Standalone server
```bash
make
./checker-dnsviz -listen :8080
```
Runtime requirements:
- `dnsviz` on `PATH` (the Python CLI: `pip install dnsviz`, plus
`pygraphviz` and `graphviz` since `dnsviz grok -t` imports the graph
module even when only producing JSON).
- A BIND-format root DNSKEY trust anchor file. The collector
auto-detects `/usr/share/dnssec-root/trusted-key.key` (Alpine's
`dnssec-root` package, Debian/Ubuntu's `dns-root-data` ships an
equivalent at `/usr/share/dns/root.key`); pass `-trust-anchors-file
<path>` to override. Without it, the root zone in the report stays at
`NOERROR` instead of `SECURE`.
### Docker
```bash
make docker
docker run -p 8080:8080 happydomain/checker-dnsviz
```
The image bundles `dnsviz`, `pygraphviz`, `graphviz` and the alpine
`dnssec-root` package, so the trust anchor is in place out of the box.
When ICANN rolls a new root KSK (KSK-2024 is scheduled to begin signing
in late 2026), rebuild the image once the upstream alpine package ships
the new key.
### happyDomain plugin
```bash
make plugin
# produces checker-dnsviz.so
```
## Options
| Scope | Id | Default | Description |
|--------|-----------------------|-----------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------|
| admin | `dnsvizBin` | `dnsviz` | Path to the dnsviz CLI. |
| admin | `probeTimeoutSeconds` | `120` | Hard timeout for `dnsviz probe`. |
| admin | `extraProbeArgs` | `-A` | Extra arguments appended verbatim to `dnsviz probe`. |
| admin | `trustAnchorsFile` | `/usr/share/dnssec-root/trusted-key.key` (auto-detected) | BIND DNSKEY file passed to `dnsviz grok -t`. When unset, the collector falls back to the Alpine `dnssec-root` package path if it exists. |
| domain | `domain_name` | auto-fill | Domain to analyse. |
## Rules
| Rule | Description |
|----------------------------|------------------------------------------------------------------------------|
| `dnsviz_overall_status` | DNSViz status of the queried domain (SECURE/INSECURE/BOGUS/INDETERMINATE). |
| `dnsviz_per_zone_status` | One state per zone in the chain (root, TLD, intermediates, leaf). |
| `dnsviz_zone_errors` | Every error reported by DNSViz, scoped to the zone where it was found. |
| `dnsviz_zone_warnings` | Every warning reported by DNSViz, scoped to the zone where it was found. |
| `dnsviz_common_failures` | Pattern-matches findings against a catalog of common DNSSEC failures. |
## Licensing
This repository is split into two licensing zones:
| Path | License | Reason |
|------|---------|--------|
| `checker/` | MIT | Pure analysis logic (types, rules, HTML report). Can be imported by third-party Go projects without GPL obligations. |
| `internal/collect/` | GPL v2 | Invokes the `dnsviz` subprocess. Covered by [DNSViz's GPL v2 licence](https://github.com/dnsviz/dnsviz/blob/master/LICENSE). |
| `main.go`, `plugin/` | GPL v2 | Wire the two together; distributed binaries include the GPL layer. |
If you only need the analysis primitives (parse grok output, evaluate rules, render the HTML report), import `git.happydns.org/checker-dnsviz/checker` and supply your own `checker.CollectFn`, for example one that calls the HTTP API instead of running DNSViz locally.

246
checker/collect.go Normal file
View file

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

223
checker/collect_test.go Normal file
View file

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

52
checker/definition.go Normal file
View file

@ -0,0 +1,52 @@
// SPDX-License-Identifier: MIT
package checker
import (
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Version is overridden at link time: -ldflags "-X ...Version=1.2.3".
var Version = "built-in"
func (p *dnsvizProvider) Definition() *sdk.CheckerDefinition {
def := &sdk.CheckerDefinition{
ID: "dnsviz",
Name: "DNSSEC (DNSViz)",
Version: Version,
Availability: sdk.CheckerAvailability{
ApplyToDomain: true,
},
ObservationKeys: []sdk.ObservationKey{ObservationKeyDNSViz},
Options: sdk.CheckerOptionsDocumentation{
AdminOpts: []sdk.CheckerOptionDocumentation{
{
Id: "probeTimeoutSeconds",
Type: "uint",
Label: "Probe timeout (s)",
Description: "Hard timeout for the `dnsviz probe` invocation. The recursive walk can take a while on slow zones.",
Default: float64(120),
},
},
DomainOpts: []sdk.CheckerOptionDocumentation{
{
Id: "domain_name",
Label: "Domain name",
AutoFill: sdk.AutoFillDomainName,
},
},
},
Rules: Rules(),
HasHTMLReport: true,
HasMetrics: true,
Interval: &sdk.CheckIntervalSpec{
Min: 15 * time.Minute,
Max: 7 * 24 * time.Hour,
Default: 6 * time.Hour,
},
}
def.BuildRulesInfo()
return def
}

39
checker/interactive.go Normal file
View file

@ -0,0 +1,39 @@
// SPDX-License-Identifier: MIT
//go:build standalone
package checker
import (
"errors"
"net/http"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// RenderForm exposes a minimal /check form when running standalone.
func (p *dnsvizProvider) RenderForm() []sdk.CheckerOptionField {
return []sdk.CheckerOptionField{
{
Id: "domain_name",
Type: "string",
Label: "Domain name",
Placeholder: "example.com",
Required: true,
Description: "Fully-qualified domain name to analyse with DNSViz.",
},
}
}
// ParseForm builds the CheckerOptions from the human-facing /check form.
func (p *dnsvizProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
domain := strings.TrimSpace(r.FormValue("domain_name"))
if domain == "" {
return nil, errors.New("domain name is required")
}
opts := sdk.CheckerOptions{
"domain_name": strings.TrimSuffix(domain, "."),
}
return opts, nil
}

31
checker/provider.go Normal file
View file

@ -0,0 +1,31 @@
// SPDX-License-Identifier: MIT
package checker
import (
"context"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// CollectFn is the function signature for the DNSViz data collection step.
// The checker package is decoupled from the subprocess invocation so it can
// be imported without GPL obligations. Implementations live in the binary or
// plugin layer (see internal/collect).
type CollectFn func(ctx context.Context, opts sdk.CheckerOptions) (any, error)
// Provider returns a new DNSViz observation provider backed by the given
// collect function.
func Provider(collect CollectFn) sdk.ObservationProvider {
return &dnsvizProvider{collect: collect}
}
type dnsvizProvider struct{ collect CollectFn }
func (p *dnsvizProvider) Key() sdk.ObservationKey {
return ObservationKeyDNSViz
}
func (p *dnsvizProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
return p.collect(ctx, opts)
}

73
checker/provider_test.go Normal file
View file

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

1000
checker/report.go Normal file

File diff suppressed because it is too large Load diff

170
checker/report_test.go Normal file
View file

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

75
checker/rule.go Normal file
View file

@ -0,0 +1,75 @@
// SPDX-License-Identifier: MIT
package checker
import (
"context"
"fmt"
"sort"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Rules returns the full rule set evaluated against a DNSVizData observation.
// Subject is the zone FQDN so a fault at the TLD is never silently merged with a leaf fault.
func Rules() []sdk.CheckRule {
return []sdk.CheckRule{
&overallStatusRule{},
&perZoneStatusRule{},
&zoneErrorsRule{},
&zoneWarningsRule{},
&commonFailuresRule{},
}
}
func loadData(ctx context.Context, obs sdk.ObservationGetter, code string) (*DNSVizData, []sdk.CheckState) {
var data DNSVizData
if err := obs.Get(ctx, ObservationKeyDNSViz, &data); err != nil {
return nil, []sdk.CheckState{{
Status: sdk.StatusError,
Code: code,
Message: fmt.Sprintf("Failed to load DNSViz observation: %v", err),
}}
}
return &data, nil
}
func orderedZones(data *DNSVizData) []string {
if len(data.Order) > 0 {
return data.Order
}
keys := make([]string, 0, len(data.Zones))
for k := range data.Zones {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
return labelDepth(keys[i]) > labelDepth(keys[j])
})
return keys
}
func statusFromGrok(s string) sdk.Status {
switch strings.ToUpper(strings.TrimSpace(s)) {
case "SECURE":
return sdk.StatusOK
case "INSECURE":
// "INSECURE" means "no DNSSEC and no parent DS": informational, not
// a failure. Rules elsewhere can still flag a missing chain.
return sdk.StatusInfo
case "BOGUS":
return sdk.StatusCrit
case "INDETERMINATE":
return sdk.StatusWarn
case "NON_EXISTENT":
return sdk.StatusInfo
case "NOERROR":
// DNS-level OK with no DNSSEC chain status reported. The zone
// resolves but isn't signed (or grok didn't classify it).
return sdk.StatusInfo
case "":
return sdk.StatusUnknown
default:
return sdk.StatusUnknown
}
}

96
checker/rule_test.go Normal file
View file

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

214
checker/rules_common.go Normal file
View file

@ -0,0 +1,214 @@
// SPDX-License-Identifier: MIT
package checker
import (
"context"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// commonFailuresRule uses substring matching rather than exact codes because
// DNSViz wording shifts between versions.
type commonFailuresRule struct{}
func (r *commonFailuresRule) Name() string { return "dnsviz_common_failures" }
func (r *commonFailuresRule) Description() string {
return "Highlights well-known DNSSEC failure scenarios (broken chain, expired signatures, missing/extra DS, algorithm mismatch, …) with remediation hints."
}
// CommonFailure is exported so the report layer can read Title/Hint from CheckState.Meta.
type CommonFailure struct {
ID string // Stable code emitted in CheckState.Code.
Title string // Short headline for the report block.
Hint string // What the user should typically do.
Patterns []string // Substrings (lowercased) matched against the finding's code+description.
Severity sdk.Status
}
// commonFailures is the curated catalog. Order matters: the first matching
// entry wins, so put more specific scenarios above the generic ones.
var commonFailures = []CommonFailure{
{
ID: "dnssec_chain_broken_no_ds",
Title: "Parent has no DS record for this zone",
Hint: "Publish the DS record(s) generated from your KSK at the registrar/parent. Without DS, validators see the zone as INSECURE even when DNSKEYs are present.",
Patterns: []string{
"no ds records",
"missing ds",
"no ds record was found",
"ds_records_missing",
},
Severity: sdk.StatusCrit,
},
{
ID: "dnssec_ds_digest_mismatch",
Title: "DS at parent does not match any DNSKEY at the child",
Hint: "The DS digest at the parent does not match any DNSKEY served by the child. Re-export the DS record from your current KSK and update it at the registrar; remove stale DS entries.",
Patterns: []string{
"ds does not match",
"no matching dnskey",
"ds_does_not_match",
"no dnskey matching",
},
Severity: sdk.StatusCrit,
},
{
ID: "dnssec_rrsig_expired",
Title: "RRSIG signature has expired",
Hint: "At least one RRSIG is past its expiration. Resign the zone (most signers do this automatically; investigate why the cron/automation didn't run).",
Patterns: []string{
"signature has expired",
"rrsig_expired",
"signature_expired",
"signature expired",
"rrsig expired",
},
Severity: sdk.StatusCrit,
},
{
ID: "dnssec_rrsig_not_yet_valid",
Title: "RRSIG signature is not yet valid",
Hint: "An RRSIG inception time is in the future. Check that the signing host's clock is synchronized (NTP) and that the signer didn't generate signatures with a future inception.",
Patterns: []string{
"signature is not yet valid",
"signature_not_yet_valid",
"inception in the future",
},
Severity: sdk.StatusCrit,
},
{
ID: "dnssec_signature_invalid",
Title: "Cryptographic signature is invalid",
Hint: "A validator could not verify the signature with the published DNSKEY. The zone may have been resigned with a key that was not published, or the served DNSKEY set is inconsistent across servers.",
Patterns: []string{
"signature_invalid",
"signature is invalid",
"bad signature",
},
Severity: sdk.StatusCrit,
},
{
ID: "dnssec_algorithm_mismatch",
Title: "Algorithm declared in DS not present in DNSKEY (or vice versa)",
Hint: "RFC 4035 §2.2 requires that for every algorithm a DS uses, the child must publish at least one DNSKEY with the same algorithm. Either add the missing DNSKEY/DS or retire the orphan.",
Patterns: []string{
"algorithm_missing",
"algorithm not signed",
"missing rrsig for algorithm",
"algorithm mismatch",
},
Severity: sdk.StatusCrit,
},
{
ID: "dnssec_deprecated_algorithm",
Title: "Deprecated DNSSEC algorithm in use",
Hint: "Algorithms 5 (RSASHA1) and 7 (RSASHA1-NSEC3) are deprecated. Roll the KSK/ZSK to algorithm 13 (ECDSAP256SHA256) or 8 (RSASHA256) and update the DS at the parent.",
Patterns: []string{
"deprecated algorithm",
"algorithm_deprecated",
"weak algorithm",
"rsasha1",
"rsa/sha-1",
"rsa-sha1",
"algorithm 5 ",
"algorithm 7 ",
},
Severity: sdk.StatusWarn,
},
{
ID: "dnssec_no_dnskey",
Title: "No DNSKEY served at the apex",
Hint: "The zone declares a DS at the parent but serves no DNSKEY at the apex. Validators see this as BOGUS. Republish the DNSKEY set or remove the DS at the parent.",
Patterns: []string{
"no dnskey",
"dnskey_missing",
},
Severity: sdk.StatusCrit,
},
{
ID: "dnssec_servfail",
Title: "An authoritative server returned SERVFAIL on DNSSEC queries",
Hint: "At least one server on the path returned SERVFAIL. Often caused by a server that doesn't have the keys it should sign with, or by EDNS/UDP fragmentation. Verify the server can answer DNSKEY/RRSIG over both UDP and TCP.",
Patterns: []string{
"servfail",
"server failure",
},
Severity: sdk.StatusCrit,
},
{
ID: "dnssec_inconsistent_responses",
Title: "Authoritative servers disagree",
Hint: "Different authoritative servers serve different DNSKEY/RRSIG/NSEC contents. Confirm that the secondary servers have completed AXFR/IXFR and are serving the same zone version.",
Patterns: []string{
"inconsistent",
"disagree",
},
Severity: sdk.StatusWarn,
},
}
func (r *commonFailuresRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errState := loadData(ctx, obs, "dnsviz_common_failures")
if errState != nil {
return errState
}
var out []sdk.CheckState
matched := map[string]struct{}{}
matchFinding := func(name string, f Finding) {
haystack := strings.ToLower(f.Code + " " + f.Description)
for _, c := range commonFailures {
if !matchesAny(haystack, c.Patterns) {
continue
}
key := name + "|" + c.ID
if _, seen := matched[key]; seen {
return
}
matched[key] = struct{}{}
out = append(out, sdk.CheckState{
Status: c.Severity,
Code: c.ID,
Subject: name,
Message: c.Title + ": " + c.Hint,
Meta: map[string]any{
"title": c.Title,
"hint": c.Hint,
"original_code": f.Code,
"original_description": f.Description,
},
})
return
}
}
for _, name := range orderedZones(data) {
z := data.Zones[name]
for _, f := range z.Errors {
matchFinding(name, f)
}
for _, f := range z.Warnings {
matchFinding(name, f)
}
}
if len(out) == 0 {
return []sdk.CheckState{{
Status: sdk.StatusOK,
Code: "dnsviz_common_failures",
Message: "No well-known DNSSEC failure scenario detected by the heuristics.",
}}
}
return out
}
func matchesAny(haystack string, needles []string) bool {
for _, n := range needles {
if n == "" {
continue
}
if strings.Contains(haystack, n) {
return true
}
}
return false
}

View file

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

164
checker/rules_status.go Normal file
View file

@ -0,0 +1,164 @@
// SPDX-License-Identifier: MIT
package checker
import (
"context"
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
)
type overallStatusRule struct{}
func (r *overallStatusRule) Name() string { return "dnsviz_overall_status" }
func (r *overallStatusRule) Description() string {
return "Reports the DNSViz status of the queried domain (SECURE, INSECURE, BOGUS, INDETERMINATE)."
}
func (r *overallStatusRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errState := loadData(ctx, obs, "dnsviz_overall_status")
if errState != nil {
return errState
}
leaf := data.Domain + "."
z, ok := data.Zones[leaf]
if !ok {
// Fall back to the most-specific zone DNSViz reported.
zones := orderedZones(data)
if len(zones) == 0 {
return []sdk.CheckState{{
Status: sdk.StatusUnknown,
Code: "dnsviz_overall_status",
Message: "DNSViz returned no zones for this domain",
}}
}
leaf = zones[0]
z = data.Zones[leaf]
}
st := sdk.CheckState{
Code: "dnsviz_overall_status",
Subject: leaf,
Status: statusFromGrok(z.Status),
Message: fmt.Sprintf("DNSViz status: %s", emptyAsUnknown(z.Status)),
Meta: map[string]any{
"status": z.Status,
"errors": len(z.Errors),
"warnings": len(z.Warnings),
},
}
return []sdk.CheckState{st}
}
// Subject is set to the zone name so each delegation level gets its own report block.
type perZoneStatusRule struct{}
func (r *perZoneStatusRule) Name() string { return "dnsviz_per_zone_status" }
func (r *perZoneStatusRule) Description() string {
return "Reports the DNSViz status of every zone in the chain (root, TLD, intermediates, leaf)."
}
func (r *perZoneStatusRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errState := loadData(ctx, obs, "dnsviz_per_zone_status")
if errState != nil {
return errState
}
zones := orderedZones(data)
if len(zones) == 0 {
return []sdk.CheckState{{
Status: sdk.StatusUnknown,
Code: "dnsviz_per_zone_status",
Message: "DNSViz returned no zones for this domain",
}}
}
out := make([]sdk.CheckState, 0, len(zones))
for _, name := range zones {
z := data.Zones[name]
out = append(out, sdk.CheckState{
Code: "dnsviz_per_zone_status",
Subject: name,
Status: statusFromGrok(z.Status),
Message: fmt.Sprintf("%s: errors=%d warnings=%d", emptyAsUnknown(z.Status), len(z.Errors), len(z.Warnings)),
})
}
return out
}
// One state per (zone, finding) pair so the UI can show a precise list.
type zoneErrorsRule struct{}
func (r *zoneErrorsRule) Name() string { return "dnsviz_zone_errors" }
func (r *zoneErrorsRule) Description() string {
return "Surfaces every error reported by DNSViz, scoped to the zone where it was found."
}
func (r *zoneErrorsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errState := loadData(ctx, obs, "dnsviz_zone_errors")
if errState != nil {
return errState
}
return zoneFindingStates(data, "dnsviz_zone_errors", sdk.StatusCrit, "errors", func(z ZoneAnalysis) []Finding { return z.Errors })
}
type zoneWarningsRule struct{}
func (r *zoneWarningsRule) Name() string { return "dnsviz_zone_warnings" }
func (r *zoneWarningsRule) Description() string {
return "Surfaces every warning reported by DNSViz, scoped to the zone where it was found."
}
func (r *zoneWarningsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errState := loadData(ctx, obs, "dnsviz_zone_warnings")
if errState != nil {
return errState
}
return zoneFindingStates(data, "dnsviz_zone_warnings", sdk.StatusWarn, "warnings", func(z ZoneAnalysis) []Finding { return z.Warnings })
}
// zoneFindingStates emits a single OK state when nothing matches so the rule outcome is always observable.
func zoneFindingStates(data *DNSVizData, ruleCode string, status sdk.Status, kindLabel string, pick func(ZoneAnalysis) []Finding) []sdk.CheckState {
var out []sdk.CheckState
for _, name := range orderedZones(data) {
for _, f := range pick(data.Zones[name]) {
out = append(out, sdk.CheckState{
Status: status,
Code: nonEmpty(f.Code, ruleCode),
Subject: name,
Message: f.Description,
Meta: findingMeta(f),
})
}
}
if len(out) == 0 {
return []sdk.CheckState{{
Status: sdk.StatusOK,
Code: ruleCode,
Message: fmt.Sprintf("DNSViz reported no %s in any zone", kindLabel),
}}
}
return out
}
func emptyAsUnknown(s string) string {
if s == "" {
return "UNKNOWN"
}
return s
}
func nonEmpty(a, b string) string {
if a != "" {
return a
}
return b
}
func findingMeta(f Finding) map[string]any {
m := map[string]any{}
if f.Code != "" {
m["code"] = f.Code
}
if len(f.Servers) > 0 {
m["servers"] = f.Servers
}
if len(m) == 0 {
return nil
}
return m
}

View file

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

View file

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

86
checker/types.go Normal file
View file

@ -0,0 +1,86 @@
// SPDX-License-Identifier: MIT
// Package checker implements a happyDomain checker that wraps DNSViz
// (https://github.com/dnsviz/dnsviz). It runs `dnsviz probe` followed by
// `dnsviz grok` against a domain, stores the structured analysis as the
// observation, and turns the per-zone errors/warnings into CheckStates.
//
// The container ships dnsviz alongside this binary, so the checker has no
// external dependency at runtime besides the network.
package checker
import (
sdk "git.happydns.org/checker-sdk-go/checker"
)
// ObservationKeyDNSViz is the observation key for DNSViz analysis output.
const ObservationKeyDNSViz sdk.ObservationKey = "dnsviz"
// DNSVizData is what Collect stores. It carries the full grok output
// (parsed into a permissive structure) plus the raw bytes for the report.
//
// DNSViz emits a single top-level object whose keys are zone FQDNs (with
// trailing dot), one per level of the chain. Inside each zone object the
// shape is permissive: many fields are conditional, so we keep most of them
// as map[string]any and only pluck out what the rules need.
type DNSVizData struct {
// Domain is the queried FQDN, with trailing dot stripped.
Domain string `json:"domain"`
// Zones is the per-zone analysis, keyed by zone FQDN (with trailing dot
// preserved, matching DNSViz's output).
Zones map[string]ZoneAnalysis `json:"zones"`
// Order is Zones' keys, sorted from the queried name up to the root.
// We surface it explicitly so the report can render in a stable order
// without having to re-sort on every render.
Order []string `json:"order,omitempty"`
// Raw is the unmodified `dnsviz grok` JSON. Kept around so the report
// can fall back on it for fields the typed view does not capture.
Raw []byte `json:"raw,omitempty"`
// ProbeStderr / GrokStderr capture the diagnostics dnsviz prints to
// stderr. Useful when collection succeeds but the analysis is partial.
ProbeStderr string `json:"probe_stderr,omitempty"`
GrokStderr string `json:"grok_stderr,omitempty"`
}
// ZoneAnalysis is a permissive view over one zone's grok block.
//
// DNSViz output puts the DNSSEC chain status at delegation.status (one of
// "SECURE", "BOGUS", "INSECURE", "INDETERMINATE") while the top-level
// "status" field carries the DNS rcode for the zone apex (e.g. "NOERROR").
// Errors and warnings are not surfaced as a flat per-zone array; instead
// they appear as nested "errors"/"warnings" arrays attached to the record
// where the problem was found (DS, DNSKEY, RRSIG, NSEC proof, query
// response, server, …). We walk the whole zone subtree to collect them.
type ZoneAnalysis struct {
// Status is the DNSSEC chain status taken from delegation.status when
// available, falling back to the top-level "status" field otherwise.
Status string `json:"status,omitempty"`
// DNSStatus is the raw top-level "status" field (DNS rcode such as
// "NOERROR"). Kept for the report so we can distinguish "DNS resolved
// fine" from "DNSSEC chain validates".
DNSStatus string `json:"dns_status,omitempty"`
Errors []Finding `json:"errors,omitempty"`
Warnings []Finding `json:"warnings,omitempty"`
}
// Finding mirrors the shape DNSViz uses for entries in errors/warnings.
// Producers occasionally use slightly different field names across versions
// of dnsviz; we accept both `description`/`message` for the human text and
// fall back to a generic stringification at parse time.
type Finding struct {
Code string `json:"code,omitempty"`
Description string `json:"description"`
Servers []string `json:"servers,omitempty"`
// Path is a slash-separated pointer to the JSON node where the finding
// was attached (e.g. "delegation/ds[0]" or
// "queries/example.com./IN/A/answer[0]/rrsig[0]"). Useful in the
// report so a generic "signature_invalid" can be located precisely.
Path string `json:"path,omitempty"`
// Extra holds the raw finding payload when no human description could
// be extracted. Surfaced by the report as a debug fallback.
Extra map[string]any `json:"extra,omitempty"`
}

5
go.mod Normal file
View file

@ -0,0 +1,5 @@
module git.happydns.org/checker-dnsviz
go 1.25.0
require git.happydns.org/checker-sdk-go v1.5.0

2
go.sum Normal file
View file

@ -0,0 +1,2 @@
git.happydns.org/checker-sdk-go v1.5.0 h1:5uD5Cm6xJ+lwnhbJ09iCXGHbYS9zRh+Yh0NeBHkAPBY=
git.happydns.org/checker-sdk-go v1.5.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=

339
internal/collect/LICENSE Normal file
View file

@ -0,0 +1,339 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Ty Coon>, 1 April 1989
Ty Coon, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.

253
internal/collect/collect.go Normal file
View file

@ -0,0 +1,253 @@
// This file is part of checker-dnsviz.
//
// checker-dnsviz is free software: you can redistribute it and/or modify it
// under the terms of the GNU General Public License as published by the Free
// Software Foundation, version 2.
//
// checker-dnsviz is distributed in the hope that it will be useful, but
// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
// more details.
//
// You should have received a copy of the GNU General Public License along with
// checker-dnsviz. If not, see <https://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-2.0-only
// Package collect contains the DNSViz subprocess invocation. It is kept
// separate from the checker package so that the checker package (pure analysis
// logic) can be imported under MIT terms without pulling in GPL-covered code.
package collect
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"strings"
"time"
"unicode/utf8"
checker "git.happydns.org/checker-dnsviz/checker"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// defaultTrustAnchorsFile is the BIND-formatted root DNSKEY trust anchor file
// shipped by Alpine's `dnssec-root` package (installed in our Docker image).
// Passed to `dnsviz grok -t` so the root zone gets classified as SECURE
// instead of staying at the DNS-rcode "NOERROR" fallback.
const defaultTrustAnchorsFile = "/usr/share/dnssec-root/trusted-key.key"
const (
defaultProbeTimeout = 120 * time.Second
maxDNSVizOutputBytes = 16 << 20 // 16 MiB
)
// Collector holds the runtime configuration for DNSViz invocations.
type Collector struct {
// Bin is the path to the dnsviz CLI. Defaults to "dnsviz".
Bin string
// ExtraArgs is a whitespace-separated list of extra arguments appended to
// `dnsviz probe`. Defaults to "-A".
ExtraArgs string
// TrustAnchorsFile is a BIND-format DNSKEY file used as DNSSEC trust
// anchor (passed to `dnsviz grok -t`). When empty, defaultTrustAnchorsFile
// is used if it exists; otherwise grok runs without `-t` and the root
// zone falls back to a NOERROR classification.
TrustAnchorsFile string
}
// Collect runs `dnsviz probe | dnsviz grok` against the domain named in opts
// and returns the structured analysis as a *checker.DNSVizData.
func (c *Collector) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
domain, _ := sdk.GetOption[string](opts, "domain_name")
domain = strings.TrimSpace(strings.TrimSuffix(domain, "."))
if domain == "" {
return nil, fmt.Errorf("missing 'domain_name' option")
}
if !isValidDomainName(domain) {
return nil, fmt.Errorf("invalid 'domain_name' option")
}
timeout := defaultProbeTimeout
if n := sdk.GetIntOption(opts, "probeTimeoutSeconds", 0); n > 0 {
timeout = time.Duration(n) * time.Second
}
bin := strings.TrimSpace(c.Bin)
if bin == "" {
bin = "dnsviz"
}
extraArgs := c.ExtraArgs
if extraArgs == "" {
extraArgs = "-A"
}
probeCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
// Probe the queried name AND every ancestor up to the root. dnsviz only
// emits a full analysis (DNSKEY set, DS at parent, queries…) for names
// passed on the command line: ancestors probed implicitly are kept as
// "stub" entries that grok ignores. Listing them explicitly is what
// makes the chain (root, TLD, intermediates, leaf) appear in the grok
// output, and therefore in the report.
names := ancestorNames(domain)
probeOut, probeErr, err := runProbe(probeCtx, bin, names, extraArgs)
if err != nil {
return nil, fmt.Errorf("dnsviz probe failed: %w (stderr: %s)", err, truncate(probeErr, 4096))
}
grokCtx, cancelGrok := context.WithTimeout(ctx, timeout)
defer cancelGrok()
trustAnchors := c.TrustAnchorsFile
if trustAnchors == "" {
if _, err := os.Stat(defaultTrustAnchorsFile); err == nil {
trustAnchors = defaultTrustAnchorsFile
}
}
grokOut, grokErr, err := runGrok(grokCtx, bin, probeOut, trustAnchors)
if err != nil {
return nil, fmt.Errorf("dnsviz grok failed: %w (stderr: %s)", err, truncate(grokErr, 4096))
}
zones, order, err := checker.ParseGrokOutput(grokOut)
if err != nil {
return nil, fmt.Errorf("decoding dnsviz grok output: %w", err)
}
return &checker.DNSVizData{
Domain: domain,
Zones: zones,
Order: order,
Raw: grokOut,
ProbeStderr: probeErr,
GrokStderr: grokErr,
}, nil
}
func runProbe(ctx context.Context, bin string, names []string, extraArgs string) ([]byte, string, error) {
args := []string{"probe"}
args = append(args, strings.Fields(extraArgs)...)
args = append(args, "--")
args = append(args, names...)
cmd := exec.CommandContext(ctx, bin, args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &capLimit{B: &stdout, max: maxDNSVizOutputBytes}
cmd.Stderr = &capLimit{B: &stderr, max: maxDNSVizOutputBytes}
if err := cmd.Run(); err != nil {
return stdout.Bytes(), stderr.String(), err
}
return stdout.Bytes(), stderr.String(), nil
}
func runGrok(ctx context.Context, bin string, probeJSON []byte, trustAnchorsFile string) ([]byte, string, error) {
args := []string{"grok"}
if trustAnchorsFile != "" {
args = append(args, "-t", trustAnchorsFile)
}
cmd := exec.CommandContext(ctx, bin, args...)
cmd.Stdin = bytes.NewReader(probeJSON)
var stdout, stderr bytes.Buffer
cmd.Stdout = &capLimit{B: &stdout, max: maxDNSVizOutputBytes}
cmd.Stderr = &capLimit{B: &stderr, max: maxDNSVizOutputBytes}
if err := cmd.Run(); err != nil {
return stdout.Bytes(), stderr.String(), err
}
return stdout.Bytes(), stderr.String(), nil
}
// ancestorNames returns the root, each parent label suffix, and finally
// the queried name itself, all in trailing-dot form. For
// "sub.example.com" the result is [".", "com.", "example.com.",
// "sub.example.com."]. The order matters: dnsviz probes names in the
// order they're given and creates a "stub" entry for each ancestor
// referenced by a previously-seen name, so listing root → leaf is the
// only ordering that yields a fully-analyzed entry for every ancestor
// in the grok output. dnsviz tolerates non-zone names on the command
// line (the analysis attaches to the enclosing zone), so we don't need
// to pre-compute the real zone cuts.
func ancestorNames(domain string) []string {
domain = strings.TrimSuffix(domain, ".")
if domain == "" {
return []string{"."}
}
rev := []string{domain + "."}
for {
i := strings.Index(domain, ".")
if i < 0 {
break
}
domain = domain[i+1:]
rev = append(rev, domain+".")
}
rev = append(rev, ".")
// Reverse to root → leaf.
out := make([]string, len(rev))
for i, n := range rev {
out[len(rev)-1-i] = n
}
return out
}
func isValidDomainName(s string) bool {
if s == "" || len(s) > 253 || s[0] == '-' || s[0] == '.' {
return false
}
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c >= 'a' && c <= 'z':
case c >= 'A' && c <= 'Z':
case c >= '0' && c <= '9':
case c == '-' || c == '.' || c == '_':
default:
return false
}
}
return true
}
// truncate returns s capped at n bytes, trimming back to a UTF-8 rune
// boundary so the appended ellipsis can't follow a half-encoded codepoint.
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
for n > 0 && !utf8.RuneStart(s[n]) {
n--
}
return s[:n] + "…"
}
// capLimit is an io.Writer that buffers up to max bytes into B and silently
// drops anything beyond the cap. It exists to keep a runaway dnsviz process
// from filling memory while still letting os/exec consume the pipe.
//
// Write deliberately reports len(p) bytes written even when it discarded
// the overflow: returning a "short write" would make os/exec abort the
// child with io.ErrShortWrite, which is not what we want: we want to keep
// reading the rest of the output (and trash it) until the process exits.
// The trade-off is that the returned count lies; callers must not rely on
// it for accounting.
type capLimit struct {
B *bytes.Buffer
max int
}
func (c *capLimit) Write(p []byte) (int, error) {
remaining := c.max - c.B.Len()
if remaining <= 0 {
return len(p), nil
}
if len(p) > remaining {
c.B.Write(p[:remaining])
return len(p), nil
}
return c.B.Write(p)
}

View file

@ -0,0 +1,224 @@
// SPDX-License-Identifier: GPL-2.0-only
package collect
import (
"bytes"
"context"
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
checker "git.happydns.org/checker-dnsviz/checker"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func TestIsValidDomainName(t *testing.T) {
good := []string{
"example.com",
"a",
"sub.domain.example.com",
"_dmarc.example.com",
"xn--bcher-kva.de",
"123.example.com",
}
bad := []string{
"",
"-bad.example",
".bad.example",
"foo bar.example",
"foo;rm -rf.example",
"foo$bar",
"héllo.example",
strings.Repeat("a", 254),
}
for _, s := range good {
if !isValidDomainName(s) {
t.Errorf("expected %q valid", s)
}
}
for _, s := range bad {
if isValidDomainName(s) {
t.Errorf("expected %q invalid", s)
}
}
}
func TestTruncate(t *testing.T) {
if truncate("short", 100) != "short" {
t.Error("short string should pass through")
}
got := truncate("abcdef", 3)
if got != "abc…" {
t.Errorf("truncate=%q", got)
}
}
func TestCapLimit_DropsOverflow(t *testing.T) {
var buf bytes.Buffer
c := &capLimit{B: &buf, max: 4}
n, err := c.Write([]byte("abcdef"))
if err != nil {
t.Fatal(err)
}
if n != 6 {
t.Errorf("Write should report full length to keep os/exec happy, got %d", n)
}
if buf.String() != "abcd" {
t.Errorf("buffer=%q want abcd", buf.String())
}
// A subsequent write while full is silently discarded.
n, _ = c.Write([]byte("g"))
if n != 1 || buf.String() != "abcd" {
t.Errorf("post-cap write: n=%d buf=%q", n, buf.String())
}
}
func TestCollect_MissingDomain(t *testing.T) {
c := &Collector{}
if _, err := c.Collect(context.Background(), sdk.CheckerOptions{}); err == nil {
t.Fatal("expected error for missing domain_name")
}
}
func TestCollect_RejectsInjection(t *testing.T) {
c := &Collector{}
_, err := c.Collect(context.Background(), sdk.CheckerOptions{"domain_name": "-A"})
if err == nil || !strings.Contains(err.Error(), "invalid 'domain_name'") {
t.Errorf("expected invalid domain error, got %v", err)
}
_, err = c.Collect(context.Background(), sdk.CheckerOptions{"domain_name": "foo;rm -rf /"})
if err == nil || !strings.Contains(err.Error(), "invalid 'domain_name'") {
t.Errorf("expected invalid domain error, got %v", err)
}
}
// fakeDNSVizScript writes a small POSIX shell that emulates `dnsviz probe`
// (always emits a fixed JSON) and `dnsviz grok` (emits a canned grok JSON,
// regardless of stdin), so Collect can run end-to-end without the real
// Python tool.
func fakeDNSVizScript(t *testing.T) string {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("POSIX shell needed for the fake dnsviz")
}
dir := t.TempDir()
path := filepath.Join(dir, "dnsviz")
body := `#!/bin/sh
case "$1" in
probe)
cat <<EOF
{"_meta":{"phase":"probe"}}
EOF
;;
grok)
cat <<EOF
{
"example.com.": {
"status": "NOERROR",
"delegation": {"status": "SECURE"}
},
"com.": {"delegation": {"status": "SECURE"}}
}
EOF
;;
failprobe)
echo "boom" 1>&2
exit 7
;;
esac
`
if err := os.WriteFile(path, []byte(body), 0o755); err != nil {
t.Fatal(err)
}
return path
}
func TestCollect_EndToEnd(t *testing.T) {
bin := fakeDNSVizScript(t)
c := &Collector{Bin: bin, ExtraArgs: ""}
out, err := c.Collect(context.Background(), sdk.CheckerOptions{
"domain_name": "example.com.",
"probeTimeoutSeconds": float64(5),
})
if err != nil {
t.Fatalf("Collect: %v", err)
}
d, ok := out.(*checker.DNSVizData)
if !ok {
t.Fatalf("type=%T", out)
}
if d.Domain != "example.com" {
t.Errorf("domain not normalized: %q", d.Domain)
}
if len(d.Zones) != 2 {
t.Errorf("expected 2 zones from grok, got %d", len(d.Zones))
}
if d.Zones["example.com."].Status != "SECURE" {
t.Errorf("status=%q", d.Zones["example.com."].Status)
}
if len(d.Raw) == 0 {
t.Error("raw should be populated")
}
}
func TestCollect_ProbeFailure(t *testing.T) {
// A non-existent binary makes probe fail. The error path should bubble up
// and not be conflated with successful execution.
c := &Collector{Bin: "/nonexistent/dnsviz/binary"}
_, err := c.Collect(context.Background(), sdk.CheckerOptions{"domain_name": "example.com"})
if err == nil || !strings.Contains(err.Error(), "dnsviz probe failed") {
t.Errorf("expected probe-failed error, got %v", err)
}
}
func TestCollect_ContextCanceled(t *testing.T) {
bin := fakeDNSVizScript(t)
c := &Collector{Bin: bin}
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := c.Collect(ctx, sdk.CheckerOptions{"domain_name": "example.com"})
if err == nil {
t.Fatal("expected error from cancelled context")
}
// Either probe or grok should report cancellation. We don't assert on
// the exact wording: just that it surfaced.
if !errors.Is(err, context.Canceled) && !strings.Contains(err.Error(), "dnsviz probe failed") &&
!strings.Contains(err.Error(), "dnsviz grok failed") {
t.Errorf("unexpected error: %v", err)
}
}
func TestCollect_TimeoutHonoured(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX shell needed")
}
dir := t.TempDir()
bin := filepath.Join(dir, "dnsviz")
// Sleep longer than the configured timeout; both probe and grok will
// stall, so the call should return a timeout-flavoured error.
// `exec sleep` so the shell process replaces itself with sleep, leaving
// a single PID for exec.CommandContext to SIGKILL on timeout (otherwise
// the orphaned sleep keeps the stdout pipe open and Wait blocks).
body := "#!/bin/sh\nexec sleep 5\n"
if err := os.WriteFile(bin, []byte(body), 0o755); err != nil {
t.Fatal(err)
}
c := &Collector{Bin: bin}
start := time.Now()
_, err := c.Collect(context.Background(), sdk.CheckerOptions{
"domain_name": "example.com",
"probeTimeoutSeconds": float64(1),
})
elapsed := time.Since(start)
if err == nil {
t.Fatal("expected timeout error")
}
if elapsed > 4*time.Second {
t.Errorf("timeout not enforced: elapsed %v", elapsed)
}
}

39
main.go Normal file
View file

@ -0,0 +1,39 @@
// SPDX-License-Identifier: GPL-2.0-only
package main
import (
"flag"
"log"
dnsviz "git.happydns.org/checker-dnsviz/checker"
"git.happydns.org/checker-dnsviz/internal/collect"
"git.happydns.org/checker-sdk-go/checker/server"
)
// Version is overridden at link time: -ldflags "-X main.Version=1.2.3".
var Version = "custom-build"
var (
listenAddr = flag.String("listen", ":8080", "HTTP listen address")
dnsvizBin = flag.String("dnsviz-bin", "dnsviz", "Path to the dnsviz CLI used by `probe` and `grok`")
extraProbeArgs = flag.String("extra-probe-args", "-A", "Whitespace-separated arguments appended to `dnsviz probe`")
trustAnchorsFile = flag.String("trust-anchors-file", "", "BIND DNSKEY file passed to `dnsviz grok -t`. Empty falls back to /usr/share/dnssec-root/trusted-key.key (alpine `dnssec-root` package) when present.")
)
func main() {
flag.Parse()
dnsviz.Version = Version
col := &collect.Collector{
Bin: *dnsvizBin,
ExtraArgs: *extraProbeArgs,
TrustAnchorsFile: *trustAnchorsFile,
}
srv := server.New(dnsviz.Provider(col.Collect))
if err := srv.ListenAndServe(*listenAddr); err != nil {
log.Fatalf("server error: %v", err)
}
}

26
plugin/plugin.go Normal file
View file

@ -0,0 +1,26 @@
// SPDX-License-Identifier: GPL-2.0-only
// Command plugin is the happyDomain plugin entrypoint for the DNSViz checker.
//
// It is built as a Go plugin (`go build -buildmode=plugin`) and loaded at
// runtime by happyDomain.
package main
import (
dnsviz "git.happydns.org/checker-dnsviz/checker"
"git.happydns.org/checker-dnsviz/internal/collect"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Version is overridden at link time: -ldflags "-X main.Version=1.2.3".
var Version = "custom-build"
// NewCheckerPlugin is the symbol resolved by happyDomain when loading the
// .so file. It returns the checker definition and the observation provider
// that the host will register in its global registries.
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
dnsviz.Version = Version
col := &collect.Collector{}
prvd := dnsviz.Provider(col.Collect)
return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil
}