Initial commit

This commit is contained in:
nemunaire 2026-04-26 18:44:22 +07:00
commit 257c7e494f
21 changed files with 1891 additions and 0 deletions

2
.gitignore vendored Normal file
View file

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

36
Dockerfile Normal file
View file

@ -0,0 +1,36 @@
# -- 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 \
&& pip3 install --no-cache-dir --break-system-packages dnsviz \
&& adduser -D -u 65534 -H -s /sbin/nologin checker || true
COPY --from=builder /checker-dnsviz /usr/local/bin/checker-dnsviz
USER 65534:65534
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD ["/usr/local/bin/checker-dnsviz", "-healthcheck"]
ENTRYPOINT ["/usr/local/bin/checker-dnsviz"]

21
LICENSE Normal file
View file

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

28
Makefile Normal file
View file

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

26
NOTICE Normal file
View file

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

88
README.md Normal file
View file

@ -0,0 +1,88 @@
# 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 <domain> | dnsviz grok
```
stores the parsed JSON output as the observation, and turns DNSViz's per-zone
errors and warnings into individual `CheckState` entries. 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) so a recursive DNSSEC failure can be located at the
exact level 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
# requires `dnsviz` on PATH
```
### Docker
```bash
make docker
docker run -p 8080:8080 happydomain/checker-dnsviz
```
### 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`. |
| 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.

115
checker/collect.go Normal file
View file

@ -0,0 +1,115 @@
// SPDX-License-Identifier: MIT
package checker
import (
"encoding/json"
"sort"
"strings"
)
// ParseGrokOutput decodes the JSON produced by `dnsviz grok` into a typed
// map of zone analyses. The returned Order slice lists zone FQDNs sorted
// from the most-specific (queried name) up to the root, matching DNSViz's
// natural chain order.
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 {
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 m map[string]json.RawMessage
if err := json.Unmarshal(raw, &m); err != nil {
var s string
if json.Unmarshal(raw, &s) == nil {
z.Status = s
}
return z
}
if v, ok := m["status"]; ok {
_ = json.Unmarshal(v, &z.Status)
}
z.Errors = decodeFindings(m["errors"])
z.Warnings = decodeFindings(m["warnings"])
delete(m, "status")
delete(m, "errors")
delete(m, "warnings")
if len(m) > 0 {
z.Extra = make(map[string]any, len(m))
for k, v := range m {
var any any
_ = json.Unmarshal(v, &any)
z.Extra[k] = any
}
}
return z
}
func decodeFindings(raw json.RawMessage) []Finding {
if len(raw) == 0 {
return nil
}
var arr []map[string]any
if err := json.Unmarshal(raw, &arr); err != nil {
var strs []string
if json.Unmarshal(raw, &strs) == nil {
out := make([]Finding, 0, len(strs))
for _, s := range strs {
out = append(out, Finding{Description: s})
}
return out
}
return nil
}
out := make([]Finding, 0, len(arr))
for _, item := range arr {
f := Finding{}
if s, ok := item["code"].(string); ok {
f.Code = s
}
if s, ok := item["description"].(string); ok && s != "" {
f.Description = s
} else if s, ok := item["message"].(string); ok && s != "" {
f.Description = s
} else {
b, _ := json.Marshal(item)
f.Description = string(b)
}
if servers, ok := item["servers"].([]any); ok {
for _, srv := range servers {
if s, ok := srv.(string); ok {
f.Servers = append(f.Servers, s)
}
}
}
out = append(out, f)
}
return out
}
func labelDepth(zone string) int {
z := strings.TrimSuffix(zone, ".")
if z == "" {
return 0
}
return strings.Count(z, ".") + 1
}

52
checker/definition.go Normal file
View file

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

39
checker/interactive.go Normal file
View file

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

31
checker/provider.go Normal file
View file

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

329
checker/report.go Normal file
View file

@ -0,0 +1,329 @@
// SPDX-License-Identifier: MIT
package checker
import (
"encoding/json"
"fmt"
"html"
"sort"
"strings"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// GetHTMLReport produces a self-contained HTML page that:
//
// - Banners the overall DNSViz status of the queried domain.
// - Lists "Fix these first": the curated common-failure matches and any
// critical state, with the human-readable hint pulled from CheckState.Meta.
// - Renders one block per zone in the chain (root → … → leaf), with the
// zone's status, errors and warnings, so a recursive DNSSEC failure
// can be located at the exact level it broke.
// - Falls back to a raw-JSON dump when no rule states were threaded in.
func (p *dnsvizProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
var data DNSVizData
if raw := ctx.Data(); len(raw) > 0 {
if err := json.Unmarshal(raw, &data); err != nil {
return "", fmt.Errorf("decoding DNSViz data: %w", err)
}
}
states := ctx.States()
var b strings.Builder
b.WriteString(`<!doctype html><html lang="en"><head><meta charset="utf-8">`)
b.WriteString(`<title>DNSSEC report — ` + html.EscapeString(data.Domain) + `</title>`)
b.WriteString(`<style>` + reportCSS + `</style></head><body>`)
fmt.Fprintf(&b, `<header><h1>DNSSEC analysis</h1><p class="domain">%s</p></header>`,
html.EscapeString(emptyAsUnknown(data.Domain)))
if len(states) == 0 && len(data.Zones) == 0 {
b.WriteString(`<p class="empty">No DNSViz data and no rule states. The check probably failed before producing any output.</p>`)
b.WriteString(`</body></html>`)
return b.String(), nil
}
writeOverallBanner(&b, &data, states)
writeFixFirst(&b, states)
writeChain(&b, &data)
writeAllStates(&b, states)
writeRawSection(&b, &data)
b.WriteString(`</body></html>`)
return b.String(), nil
}
// ExtractMetrics turns the rule output into time-series points so a
// happyDomain dashboard can show DNSSEC drift over time.
func (p *dnsvizProvider) ExtractMetrics(ctx sdk.ReportContext, collectedAt time.Time) ([]sdk.CheckMetric, error) {
var data DNSVizData
if raw := ctx.Data(); len(raw) > 0 {
if err := json.Unmarshal(raw, &data); err != nil {
return nil, err
}
}
metrics := []sdk.CheckMetric{
{
Name: "dnsviz.zones.count",
Value: float64(len(data.Zones)),
Timestamp: collectedAt,
},
}
var totalErrors, totalWarnings int
for _, z := range data.Zones {
totalErrors += len(z.Errors)
totalWarnings += len(z.Warnings)
}
metrics = append(metrics,
sdk.CheckMetric{Name: "dnsviz.errors.count", Value: float64(totalErrors), Timestamp: collectedAt},
sdk.CheckMetric{Name: "dnsviz.warnings.count", Value: float64(totalWarnings), Timestamp: collectedAt},
)
byStatus := map[sdk.Status]int{}
for _, s := range ctx.States() {
byStatus[s.Status]++
}
for status, n := range byStatus {
metrics = append(metrics, sdk.CheckMetric{
Name: "dnsviz.findings.count",
Value: float64(n),
Labels: map[string]string{"status": status.String()},
Timestamp: collectedAt,
})
}
return metrics, nil
}
// ── HTML rendering helpers ───────────────────────────────────────────────
const reportCSS = `
*,*::before,*::after{box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;margin:0;padding:1.5rem;background:#fafafa;color:#222;line-height:1.45}
header h1{margin:0 0 .25rem;font-size:1.6rem}
header .domain{margin:0 0 1rem;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:#555}
.banner{display:inline-block;padding:.6rem 1rem;border-radius:.4rem;color:#fff;font-weight:600;margin:0 0 1.5rem}
.banner small{display:block;font-weight:400;opacity:.85;font-size:.85rem;margin-top:.25rem}
.s-OK{background:#2e7d32}.s-INFO{background:#0277bd}.s-WARN{background:#ef6c00}.s-CRIT{background:#c62828}.s-ERROR{background:#6a1b9a}.s-UNKNOWN{background:#555}
.section{background:#fff;border:1px solid #e0e0e0;border-radius:.4rem;padding:1rem 1.25rem;margin:0 0 1.5rem;box-shadow:0 1px 2px rgba(0,0,0,.04)}
.section h2{margin:0 0 .75rem;font-size:1.15rem}
.zone{border-left:4px solid #ccc;padding:.5rem .75rem;margin:.5rem 0;background:#fafafa;border-radius:0 .3rem .3rem 0}
.zone.s-OK{border-color:#2e7d32}.zone.s-INFO{border-color:#0277bd}.zone.s-WARN{border-color:#ef6c00}.zone.s-CRIT{border-color:#c62828}.zone.s-UNKNOWN{border-color:#777}
.zone h3{margin:0 0 .25rem;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:1rem}
.zone .status{font-size:.8rem;text-transform:uppercase;letter-spacing:.05em;color:#fff;padding:.1rem .4rem;border-radius:.2rem;margin-left:.5rem}
.findings{margin:.5rem 0 0;padding:0;list-style:none}
.findings li{padding:.35rem .5rem;border-radius:.25rem;margin:.2rem 0;font-size:.9rem}
.findings li.err{background:#ffebee;color:#b71c1c}
.findings li.warn{background:#fff3e0;color:#bf360c}
.findings li code{background:rgba(0,0,0,.06);padding:0 .2rem;border-radius:.15rem;font-size:.8rem}
table{border-collapse:collapse;width:100%;font-size:.9rem}
th,td{border:1px solid #e0e0e0;padding:.35rem .5rem;text-align:left;vertical-align:top}
th{background:#f5f5f5;font-weight:600}
.fix-card{border-left:4px solid #c62828;background:#fff;padding:.75rem 1rem;border-radius:0 .3rem .3rem 0;margin:.5rem 0}
.fix-card.warn{border-color:#ef6c00}
.fix-card h4{margin:0 0 .25rem;font-size:1rem}
.fix-card .where{color:#666;font-size:.85rem;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
.fix-card .hint{margin:.4rem 0 0}
details>summary{cursor:pointer;color:#555}
pre{background:#f5f5f5;padding:.75rem;border-radius:.3rem;overflow-x:auto;font-size:.8rem;line-height:1.4}
.empty{padding:2rem;text-align:center;color:#777}
`
func writeOverallBanner(b *strings.Builder, data *DNSVizData, states []sdk.CheckState) {
leaf := data.Domain + "."
z, ok := data.Zones[leaf]
if !ok {
zones := orderedZones(data)
if len(zones) > 0 {
leaf = zones[0]
z = data.Zones[leaf]
}
}
st := statusFromGrok(z.Status)
if w := worstStatus(states); w > st {
st = w
}
fmt.Fprintf(b,
`<div class="banner s-%s">Overall: %s<small>DNSViz status of %s: %s</small></div>`,
st.String(), st.String(),
html.EscapeString(strings.TrimSuffix(leaf, ".")),
html.EscapeString(emptyAsUnknown(z.Status)),
)
}
func writeFixFirst(b *strings.Builder, states []sdk.CheckState) {
type item struct {
state sdk.CheckState
}
var items []item
for _, s := range states {
if s.Status < sdk.StatusWarn {
continue
}
items = append(items, item{state: s})
}
if len(items) == 0 {
return
}
sort.SliceStable(items, func(i, j int) bool {
if items[i].state.Status != items[j].state.Status {
return items[i].state.Status > items[j].state.Status
}
return items[i].state.Subject < items[j].state.Subject
})
b.WriteString(`<section class="section"><h2>Fix these first</h2>`)
for _, it := range items {
s := it.state
title, hint := titleAndHint(s)
klass := "fix-card"
if s.Status == sdk.StatusWarn {
klass = "fix-card warn"
}
fmt.Fprintf(b, `<div class="%s"><h4>%s</h4><div class="where">at <code>%s</code> — rule <code>%s</code></div>`,
klass,
html.EscapeString(title),
html.EscapeString(s.Subject),
html.EscapeString(s.Code),
)
if hint != "" {
fmt.Fprintf(b, `<p class="hint">%s</p>`, html.EscapeString(hint))
} else if s.Message != "" {
fmt.Fprintf(b, `<p class="hint">%s</p>`, html.EscapeString(s.Message))
}
b.WriteString(`</div>`)
}
b.WriteString(`</section>`)
}
func titleAndHint(s sdk.CheckState) (title, hint string) {
if s.Meta != nil {
if v, ok := s.Meta["title"].(string); ok {
title = v
}
if v, ok := s.Meta["hint"].(string); ok {
hint = v
}
}
if title == "" {
title = s.Message
}
return
}
func writeChain(b *strings.Builder, data *DNSVizData) {
zones := orderedZones(data)
if len(zones) == 0 {
return
}
b.WriteString(`<section class="section"><h2>Per-zone analysis (root → leaf)</h2>`)
// Render root first, leaf last for a chain narrative.
for i := len(zones) - 1; i >= 0; i-- {
name := zones[i]
z := data.Zones[name]
st := statusFromGrok(z.Status)
fmt.Fprintf(b, `<div class="zone s-%s"><h3>%s<span class="status s-%s">%s</span></h3>`,
st.String(),
html.EscapeString(name),
st.String(),
html.EscapeString(emptyAsUnknown(z.Status)),
)
if len(z.Errors) > 0 {
b.WriteString(`<ul class="findings">`)
for _, f := range z.Errors {
writeFindingLI(b, f, "err")
}
b.WriteString(`</ul>`)
}
if len(z.Warnings) > 0 {
b.WriteString(`<ul class="findings">`)
for _, f := range z.Warnings {
writeFindingLI(b, f, "warn")
}
b.WriteString(`</ul>`)
}
if len(z.Errors) == 0 && len(z.Warnings) == 0 {
b.WriteString(`<p style="margin:.4rem 0;color:#2e7d32">No DNSViz finding at this level.</p>`)
}
b.WriteString(`</div>`)
}
b.WriteString(`</section>`)
}
func writeFindingLI(b *strings.Builder, f Finding, klass string) {
fmt.Fprintf(b, `<li class="%s">`, klass)
if f.Code != "" {
fmt.Fprintf(b, `<code>%s</code> `, html.EscapeString(f.Code))
}
b.WriteString(html.EscapeString(f.Description))
if len(f.Servers) > 0 {
fmt.Fprintf(b, ` <small>(%s)</small>`, html.EscapeString(strings.Join(f.Servers, ", ")))
}
b.WriteString(`</li>`)
}
func writeAllStates(b *strings.Builder, states []sdk.CheckState) {
if len(states) == 0 {
return
}
sorted := append([]sdk.CheckState(nil), states...)
sort.SliceStable(sorted, func(i, j int) bool {
if sorted[i].Status != sorted[j].Status {
return sorted[i].Status > sorted[j].Status
}
if sorted[i].Subject != sorted[j].Subject {
return sorted[i].Subject < sorted[j].Subject
}
return sorted[i].Code < sorted[j].Code
})
b.WriteString(`<section class="section"><h2>All rule states</h2><table><thead><tr><th>Status</th><th>Subject</th><th>Code</th><th>Message</th></tr></thead><tbody>`)
for _, s := range sorted {
fmt.Fprintf(b, `<tr><td><span class="status s-%s">%s</span></td><td><code>%s</code></td><td><code>%s</code></td><td>%s</td></tr>`,
s.Status.String(), s.Status.String(),
html.EscapeString(s.Subject),
html.EscapeString(s.Code),
html.EscapeString(s.Message),
)
}
b.WriteString(`</tbody></table></section>`)
}
func writeRawSection(b *strings.Builder, data *DNSVizData) {
if len(data.Raw) == 0 {
return
}
b.WriteString(`<section class="section"><details><summary>Raw <code>dnsviz grok</code> output</summary><pre>`)
b.WriteString(html.EscapeString(string(data.Raw)))
b.WriteString(`</pre></details>`)
if data.ProbeStderr != "" {
b.WriteString(`<details><summary>dnsviz probe stderr</summary><pre>`)
b.WriteString(html.EscapeString(data.ProbeStderr))
b.WriteString(`</pre></details>`)
}
if data.GrokStderr != "" {
b.WriteString(`<details><summary>dnsviz grok stderr</summary><pre>`)
b.WriteString(html.EscapeString(data.GrokStderr))
b.WriteString(`</pre></details>`)
}
b.WriteString(`</section>`)
}
// worstStatus returns the highest-severity status in states, using the same
// ordering writeFixFirst relies on (Crit > Error > Warn > Info > OK > Unknown).
// Returns StatusOK when states is empty.
func worstStatus(states []sdk.CheckState) sdk.Status {
if len(states) == 0 {
return sdk.StatusOK
}
worst := states[0].Status
for _, s := range states[1:] {
if s.Status > worst {
worst = s.Status
}
}
return worst
}

78
checker/rule.go Normal file
View file

@ -0,0 +1,78 @@
// 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.
//
// Each rule maps to a single concern so the UI can show a clean checklist.
// Most rules iterate over zones in the chain and emit one CheckState per
// zone — Subject is the zone FQDN — so a fault at the TLD never gets
// silently merged with a fault at the leaf.
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
}
// orderedZones returns zone keys in the report-friendly order (queried name
// first, root last), preferring DNSVizData.Order when populated.
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
}
// statusFromGrok turns a DNSViz status string into our SDK Status.
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 "":
return sdk.StatusUnknown
default:
return sdk.StatusUnknown
}
}

209
checker/rules_common.go Normal file
View file

@ -0,0 +1,209 @@
// SPDX-License-Identifier: MIT
package checker
import (
"context"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// commonFailuresRule pattern-matches DNSViz finding descriptions and codes
// against a curated list of "user-facing" failure scenarios so the report
// can put plain-language explanations and remediation hints next to them.
//
// The matching is intentionally permissive: DNSViz wording shifts between
// versions, so we look for substrings rather than exact codes.
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 the catalog entry for a recognized scenario. It is
// exported so the report layer can pull the human explanation/hint out of
// CheckState.Meta and render the curated "fix this first" sections.
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",
"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",
"weak algorithm",
"sha1",
"rsasha1",
},
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{}{}
for _, name := range orderedZones(data) {
z := data.Zones[name]
for _, f := range append(append([]Finding(nil), z.Errors...), z.Warnings...) {
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 {
continue
}
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,
},
})
break
}
}
}
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
}

185
checker/rules_status.go Normal file
View file

@ -0,0 +1,185 @@
// SPDX-License-Identifier: MIT
package checker
import (
"context"
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// overallStatusRule reports on the leaf zone's DNSViz status. A SECURE leaf
// means the entire chain validates from the root; BOGUS means at least one
// link of the chain is broken.
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}
}
// perZoneStatusRule emits one CheckState per zone in the chain. This is what
// powers the "every authoritative/parent in a dedicated block" requirement
// of the report: each entry has Subject set to the zone name.
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
}
// zoneErrorsRule turns every DNSViz "error" entry into a Crit CheckState.
// 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
}
var out []sdk.CheckState
for _, name := range orderedZones(data) {
for _, f := range data.Zones[name].Errors {
out = append(out, sdk.CheckState{
Status: sdk.StatusCrit,
Code: nonEmpty(f.Code, "dnsviz_zone_errors"),
Subject: name,
Message: f.Description,
Meta: findingMeta(f),
})
}
}
if len(out) == 0 {
return []sdk.CheckState{{
Status: sdk.StatusOK,
Code: "dnsviz_zone_errors",
Message: "DNSViz reported no errors in any zone",
}}
}
return out
}
// zoneWarningsRule mirrors zoneErrorsRule for warnings (StatusWarn).
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
}
var out []sdk.CheckState
for _, name := range orderedZones(data) {
for _, f := range data.Zones[name].Warnings {
out = append(out, sdk.CheckState{
Status: sdk.StatusWarn,
Code: nonEmpty(f.Code, "dnsviz_zone_warnings"),
Subject: name,
Message: f.Description,
Meta: findingMeta(f),
})
}
}
if len(out) == 0 {
return []sdk.CheckState{{
Status: sdk.StatusOK,
Code: "dnsviz_zone_warnings",
Message: "DNSViz reported no warnings in any zone",
}}
}
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
}

71
checker/types.go Normal file
View file

@ -0,0 +1,71 @@
// 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 uses a small set of statuses ("SECURE", "BOGUS", "INSECURE",
// "INDETERMINATE", "NON_EXISTENT") and groups problems into "errors" and
// "warnings" arrays. Each finding has a "description" and may carry a
// "code" plus a list of servers it was observed on. We expose those as a
// stable Finding type and keep everything else under Extra for the report.
type ZoneAnalysis struct {
Status string `json:"status,omitempty"`
Errors []Finding `json:"errors,omitempty"`
Warnings []Finding `json:"warnings,omitempty"`
Extra map[string]any `json:"extra,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"`
}

5
go.mod Normal file
View file

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

2
go.sum Normal file
View file

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

339
internal/collect/LICENSE Normal file
View file

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

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

@ -0,0 +1,172 @@
// 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/exec"
"strings"
"time"
checker "git.happydns.org/checker-dnsviz/checker"
sdk "git.happydns.org/checker-sdk-go/checker"
)
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
}
// 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()
probeOut, probeErr, err := runProbe(probeCtx, bin, domain, 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()
grokOut, grokErr, err := runGrok(grokCtx, bin, probeOut)
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, domain, extraArgs string) ([]byte, string, error) {
args := []string{"probe"}
args = append(args, strings.Fields(extraArgs)...)
args = append(args, "--", domain)
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) ([]byte, string, error) {
cmd := exec.CommandContext(ctx, bin, "grok")
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
}
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
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
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)
}

37
main.go Normal file
View file

@ -0,0 +1,37 @@
// 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`")
)
func main() {
flag.Parse()
dnsviz.Version = Version
col := &collect.Collector{
Bin: *dnsvizBin,
ExtraArgs: *extraProbeArgs,
}
srv := server.New(dnsviz.Provider(col.Collect))
if err := srv.ListenAndServe(*listenAddr); err != nil {
log.Fatalf("server error: %v", err)
}
}

26
plugin/plugin.go Normal file
View file

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