Initial commit
This commit is contained in:
commit
53626dd36a
29 changed files with 3940 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
checker-dnsviz
|
||||
checker-dnsviz.so
|
||||
37
Dockerfile
Normal file
37
Dockerfile
Normal 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
21
LICENSE
Normal 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
28
Makefile
Normal 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
26
NOTICE
Normal 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
121
README.md
Normal 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
246
checker/collect.go
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ParseGrokOutput decodes the JSON produced by `dnsviz grok` into a typed
|
||||
// map of zone analyses. Order lists zones from most-specific to root.
|
||||
func ParseGrokOutput(raw []byte) (map[string]ZoneAnalysis, []string, error) {
|
||||
var top map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &top); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
out := make(map[string]ZoneAnalysis, len(top))
|
||||
for k, v := range top {
|
||||
// Skip non-zone keys some dnsviz versions emit (e.g. "_meta").
|
||||
if k == "" || strings.HasPrefix(k, "_") {
|
||||
continue
|
||||
}
|
||||
out[k] = decodeZone(v)
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(out))
|
||||
for k := range out {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
return labelDepth(keys[i]) > labelDepth(keys[j])
|
||||
})
|
||||
return out, keys, nil
|
||||
}
|
||||
|
||||
func decodeZone(raw json.RawMessage) ZoneAnalysis {
|
||||
var z ZoneAnalysis
|
||||
var node any
|
||||
if err := json.Unmarshal(raw, &node); err != nil {
|
||||
return z
|
||||
}
|
||||
m, ok := node.(map[string]any)
|
||||
if !ok {
|
||||
if s, ok := node.(string); ok {
|
||||
z.Status = s
|
||||
}
|
||||
return z
|
||||
}
|
||||
|
||||
if s, ok := m["status"].(string); ok {
|
||||
z.DNSStatus = s
|
||||
}
|
||||
// DNSSEC chain status lives under delegation.status.
|
||||
if del, ok := m["delegation"].(map[string]any); ok {
|
||||
if s, ok := del["status"].(string); ok {
|
||||
z.Status = s
|
||||
}
|
||||
}
|
||||
// Root has no parent and therefore no delegation block. dnsviz signals
|
||||
// trust-anchor validation through the RRSIG covering the apex DNSKEY
|
||||
// rrset (queries.<zone>/IN/DNSKEY.answer[*].rrsig[*].status). With
|
||||
// `dnsviz grok -t …` and a matching anchor, that RRSIG becomes VALID
|
||||
// and we lift the zone to SECURE; an INVALID/EXPIRED RRSIG drags it
|
||||
// to BOGUS. Without a trust anchor, this leaves Status empty and we
|
||||
// fall back to the DNS rcode below.
|
||||
if z.Status == "" {
|
||||
z.Status = inferApexDNSKEYStatus(m["queries"])
|
||||
}
|
||||
if z.Status == "" {
|
||||
z.Status = z.DNSStatus
|
||||
}
|
||||
|
||||
z.Errors, z.Warnings = collectFindings(m, "")
|
||||
return z
|
||||
}
|
||||
|
||||
func collectFindings(node any, path string) (errs, warns []Finding) {
|
||||
switch v := node.(type) {
|
||||
case map[string]any:
|
||||
for k, val := range v {
|
||||
sub := joinPath(path, k)
|
||||
switch k {
|
||||
case "errors":
|
||||
errs = append(errs, asFindings(val, path)...)
|
||||
continue
|
||||
case "warnings":
|
||||
warns = append(warns, asFindings(val, path)...)
|
||||
continue
|
||||
}
|
||||
e, w := collectFindings(val, sub)
|
||||
errs = append(errs, e...)
|
||||
warns = append(warns, w...)
|
||||
}
|
||||
case []any:
|
||||
for i, item := range v {
|
||||
sub := fmt.Sprintf("%s[%d]", path, i)
|
||||
e, w := collectFindings(item, sub)
|
||||
errs = append(errs, e...)
|
||||
warns = append(warns, w...)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func joinPath(parent, key string) string {
|
||||
if parent == "" {
|
||||
return key
|
||||
}
|
||||
return parent + "/" + key
|
||||
}
|
||||
|
||||
// asFindings turns a value attached to an "errors"/"warnings" key into a
|
||||
// slice of Finding. DNSViz uses a few shapes here across versions:
|
||||
// - []object{description, code, servers}
|
||||
// - []string (rare, very old grok)
|
||||
// - object keyed by code -> entry (newer grok flattens findings by code)
|
||||
func asFindings(raw any, path string) []Finding {
|
||||
switch v := raw.(type) {
|
||||
case []any:
|
||||
out := make([]Finding, 0, len(v))
|
||||
for _, item := range v {
|
||||
out = append(out, makeFinding(item, "", path))
|
||||
}
|
||||
return out
|
||||
case map[string]any:
|
||||
out := make([]Finding, 0, len(v))
|
||||
keys := make([]string, 0, len(v))
|
||||
for k := range v {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
out = append(out, makeFinding(v[k], k, path))
|
||||
}
|
||||
return out
|
||||
case []string:
|
||||
out := make([]Finding, 0, len(v))
|
||||
for _, s := range v {
|
||||
out = append(out, Finding{Description: s, Path: path})
|
||||
}
|
||||
return out
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func makeFinding(item any, codeHint, path string) Finding {
|
||||
f := Finding{Path: path, Code: codeHint}
|
||||
switch v := item.(type) {
|
||||
case string:
|
||||
f.Description = v
|
||||
case map[string]any:
|
||||
if s, ok := v["code"].(string); ok && s != "" {
|
||||
f.Code = s
|
||||
}
|
||||
if s, ok := v["description"].(string); ok && s != "" {
|
||||
f.Description = s
|
||||
} else if s, ok := v["message"].(string); ok && s != "" {
|
||||
f.Description = s
|
||||
}
|
||||
if servers, ok := v["servers"].([]any); ok {
|
||||
for _, s := range servers {
|
||||
if str, ok := s.(string); ok {
|
||||
f.Servers = append(f.Servers, str)
|
||||
}
|
||||
}
|
||||
}
|
||||
// If we couldn't extract a human description, keep the raw structure
|
||||
// in Extra rather than synthesising a JSON blob into the description
|
||||
// field (which would then be rendered as ugly text in the report).
|
||||
if f.Description == "" {
|
||||
f.Extra = v
|
||||
}
|
||||
default:
|
||||
// Unknown shape: stash the raw value so the report can still surface
|
||||
// it from a debug section, but don't pollute Description.
|
||||
f.Extra = map[string]any{"value": item}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// inferApexDNSKEYStatus returns "SECURE", "BOGUS", or "" based on the
|
||||
// status of RRSIGs covering the zone's apex DNSKEY rrset. dnsviz attaches
|
||||
// a per-RRSIG status whenever a key reaches it (either through DS from
|
||||
// the parent or through a configured trust anchor at this zone). For
|
||||
// the root, this is the only place where trust-anchor validation
|
||||
// surfaces in the grok output.
|
||||
//
|
||||
// queries is the value at zone["queries"], a map keyed by
|
||||
// "<zone>/IN/<RRTYPE>". We pick the DNSKEY query and look at every
|
||||
// RRSIG inside its answer.
|
||||
func inferApexDNSKEYStatus(queries any) string {
|
||||
q, ok := queries.(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
var dnskeyQ map[string]any
|
||||
for k, v := range q {
|
||||
if !strings.HasSuffix(k, "/IN/DNSKEY") {
|
||||
continue
|
||||
}
|
||||
if m, ok := v.(map[string]any); ok {
|
||||
dnskeyQ = m
|
||||
break
|
||||
}
|
||||
}
|
||||
if dnskeyQ == nil {
|
||||
return ""
|
||||
}
|
||||
answers, _ := dnskeyQ["answer"].([]any)
|
||||
sawValid := false
|
||||
for _, a := range answers {
|
||||
am, _ := a.(map[string]any)
|
||||
if am == nil {
|
||||
continue
|
||||
}
|
||||
rrsigs, _ := am["rrsig"].([]any)
|
||||
for _, rs := range rrsigs {
|
||||
rm, _ := rs.(map[string]any)
|
||||
if rm == nil {
|
||||
continue
|
||||
}
|
||||
s, _ := rm["status"].(string)
|
||||
switch strings.ToUpper(s) {
|
||||
case "INVALID", "BOGUS", "EXPIRED", "PREMATURE":
|
||||
return "BOGUS"
|
||||
case "VALID", "SECURE":
|
||||
sawValid = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if sawValid {
|
||||
return "SECURE"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func labelDepth(zone string) int {
|
||||
z := strings.TrimSuffix(zone, ".")
|
||||
if z == "" {
|
||||
return 0
|
||||
}
|
||||
return strings.Count(z, ".") + 1
|
||||
}
|
||||
223
checker/collect_test.go
Normal file
223
checker/collect_test.go
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLabelDepth(t *testing.T) {
|
||||
cases := map[string]int{
|
||||
"": 0,
|
||||
".": 0,
|
||||
"com.": 1,
|
||||
"example.com.": 2,
|
||||
"www.example.com": 3,
|
||||
"a.b.c.d.e.": 5,
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := labelDepth(in); got != want {
|
||||
t.Errorf("labelDepth(%q) = %d, want %d", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestJoinPath(t *testing.T) {
|
||||
cases := []struct {
|
||||
parent, key, want string
|
||||
}{
|
||||
{"", "errors", "errors"},
|
||||
{"delegation", "errors", "delegation/errors"},
|
||||
{"queries/example.com.", "answer", "queries/example.com./answer"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := joinPath(c.parent, c.key); got != c.want {
|
||||
t.Errorf("joinPath(%q,%q) = %q, want %q", c.parent, c.key, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGrokOutput_OrderAndShape(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"example.com.": {
|
||||
"status": "NOERROR",
|
||||
"delegation": {"status": "SECURE"},
|
||||
"queries": {"example.com./A": {"errors": [{"code": "X", "description": "boom"}]}}
|
||||
},
|
||||
"com.": {"delegation": {"status": "SECURE"}},
|
||||
".": {"delegation": {"status": "SECURE"}},
|
||||
"_meta": {"ignored": true}
|
||||
}`)
|
||||
|
||||
zones, order, err := ParseGrokOutput(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseGrokOutput: %v", err)
|
||||
}
|
||||
if _, ok := zones["_meta"]; ok {
|
||||
t.Errorf("expected _meta-prefixed key to be skipped, got it in zones")
|
||||
}
|
||||
if len(zones) != 3 {
|
||||
t.Errorf("expected 3 zones, got %d (%v)", len(zones), zones)
|
||||
}
|
||||
// Order: most-specific first (example.com.), root last.
|
||||
if !reflect.DeepEqual(order, []string{"example.com.", "com.", "."}) {
|
||||
t.Errorf("unexpected order: %v", order)
|
||||
}
|
||||
if zones["example.com."].Status != "SECURE" {
|
||||
t.Errorf("expected delegation.status to win for example.com., got %q", zones["example.com."].Status)
|
||||
}
|
||||
if zones["example.com."].DNSStatus != "NOERROR" {
|
||||
t.Errorf("expected DNSStatus=NOERROR, got %q", zones["example.com."].DNSStatus)
|
||||
}
|
||||
if len(zones["example.com."].Errors) != 1 {
|
||||
t.Fatalf("expected 1 error, got %v", zones["example.com."].Errors)
|
||||
}
|
||||
if zones["example.com."].Errors[0].Code != "X" {
|
||||
t.Errorf("expected code=X, got %q", zones["example.com."].Errors[0].Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGrokOutput_InvalidJSON(t *testing.T) {
|
||||
if _, _, err := ParseGrokOutput([]byte("not json")); err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGrokOutput_StringZone(t *testing.T) {
|
||||
// Old grok: a zone may collapse into a bare string status.
|
||||
raw := []byte(`{"missing.example.": "NON_EXISTENT"}`)
|
||||
zones, _, err := ParseGrokOutput(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseGrokOutput: %v", err)
|
||||
}
|
||||
if zones["missing.example."].Status != "NON_EXISTENT" {
|
||||
t.Errorf("got %q, want NON_EXISTENT", zones["missing.example."].Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeZone_StatusFallbacks(t *testing.T) {
|
||||
// Only top-level status; no delegation block. Status must fall back to it.
|
||||
raw := []byte(`{"status": "NOERROR"}`)
|
||||
z := decodeZone(raw)
|
||||
if z.DNSStatus != "NOERROR" || z.Status != "NOERROR" {
|
||||
t.Errorf("expected Status and DNSStatus = NOERROR, got %+v", z)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectFindings_Nested(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"delegation": {
|
||||
"errors": [{"code": "DS", "description": "missing"}]
|
||||
},
|
||||
"queries": {
|
||||
"example.com./A": {
|
||||
"answer": [
|
||||
{"warnings": [{"code": "W1", "description": "smelly"}]}
|
||||
]
|
||||
}
|
||||
}
|
||||
}`)
|
||||
z := decodeZone(raw)
|
||||
if len(z.Errors) != 1 || z.Errors[0].Path != "delegation" {
|
||||
t.Errorf("expected one error tagged delegation, got %+v", z.Errors)
|
||||
}
|
||||
if len(z.Warnings) != 1 {
|
||||
t.Fatalf("expected one warning, got %+v", z.Warnings)
|
||||
}
|
||||
w := z.Warnings[0]
|
||||
if !strings.HasPrefix(w.Path, "queries/example.com./A/answer[") {
|
||||
t.Errorf("unexpected warning path: %q", w.Path)
|
||||
}
|
||||
if w.Code != "W1" || w.Description != "smelly" {
|
||||
t.Errorf("unexpected warning content: %+v", w)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsFindings_VariantShapes(t *testing.T) {
|
||||
// Object-keyed-by-code variant.
|
||||
out := asFindings(map[string]any{
|
||||
"CODE_B": map[string]any{"description": "second"},
|
||||
"CODE_A": map[string]any{"description": "first"},
|
||||
}, "p")
|
||||
if len(out) != 2 {
|
||||
t.Fatalf("expected 2 findings, got %v", out)
|
||||
}
|
||||
// Sorted by key for stability.
|
||||
if out[0].Code != "CODE_A" || out[1].Code != "CODE_B" {
|
||||
t.Errorf("findings not sorted by key: %+v", out)
|
||||
}
|
||||
for _, f := range out {
|
||||
if f.Path != "p" {
|
||||
t.Errorf("expected path=p, got %q", f.Path)
|
||||
}
|
||||
}
|
||||
|
||||
// []string variant (rare but supported via direct call).
|
||||
strs := asFindings([]string{"raw1", "raw2"}, "p")
|
||||
if len(strs) != 2 || strs[0].Description != "raw1" {
|
||||
t.Errorf("string-list shape mishandled: %+v", strs)
|
||||
}
|
||||
|
||||
// Unsupported scalar shape returns nil.
|
||||
if asFindings(42, "p") != nil {
|
||||
t.Errorf("expected nil for non-list non-map non-string-slice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMakeFinding_FallbackAndServers(t *testing.T) {
|
||||
// description missing, message present.
|
||||
f := makeFinding(map[string]any{
|
||||
"message": "use-message",
|
||||
"servers": []any{"ns1.example.", "ns2.example.", 42 /*ignored*/},
|
||||
}, "fallback_code", "p")
|
||||
if f.Description != "use-message" {
|
||||
t.Errorf("wanted message fallback, got %q", f.Description)
|
||||
}
|
||||
if !reflect.DeepEqual(f.Servers, []string{"ns1.example.", "ns2.example."}) {
|
||||
t.Errorf("non-string server entries should be skipped, got %v", f.Servers)
|
||||
}
|
||||
if f.Code != "fallback_code" {
|
||||
t.Errorf("expected codeHint to be used when item has no code, got %q", f.Code)
|
||||
}
|
||||
|
||||
// neither description nor message: keep the raw payload in Extra
|
||||
// instead of synthesising a JSON blob into Description (which would
|
||||
// then render as ugly text in the report).
|
||||
f2 := makeFinding(map[string]any{"weird": 1}, "", "p")
|
||||
if f2.Description != "" {
|
||||
t.Errorf("expected empty Description when no human text available, got %q", f2.Description)
|
||||
}
|
||||
if f2.Extra == nil || f2.Extra["weird"] != 1 {
|
||||
t.Errorf("expected raw payload in Extra, got %+v", f2.Extra)
|
||||
}
|
||||
|
||||
// Plain string item.
|
||||
f3 := makeFinding("just a string", "h", "p")
|
||||
if f3.Description != "just a string" || f3.Code != "h" {
|
||||
t.Errorf("string item mishandled: %+v", f3)
|
||||
}
|
||||
|
||||
// Item explicit code overrides codeHint.
|
||||
f4 := makeFinding(map[string]any{"code": "REAL", "description": "d"}, "hint", "p")
|
||||
if f4.Code != "REAL" {
|
||||
t.Errorf("expected explicit code to win, got %q", f4.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGrokOutput_OrderStable(t *testing.T) {
|
||||
// Same-depth zones should still produce a deterministic slice (keys order
|
||||
// in Go maps is randomized) - just checks the zones each appear once.
|
||||
raw := []byte(`{"a.": {}, "b.": {}}`)
|
||||
_, order, err := ParseGrokOutput(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cp := append([]string(nil), order...)
|
||||
sort.Strings(cp)
|
||||
if !reflect.DeepEqual(cp, []string{"a.", "b."}) {
|
||||
t.Errorf("missing zones in order: %v", order)
|
||||
}
|
||||
}
|
||||
52
checker/definition.go
Normal file
52
checker/definition.go
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Version is overridden at link time: -ldflags "-X ...Version=1.2.3".
|
||||
var Version = "built-in"
|
||||
|
||||
func (p *dnsvizProvider) Definition() *sdk.CheckerDefinition {
|
||||
def := &sdk.CheckerDefinition{
|
||||
ID: "dnsviz",
|
||||
Name: "DNSSEC (DNSViz)",
|
||||
Version: Version,
|
||||
Availability: sdk.CheckerAvailability{
|
||||
ApplyToDomain: true,
|
||||
},
|
||||
ObservationKeys: []sdk.ObservationKey{ObservationKeyDNSViz},
|
||||
Options: sdk.CheckerOptionsDocumentation{
|
||||
AdminOpts: []sdk.CheckerOptionDocumentation{
|
||||
{
|
||||
Id: "probeTimeoutSeconds",
|
||||
Type: "uint",
|
||||
Label: "Probe timeout (s)",
|
||||
Description: "Hard timeout for the `dnsviz probe` invocation. The recursive walk can take a while on slow zones.",
|
||||
Default: float64(120),
|
||||
},
|
||||
},
|
||||
DomainOpts: []sdk.CheckerOptionDocumentation{
|
||||
{
|
||||
Id: "domain_name",
|
||||
Label: "Domain name",
|
||||
AutoFill: sdk.AutoFillDomainName,
|
||||
},
|
||||
},
|
||||
},
|
||||
Rules: Rules(),
|
||||
HasHTMLReport: true,
|
||||
HasMetrics: true,
|
||||
Interval: &sdk.CheckIntervalSpec{
|
||||
Min: 15 * time.Minute,
|
||||
Max: 7 * 24 * time.Hour,
|
||||
Default: 6 * time.Hour,
|
||||
},
|
||||
}
|
||||
def.BuildRulesInfo()
|
||||
return def
|
||||
}
|
||||
39
checker/interactive.go
Normal file
39
checker/interactive.go
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build standalone
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// RenderForm exposes a minimal /check form when running standalone.
|
||||
func (p *dnsvizProvider) RenderForm() []sdk.CheckerOptionField {
|
||||
return []sdk.CheckerOptionField{
|
||||
{
|
||||
Id: "domain_name",
|
||||
Type: "string",
|
||||
Label: "Domain name",
|
||||
Placeholder: "example.com",
|
||||
Required: true,
|
||||
Description: "Fully-qualified domain name to analyse with DNSViz.",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ParseForm builds the CheckerOptions from the human-facing /check form.
|
||||
func (p *dnsvizProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
|
||||
domain := strings.TrimSpace(r.FormValue("domain_name"))
|
||||
if domain == "" {
|
||||
return nil, errors.New("domain name is required")
|
||||
}
|
||||
opts := sdk.CheckerOptions{
|
||||
"domain_name": strings.TrimSuffix(domain, "."),
|
||||
}
|
||||
return opts, nil
|
||||
}
|
||||
31
checker/provider.go
Normal file
31
checker/provider.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// CollectFn is the function signature for the DNSViz data collection step.
|
||||
// The checker package is decoupled from the subprocess invocation so it can
|
||||
// be imported without GPL obligations. Implementations live in the binary or
|
||||
// plugin layer (see internal/collect).
|
||||
type CollectFn func(ctx context.Context, opts sdk.CheckerOptions) (any, error)
|
||||
|
||||
// Provider returns a new DNSViz observation provider backed by the given
|
||||
// collect function.
|
||||
func Provider(collect CollectFn) sdk.ObservationProvider {
|
||||
return &dnsvizProvider{collect: collect}
|
||||
}
|
||||
|
||||
type dnsvizProvider struct{ collect CollectFn }
|
||||
|
||||
func (p *dnsvizProvider) Key() sdk.ObservationKey {
|
||||
return ObservationKeyDNSViz
|
||||
}
|
||||
|
||||
func (p *dnsvizProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
|
||||
return p.collect(ctx, opts)
|
||||
}
|
||||
73
checker/provider_test.go
Normal file
73
checker/provider_test.go
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
func TestProvider_DelegatesCollect(t *testing.T) {
|
||||
called := false
|
||||
want := errors.New("sentinel")
|
||||
p := Provider(func(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
|
||||
called = true
|
||||
return "value", want
|
||||
})
|
||||
got, err := p.Collect(context.Background(), sdk.CheckerOptions{})
|
||||
if !called {
|
||||
t.Fatal("collect fn not called")
|
||||
}
|
||||
if got != "value" || err != want {
|
||||
t.Errorf("unexpected return: %v, %v", got, err)
|
||||
}
|
||||
if p.Key() != ObservationKeyDNSViz {
|
||||
t.Errorf("Key=%q, want %q", p.Key(), ObservationKeyDNSViz)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefinition(t *testing.T) {
|
||||
p := Provider(func(_ context.Context, _ sdk.CheckerOptions) (any, error) { return nil, nil })
|
||||
dp, ok := p.(sdk.CheckerDefinitionProvider)
|
||||
if !ok {
|
||||
t.Fatal("provider does not implement CheckerDefinitionProvider")
|
||||
}
|
||||
def := dp.Definition()
|
||||
if def.ID != "dnsviz" {
|
||||
t.Errorf("ID=%q", def.ID)
|
||||
}
|
||||
if !def.HasHTMLReport || !def.HasMetrics {
|
||||
t.Error("expected HasHTMLReport and HasMetrics to be true")
|
||||
}
|
||||
if !def.Availability.ApplyToDomain {
|
||||
t.Error("expected ApplyToDomain")
|
||||
}
|
||||
if def.Interval == nil || def.Interval.Default <= 0 {
|
||||
t.Errorf("interval not set: %+v", def.Interval)
|
||||
}
|
||||
if len(def.Rules) == 0 || len(def.RulesInfo) != len(def.Rules) {
|
||||
t.Errorf("rules vs rulesInfo: %d / %d", len(def.Rules), len(def.RulesInfo))
|
||||
}
|
||||
// At least one rule per published name.
|
||||
for _, ri := range def.RulesInfo {
|
||||
if ri.Name == "" || ri.Description == "" {
|
||||
t.Errorf("missing name/description in RulesInfo: %+v", ri)
|
||||
}
|
||||
}
|
||||
if len(def.ObservationKeys) == 0 || def.ObservationKeys[0] != ObservationKeyDNSViz {
|
||||
t.Errorf("observation keys: %v", def.ObservationKeys)
|
||||
}
|
||||
// Sanity: the domain-level option declares the auto-fill we rely on.
|
||||
hasDomain := false
|
||||
for _, o := range def.Options.DomainOpts {
|
||||
if o.Id == "domain_name" && o.AutoFill == sdk.AutoFillDomainName {
|
||||
hasDomain = true
|
||||
}
|
||||
}
|
||||
if !hasDomain {
|
||||
t.Error("expected domain_name option with AutoFillDomainName")
|
||||
}
|
||||
}
|
||||
1000
checker/report.go
Normal file
1000
checker/report.go
Normal file
File diff suppressed because it is too large
Load diff
170
checker/report_test.go
Normal file
170
checker/report_test.go
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
func TestWorstStatus(t *testing.T) {
|
||||
if got := worstStatus(nil); got != sdk.StatusOK {
|
||||
t.Errorf("nil states: got %v, want OK", got)
|
||||
}
|
||||
got := worstStatus([]sdk.CheckState{
|
||||
{Status: sdk.StatusOK},
|
||||
{Status: sdk.StatusWarn},
|
||||
{Status: sdk.StatusCrit},
|
||||
{Status: sdk.StatusInfo},
|
||||
})
|
||||
if got != sdk.StatusCrit {
|
||||
t.Errorf("got %v, want Crit", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTitleAndHint(t *testing.T) {
|
||||
title, hint := titleAndHint(sdk.CheckState{
|
||||
Message: "fallback",
|
||||
Meta: map[string]any{"title": "T", "hint": "H"},
|
||||
})
|
||||
if title != "T" || hint != "H" {
|
||||
t.Errorf("got (%q,%q), want (T,H)", title, hint)
|
||||
}
|
||||
// Falls back to message when no title in meta.
|
||||
title, _ = titleAndHint(sdk.CheckState{Message: "fb"})
|
||||
if title != "fb" {
|
||||
t.Errorf("expected fallback to Message, got %q", title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetHTMLReport_EmptyContext(t *testing.T) {
|
||||
p := &dnsvizProvider{}
|
||||
out, err := p.GetHTMLReport(sdk.StaticReportContext(nil))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, "No DNSViz data and no rule states") {
|
||||
t.Errorf("expected empty banner, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetHTMLReport_FullDocument(t *testing.T) {
|
||||
data := &DNSVizData{
|
||||
Domain: "example.com",
|
||||
Order: []string{"example.com.", "com.", "."},
|
||||
Zones: map[string]ZoneAnalysis{
|
||||
"example.com.": {
|
||||
Status: "BOGUS",
|
||||
Errors: []Finding{{Code: "RRSIG_EXPIRED", Description: "signature has expired", Servers: []string{"ns1"}}},
|
||||
},
|
||||
"com.": {Status: "SECURE"},
|
||||
".": {Status: "SECURE"},
|
||||
},
|
||||
Raw: []byte(`{"example.com.": {"status": "BOGUS"}}`),
|
||||
ProbeStderr: "probe-warning",
|
||||
GrokStderr: "grok-warning",
|
||||
}
|
||||
rawJSON, _ := json.Marshal(data)
|
||||
states := []sdk.CheckState{
|
||||
{Status: sdk.StatusCrit, Code: "dnssec_rrsig_expired", Subject: "example.com.", Message: "Signature expired",
|
||||
Meta: map[string]any{"title": "Signature expired", "hint": "Re-sign the zone."}},
|
||||
{Status: sdk.StatusOK, Code: "dnsviz_overall_status", Message: "ok"},
|
||||
}
|
||||
p := &dnsvizProvider{}
|
||||
out, err := p.GetHTMLReport(sdk.NewReportContext(rawJSON, nil, states))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantContains := []string{
|
||||
"<title>DNSSEC report: example.com</title>",
|
||||
`class="banner s-CRIT"`,
|
||||
"Fix these first",
|
||||
"Re-sign the zone.",
|
||||
"DNS hierarchy",
|
||||
"RRSIG_EXPIRED",
|
||||
"All rule states",
|
||||
"probe-warning",
|
||||
"grok-warning",
|
||||
}
|
||||
for _, sub := range wantContains {
|
||||
if !strings.Contains(out, sub) {
|
||||
t.Errorf("HTML missing %q", sub)
|
||||
}
|
||||
}
|
||||
// Ensure XSS-prone strings are escaped.
|
||||
xssData := &DNSVizData{
|
||||
Domain: `<script>alert(1)</script>`,
|
||||
Order: []string{"x."},
|
||||
Zones: map[string]ZoneAnalysis{"x.": {Status: "SECURE"}},
|
||||
}
|
||||
rawXSS, _ := json.Marshal(xssData)
|
||||
xssOut, _ := p.GetHTMLReport(sdk.StaticReportContext(rawXSS))
|
||||
if strings.Contains(xssOut, "<script>alert(1)</script>") {
|
||||
t.Errorf("unescaped <script> in report: %s", xssOut)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetHTMLReport_BadJSON(t *testing.T) {
|
||||
p := &dnsvizProvider{}
|
||||
_, err := p.GetHTMLReport(sdk.StaticReportContext(json.RawMessage("not json")))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for malformed data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractMetrics(t *testing.T) {
|
||||
data := &DNSVizData{
|
||||
Zones: map[string]ZoneAnalysis{
|
||||
"example.com.": {Errors: []Finding{{}, {}}, Warnings: []Finding{{}}},
|
||||
"com.": {},
|
||||
},
|
||||
}
|
||||
rawJSON, _ := json.Marshal(data)
|
||||
states := []sdk.CheckState{
|
||||
{Status: sdk.StatusOK},
|
||||
{Status: sdk.StatusCrit},
|
||||
{Status: sdk.StatusCrit},
|
||||
}
|
||||
p := &dnsvizProvider{}
|
||||
now := time.Now()
|
||||
metrics, err := p.ExtractMetrics(sdk.NewReportContext(rawJSON, nil, states), now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
byName := map[string]float64{}
|
||||
statusCounts := map[string]float64{}
|
||||
for _, m := range metrics {
|
||||
if !m.Timestamp.Equal(now) {
|
||||
t.Errorf("metric %q has wrong timestamp", m.Name)
|
||||
}
|
||||
if m.Name == "dnsviz.findings.count" {
|
||||
statusCounts[m.Labels["status"]] = m.Value
|
||||
continue
|
||||
}
|
||||
byName[m.Name] = m.Value
|
||||
}
|
||||
if byName["dnsviz.zones.count"] != 2 {
|
||||
t.Errorf("zones.count = %v, want 2", byName["dnsviz.zones.count"])
|
||||
}
|
||||
if byName["dnsviz.errors.count"] != 2 {
|
||||
t.Errorf("errors.count = %v, want 2", byName["dnsviz.errors.count"])
|
||||
}
|
||||
if byName["dnsviz.warnings.count"] != 1 {
|
||||
t.Errorf("warnings.count = %v, want 1", byName["dnsviz.warnings.count"])
|
||||
}
|
||||
if statusCounts["CRIT"] != 2 || statusCounts["OK"] != 1 {
|
||||
t.Errorf("findings counts: %v", statusCounts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractMetrics_BadJSON(t *testing.T) {
|
||||
p := &dnsvizProvider{}
|
||||
_, err := p.ExtractMetrics(sdk.StaticReportContext(json.RawMessage("not json")), time.Now())
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
75
checker/rule.go
Normal file
75
checker/rule.go
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Rules returns the full rule set evaluated against a DNSVizData observation.
|
||||
// Subject is the zone FQDN so a fault at the TLD is never silently merged with a leaf fault.
|
||||
func Rules() []sdk.CheckRule {
|
||||
return []sdk.CheckRule{
|
||||
&overallStatusRule{},
|
||||
&perZoneStatusRule{},
|
||||
&zoneErrorsRule{},
|
||||
&zoneWarningsRule{},
|
||||
&commonFailuresRule{},
|
||||
}
|
||||
}
|
||||
|
||||
func loadData(ctx context.Context, obs sdk.ObservationGetter, code string) (*DNSVizData, []sdk.CheckState) {
|
||||
var data DNSVizData
|
||||
if err := obs.Get(ctx, ObservationKeyDNSViz, &data); err != nil {
|
||||
return nil, []sdk.CheckState{{
|
||||
Status: sdk.StatusError,
|
||||
Code: code,
|
||||
Message: fmt.Sprintf("Failed to load DNSViz observation: %v", err),
|
||||
}}
|
||||
}
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
func orderedZones(data *DNSVizData) []string {
|
||||
if len(data.Order) > 0 {
|
||||
return data.Order
|
||||
}
|
||||
keys := make([]string, 0, len(data.Zones))
|
||||
for k := range data.Zones {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
return labelDepth(keys[i]) > labelDepth(keys[j])
|
||||
})
|
||||
return keys
|
||||
}
|
||||
|
||||
func statusFromGrok(s string) sdk.Status {
|
||||
switch strings.ToUpper(strings.TrimSpace(s)) {
|
||||
case "SECURE":
|
||||
return sdk.StatusOK
|
||||
case "INSECURE":
|
||||
// "INSECURE" means "no DNSSEC and no parent DS": informational, not
|
||||
// a failure. Rules elsewhere can still flag a missing chain.
|
||||
return sdk.StatusInfo
|
||||
case "BOGUS":
|
||||
return sdk.StatusCrit
|
||||
case "INDETERMINATE":
|
||||
return sdk.StatusWarn
|
||||
case "NON_EXISTENT":
|
||||
return sdk.StatusInfo
|
||||
case "NOERROR":
|
||||
// DNS-level OK with no DNSSEC chain status reported. The zone
|
||||
// resolves but isn't signed (or grok didn't classify it).
|
||||
return sdk.StatusInfo
|
||||
case "":
|
||||
return sdk.StatusUnknown
|
||||
default:
|
||||
return sdk.StatusUnknown
|
||||
}
|
||||
}
|
||||
96
checker/rule_test.go
Normal file
96
checker/rule_test.go
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
func TestStatusFromGrok(t *testing.T) {
|
||||
cases := map[string]sdk.Status{
|
||||
"SECURE": sdk.StatusOK,
|
||||
" secure ": sdk.StatusOK,
|
||||
"INSECURE": sdk.StatusInfo,
|
||||
"BOGUS": sdk.StatusCrit,
|
||||
"INDETERMINATE": sdk.StatusWarn,
|
||||
"NON_EXISTENT": sdk.StatusInfo,
|
||||
"NOERROR": sdk.StatusInfo,
|
||||
"": sdk.StatusUnknown,
|
||||
"WHATEVER": sdk.StatusUnknown,
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := statusFromGrok(in); got != want {
|
||||
t.Errorf("statusFromGrok(%q) = %v, want %v", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderedZones_PrefersOrder(t *testing.T) {
|
||||
d := &DNSVizData{
|
||||
Order: []string{"keep.", "this."},
|
||||
Zones: map[string]ZoneAnalysis{"keep.": {}, "this.": {}, "other.": {}},
|
||||
}
|
||||
if got := orderedZones(d); !reflect.DeepEqual(got, []string{"keep.", "this."}) {
|
||||
t.Errorf("expected explicit Order to be returned, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderedZones_SortsByDepth(t *testing.T) {
|
||||
d := &DNSVizData{
|
||||
Zones: map[string]ZoneAnalysis{
|
||||
".": {},
|
||||
"com.": {},
|
||||
"example.com.": {},
|
||||
},
|
||||
}
|
||||
got := orderedZones(d)
|
||||
want := []string{"example.com.", "com.", "."}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("orderedZones=%v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadData_ReturnsErrorState(t *testing.T) {
|
||||
obs := stubObs{err: errors.New("boom")}
|
||||
data, errState := loadData(context.Background(), obs, "code_x")
|
||||
if data != nil {
|
||||
t.Errorf("expected nil data on error, got %+v", data)
|
||||
}
|
||||
if len(errState) != 1 || errState[0].Status != sdk.StatusError || errState[0].Code != "code_x" {
|
||||
t.Errorf("unexpected error state: %+v", errState)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadData_Success(t *testing.T) {
|
||||
d := &DNSVizData{Domain: "example.com", Zones: map[string]ZoneAnalysis{"example.com.": {Status: "SECURE"}}}
|
||||
obs := stubObs{value: d}
|
||||
got, errState := loadData(context.Background(), obs, "code_y")
|
||||
if errState != nil {
|
||||
t.Fatalf("unexpected error state: %+v", errState)
|
||||
}
|
||||
if got == nil || got.Domain != "example.com" {
|
||||
t.Errorf("unexpected data: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRules_Wired(t *testing.T) {
|
||||
rs := Rules()
|
||||
if len(rs) == 0 {
|
||||
t.Fatal("expected at least one rule")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, r := range rs {
|
||||
if r.Name() == "" || r.Description() == "" {
|
||||
t.Errorf("rule has empty Name/Description: %T", r)
|
||||
}
|
||||
if seen[r.Name()] {
|
||||
t.Errorf("duplicate rule name: %s", r.Name())
|
||||
}
|
||||
seen[r.Name()] = true
|
||||
}
|
||||
}
|
||||
214
checker/rules_common.go
Normal file
214
checker/rules_common.go
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// commonFailuresRule uses substring matching rather than exact codes because
|
||||
// DNSViz wording shifts between versions.
|
||||
type commonFailuresRule struct{}
|
||||
|
||||
func (r *commonFailuresRule) Name() string { return "dnsviz_common_failures" }
|
||||
func (r *commonFailuresRule) Description() string {
|
||||
return "Highlights well-known DNSSEC failure scenarios (broken chain, expired signatures, missing/extra DS, algorithm mismatch, …) with remediation hints."
|
||||
}
|
||||
|
||||
// CommonFailure is exported so the report layer can read Title/Hint from CheckState.Meta.
|
||||
type CommonFailure struct {
|
||||
ID string // Stable code emitted in CheckState.Code.
|
||||
Title string // Short headline for the report block.
|
||||
Hint string // What the user should typically do.
|
||||
Patterns []string // Substrings (lowercased) matched against the finding's code+description.
|
||||
Severity sdk.Status
|
||||
}
|
||||
|
||||
// commonFailures is the curated catalog. Order matters: the first matching
|
||||
// entry wins, so put more specific scenarios above the generic ones.
|
||||
var commonFailures = []CommonFailure{
|
||||
{
|
||||
ID: "dnssec_chain_broken_no_ds",
|
||||
Title: "Parent has no DS record for this zone",
|
||||
Hint: "Publish the DS record(s) generated from your KSK at the registrar/parent. Without DS, validators see the zone as INSECURE even when DNSKEYs are present.",
|
||||
Patterns: []string{
|
||||
"no ds records",
|
||||
"missing ds",
|
||||
"no ds record was found",
|
||||
"ds_records_missing",
|
||||
},
|
||||
Severity: sdk.StatusCrit,
|
||||
},
|
||||
{
|
||||
ID: "dnssec_ds_digest_mismatch",
|
||||
Title: "DS at parent does not match any DNSKEY at the child",
|
||||
Hint: "The DS digest at the parent does not match any DNSKEY served by the child. Re-export the DS record from your current KSK and update it at the registrar; remove stale DS entries.",
|
||||
Patterns: []string{
|
||||
"ds does not match",
|
||||
"no matching dnskey",
|
||||
"ds_does_not_match",
|
||||
"no dnskey matching",
|
||||
},
|
||||
Severity: sdk.StatusCrit,
|
||||
},
|
||||
{
|
||||
ID: "dnssec_rrsig_expired",
|
||||
Title: "RRSIG signature has expired",
|
||||
Hint: "At least one RRSIG is past its expiration. Resign the zone (most signers do this automatically; investigate why the cron/automation didn't run).",
|
||||
Patterns: []string{
|
||||
"signature has expired",
|
||||
"rrsig_expired",
|
||||
"signature_expired",
|
||||
"signature expired",
|
||||
"rrsig expired",
|
||||
},
|
||||
Severity: sdk.StatusCrit,
|
||||
},
|
||||
{
|
||||
ID: "dnssec_rrsig_not_yet_valid",
|
||||
Title: "RRSIG signature is not yet valid",
|
||||
Hint: "An RRSIG inception time is in the future. Check that the signing host's clock is synchronized (NTP) and that the signer didn't generate signatures with a future inception.",
|
||||
Patterns: []string{
|
||||
"signature is not yet valid",
|
||||
"signature_not_yet_valid",
|
||||
"inception in the future",
|
||||
},
|
||||
Severity: sdk.StatusCrit,
|
||||
},
|
||||
{
|
||||
ID: "dnssec_signature_invalid",
|
||||
Title: "Cryptographic signature is invalid",
|
||||
Hint: "A validator could not verify the signature with the published DNSKEY. The zone may have been resigned with a key that was not published, or the served DNSKEY set is inconsistent across servers.",
|
||||
Patterns: []string{
|
||||
"signature_invalid",
|
||||
"signature is invalid",
|
||||
"bad signature",
|
||||
},
|
||||
Severity: sdk.StatusCrit,
|
||||
},
|
||||
{
|
||||
ID: "dnssec_algorithm_mismatch",
|
||||
Title: "Algorithm declared in DS not present in DNSKEY (or vice versa)",
|
||||
Hint: "RFC 4035 §2.2 requires that for every algorithm a DS uses, the child must publish at least one DNSKEY with the same algorithm. Either add the missing DNSKEY/DS or retire the orphan.",
|
||||
Patterns: []string{
|
||||
"algorithm_missing",
|
||||
"algorithm not signed",
|
||||
"missing rrsig for algorithm",
|
||||
"algorithm mismatch",
|
||||
},
|
||||
Severity: sdk.StatusCrit,
|
||||
},
|
||||
{
|
||||
ID: "dnssec_deprecated_algorithm",
|
||||
Title: "Deprecated DNSSEC algorithm in use",
|
||||
Hint: "Algorithms 5 (RSASHA1) and 7 (RSASHA1-NSEC3) are deprecated. Roll the KSK/ZSK to algorithm 13 (ECDSAP256SHA256) or 8 (RSASHA256) and update the DS at the parent.",
|
||||
Patterns: []string{
|
||||
"deprecated algorithm",
|
||||
"algorithm_deprecated",
|
||||
"weak algorithm",
|
||||
"rsasha1",
|
||||
"rsa/sha-1",
|
||||
"rsa-sha1",
|
||||
"algorithm 5 ",
|
||||
"algorithm 7 ",
|
||||
},
|
||||
Severity: sdk.StatusWarn,
|
||||
},
|
||||
{
|
||||
ID: "dnssec_no_dnskey",
|
||||
Title: "No DNSKEY served at the apex",
|
||||
Hint: "The zone declares a DS at the parent but serves no DNSKEY at the apex. Validators see this as BOGUS. Republish the DNSKEY set or remove the DS at the parent.",
|
||||
Patterns: []string{
|
||||
"no dnskey",
|
||||
"dnskey_missing",
|
||||
},
|
||||
Severity: sdk.StatusCrit,
|
||||
},
|
||||
{
|
||||
ID: "dnssec_servfail",
|
||||
Title: "An authoritative server returned SERVFAIL on DNSSEC queries",
|
||||
Hint: "At least one server on the path returned SERVFAIL. Often caused by a server that doesn't have the keys it should sign with, or by EDNS/UDP fragmentation. Verify the server can answer DNSKEY/RRSIG over both UDP and TCP.",
|
||||
Patterns: []string{
|
||||
"servfail",
|
||||
"server failure",
|
||||
},
|
||||
Severity: sdk.StatusCrit,
|
||||
},
|
||||
{
|
||||
ID: "dnssec_inconsistent_responses",
|
||||
Title: "Authoritative servers disagree",
|
||||
Hint: "Different authoritative servers serve different DNSKEY/RRSIG/NSEC contents. Confirm that the secondary servers have completed AXFR/IXFR and are serving the same zone version.",
|
||||
Patterns: []string{
|
||||
"inconsistent",
|
||||
"disagree",
|
||||
},
|
||||
Severity: sdk.StatusWarn,
|
||||
},
|
||||
}
|
||||
|
||||
func (r *commonFailuresRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errState := loadData(ctx, obs, "dnsviz_common_failures")
|
||||
if errState != nil {
|
||||
return errState
|
||||
}
|
||||
var out []sdk.CheckState
|
||||
matched := map[string]struct{}{}
|
||||
matchFinding := func(name string, f Finding) {
|
||||
haystack := strings.ToLower(f.Code + " " + f.Description)
|
||||
for _, c := range commonFailures {
|
||||
if !matchesAny(haystack, c.Patterns) {
|
||||
continue
|
||||
}
|
||||
key := name + "|" + c.ID
|
||||
if _, seen := matched[key]; seen {
|
||||
return
|
||||
}
|
||||
matched[key] = struct{}{}
|
||||
out = append(out, sdk.CheckState{
|
||||
Status: c.Severity,
|
||||
Code: c.ID,
|
||||
Subject: name,
|
||||
Message: c.Title + ": " + c.Hint,
|
||||
Meta: map[string]any{
|
||||
"title": c.Title,
|
||||
"hint": c.Hint,
|
||||
"original_code": f.Code,
|
||||
"original_description": f.Description,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, name := range orderedZones(data) {
|
||||
z := data.Zones[name]
|
||||
for _, f := range z.Errors {
|
||||
matchFinding(name, f)
|
||||
}
|
||||
for _, f := range z.Warnings {
|
||||
matchFinding(name, f)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusOK,
|
||||
Code: "dnsviz_common_failures",
|
||||
Message: "No well-known DNSSEC failure scenario detected by the heuristics.",
|
||||
}}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func matchesAny(haystack string, needles []string) bool {
|
||||
for _, n := range needles {
|
||||
if n == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(haystack, n) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
122
checker/rules_common_test.go
Normal file
122
checker/rules_common_test.go
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
func TestMatchesAny(t *testing.T) {
|
||||
if !matchesAny("foo signature has expired bar", []string{"signature has expired"}) {
|
||||
t.Error("expected substring match")
|
||||
}
|
||||
if matchesAny("clean", []string{"missing"}) {
|
||||
t.Error("did not expect a match")
|
||||
}
|
||||
if matchesAny("anything", []string{""}) {
|
||||
t.Error("empty needle must not match")
|
||||
}
|
||||
if matchesAny("anything", nil) {
|
||||
t.Error("nil needles must not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommonFailuresRule_Match(t *testing.T) {
|
||||
d := &DNSVizData{
|
||||
Order: []string{"example.com."},
|
||||
Zones: map[string]ZoneAnalysis{
|
||||
"example.com.": {
|
||||
Errors: []Finding{
|
||||
{Code: "X", Description: "An RRSIG signature has expired"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
r := &commonFailuresRule{}
|
||||
states := r.Evaluate(context.Background(), stubObs{value: d}, nil)
|
||||
if len(states) != 1 {
|
||||
t.Fatalf("expected 1 match, got %+v", states)
|
||||
}
|
||||
if states[0].Code != "dnssec_rrsig_expired" {
|
||||
t.Errorf("expected curated id, got %q", states[0].Code)
|
||||
}
|
||||
if states[0].Status != sdk.StatusCrit {
|
||||
t.Errorf("expected severity Crit, got %v", states[0].Status)
|
||||
}
|
||||
if _, ok := states[0].Meta["hint"].(string); !ok {
|
||||
t.Errorf("expected hint in Meta, got %+v", states[0].Meta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommonFailuresRule_NoMatchEmitsOK(t *testing.T) {
|
||||
d := &DNSVizData{
|
||||
Order: []string{"example.com."},
|
||||
Zones: map[string]ZoneAnalysis{
|
||||
"example.com.": {Errors: []Finding{{Description: "totally unrelated message"}}},
|
||||
},
|
||||
}
|
||||
r := &commonFailuresRule{}
|
||||
states := r.Evaluate(context.Background(), stubObs{value: d}, nil)
|
||||
if len(states) != 1 || states[0].Status != sdk.StatusOK {
|
||||
t.Errorf("expected single OK summary, got %+v", states)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommonFailuresRule_DedupePerZone(t *testing.T) {
|
||||
// Same scenario surfaced multiple times in a single zone should only emit
|
||||
// one curated state for that (zone, scenario) pair.
|
||||
d := &DNSVizData{
|
||||
Order: []string{"example.com."},
|
||||
Zones: map[string]ZoneAnalysis{
|
||||
"example.com.": {
|
||||
Errors: []Finding{
|
||||
{Description: "no DS records found"},
|
||||
{Description: "missing DS at the parent"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
r := &commonFailuresRule{}
|
||||
states := r.Evaluate(context.Background(), stubObs{value: d}, nil)
|
||||
if len(states) != 1 {
|
||||
t.Fatalf("expected dedup to a single state, got %+v", states)
|
||||
}
|
||||
if states[0].Code != "dnssec_chain_broken_no_ds" {
|
||||
t.Errorf("unexpected curated code: %q", states[0].Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommonFailuresRule_FirstMatchWins(t *testing.T) {
|
||||
// "rrsig_expired" is listed before generic "expired" patterns; verify a
|
||||
// finding mentioning multiple patterns yields exactly one curated entry.
|
||||
d := &DNSVizData{
|
||||
Order: []string{"x."},
|
||||
Zones: map[string]ZoneAnalysis{
|
||||
"x.": {Errors: []Finding{{Description: "signature has expired and DS does not match"}}},
|
||||
},
|
||||
}
|
||||
r := &commonFailuresRule{}
|
||||
states := r.Evaluate(context.Background(), stubObs{value: d}, nil)
|
||||
if len(states) != 1 {
|
||||
t.Fatalf("expected single curated entry, got %d: %+v", len(states), states)
|
||||
}
|
||||
if !strings.HasPrefix(states[0].Code, "dnssec_") {
|
||||
t.Errorf("unexpected code %q", states[0].Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommonFailures_PatternsLowercase(t *testing.T) {
|
||||
// All catalog patterns are lowercased for substring matching against a
|
||||
// lowercased haystack. A non-lowercase pattern would silently never match.
|
||||
for _, c := range commonFailures {
|
||||
for _, p := range c.Patterns {
|
||||
if p != strings.ToLower(p) {
|
||||
t.Errorf("pattern %q in %q is not lowercase", p, c.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
164
checker/rules_status.go
Normal file
164
checker/rules_status.go
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
type overallStatusRule struct{}
|
||||
|
||||
func (r *overallStatusRule) Name() string { return "dnsviz_overall_status" }
|
||||
func (r *overallStatusRule) Description() string {
|
||||
return "Reports the DNSViz status of the queried domain (SECURE, INSECURE, BOGUS, INDETERMINATE)."
|
||||
}
|
||||
func (r *overallStatusRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errState := loadData(ctx, obs, "dnsviz_overall_status")
|
||||
if errState != nil {
|
||||
return errState
|
||||
}
|
||||
leaf := data.Domain + "."
|
||||
z, ok := data.Zones[leaf]
|
||||
if !ok {
|
||||
// Fall back to the most-specific zone DNSViz reported.
|
||||
zones := orderedZones(data)
|
||||
if len(zones) == 0 {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusUnknown,
|
||||
Code: "dnsviz_overall_status",
|
||||
Message: "DNSViz returned no zones for this domain",
|
||||
}}
|
||||
}
|
||||
leaf = zones[0]
|
||||
z = data.Zones[leaf]
|
||||
}
|
||||
st := sdk.CheckState{
|
||||
Code: "dnsviz_overall_status",
|
||||
Subject: leaf,
|
||||
Status: statusFromGrok(z.Status),
|
||||
Message: fmt.Sprintf("DNSViz status: %s", emptyAsUnknown(z.Status)),
|
||||
Meta: map[string]any{
|
||||
"status": z.Status,
|
||||
"errors": len(z.Errors),
|
||||
"warnings": len(z.Warnings),
|
||||
},
|
||||
}
|
||||
return []sdk.CheckState{st}
|
||||
}
|
||||
|
||||
// Subject is set to the zone name so each delegation level gets its own report block.
|
||||
type perZoneStatusRule struct{}
|
||||
|
||||
func (r *perZoneStatusRule) Name() string { return "dnsviz_per_zone_status" }
|
||||
func (r *perZoneStatusRule) Description() string {
|
||||
return "Reports the DNSViz status of every zone in the chain (root, TLD, intermediates, leaf)."
|
||||
}
|
||||
func (r *perZoneStatusRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errState := loadData(ctx, obs, "dnsviz_per_zone_status")
|
||||
if errState != nil {
|
||||
return errState
|
||||
}
|
||||
zones := orderedZones(data)
|
||||
if len(zones) == 0 {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusUnknown,
|
||||
Code: "dnsviz_per_zone_status",
|
||||
Message: "DNSViz returned no zones for this domain",
|
||||
}}
|
||||
}
|
||||
out := make([]sdk.CheckState, 0, len(zones))
|
||||
for _, name := range zones {
|
||||
z := data.Zones[name]
|
||||
out = append(out, sdk.CheckState{
|
||||
Code: "dnsviz_per_zone_status",
|
||||
Subject: name,
|
||||
Status: statusFromGrok(z.Status),
|
||||
Message: fmt.Sprintf("%s: errors=%d warnings=%d", emptyAsUnknown(z.Status), len(z.Errors), len(z.Warnings)),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// One state per (zone, finding) pair so the UI can show a precise list.
|
||||
type zoneErrorsRule struct{}
|
||||
|
||||
func (r *zoneErrorsRule) Name() string { return "dnsviz_zone_errors" }
|
||||
func (r *zoneErrorsRule) Description() string {
|
||||
return "Surfaces every error reported by DNSViz, scoped to the zone where it was found."
|
||||
}
|
||||
func (r *zoneErrorsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errState := loadData(ctx, obs, "dnsviz_zone_errors")
|
||||
if errState != nil {
|
||||
return errState
|
||||
}
|
||||
return zoneFindingStates(data, "dnsviz_zone_errors", sdk.StatusCrit, "errors", func(z ZoneAnalysis) []Finding { return z.Errors })
|
||||
}
|
||||
|
||||
type zoneWarningsRule struct{}
|
||||
|
||||
func (r *zoneWarningsRule) Name() string { return "dnsviz_zone_warnings" }
|
||||
func (r *zoneWarningsRule) Description() string {
|
||||
return "Surfaces every warning reported by DNSViz, scoped to the zone where it was found."
|
||||
}
|
||||
func (r *zoneWarningsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errState := loadData(ctx, obs, "dnsviz_zone_warnings")
|
||||
if errState != nil {
|
||||
return errState
|
||||
}
|
||||
return zoneFindingStates(data, "dnsviz_zone_warnings", sdk.StatusWarn, "warnings", func(z ZoneAnalysis) []Finding { return z.Warnings })
|
||||
}
|
||||
|
||||
// zoneFindingStates emits a single OK state when nothing matches so the rule outcome is always observable.
|
||||
func zoneFindingStates(data *DNSVizData, ruleCode string, status sdk.Status, kindLabel string, pick func(ZoneAnalysis) []Finding) []sdk.CheckState {
|
||||
var out []sdk.CheckState
|
||||
for _, name := range orderedZones(data) {
|
||||
for _, f := range pick(data.Zones[name]) {
|
||||
out = append(out, sdk.CheckState{
|
||||
Status: status,
|
||||
Code: nonEmpty(f.Code, ruleCode),
|
||||
Subject: name,
|
||||
Message: f.Description,
|
||||
Meta: findingMeta(f),
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusOK,
|
||||
Code: ruleCode,
|
||||
Message: fmt.Sprintf("DNSViz reported no %s in any zone", kindLabel),
|
||||
}}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func emptyAsUnknown(s string) string {
|
||||
if s == "" {
|
||||
return "UNKNOWN"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func nonEmpty(a, b string) string {
|
||||
if a != "" {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func findingMeta(f Finding) map[string]any {
|
||||
m := map[string]any{}
|
||||
if f.Code != "" {
|
||||
m["code"] = f.Code
|
||||
}
|
||||
if len(f.Servers) > 0 {
|
||||
m["servers"] = f.Servers
|
||||
}
|
||||
if len(m) == 0 {
|
||||
return nil
|
||||
}
|
||||
return m
|
||||
}
|
||||
189
checker/rules_status_test.go
Normal file
189
checker/rules_status_test.go
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
func sampleData() *DNSVizData {
|
||||
return &DNSVizData{
|
||||
Domain: "example.com",
|
||||
Order: []string{"example.com.", "com.", "."},
|
||||
Zones: map[string]ZoneAnalysis{
|
||||
"example.com.": {
|
||||
Status: "BOGUS",
|
||||
Errors: []Finding{{Code: "RRSIG_EXPIRED", Description: "signature has expired"}},
|
||||
},
|
||||
"com.": {Status: "SECURE"},
|
||||
".": {Status: "SECURE"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverallStatusRule(t *testing.T) {
|
||||
r := &overallStatusRule{}
|
||||
states := r.Evaluate(context.Background(), stubObs{value: sampleData()}, nil)
|
||||
if len(states) != 1 {
|
||||
t.Fatalf("expected 1 state, got %d", len(states))
|
||||
}
|
||||
if states[0].Status != sdk.StatusCrit {
|
||||
t.Errorf("expected StatusCrit for BOGUS leaf, got %v", states[0].Status)
|
||||
}
|
||||
if states[0].Subject != "example.com." {
|
||||
t.Errorf("subject: got %q", states[0].Subject)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverallStatusRule_FallbackToFirstZone(t *testing.T) {
|
||||
d := &DNSVizData{
|
||||
Domain: "missing",
|
||||
Order: []string{"other.zone."},
|
||||
Zones: map[string]ZoneAnalysis{"other.zone.": {Status: "SECURE"}},
|
||||
}
|
||||
r := &overallStatusRule{}
|
||||
states := r.Evaluate(context.Background(), stubObs{value: d}, nil)
|
||||
if states[0].Subject != "other.zone." {
|
||||
t.Errorf("expected fallback to first zone, got %q", states[0].Subject)
|
||||
}
|
||||
if states[0].Status != sdk.StatusOK {
|
||||
t.Errorf("expected SECURE -> OK, got %v", states[0].Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverallStatusRule_NoZones(t *testing.T) {
|
||||
r := &overallStatusRule{}
|
||||
states := r.Evaluate(context.Background(), stubObs{value: &DNSVizData{Domain: "x"}}, nil)
|
||||
if states[0].Status != sdk.StatusUnknown {
|
||||
t.Errorf("expected Unknown for empty zones, got %v", states[0].Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverallStatusRule_LoadError(t *testing.T) {
|
||||
r := &overallStatusRule{}
|
||||
states := r.Evaluate(context.Background(), stubObs{err: errors.New("nope")}, nil)
|
||||
if len(states) != 1 || states[0].Status != sdk.StatusError {
|
||||
t.Errorf("expected one error state, got %+v", states)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerZoneStatusRule(t *testing.T) {
|
||||
r := &perZoneStatusRule{}
|
||||
states := r.Evaluate(context.Background(), stubObs{value: sampleData()}, nil)
|
||||
if len(states) != 3 {
|
||||
t.Fatalf("expected 3 per-zone states, got %d", len(states))
|
||||
}
|
||||
subjects := make([]string, len(states))
|
||||
for i, s := range states {
|
||||
subjects[i] = s.Subject
|
||||
}
|
||||
want := []string{"example.com.", "com.", "."}
|
||||
for i := range want {
|
||||
if subjects[i] != want[i] {
|
||||
t.Errorf("subjects[%d]=%q, want %q", i, subjects[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerZoneStatusRule_NoZones(t *testing.T) {
|
||||
r := &perZoneStatusRule{}
|
||||
states := r.Evaluate(context.Background(), stubObs{value: &DNSVizData{}}, nil)
|
||||
if len(states) != 1 || states[0].Status != sdk.StatusUnknown {
|
||||
t.Errorf("expected single Unknown state, got %+v", states)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneErrorsRule(t *testing.T) {
|
||||
r := &zoneErrorsRule{}
|
||||
states := r.Evaluate(context.Background(), stubObs{value: sampleData()}, nil)
|
||||
if len(states) != 1 {
|
||||
t.Fatalf("expected 1 error state, got %d", len(states))
|
||||
}
|
||||
if states[0].Status != sdk.StatusCrit {
|
||||
t.Errorf("expected Crit, got %v", states[0].Status)
|
||||
}
|
||||
if states[0].Code != "RRSIG_EXPIRED" {
|
||||
t.Errorf("expected the finding code to be used, got %q", states[0].Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneErrorsRule_NoFindings(t *testing.T) {
|
||||
d := &DNSVizData{Order: []string{"a."}, Zones: map[string]ZoneAnalysis{"a.": {Status: "SECURE"}}}
|
||||
r := &zoneErrorsRule{}
|
||||
states := r.Evaluate(context.Background(), stubObs{value: d}, nil)
|
||||
if len(states) != 1 || states[0].Status != sdk.StatusOK {
|
||||
t.Errorf("expected OK summary state, got %+v", states)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneWarningsRule(t *testing.T) {
|
||||
d := &DNSVizData{
|
||||
Order: []string{"a."},
|
||||
Zones: map[string]ZoneAnalysis{"a.": {Warnings: []Finding{{Description: "soft"}}}},
|
||||
}
|
||||
r := &zoneWarningsRule{}
|
||||
states := r.Evaluate(context.Background(), stubObs{value: d}, nil)
|
||||
if len(states) != 1 || states[0].Status != sdk.StatusWarn {
|
||||
t.Errorf("expected Warn state, got %+v", states)
|
||||
}
|
||||
// Code falls back to the rule code when finding has none.
|
||||
if states[0].Code != "dnsviz_zone_warnings" {
|
||||
t.Errorf("expected fallback code, got %q", states[0].Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyAsUnknown(t *testing.T) {
|
||||
if emptyAsUnknown("") != "UNKNOWN" {
|
||||
t.Error("empty should map to UNKNOWN")
|
||||
}
|
||||
if emptyAsUnknown("X") != "X" {
|
||||
t.Error("non-empty should pass through")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonEmpty(t *testing.T) {
|
||||
if nonEmpty("a", "b") != "a" || nonEmpty("", "b") != "b" {
|
||||
t.Error("nonEmpty did not pick non-empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindingMeta(t *testing.T) {
|
||||
if findingMeta(Finding{}) != nil {
|
||||
t.Error("expected nil for empty finding")
|
||||
}
|
||||
m := findingMeta(Finding{Code: "C", Servers: []string{"a"}})
|
||||
if m["code"] != "C" {
|
||||
t.Errorf("missing code in meta: %v", m)
|
||||
}
|
||||
srvs, _ := m["servers"].([]string)
|
||||
if len(srvs) != 1 || srvs[0] != "a" {
|
||||
t.Errorf("missing servers in meta: %v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneErrorsRule_ConcatPerZone(t *testing.T) {
|
||||
d := &DNSVizData{
|
||||
Order: []string{"leaf.", "tld."},
|
||||
Zones: map[string]ZoneAnalysis{
|
||||
"leaf.": {Errors: []Finding{{Description: "leaf-err"}}},
|
||||
"tld.": {Errors: []Finding{{Description: "tld-err"}}},
|
||||
},
|
||||
}
|
||||
r := &zoneErrorsRule{}
|
||||
states := r.Evaluate(context.Background(), stubObs{value: d}, nil)
|
||||
if len(states) != 2 {
|
||||
t.Fatalf("expected 2 states, got %d", len(states))
|
||||
}
|
||||
// Subjects should both appear, leaf first per Order.
|
||||
if states[0].Subject != "leaf." || states[1].Subject != "tld." {
|
||||
t.Errorf("subjects out of order: %q,%q", states[0].Subject, states[1].Subject)
|
||||
}
|
||||
if !strings.Contains(states[0].Message, "leaf-err") {
|
||||
t.Errorf("leaf message lost: %q", states[0].Message)
|
||||
}
|
||||
}
|
||||
37
checker/testhelpers_test.go
Normal file
37
checker/testhelpers_test.go
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// stubObs is a minimal ObservationGetter for rule tests. It JSON round-trips
|
||||
// the stored value into the destination so it exercises the same code path
|
||||
// rules see in production.
|
||||
type stubObs struct {
|
||||
value any
|
||||
err error
|
||||
}
|
||||
|
||||
func (s stubObs) Get(_ context.Context, _ sdk.ObservationKey, dest any) error {
|
||||
if s.err != nil {
|
||||
return s.err
|
||||
}
|
||||
if s.value == nil {
|
||||
return errors.New("no value")
|
||||
}
|
||||
raw, err := json.Marshal(s.value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(raw, dest)
|
||||
}
|
||||
|
||||
func (s stubObs) GetRelated(_ context.Context, _ sdk.ObservationKey) ([]sdk.RelatedObservation, error) {
|
||||
return nil, nil
|
||||
}
|
||||
86
checker/types.go
Normal file
86
checker/types.go
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package checker implements a happyDomain checker that wraps DNSViz
|
||||
// (https://github.com/dnsviz/dnsviz). It runs `dnsviz probe` followed by
|
||||
// `dnsviz grok` against a domain, stores the structured analysis as the
|
||||
// observation, and turns the per-zone errors/warnings into CheckStates.
|
||||
//
|
||||
// The container ships dnsviz alongside this binary, so the checker has no
|
||||
// external dependency at runtime besides the network.
|
||||
package checker
|
||||
|
||||
import (
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// ObservationKeyDNSViz is the observation key for DNSViz analysis output.
|
||||
const ObservationKeyDNSViz sdk.ObservationKey = "dnsviz"
|
||||
|
||||
// DNSVizData is what Collect stores. It carries the full grok output
|
||||
// (parsed into a permissive structure) plus the raw bytes for the report.
|
||||
//
|
||||
// DNSViz emits a single top-level object whose keys are zone FQDNs (with
|
||||
// trailing dot), one per level of the chain. Inside each zone object the
|
||||
// shape is permissive: many fields are conditional, so we keep most of them
|
||||
// as map[string]any and only pluck out what the rules need.
|
||||
type DNSVizData struct {
|
||||
// Domain is the queried FQDN, with trailing dot stripped.
|
||||
Domain string `json:"domain"`
|
||||
|
||||
// Zones is the per-zone analysis, keyed by zone FQDN (with trailing dot
|
||||
// preserved, matching DNSViz's output).
|
||||
Zones map[string]ZoneAnalysis `json:"zones"`
|
||||
|
||||
// Order is Zones' keys, sorted from the queried name up to the root.
|
||||
// We surface it explicitly so the report can render in a stable order
|
||||
// without having to re-sort on every render.
|
||||
Order []string `json:"order,omitempty"`
|
||||
|
||||
// Raw is the unmodified `dnsviz grok` JSON. Kept around so the report
|
||||
// can fall back on it for fields the typed view does not capture.
|
||||
Raw []byte `json:"raw,omitempty"`
|
||||
|
||||
// ProbeStderr / GrokStderr capture the diagnostics dnsviz prints to
|
||||
// stderr. Useful when collection succeeds but the analysis is partial.
|
||||
ProbeStderr string `json:"probe_stderr,omitempty"`
|
||||
GrokStderr string `json:"grok_stderr,omitempty"`
|
||||
}
|
||||
|
||||
// ZoneAnalysis is a permissive view over one zone's grok block.
|
||||
//
|
||||
// DNSViz output puts the DNSSEC chain status at delegation.status (one of
|
||||
// "SECURE", "BOGUS", "INSECURE", "INDETERMINATE") while the top-level
|
||||
// "status" field carries the DNS rcode for the zone apex (e.g. "NOERROR").
|
||||
// Errors and warnings are not surfaced as a flat per-zone array; instead
|
||||
// they appear as nested "errors"/"warnings" arrays attached to the record
|
||||
// where the problem was found (DS, DNSKEY, RRSIG, NSEC proof, query
|
||||
// response, server, …). We walk the whole zone subtree to collect them.
|
||||
type ZoneAnalysis struct {
|
||||
// Status is the DNSSEC chain status taken from delegation.status when
|
||||
// available, falling back to the top-level "status" field otherwise.
|
||||
Status string `json:"status,omitempty"`
|
||||
// DNSStatus is the raw top-level "status" field (DNS rcode such as
|
||||
// "NOERROR"). Kept for the report so we can distinguish "DNS resolved
|
||||
// fine" from "DNSSEC chain validates".
|
||||
DNSStatus string `json:"dns_status,omitempty"`
|
||||
Errors []Finding `json:"errors,omitempty"`
|
||||
Warnings []Finding `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
// Finding mirrors the shape DNSViz uses for entries in errors/warnings.
|
||||
// Producers occasionally use slightly different field names across versions
|
||||
// of dnsviz; we accept both `description`/`message` for the human text and
|
||||
// fall back to a generic stringification at parse time.
|
||||
type Finding struct {
|
||||
Code string `json:"code,omitempty"`
|
||||
Description string `json:"description"`
|
||||
Servers []string `json:"servers,omitempty"`
|
||||
// Path is a slash-separated pointer to the JSON node where the finding
|
||||
// was attached (e.g. "delegation/ds[0]" or
|
||||
// "queries/example.com./IN/A/answer[0]/rrsig[0]"). Useful in the
|
||||
// report so a generic "signature_invalid" can be located precisely.
|
||||
Path string `json:"path,omitempty"`
|
||||
// Extra holds the raw finding payload when no human description could
|
||||
// be extracted. Surfaced by the report as a debug fallback.
|
||||
Extra map[string]any `json:"extra,omitempty"`
|
||||
}
|
||||
5
go.mod
Normal file
5
go.mod
Normal 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
2
go.sum
Normal 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
339
internal/collect/LICENSE
Normal 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
253
internal/collect/collect.go
Normal 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)
|
||||
}
|
||||
224
internal/collect/collect_test.go
Normal file
224
internal/collect/collect_test.go
Normal 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
39
main.go
Normal 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
26
plugin/plugin.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue