Initial commit

This commit is contained in:
nemunaire 2026-04-26 11:06:47 +07:00
commit 2b72492a59
25 changed files with 3147 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
checker-authoritative-consistency
checker-authoritative-consistency.so

17
Dockerfile Normal file
View file

@ -0,0 +1,17 @@
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-authoritative-consistency .
FROM scratch
COPY --from=builder /checker-authoritative-consistency /checker-authoritative-consistency
USER 65534:65534
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD ["/checker-authoritative-consistency", "-healthcheck"]
ENTRYPOINT ["/checker-authoritative-consistency"]

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-authoritative-consistency
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

101
README.md Normal file
View file

@ -0,0 +1,101 @@
# checker-authoritative-consistency
Authoritative name server consistency checker for [happyDomain](https://www.happydomain.org/).
Probes every authoritative name server of a zone and verifies they agree
with each other and with the parent delegation: NS RRset alignment
(RFC 1034 §4.2), SOA serial and field consistency (RFC 1035 §3.3.13,
RFC 1912 §2.2), reachability over UDP and TCP (RFC 7766), EDNS0 support
(RFC 6891), authoritative answers (no lame delegation), and response
latency.
## Usage
### Standalone HTTP server
```bash
# Build and run
make
./checker-authoritative-consistency -listen :8080
```
The server exposes:
- `GET /health`: health check
- `POST /collect`: collect authoritative-consistency observations (happyDomain external checker protocol)
### Docker
```bash
make docker
docker run -p 8080:8080 happydomain/checker-authoritative-consistency
```
### happyDomain plugin
```bash
make plugin
# produces checker-authoritative-consistency.so, loadable by happyDomain as a Go plugin
```
The plugin exposes a `NewCheckerPlugin` symbol returning the checker
definition and observation provider, which happyDomain registers in its
global registries at load time.
### Versioning
The binary, plugin, and Docker image embed a version string overridable
at build time:
```bash
make CHECKER_VERSION=1.2.3
make plugin CHECKER_VERSION=1.2.3
make docker CHECKER_VERSION=1.2.3
```
### happyDomain remote endpoint
Set the `endpoint` admin option for the propagation checker to the URL
of the running checker-authoritative-consistency server (e.g.,
`http://checker-authoritative-consistency:8080`). happyDomain will
delegate observation collection to this endpoint.
## Options
| Id | Type | Default | Description |
|----------------------|------|---------|--------------------------------------------------------------------------------------------------------|
| `requireTCP` | bool | `true` | When enabled, an authoritative server that fails to answer over TCP is critical (otherwise warning). |
| `checkEDNS` | bool | `true` | Probe each name server for EDNS0 (RFC 6891). Servers that drop or mishandle EDNS0 break DNSSEC. |
| `checkLatency` | bool | `true` | Measure response time of every name server and warn on slow responders. |
| `latencyThresholdMs` | uint | `500` | Response times above this value trigger a slow-server warning. |
| `useParentNS` | bool | `true` | Query the parent zone for the delegation NS RRset and compare it to the service's declared NS list. |
| `warnOnStaleSaved` | bool | `true` | When the saved SOA serial in happyDomain is newer than what authoritative servers publish, warn. |
| `minNameServers` | uint | `2` | Below this count, a warning is emitted (RFC 1034 recommends at least 2). |
## Rules
Each rule emits a finding code. Severity can be affected by the options above.
| Code | Default severity | Condition |
|------|-----------------|-----------|
| `authoritative_consistency_no_ns` | critical | No name servers could be discovered for the zone (declared list empty and parent query returned nothing). |
| `authoritative_consistency_too_few_ns` | warning | Fewer name servers are declared than `minNameServers` (RFC 1034 recommends at least 2). |
| `authoritative_consistency_parent_query_failed` | warning | The parent delegation query failed (network error, REFUSED, etc.). |
| `authoritative_consistency_parent_drift` | warning | The parent zone's NS RRset does not match the NS declared in the service. |
| `authoritative_consistency_ns_unresolvable` | critical | A declared name server has no A or AAAA record. |
| `authoritative_consistency_ns_udp_failed` | critical | A name server did not answer any SOA query over UDP/53. |
| `authoritative_consistency_ns_tcp_failed` | critical with `requireTCP` / warning otherwise | A name server did not answer over TCP/53 (required by RFC 7766 and DNSSEC). |
| `authoritative_consistency_lame` | critical | A name server answered without the AA bit set for the zone (lame delegation). |
| `authoritative_consistency_no_soa` | critical | A name server is authoritative but returned no SOA for the zone. |
| `authoritative_consistency_edns_unsupported` | warning | A name server drops or mishandles EDNS0 queries (RFC 6891). |
| `authoritative_consistency_slow_ns` | info | A name server's response time exceeded `latencyThresholdMs`. |
| `authoritative_consistency_serial_drift` | warning | Authoritative servers disagree on the SOA serial (zone not fully propagated). |
| `authoritative_consistency_serial_stale_vs_saved` | warning | The saved SOA serial in happyDomain is newer than what authoritative servers publish (likely an un-pushed change). |
| `authoritative_consistency_serial_ahead_of_saved` | info | Authoritative servers publish a SOA serial newer than the one saved in happyDomain (out-of-band change). |
| `authoritative_consistency_soa_fields_drift` | warning | Authoritative servers disagree on SOA fields (MNAME, RNAME, refresh, retry, expire, minimum). |
| `authoritative_consistency_ns_rrset_drift` | warning | Authoritative servers disagree on the NS RRset they publish at the apex. |
| `authoritative_consistency_ns_rrset_mismatch_config` | warning | The NS RRset published by authoritative servers does not match the NS declared in the service. |
## License
Licensed under the **MIT License** (see `LICENSE`).

229
checker/collect.go Normal file
View file

@ -0,0 +1,229 @@
package checker
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"sync"
"github.com/miekg/dns"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Gathers raw per-NS DNS answers. No severity or pass/fail is decided here;
// rules turn the resulting ObservationData into CheckStates.
func (p *authoritativeConsistencyProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
svc, err := loadService(opts)
if err != nil {
return nil, err
}
zone, err := loadZone(opts, svc)
if err != nil {
return nil, err
}
checkEDNS := sdk.GetBoolOption(opts, "checkEDNS", true)
useParentNS := sdk.GetBoolOption(opts, "useParentNS", true)
data := &ObservationData{
Zone: dns.Fqdn(zone),
HasSOA: svc.SOA != nil,
DeclaredNS: normalizeNSList(svc.NameServers),
Results: map[string]*NSResult{},
}
if svc.SOA != nil {
data.DeclaredSerial = svc.SOA.Serial
}
if useParentNS {
parentNS, perr := parentReferral(ctx, data.Zone)
if perr != nil {
data.ParentQueryError = perr.Error()
} else {
data.ParentNS = parentNS
}
}
data.Probed = unionStrings(data.DeclaredNS, data.ParentNS)
if len(data.Probed) == 0 {
return data, nil
}
// Cap fan-out: an unbounded Origin NS list would otherwise spawn one
// goroutine and a fresh batch of UDP/TCP sockets per name.
const maxConcurrentProbes = 16
sem := make(chan struct{}, maxConcurrentProbes)
var wg sync.WaitGroup
var mu sync.Mutex
for _, nsName := range data.Probed {
nsName := nsName
wg.Add(1)
sem <- struct{}{}
go func() {
defer wg.Done()
defer func() { <-sem }()
res := probeNS(ctx, data.Zone, nsName, checkEDNS)
mu.Lock()
data.Results[nsName] = res
mu.Unlock()
}()
}
wg.Wait()
return data, nil
}
// First authoritative answer wins as the canonical view of this NS;
// subsequent addresses only contribute reachability/error state. Avoids
// dual-homed servers appearing twice in the drift matrix while still
// surfacing IPv4/IPv6-specific failures.
func probeNS(ctx context.Context, zone, nsName string, checkEDNS bool) *NSResult {
res := &NSResult{Name: nsName}
addrs, err := resolveHost(ctx, nsName)
if err != nil {
res.ResolveError = err.Error()
return res
}
if len(addrs) == 0 {
res.ResolveError = "no A/AAAA records"
return res
}
res.Addresses = addrs
for _, addr := range addrs {
srv := hostPort(addr, "53")
soa, aa, rtt, qerr := querySOA(ctx, "", srv, zone)
if qerr != nil {
res.appendError("UDP %s: %v", addr, qerr)
continue
}
res.UDPReachable = true
if res.LatencyMs == 0 {
res.LatencyMs = rtt.Milliseconds()
}
if aa {
res.Authoritative = true
}
if soa != nil && res.SOA == nil {
res.SOA = soa
res.Serial = soa.Serial
}
if _, _, _, terr := querySOA(ctx, "tcp", srv, zone); terr != nil {
res.appendError("TCP %s: %v", addr, terr)
} else {
res.TCPReachable = true
}
if checkEDNS {
if eerr := probeEDNS0(ctx, srv, zone); eerr != nil {
res.appendError("EDNS0 %s: %v", addr, eerr)
} else {
res.EDNSSupported = true
}
}
if nss, nerr := queryNSAt(ctx, srv, zone); nerr == nil && len(res.NSRRset) == 0 {
sort.Strings(nss)
res.NSRRset = nss
}
}
return res
}
func loadService(opts sdk.CheckerOptions) (*originService, error) {
svc, ok := sdk.GetOption[serviceMessage](opts, "service")
if !ok {
return nil, fmt.Errorf("missing 'service' option")
}
switch svc.Type {
case "", "abstract.Origin", "abstract.NSOnlyOrigin":
default:
return nil, fmt.Errorf("service is %s, expected abstract.Origin or abstract.NSOnlyOrigin", svc.Type)
}
var d originService
if err := json.Unmarshal(svc.Service, &d); err != nil {
return nil, fmt.Errorf("decoding origin service: %w", err)
}
return &d, nil
}
// Falls back to the service's SOA owner name when domain_name is unset.
func loadZone(opts sdk.CheckerOptions, svc *originService) (string, error) {
if v, ok := sdk.GetOption[string](opts, "domain_name"); ok && v != "" {
return dns.Fqdn(v), nil
}
if svc.SOA != nil && svc.SOA.Header().Name != "" {
return dns.Fqdn(svc.SOA.Header().Name), nil
}
return "", fmt.Errorf("no zone name provided (missing 'domain_name' option and SOA header)")
}
func normalizeNSList(ns []*dns.NS) []string {
out := make([]string, 0, len(ns))
for _, n := range ns {
if n == nil {
continue
}
out = append(out, strings.ToLower(dns.Fqdn(n.Ns)))
}
sort.Strings(out)
return out
}
func unionStrings(a, b []string) []string {
seen := map[string]bool{}
var out []string
for _, s := range a {
if !seen[s] {
seen[s] = true
out = append(out, s)
}
}
for _, s := range b {
if !seen[s] {
seen[s] = true
out = append(out, s)
}
}
sort.Strings(out)
return out
}
func diffStringSets(want, got []string) (missing, extra []string) {
w := map[string]bool{}
for _, v := range want {
w[strings.ToLower(strings.TrimSuffix(v, "."))] = true
}
g := map[string]bool{}
for _, v := range got {
g[strings.ToLower(strings.TrimSuffix(v, "."))] = true
}
for k := range w {
if !g[k] {
missing = append(missing, k)
}
}
for k := range g {
if !w[k] {
extra = append(extra, k)
}
}
sort.Strings(missing)
sort.Strings(extra)
return
}
// RFC 1982 serial-number arithmetic (handles wraparound).
func serialLess(a, b uint32) bool {
diff := b - a
return diff != 0 && diff < (1<<31)
}

112
checker/collect_test.go Normal file
View file

@ -0,0 +1,112 @@
package checker
import (
"reflect"
"testing"
"github.com/miekg/dns"
)
func TestSerialLess(t *testing.T) {
tests := []struct {
name string
a, b uint32
want bool
}{
{"equal", 100, 100, false},
{"a<b small", 100, 200, true},
{"a>b small", 200, 100, false},
{"wrap b ahead", 0xFFFFFFFE, 1, true},
{"wrap a ahead", 1, 0xFFFFFFFE, false},
{"zero<one", 0, 1, true},
{"max distance same direction", 0, 1<<31 - 1, true},
{"max distance other direction", 1<<31 - 1, 0, false},
{"undefined boundary equal-half", 0, 1 << 31, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := serialLess(tt.a, tt.b); got != tt.want {
t.Errorf("serialLess(%d,%d) = %v, want %v", tt.a, tt.b, got, tt.want)
}
})
}
}
func TestUnionStrings(t *testing.T) {
tests := []struct {
name string
a, b []string
wantOut []string
}{
{"both empty", nil, nil, nil},
{"only a", []string{"x", "a"}, nil, []string{"a", "x"}},
{"only b", nil, []string{"b", "a"}, []string{"a", "b"}},
{"overlap", []string{"a", "b"}, []string{"b", "c"}, []string{"a", "b", "c"}},
{"duplicates within a", []string{"a", "a"}, []string{"b"}, []string{"a", "b"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := unionStrings(tt.a, tt.b)
if len(got) == 0 && len(tt.wantOut) == 0 {
return
}
if !reflect.DeepEqual(got, tt.wantOut) {
t.Errorf("got %v, want %v", got, tt.wantOut)
}
})
}
}
func TestDiffStringSets(t *testing.T) {
want := []string{"ns1.example.com.", "ns2.example.com.", "NS3.Example.com"}
got := []string{"ns2.example.com", "ns3.example.com.", "ns4.example.com"}
missing, extra := diffStringSets(want, got)
if !reflect.DeepEqual(missing, []string{"ns1.example.com"}) {
t.Errorf("missing = %v, want [ns1.example.com]", missing)
}
if !reflect.DeepEqual(extra, []string{"ns4.example.com"}) {
t.Errorf("extra = %v, want [ns4.example.com]", extra)
}
}
func TestDiffStringSets_Equal(t *testing.T) {
missing, extra := diffStringSets(
[]string{"a.example.", "b.example."},
[]string{"A.example", "b.EXAMPLE."},
)
if len(missing) != 0 || len(extra) != 0 {
t.Errorf("equal sets should produce no diff, got missing=%v extra=%v", missing, extra)
}
}
func TestNormalizeNSList(t *testing.T) {
in := []*dns.NS{
{Ns: "NS2.Example.COM"},
nil,
{Ns: "ns1.example.com."},
{Ns: "NS1.example.com"},
}
got := normalizeNSList(in)
want := []string{"ns1.example.com.", "ns1.example.com.", "ns2.example.com."}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %v, want %v", got, want)
}
}
func TestHostPort(t *testing.T) {
tests := []struct {
host, port, want string
}{
{"192.0.2.1", "53", "192.0.2.1:53"},
{"2001:db8::1", "53", "[2001:db8::1]:53"},
{"ns.example.com.", "53", "ns.example.com:53"},
{"ns.example.com", "5353", "ns.example.com:5353"},
}
for _, tt := range tests {
t.Run(tt.host, func(t *testing.T) {
if got := hostPort(tt.host, tt.port); got != tt.want {
t.Errorf("hostPort(%q,%q) = %q, want %q", tt.host, tt.port, got, tt.want)
}
})
}
}

122
checker/definition.go Normal file
View file

@ -0,0 +1,122 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 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 Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker
import (
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Overridden via -ldflags by main/plugin at build time.
var Version = "built-in"
func (p *authoritativeConsistencyProvider) Definition() *sdk.CheckerDefinition {
return &sdk.CheckerDefinition{
ID: "authoritative-consistency",
Name: "Authoritative consistency",
Version: Version,
Availability: sdk.CheckerAvailability{
ApplyToService: true,
LimitToServices: []string{
"abstract.Origin",
"abstract.NSOnlyOrigin",
},
},
ObservationKeys: []sdk.ObservationKey{ObservationKey},
HasHTMLReport: true,
HasMetrics: true,
Options: sdk.CheckerOptionsDocumentation{
UserOpts: []sdk.CheckerOptionDocumentation{
{
Id: "requireTCP",
Type: "bool",
Label: "Require DNS over TCP",
Description: "When enabled, an authoritative server that fails to answer over TCP is flagged as critical (otherwise as warning). TCP/53 is required by RFC 7766 and by DNSSEC.",
Default: true,
},
{
Id: "checkEDNS",
Type: "bool",
Label: "Check EDNS0 support",
Description: "Probe each name server for EDNS0 (RFC 6891). Servers that drop or mishandle EDNS0 break DNSSEC and large answers.",
Default: true,
},
{
Id: "checkLatency",
Type: "bool",
Label: "Measure response latency",
Description: "Measure response time of every name server and warn on slow responders.",
Default: true,
},
{
Id: "latencyThresholdMs",
Type: "uint",
Label: "Latency warning threshold (ms)",
Description: "Response times above this value trigger a slow-server warning.",
Default: float64(500),
},
{
Id: "useParentNS",
Type: "bool",
Label: "Cross-check with parent delegation",
Description: "Query the parent zone for the delegation NS RRset and compare it to the service's declared name servers. Drifts are reported so the user can reconcile.",
Default: true,
},
{
Id: "warnOnStaleSaved",
Type: "bool",
Label: "Warn when live serial is older than the saved one",
Description: "When the saved SOA serial in happyDomain is newer than what the authoritative servers publish, report a warning, typically an un-pushed change.",
Default: true,
},
{
Id: "minNameServers",
Type: "uint",
Label: "Minimum number of name servers",
Description: "Below this count, a warning is emitted (RFC 1034 recommends at least 2).",
Default: float64(2),
},
},
DomainOpts: []sdk.CheckerOptionDocumentation{
{
Id: "domain_name",
Label: "Zone name",
AutoFill: sdk.AutoFillDomainName,
},
},
ServiceOpts: []sdk.CheckerOptionDocumentation{
{
Id: "service",
Label: "Origin service",
AutoFill: sdk.AutoFillService,
},
},
},
Rules: Rules(),
Interval: &sdk.CheckIntervalSpec{
Min: 1 * time.Minute,
Max: 6 * time.Hour,
Default: 10 * time.Minute,
},
}
}

188
checker/dns.go Normal file
View file

@ -0,0 +1,188 @@
package checker
import (
"context"
"fmt"
"net"
"strings"
"time"
"github.com/miekg/dns"
)
const dnsTimeout = 5 * time.Second
// proto is "" for UDP or "tcp"; server must already include a port.
// RD is forced off: this checker only talks to authoritative servers.
func dnsExchange(ctx context.Context, proto, server string, q dns.Question, edns bool) (*dns.Msg, time.Duration, error) {
client := dns.Client{Net: proto, Timeout: dnsTimeout}
m := new(dns.Msg)
m.Id = dns.Id()
m.Question = []dns.Question{q}
m.RecursionDesired = false
if edns {
m.SetEdns0(4096, true)
}
if deadline, ok := ctx.Deadline(); ok {
if d := time.Until(deadline); d > 0 && d < client.Timeout {
client.Timeout = d
}
}
r, rtt, err := client.Exchange(m, server)
if err != nil {
return nil, rtt, err
}
if r == nil {
return nil, rtt, fmt.Errorf("nil response from %s", server)
}
return r, rtt, nil
}
// Brackets IPv6 literals so the result is dialable by net.Dial.
func hostPort(host, port string) string {
if ip := net.ParseIP(host); ip != nil && ip.To4() == nil {
return "[" + host + "]:" + port
}
host = strings.TrimSuffix(host, ".")
return host + ":" + port
}
func resolveHost(ctx context.Context, host string) ([]string, error) {
var resolver net.Resolver
addrs, err := resolver.LookupHost(ctx, strings.TrimSuffix(host, "."))
if err != nil {
return nil, err
}
return addrs, nil
}
func querySOA(ctx context.Context, proto, server, zone string) (soa *dns.SOA, aa bool, rtt time.Duration, err error) {
q := dns.Question{Name: dns.Fqdn(zone), Qtype: dns.TypeSOA, Qclass: dns.ClassINET}
r, rtt, err := dnsExchange(ctx, proto, server, q, false)
if err != nil {
return nil, false, rtt, err
}
if r.Rcode != dns.RcodeSuccess {
return nil, r.Authoritative, rtt, fmt.Errorf("server answered %s", dns.RcodeToString[r.Rcode])
}
for _, rr := range r.Answer {
if t, ok := rr.(*dns.SOA); ok {
return t, r.Authoritative, rtt, nil
}
}
// Some servers place the SOA in the Authority section instead of Answer.
for _, rr := range r.Ns {
if t, ok := rr.(*dns.SOA); ok {
return t, r.Authoritative, rtt, nil
}
}
return nil, r.Authoritative, rtt, fmt.Errorf("no SOA in answer section")
}
func queryNSAt(ctx context.Context, server, zone string) ([]string, error) {
q := dns.Question{Name: dns.Fqdn(zone), Qtype: dns.TypeNS, Qclass: dns.ClassINET}
r, _, err := dnsExchange(ctx, "", server, q, false)
if err != nil {
return nil, err
}
if r.Rcode != dns.RcodeSuccess {
return nil, fmt.Errorf("server answered %s", dns.RcodeToString[r.Rcode])
}
var out []string
for _, rr := range r.Answer {
if t, ok := rr.(*dns.NS); ok {
out = append(out, strings.ToLower(dns.Fqdn(t.Ns)))
}
}
return out, nil
}
// Falls back to TCP on UDP failure: some middleboxes drop large UDP packets
// carrying OPT but let TCP/53 through (RFC 7766 mandates TCP fallback).
func probeEDNS0(ctx context.Context, server, zone string) error {
q := dns.Question{Name: dns.Fqdn(zone), Qtype: dns.TypeSOA, Qclass: dns.ClassINET}
r, _, err := dnsExchange(ctx, "", server, q, true)
if err != nil {
rt, _, terr := dnsExchange(ctx, "tcp", server, q, true)
if terr != nil {
return fmt.Errorf("EDNS0 query failed over UDP (%v) and TCP (%w)", err, terr)
}
r = rt
}
if r.Rcode == dns.RcodeFormatError {
return fmt.Errorf("server returned FORMERR on EDNS0 query")
}
if r.Rcode != dns.RcodeSuccess {
return fmt.Errorf("server answered %s on EDNS0 query", dns.RcodeToString[r.Rcode])
}
// RFC 6891 requires the OPT pseudo-RR to be echoed in the response.
if r.IsEdns0() == nil {
return fmt.Errorf("server stripped the EDNS0 OPT record from its response")
}
return nil
}
// First parent server returning a non-empty referral wins.
func parentReferral(ctx context.Context, zone string) ([]string, error) {
zone = dns.Fqdn(zone)
labels := dns.SplitDomainName(zone)
if len(labels) < 2 {
return nil, fmt.Errorf("zone %q has no parent", zone)
}
parent := dns.Fqdn(strings.Join(labels[1:], "."))
resolver := net.Resolver{}
nss, err := resolver.LookupNS(ctx, strings.TrimSuffix(parent, "."))
if err != nil {
return nil, fmt.Errorf("resolving NS of parent zone %q: %w", parent, err)
}
var lastErr error
seen := map[string]bool{}
var out []string
for _, ns := range nss {
addrs, rerr := resolver.LookupHost(ctx, strings.TrimSuffix(ns.Host, "."))
if rerr != nil || len(addrs) == 0 {
lastErr = rerr
continue
}
for _, a := range addrs {
srv := hostPort(a, "53")
q := dns.Question{Name: zone, Qtype: dns.TypeNS, Qclass: dns.ClassINET}
r, _, qerr := dnsExchange(ctx, "", srv, q, true)
if qerr != nil {
lastErr = qerr
continue
}
if r.Rcode != dns.RcodeSuccess {
lastErr = fmt.Errorf("parent %s answered %s", ns.Host, dns.RcodeToString[r.Rcode])
continue
}
collect := func(records []dns.RR) {
for _, rr := range records {
if t, ok := rr.(*dns.NS); ok {
if strings.EqualFold(strings.TrimSuffix(t.Header().Name, "."), strings.TrimSuffix(zone, ".")) {
name := strings.ToLower(dns.Fqdn(t.Ns))
if !seen[name] {
seen[name] = true
out = append(out, name)
}
}
}
}
}
collect(r.Answer)
collect(r.Ns)
if len(out) > 0 {
return out, nil
}
}
}
if lastErr != nil {
return nil, lastErr
}
return nil, fmt.Errorf("no parent server returned a delegation for %s", zone)
}

206
checker/evaluate.go Normal file
View file

@ -0,0 +1,206 @@
package checker
import (
"encoding/json"
"fmt"
"strings"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Implements sdk.CheckerMetricsReporter.
func (p *authoritativeConsistencyProvider) ExtractMetrics(ctx sdk.ReportContext, collectedAt time.Time) ([]sdk.CheckMetric, error) {
var data ObservationData
if err := json.Unmarshal(ctx.Data(), &data); err != nil {
return nil, fmt.Errorf("checker: decoding observation: %w", err)
}
var out []sdk.CheckMetric
for name, r := range data.Results {
labels := map[string]string{"zone": data.Zone, "ns": name}
up := float64(0)
if r.UDPReachable && r.Authoritative {
up = 1
}
out = append(out, sdk.CheckMetric{
Name: "authoritative_consistency_ns_up",
Value: up,
Labels: labels,
Timestamp: collectedAt,
})
tcp := float64(0)
if r.TCPReachable {
tcp = 1
}
out = append(out, sdk.CheckMetric{
Name: "authoritative_consistency_ns_tcp",
Value: tcp,
Labels: labels,
Timestamp: collectedAt,
})
if r.LatencyMs > 0 {
out = append(out, sdk.CheckMetric{
Name: "authoritative_consistency_ns_latency_ms",
Value: float64(r.LatencyMs),
Unit: "ms",
Labels: labels,
Timestamp: collectedAt,
})
}
if r.Serial > 0 {
out = append(out, sdk.CheckMetric{
Name: "authoritative_consistency_ns_serial",
Value: float64(r.Serial),
Labels: labels,
Timestamp: collectedAt,
})
}
resolvable := float64(0)
if len(r.Addresses) > 0 {
resolvable = 1
}
out = append(out, sdk.CheckMetric{
Name: "authoritative_consistency_ns_resolvable",
Value: resolvable,
Labels: labels,
Timestamp: collectedAt,
})
// EDNS support is only meaningful once the server has actually answered.
if r.UDPReachable {
edns := float64(0)
if r.EDNSSupported {
edns = 1
}
out = append(out, sdk.CheckMetric{
Name: "authoritative_consistency_ns_edns",
Value: edns,
Labels: labels,
Timestamp: collectedAt,
})
}
}
zoneLabels := map[string]string{"zone": data.Zone}
uniqueSerials := map[uint32]struct{}{}
var minSerial, maxSerial uint32
serialSeen := false
for _, r := range data.Results {
if r == nil || !r.Authoritative || r.SOA == nil {
continue
}
uniqueSerials[r.Serial] = struct{}{}
if !serialSeen {
minSerial, maxSerial = r.Serial, r.Serial
serialSeen = true
continue
}
if r.Serial < minSerial {
minSerial = r.Serial
}
if r.Serial > maxSerial {
maxSerial = r.Serial
}
}
out = append(out, sdk.CheckMetric{
Name: "authoritative_consistency_unique_serials",
Value: float64(len(uniqueSerials)),
Labels: zoneLabels,
Timestamp: collectedAt,
})
if data.HasSOA && data.DeclaredSerial > 0 {
out = append(out, sdk.CheckMetric{
Name: "authoritative_consistency_serial",
Value: float64(data.DeclaredSerial),
Labels: zoneLabels,
Timestamp: collectedAt,
})
}
if serialSeen {
out = append(out, sdk.CheckMetric{
Name: "authoritative_consistency_serial_spread",
Value: float64(maxSerial - minSerial),
Labels: zoneLabels,
Timestamp: collectedAt,
})
}
out = append(out, sdk.CheckMetric{
Name: "authoritative_consistency_ns_declared",
Value: float64(len(data.DeclaredNS)),
Labels: zoneLabels,
Timestamp: collectedAt,
})
reachable := 0
for _, r := range data.Results {
if r != nil && r.UDPReachable && r.Authoritative {
reachable++
}
}
out = append(out, sdk.CheckMetric{
Name: "authoritative_consistency_ns_reachable",
Value: float64(reachable),
Labels: zoneLabels,
Timestamp: collectedAt,
})
if data.ParentQueryError == "" && len(data.ParentNS) > 0 && len(data.DeclaredNS) > 0 {
missing, extra := diffStringSets(data.DeclaredNS, data.ParentNS)
match := float64(1)
if len(missing) > 0 || len(extra) > 0 {
match = 0
}
out = append(out, sdk.CheckMetric{
Name: "authoritative_consistency_parent_delegation_match",
Value: match,
Labels: zoneLabels,
Timestamp: collectedAt,
})
}
rrsetGroups := map[string]struct{}{}
for _, r := range data.Results {
if r == nil || !r.Authoritative || len(r.NSRRset) == 0 {
continue
}
rrsetGroups[strings.Join(r.NSRRset, "|")] = struct{}{}
}
if len(rrsetGroups) > 0 {
v := float64(1)
if len(rrsetGroups) > 1 {
v = 0
}
out = append(out, sdk.CheckMetric{
Name: "authoritative_consistency_ns_rrset_consistent",
Value: v,
Labels: zoneLabels,
Timestamp: collectedAt,
})
}
if data.HasSOA && serialSeen {
v := float64(1)
if len(collectSOAFieldsDrift(&data)) > 0 {
v = 0
}
out = append(out, sdk.CheckMetric{
Name: "authoritative_consistency_soa_fields_consistent",
Value: v,
Labels: zoneLabels,
Timestamp: collectedAt,
})
}
return out, nil
}

141
checker/interactive.go Normal file
View file

@ -0,0 +1,141 @@
//go:build standalone
package checker
import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"strings"
"sync"
"github.com/miekg/dns"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func (p *authoritativeConsistencyProvider) RenderForm() []sdk.CheckerOptionField {
return []sdk.CheckerOptionField{
{
Id: "zone",
Type: "string",
Label: "Zone name",
Placeholder: "example.com",
Required: true,
Description: "Apex name of the zone whose authoritative servers should be cross-checked.",
},
}
}
func (p *authoritativeConsistencyProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
zone := strings.TrimSpace(r.FormValue("zone"))
if zone == "" {
return nil, fmt.Errorf("zone is required")
}
fqdn := dns.Fqdn(zone)
resolver := interactiveResolver()
ctx := r.Context()
var (
wg sync.WaitGroup
nsRecords []*dns.NS
soaRecord *dns.SOA
nsErr error
soaErr error
)
wg.Add(2)
go func() {
defer wg.Done()
nsRecords, nsErr = lookupRecords[*dns.NS](ctx, resolver, fqdn, dns.TypeNS, false)
}()
go func() {
defer wg.Done()
soas, err := lookupRecords[*dns.SOA](ctx, resolver, fqdn, dns.TypeSOA, false)
if err != nil {
soaErr = err
return
}
if len(soas) > 0 {
soaRecord = soas[0]
}
}()
wg.Wait()
if nsErr != nil {
return nil, fmt.Errorf("NS lookup for %s: %w", zone, nsErr)
}
if len(nsRecords) == 0 {
return nil, fmt.Errorf("no NS records found for %s", zone)
}
if soaErr != nil {
return nil, fmt.Errorf("SOA lookup for %s: %w", zone, soaErr)
}
svcType := "abstract.Origin"
payload := originService{SOA: soaRecord, NameServers: nsRecords}
if soaRecord == nil {
svcType = "abstract.NSOnlyOrigin"
}
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal origin service: %w", err)
}
svc := serviceMessage{
Type: svcType,
Service: body,
}
return sdk.CheckerOptions{
"domain_name": strings.TrimSuffix(fqdn, "."),
"service": svc,
}, nil
}
var (
resolverOnce sync.Once
resolverAddr string
interactiveClient = &dns.Client{Timeout: dnsTimeout}
)
func interactiveResolver() string {
resolverOnce.Do(func() {
cfg, err := dns.ClientConfigFromFile("/etc/resolv.conf")
if err != nil || len(cfg.Servers) == 0 {
resolverAddr = net.JoinHostPort("1.1.1.1", "53")
return
}
resolverAddr = net.JoinHostPort(cfg.Servers[0], cfg.Port)
})
return resolverAddr
}
func lookupRecords[T dns.RR](ctx context.Context, resolver, fqdn string, qtype uint16, edns bool) ([]T, error) {
msg := new(dns.Msg)
msg.SetQuestion(fqdn, qtype)
msg.RecursionDesired = true
if edns {
msg.SetEdns0(4096, true)
}
in, _, err := interactiveClient.ExchangeContext(ctx, msg, resolver)
if err != nil {
return nil, err
}
if in.Rcode != dns.RcodeSuccess && in.Rcode != dns.RcodeNameError {
return nil, fmt.Errorf("rcode %s", dns.RcodeToString[in.Rcode])
}
var out []T
for _, rr := range in.Answer {
if t, ok := rr.(T); ok {
out = append(out, t)
}
}
return out, nil
}

15
checker/provider.go Normal file
View file

@ -0,0 +1,15 @@
package checker
import (
sdk "git.happydns.org/checker-sdk-go/checker"
)
func Provider() sdk.ObservationProvider {
return &authoritativeConsistencyProvider{}
}
type authoritativeConsistencyProvider struct{}
func (p *authoritativeConsistencyProvider) Key() sdk.ObservationKey {
return ObservationKey
}

553
checker/report.go Normal file
View file

@ -0,0 +1,553 @@
package checker
import (
"encoding/json"
"fmt"
"html/template"
"sort"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Concrete remediation: the user should know what to do next without
// leaving the report page.
var remediationHints = map[string]string{
CodeSerialDrift: "Some authoritative servers are lagging behind. On the hidden primary, trigger a NOTIFY (rndc notify / nsd-control notify / knsc zone-reload); if that doesn't help, check that the primary is reachable from the secondaries (port 53 TCP for AXFR/IXFR) and that their zone file isn't frozen.",
CodeSerialStaleVsSaved: "You edited the zone in happyDomain but the changes have not been pushed to your DNS provider yet. Open the zone and click \"Apply changes\": the provider's API will receive the new serial and propagate it.",
CodeSerialAheadOfSaved: "The zone was modified outside happyDomain. Re-import the zone from the provider so happyDomain's view is up to date.",
CodeNSUnreachable: "This server did not answer any query. Check that the host is up and that UDP/TCP 53 is not filtered by a firewall.",
CodeNSUDPFailed: "UDP/53 is filtered or the server is down. Verify the service, firewall and any upstream load balancer. A DNS server that cannot be reached over UDP is effectively offline.",
CodeNSTCPFailed: "TCP/53 is required by RFC 7766 and by DNSSEC: truncated UDP answers fall back to TCP. Check your firewall and any middleboxes (many consumer firewalls block TCP/53 by default).",
CodeNSUnresolvable: "This NS hostname has no A or AAAA record. Add glue at the registrar if it is in-bailiwick, or point it to a resolvable hostname otherwise.",
CodeLame: "This server answers but is not authoritative for the zone; it has no copy of the zone file. Either configure the zone on it, or remove it from the NS RRset to stop resolvers from wasting queries on it.",
CodeNoSOA: "The server claims authority but does not return a SOA record. Check the zone is fully loaded (no parse error in the zone file, no uncommitted transaction).",
CodeNSRRsetDrift: "The NS RRset differs between authoritative servers. Force a zone transfer from the primary to the lagging server(s), or align the NS records manually.",
CodeNSRRsetMismatchConfig: "The NS records served by the zone do not match what you configured in happyDomain. Either update the service to match reality, or push the declared NS list to your DNS provider.",
CodeParentDrift: "The NS RRset at the parent zone (your registrar) does not match the NS declared here. Log into your registrar and reconcile the delegation.",
CodeParentQueryFailed: "The parent delegation could not be resolved. The cross-check with the parent is skipped for this run; verify the zone name and that its parent is reachable.",
CodeSOAFieldsDrift: "The SOA RDATA (MNAME, RNAME, TTL fields) differs between authoritative servers. This usually means a secondary still serves an old zone file. Force a fresh AXFR.",
CodeSlowNS: "This server answers slowly. It still works, but users on distant networks will see sluggish resolution. Consider an anycast upgrade or moving the server closer to your audience.",
CodeEDNSUnsupported: "This server does not correctly handle EDNS0 (RFC 6891). DNSSEC validation and large answers will fail. Upgrade the DNS software or, on a firewall, allow DNS packets larger than 512 bytes and the OPT record.",
CodeTooFewNS: "A zone with a single NS is fragile. RFC 1034 recommends at least two, ideally on separate networks.",
CodeNoNS: "No authoritative servers were discovered. The zone cannot be served in its current state.",
}
type reportNS struct {
Name string
Addresses string
UDP bool
TCP bool
AA bool
Serial uint32
Latency int64
EDNS bool
BadUDP bool
BadTCP bool
BadAA bool
BadEDNS bool
Errors []string
}
type reportFinding struct {
Code string
Severity string
Message string
Server string
Hint string
Class string // CSS class
}
type reportSerialGroup struct {
Serial uint32
Servers []string
Majority bool
}
type reportData struct {
Zone string
HasSOA bool
DeclaredSerial uint32
DeclaredNS []string
ParentNS []string
ParentError string
Headline string
HeadlineClass string
HeadlineHint string
Totals map[string]int
NS []reportNS
SerialGroups []reportSerialGroup
ShowSerialTable bool
Findings []reportFinding
}
var htmlTemplate = template.Must(
template.New("authoritative-consistency").
Funcs(template.FuncMap{
"join": func(s []string) string { return strings.Join(s, ", ") },
"boolBadge": func(ok bool) template.HTML {
if ok {
return template.HTML(`<span class="pill pill-ok">OK</span>`)
}
return template.HTML(`<span class="pill pill-bad">KO</span>`)
},
"naBadge": func(ok bool, relevant bool) template.HTML {
if !relevant {
return template.HTML(`<span class="pill pill-na">—</span>`)
}
if ok {
return template.HTML(`<span class="pill pill-ok">OK</span>`)
}
return template.HTML(`<span class="pill pill-bad">KO</span>`)
},
}).
Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Authoritative consistency: {{.Zone}}</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
:root {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
line-height: 1.5;
color: #1f2937;
background: #f3f4f6;
}
body { margin: 0; padding: 1rem; }
code { font-family: ui-monospace, monospace; font-size: .9em; }
.card {
background: #fff;
border-radius: 10px;
padding: 1rem 1.25rem;
margin-bottom: .75rem;
box-shadow: 0 1px 3px rgba(0,0,0,.08);
}
.card h2 {
font-size: .95rem;
font-weight: 700;
margin: 0 0 .6rem;
color: #374151;
letter-spacing: .02em;
text-transform: uppercase;
}
.headline {
border-left: 4px solid #9ca3af;
padding-left: .8rem;
}
.headline h1 { margin: 0 0 .25rem; font-size: 1.15rem; font-weight: 700; }
.headline .sub { color: #6b7280; font-size: .85rem; }
.headline .hint { margin-top: .5rem; font-size: .9rem; color: #374151; }
.headline-crit { border-left-color: #dc2626; }
.headline-warn { border-left-color: #d97706; }
.headline-info { border-left-color: #2563eb; }
.headline-ok { border-left-color: #16a34a; }
.totals { display: flex; gap: .35rem; flex-wrap: wrap; margin-top: .5rem; }
.badge {
display: inline-flex; align-items: center;
padding: .18em .55em;
border-radius: 9999px;
font-size: .72rem; font-weight: 700;
letter-spacing: .02em; white-space: nowrap;
}
.badge-crit { background: #fee2e2; color: #991b1b; }
.badge-warn { background: #fef3c7; color: #92400e; }
.badge-info { background: #dbeafe; color: #1e40af; }
.badge-ok { background: #dcfce7; color: #166534; }
table { width: 100%; border-collapse: collapse; font-size: .88rem; }
th, td {
padding: .45rem .6rem;
border-bottom: 1px solid #f3f4f6;
text-align: left;
vertical-align: top;
}
th { color: #6b7280; font-weight: 600; font-size: .75rem; text-transform: uppercase; letter-spacing: .03em; }
tr:last-child td { border-bottom: none; }
.ns-name { font-weight: 600; }
.ns-addrs { color: #6b7280; font-size: .75rem; }
.pill {
display: inline-block; padding: .1em .5em;
border-radius: 9999px;
font-size: .72rem; font-weight: 700;
}
.pill-ok { background: #dcfce7; color: #166534; }
.pill-bad { background: #fee2e2; color: #991b1b; }
.pill-na { background: #f3f4f6; color: #6b7280; }
.serial-row td { padding-top: .3rem; padding-bottom: .3rem; }
.serial-majority { font-weight: 700; color: #166534; }
.serial-lag { color: #991b1b; }
.serial-ahead { color: #1e40af; }
.finding {
border-left: 4px solid #d1d5db;
padding: .5rem .8rem;
margin-bottom: .5rem;
background: #fafafa;
border-radius: 4px;
}
.finding-crit { border-left-color: #dc2626; background: #fef2f2; }
.finding-warn { border-left-color: #d97706; background: #fffbeb; }
.finding-info { border-left-color: #2563eb; background: #eff6ff; }
.finding .title { font-weight: 600; margin-bottom: .2rem; }
.finding .server { font-size: .78rem; color: #6b7280; }
.finding .hint { margin-top: .4rem; font-size: .85rem; color: #374151; }
.small { color: #6b7280; font-size: .82rem; }
.muted { color: #9ca3af; }
</style>
</head>
<body>
<div class="card headline headline-{{.HeadlineClass}}">
<h1>{{.Headline}}</h1>
<div class="sub"><code>{{.Zone}}</code>{{if .HasSOA}}, saved SOA serial <code>{{.DeclaredSerial}}</code>{{end}}</div>
<div class="totals">
{{- range $lvl, $n := .Totals}}{{if $n}}
<span class="badge badge-{{$lvl}}">{{$lvl}}&nbsp;{{$n}}</span>
{{end}}{{end}}
</div>
{{if .HeadlineHint}}<div class="hint">{{.HeadlineHint}}</div>{{end}}
</div>
{{if .ShowSerialTable}}
<div class="card">
<h2>Serial consistency</h2>
<table>
<thead>
<tr><th>SOA serial</th><th>Servers</th></tr>
</thead>
<tbody>
{{- range .SerialGroups}}
<tr class="serial-row">
<td>
<code>{{.Serial}}</code>
{{if .Majority}}<span class="serial-majority"> consensus</span>{{end}}
</td>
<td>{{join .Servers}}</td>
</tr>
{{- end}}
</tbody>
</table>
{{if .DeclaredSerial}}<div class="small" style="margin-top:.5rem">Saved in happyDomain: <code>{{.DeclaredSerial}}</code></div>{{end}}
</div>
{{end}}
<div class="card">
<h2>Per-server probe</h2>
<table>
<thead>
<tr>
<th>Name server</th>
<th>UDP/53</th>
<th>TCP/53</th>
<th>Authoritative</th>
<th>Serial</th>
<th>Latency</th>
<th>EDNS0</th>
</tr>
</thead>
<tbody>
{{- range .NS}}
<tr>
<td>
<div class="ns-name">{{.Name}}</div>
{{if .Addresses}}<div class="ns-addrs">{{.Addresses}}</div>{{end}}
{{- range .Errors}}<div class="ns-addrs"> {{.}}</div>{{end}}
</td>
<td>{{boolBadge .UDP}}</td>
<td>{{boolBadge .TCP}}</td>
<td>{{boolBadge .AA}}</td>
<td>{{if .Serial}}<code>{{.Serial}}</code>{{else}}<span class="muted"></span>{{end}}</td>
<td>{{if .Latency}}{{.Latency}} ms{{else}}<span class="muted"></span>{{end}}</td>
<td>{{naBadge .EDNS .UDP}}</td>
</tr>
{{- end}}
</tbody>
</table>
</div>
{{if .DeclaredNS}}
<div class="card">
<h2>Declared vs observed NS</h2>
<table>
<tbody>
<tr><td class="small">Declared in service</td><td><code>{{join .DeclaredNS}}</code></td></tr>
{{if .ParentNS}}<tr><td class="small">Parent delegation</td><td><code>{{join .ParentNS}}</code></td></tr>{{end}}
{{if .ParentError}}<tr><td class="small">Parent query</td><td class="serial-lag">{{.ParentError}}</td></tr>{{end}}
</tbody>
</table>
</div>
{{end}}
<div class="card">
<h2>Findings</h2>
{{if .Findings}}
{{- range .Findings}}
<div class="finding finding-{{.Class}}">
<div class="title">
<span class="badge badge-{{.Class}}">{{.Severity}}</span>
{{.Message}}
</div>
{{if .Server}}<div class="server">on <code>{{.Server}}</code></div>{{end}}
{{if .Hint}}<div class="hint">💡 {{.Hint}}</div>{{end}}
</div>
{{- end}}
{{else}}
<div class="small">No issue detected. Every authoritative server agrees on the zone.</div>
{{end}}
</div>
</body>
</html>`),
)
// Implements sdk.CheckerHTMLReporter.
func (p *authoritativeConsistencyProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
var data ObservationData
if err := json.Unmarshal(ctx.Data(), &data); err != nil {
return "", fmt.Errorf("checker: unmarshal observation: %w", err)
}
rd := reportData{
Zone: data.Zone,
HasSOA: data.HasSOA,
DeclaredSerial: data.DeclaredSerial,
DeclaredNS: data.DeclaredNS,
ParentNS: data.ParentNS,
ParentError: data.ParentQueryError,
Totals: map[string]int{"crit": 0, "warn": 0, "info": 0},
}
for _, name := range data.Probed {
r := data.Results[name]
if r == nil {
rd.NS = append(rd.NS, reportNS{Name: name, Errors: []string{"no probe result"}})
continue
}
rd.NS = append(rd.NS, reportNS{
Name: name,
Addresses: strings.Join(r.Addresses, ", "),
UDP: r.UDPReachable,
TCP: r.TCPReachable,
AA: r.Authoritative,
Serial: r.Serial,
Latency: r.LatencyMs,
EDNS: r.EDNSSupported,
Errors: r.Errors,
})
}
if data.HasSOA {
groups := map[uint32][]string{}
for _, name := range data.Probed {
r := data.Results[name]
if r == nil || !r.Authoritative || r.SOA == nil {
continue
}
groups[r.Serial] = append(groups[r.Serial], name)
}
if len(groups) > 0 {
rd.ShowSerialTable = len(groups) > 1 || data.DeclaredSerial != 0
serials := make([]uint32, 0, len(groups))
for s := range groups {
serials = append(serials, s)
}
sort.Slice(serials, func(i, j int) bool { return len(groups[serials[i]]) > len(groups[serials[j]]) })
majority := serials[0]
for _, s := range serials {
srv := groups[s]
sort.Strings(srv)
rd.SerialGroups = append(rd.SerialGroups, reportSerialGroup{
Serial: s,
Servers: srv,
Majority: s == majority && len(groups) > 1,
})
}
}
}
// When the host doesn't pass states (data-only render), Findings stays
// empty and the page shows only the raw per-NS observations.
states := ctx.States()
for _, st := range states {
sev := statusToSeverity(st.Status)
if sev == "" {
continue
}
rf := reportFinding{
Code: st.Code,
Severity: strings.ToUpper(string(sev)),
Message: st.Message,
Server: st.Subject,
}
if st.Meta != nil {
if fix, ok := st.Meta["fix"].(string); ok {
rf.Hint = fix
}
}
switch sev {
case SeverityCrit:
rf.Class = "crit"
rd.Totals["crit"]++
case SeverityWarn:
rf.Class = "warn"
rd.Totals["warn"]++
case SeverityInfo:
rf.Class = "info"
rd.Totals["info"]++
}
rd.Findings = append(rd.Findings, rf)
}
// Headline surfaces the worst issue so the remediation hint stays above
// the fold; data-only renders fall back to a neutral title.
if len(states) == 0 {
rd.Headline = fmt.Sprintf("Raw authoritative-consistency observation for %s", data.Zone)
rd.HeadlineClass = "info"
rd.HeadlineHint = ""
} else {
rd.Headline, rd.HeadlineClass, rd.HeadlineHint = headlineFromStates(&data, states, rd.Findings)
}
var buf strings.Builder
if err := htmlTemplate.Execute(&buf, rd); err != nil {
return "", fmt.Errorf("checker: rendering HTML: %w", err)
}
return buf.String(), nil
}
// Returns "" for OK/Unknown: those don't go in the findings list.
func statusToSeverity(s sdk.Status) Severity {
switch s {
case sdk.StatusCrit:
return SeverityCrit
case sdk.StatusWarn:
return SeverityWarn
case sdk.StatusInfo:
return SeverityInfo
}
return ""
}
// Priority order is hand-curated: unreachable/lame trump drift, drift trumps
// merely-slow servers. Reorder with care: the headline drives user attention.
func headlineFromStates(data *ObservationData, states []sdk.CheckState, renderedFindings []reportFinding) (title, class, hint string) {
codesPresent := map[string]bool{}
for _, st := range states {
if statusToSeverity(st.Status) == "" {
continue
}
codesPresent[st.Code] = true
}
priorities := []string{
CodeNSUDPFailed,
CodeNSUnreachable,
CodeLame,
CodeSerialDrift,
CodeSerialStaleVsSaved,
CodeNSRRsetDrift,
CodeNSRRsetMismatchConfig,
CodeParentDrift,
CodeSOAFieldsDrift,
CodeNSTCPFailed,
CodeEDNSUnsupported,
CodeSerialAheadOfSaved,
CodeSlowNS,
}
for _, code := range priorities {
if codesPresent[code] {
return headlineCopyFor(code, data)
}
}
if len(renderedFindings) == 0 {
if data.HasSOA {
return "Zone is propagated consistently on every name server", "ok", fmt.Sprintf("Serial %d is served identically by all %d probed servers.", mostCommonSerial(data), len(data.Probed))
}
return "Every declared name server is reachable and authoritative", "ok", ""
}
return fmt.Sprintf("%d issue(s) detected", len(renderedFindings)), "warn", "See the findings list below for details."
}
func headlineCopyFor(code string, data *ObservationData) (title, class, hint string) {
class = "warn"
switch code {
case CodeNSUDPFailed, CodeNSUnreachable:
return "One or more name servers are unreachable",
"crit",
remediationHints[code]
case CodeLame:
return "Lame delegation detected",
"crit",
remediationHints[CodeLame]
case CodeSerialDrift:
return "Zone is not fully propagated: SOA serials disagree",
"crit",
remediationHints[CodeSerialDrift]
case CodeSerialStaleVsSaved:
return "Pending changes have not reached the authoritative servers",
"warn",
remediationHints[CodeSerialStaleVsSaved]
case CodeNSRRsetDrift:
return "NS RRset differs between servers",
"warn",
remediationHints[CodeNSRRsetDrift]
case CodeNSRRsetMismatchConfig:
return "NS RRset served does not match the configured one",
"warn",
remediationHints[CodeNSRRsetMismatchConfig]
case CodeParentDrift:
return "Parent delegation does not match the configured NS list",
"warn",
remediationHints[CodeParentDrift]
case CodeSOAFieldsDrift:
return "SOA fields disagree between servers",
"warn",
remediationHints[CodeSOAFieldsDrift]
case CodeNSTCPFailed:
return "TCP/53 is not answered by every server",
"warn",
remediationHints[CodeNSTCPFailed]
case CodeEDNSUnsupported:
return "EDNS0 is not supported by every server",
"warn",
remediationHints[CodeEDNSUnsupported]
case CodeSerialAheadOfSaved:
return "Live serial is ahead of happyDomain's saved value",
"info",
remediationHints[CodeSerialAheadOfSaved]
case CodeSlowNS:
return "At least one name server responds slowly",
"info",
remediationHints[CodeSlowNS]
}
return "Issues detected", "warn", ""
}
// Only meaningful when HasSOA.
func mostCommonSerial(data *ObservationData) uint32 {
counts := map[uint32]int{}
for _, r := range data.Results {
if r == nil || !r.Authoritative || r.SOA == nil {
continue
}
counts[r.Serial]++
}
var best uint32
var bestN int
for s, n := range counts {
if n > bestN {
best = s
bestN = n
}
}
return best
}

104
checker/report_test.go Normal file
View file

@ -0,0 +1,104 @@
package checker
import (
"testing"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func TestSeverityToStatus(t *testing.T) {
cases := []struct {
sev Severity
want sdk.Status
}{
{SeverityCrit, sdk.StatusCrit},
{SeverityWarn, sdk.StatusWarn},
{SeverityInfo, sdk.StatusInfo},
{Severity("nonsense"), sdk.StatusOK},
}
for _, c := range cases {
if got := severityToStatus(c.sev); got != c.want {
t.Errorf("severityToStatus(%q) = %v, want %v", c.sev, got, c.want)
}
}
}
func TestStatusToSeverity(t *testing.T) {
cases := []struct {
s sdk.Status
want Severity
}{
{sdk.StatusCrit, SeverityCrit},
{sdk.StatusWarn, SeverityWarn},
{sdk.StatusInfo, SeverityInfo},
{sdk.StatusOK, ""},
{sdk.StatusUnknown, ""},
}
for _, c := range cases {
if got := statusToSeverity(c.s); got != c.want {
t.Errorf("statusToSeverity(%v) = %q, want %q", c.s, got, c.want)
}
}
}
func TestMostCommonSerial(t *testing.T) {
d := &ObservationData{
Results: map[string]*NSResult{
"a.": {Authoritative: true, SOA: mkSOA(10), Serial: 10},
"b.": {Authoritative: true, SOA: mkSOA(10), Serial: 10},
"c.": {Authoritative: true, SOA: mkSOA(11), Serial: 11},
"d.": {Authoritative: false, SOA: mkSOA(99), Serial: 99}, // ignored
"e.": {Authoritative: true, SOA: nil, Serial: 0}, // ignored
"f.": nil, // ignored
},
}
if got := mostCommonSerial(d); got != 10 {
t.Errorf("mostCommonSerial = %d, want 10", got)
}
}
func TestFindingsToStates_AttachesHint(t *testing.T) {
in := []Finding{
{Code: CodeNoNS, Severity: SeverityCrit, Message: "x"},
{Code: "no_such_code", Severity: SeverityWarn, Message: "y"},
}
got := findingsToStates(in)
if len(got) != 2 {
t.Fatalf("want 2 states, got %d", len(got))
}
if got[0].Status != sdk.StatusCrit || got[0].Code != CodeNoNS {
t.Errorf("state[0] = %#v", got[0])
}
if got[0].Meta == nil || got[0].Meta["fix"] == nil {
t.Errorf("state[0] should carry remediation hint, got %#v", got[0].Meta)
}
if got[1].Meta != nil {
t.Errorf("state[1] should have no Meta, got %#v", got[1].Meta)
}
}
func TestHeadlineFromStates_AllGood(t *testing.T) {
d := &ObservationData{HasSOA: true, Probed: []string{"a.", "b."}, Results: map[string]*NSResult{
"a.": {Authoritative: true, SOA: mkSOA(7), Serial: 7},
"b.": {Authoritative: true, SOA: mkSOA(7), Serial: 7},
}}
states := []sdk.CheckState{{Status: sdk.StatusOK, Code: "ok"}}
_, class, _ := headlineFromStates(d, states, nil)
if class != "ok" {
t.Errorf("class = %q, want ok", class)
}
}
func TestHeadlineFromStates_PrioritisesCrit(t *testing.T) {
d := &ObservationData{HasSOA: true}
states := []sdk.CheckState{
{Status: sdk.StatusWarn, Code: CodeSlowNS},
{Status: sdk.StatusCrit, Code: CodeLame},
{Status: sdk.StatusWarn, Code: CodeNSRRsetDrift},
}
rendered := []reportFinding{{Class: "crit"}, {Class: "warn"}, {Class: "warn"}}
title, class, _ := headlineFromStates(d, states, rendered)
if class != "crit" || title == "" {
t.Errorf("expected crit headline, got class=%q title=%q", class, title)
}
}

86
checker/rule.go Normal file
View file

@ -0,0 +1,86 @@
package checker
import (
"context"
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// One rule per concern so the UI can list what passed and what didn't.
func Rules() []sdk.CheckRule {
return []sdk.CheckRule{
&nsDeclaredRule{},
&parentDelegationRule{},
&nsResolvableRule{},
&nsReachableRule{},
&authoritativeRule{},
&ednsRule{},
&latencyRule{},
&serialConsistencyRule{},
&serialVsSavedRule{},
&soaFieldsConsistencyRule{},
&nsRRsetConsistencyRule{},
}
}
// On error, returns a CheckState the caller should emit to short-circuit.
func loadObservation(ctx context.Context, obs sdk.ObservationGetter) (*ObservationData, *sdk.CheckState) {
var data ObservationData
if err := obs.Get(ctx, ObservationKey, &data); err != nil {
return nil, &sdk.CheckState{
Status: sdk.StatusError,
Message: fmt.Sprintf("Failed to get observation: %v", err),
Code: "authoritative_consistency_error",
}
}
return &data, nil
}
// Hint is copied into Meta["fix"] so the HTML reporter can surface it
// without re-deriving from the code.
func findingsToStates(findings []Finding) []sdk.CheckState {
out := make([]sdk.CheckState, 0, len(findings))
for _, f := range findings {
st := sdk.CheckState{
Status: severityToStatus(f.Severity),
Message: f.Message,
Code: f.Code,
Subject: f.Server,
}
if hint, ok := remediationHints[f.Code]; ok && hint != "" {
st.Meta = map[string]any{"fix": hint}
}
out = append(out, st)
}
return out
}
func severityToStatus(sev Severity) sdk.Status {
switch sev {
case SeverityCrit:
return sdk.StatusCrit
case SeverityWarn:
return sdk.StatusWarn
case SeverityInfo:
return sdk.StatusInfo
default:
return sdk.StatusOK
}
}
func passState(code, message string) sdk.CheckState {
return sdk.CheckState{
Status: sdk.StatusOK,
Message: message,
Code: code,
}
}
func notTestedState(code, message string) sdk.CheckState {
return sdk.CheckState{
Status: sdk.StatusUnknown,
Message: message,
Code: code,
}
}

View file

@ -0,0 +1,291 @@
package checker
import (
"context"
"fmt"
"sort"
"strings"
"github.com/miekg/dns"
sdk "git.happydns.org/checker-sdk-go/checker"
)
type serialConsistencyRule struct{}
func (r *serialConsistencyRule) Name() string { return "authoritative_consistency.serial_consistency" }
func (r *serialConsistencyRule) Description() string {
return "Verifies that every authoritative name server returns the same SOA serial (detects incomplete zone transfer)."
}
func (r *serialConsistencyRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadObservation(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if !data.HasSOA {
return []sdk.CheckState{notTestedState("authoritative_consistency.serial_consistency.skipped", "Zone does not declare a SOA record.")}
}
findings := collectSerialDrift(data)
if len(findings) == 0 {
return []sdk.CheckState{passState("authoritative_consistency.serial_consistency.ok", "Every authoritative name server returns the same SOA serial.")}
}
return findingsToStates(findings)
}
func collectSerialDrift(data *ObservationData) []Finding {
bySerial := map[uint32][]string{}
for _, ns := range data.Probed {
r := data.Results[ns]
if r == nil || !r.Authoritative || r.SOA == nil {
continue
}
bySerial[r.Serial] = append(bySerial[r.Serial], ns)
}
if len(bySerial) < 2 {
return nil
}
var pairs []string
serials := make([]uint32, 0, len(bySerial))
for s := range bySerial {
serials = append(serials, s)
}
sort.Slice(serials, func(i, j int) bool { return serials[i] < serials[j] })
for _, s := range serials {
servers := bySerial[s]
sort.Strings(servers)
pairs = append(pairs, fmt.Sprintf("serial %d: %s", s, strings.Join(servers, ", ")))
}
return []Finding{{
Code: CodeSerialDrift,
Severity: SeverityCrit,
Message: "SOA serial drift between authoritative servers: " + strings.Join(pairs, "; "),
}}
}
type serialVsSavedRule struct{}
func (r *serialVsSavedRule) Name() string { return "authoritative_consistency.serial_vs_saved" }
func (r *serialVsSavedRule) Description() string {
return "Compares the live SOA serial with the one saved in happyDomain (detects un-pushed edits and out-of-band changes)."
}
func (r *serialVsSavedRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadObservation(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if !data.HasSOA || data.DeclaredSerial == 0 {
return []sdk.CheckState{notTestedState("authoritative_consistency.serial_vs_saved.skipped", "No saved serial to compare against.")}
}
warnOnStale := sdk.GetBoolOption(opts, "warnOnStaleSaved", true)
findings := collectSerialVsSaved(data, warnOnStale)
if len(findings) == 0 {
return []sdk.CheckState{passState("authoritative_consistency.serial_vs_saved.ok", fmt.Sprintf("Live serials match the saved value %d.", data.DeclaredSerial))}
}
return findingsToStates(findings)
}
func collectSerialVsSaved(data *ObservationData, warn bool) []Finding {
saved := data.DeclaredSerial
if saved == 0 {
return nil
}
var below, above []string
for _, ns := range data.Probed {
r := data.Results[ns]
if r == nil || !r.Authoritative || r.SOA == nil {
continue
}
switch {
case serialLess(r.Serial, saved):
below = append(below, ns)
case serialLess(saved, r.Serial):
above = append(above, ns)
}
}
var out []Finding
if len(below) > 0 && warn {
sort.Strings(below)
out = append(out, Finding{
Code: CodeSerialStaleVsSaved,
Severity: SeverityWarn,
Message: fmt.Sprintf(
"saved serial %d is newer than live serial on %s; changes have not propagated yet or have not been applied to the provider",
saved, strings.Join(below, ", "),
),
})
}
if len(above) > 0 {
sort.Strings(above)
out = append(out, Finding{
Code: CodeSerialAheadOfSaved,
Severity: SeverityInfo,
Message: fmt.Sprintf(
"live serial on %s is ahead of the saved serial %d; the zone was modified outside happyDomain",
strings.Join(above, ", "), saved,
),
})
}
return out
}
type soaFieldsConsistencyRule struct{}
func (r *soaFieldsConsistencyRule) Name() string {
return "authoritative_consistency.soa_fields_consistency"
}
func (r *soaFieldsConsistencyRule) Description() string {
return "Verifies that every authoritative name server returns the same SOA RDATA (MNAME, RNAME, refresh, retry, expire, minimum)."
}
func (r *soaFieldsConsistencyRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadObservation(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if !data.HasSOA {
return []sdk.CheckState{notTestedState("authoritative_consistency.soa_fields_consistency.skipped", "Zone does not declare a SOA record.")}
}
findings := collectSOAFieldsDrift(data)
if len(findings) == 0 {
return []sdk.CheckState{passState("authoritative_consistency.soa_fields_consistency.ok", "Every authoritative name server returns the same SOA RDATA.")}
}
return findingsToStates(findings)
}
func collectSOAFieldsDrift(data *ObservationData) []Finding {
type soaSig struct {
mname, rname string
refresh, retry uint32
expire, minimum, serial uint32
}
groups := map[soaSig][]string{}
sig := func(s *dns.SOA) soaSig {
return soaSig{
mname: strings.ToLower(strings.TrimSuffix(s.Ns, ".")),
rname: strings.ToLower(strings.TrimSuffix(s.Mbox, ".")),
refresh: s.Refresh,
retry: s.Retry,
expire: s.Expire,
minimum: s.Minttl,
serial: s.Serial,
}
}
for _, ns := range data.Probed {
r := data.Results[ns]
if r == nil || r.SOA == nil {
continue
}
k := sig(r.SOA)
k.serial = 0 // serial drift is reported separately
groups[k] = append(groups[k], ns)
}
if len(groups) < 2 {
return nil
}
var lines []string
keys := make([]soaSig, 0, len(groups))
for k := range groups {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool { return len(groups[keys[i]]) > len(groups[keys[j]]) })
for _, k := range keys {
srv := groups[k]
sort.Strings(srv)
lines = append(lines, fmt.Sprintf(
"mname=%s rname=%s refresh=%d retry=%d expire=%d minimum=%d → %s",
k.mname, k.rname, k.refresh, k.retry, k.expire, k.minimum, strings.Join(srv, ", "),
))
}
return []Finding{{
Code: CodeSOAFieldsDrift,
Severity: SeverityWarn,
Message: "SOA fields differ between authoritative servers: " + strings.Join(lines, "; "),
}}
}
type nsRRsetConsistencyRule struct{}
func (r *nsRRsetConsistencyRule) Name() string {
return "authoritative_consistency.ns_rrset_consistency"
}
func (r *nsRRsetConsistencyRule) Description() string {
return "Verifies every authoritative name server returns the same NS RRset, and that this RRset matches the NS declared in the service."
}
func (r *nsRRsetConsistencyRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadObservation(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
findings := collectNSRRsetDrift(data)
if len(findings) == 0 {
return []sdk.CheckState{passState("authoritative_consistency.ns_rrset_consistency.ok", "NS RRset is consistent across authoritative servers and matches the declared list.")}
}
return findingsToStates(findings)
}
func collectNSRRsetDrift(data *ObservationData) []Finding {
groups := map[string][]string{}
for _, ns := range data.Probed {
r := data.Results[ns]
if r == nil || !r.Authoritative || len(r.NSRRset) == 0 {
continue
}
k := strings.Join(r.NSRRset, "|")
groups[k] = append(groups[k], ns)
}
if len(groups) == 0 {
return nil
}
var findings []Finding
if len(groups) > 1 {
var lines []string
keys := make([]string, 0, len(groups))
for k := range groups {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool { return len(groups[keys[i]]) > len(groups[keys[j]]) })
for _, k := range keys {
srv := groups[k]
sort.Strings(srv)
lines = append(lines, fmt.Sprintf("NS RRset [%s] → %s", strings.ReplaceAll(k, "|", ", "), strings.Join(srv, ", ")))
}
findings = append(findings, Finding{
Code: CodeNSRRsetDrift,
Severity: SeverityWarn,
Message: "NS RRset differs between authoritative servers: " + strings.Join(lines, "; "),
})
}
if len(data.DeclaredNS) == 0 {
return findings
}
var majority []string
var majorityCount int
for k, servers := range groups {
if len(servers) > majorityCount {
majority = strings.Split(k, "|")
majorityCount = len(servers)
}
}
if len(majority) == 0 {
return findings
}
missing, extra := diffStringSets(data.DeclaredNS, majority)
if len(missing) > 0 || len(extra) > 0 {
findings = append(findings, Finding{
Code: CodeNSRRsetMismatchConfig,
Severity: SeverityWarn,
Message: fmt.Sprintf(
"NS RRset served by authoritative servers does not match declared service: missing=%v extra=%v",
missing, extra,
),
})
}
return findings
}

View file

@ -0,0 +1,219 @@
package checker
import (
"strings"
"testing"
"github.com/miekg/dns"
)
func mkSOA(serial uint32) *dns.SOA {
return &dns.SOA{
Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeSOA},
Ns: "ns1.example.com.",
Mbox: "hostmaster.example.com.",
Serial: serial,
Refresh: 3600,
Retry: 600,
Expire: 86400,
Minttl: 300,
}
}
func TestCollectSerialDrift_NoDrift(t *testing.T) {
d := &ObservationData{
Probed: []string{"ns1.example.com.", "ns2.example.com."},
Results: map[string]*NSResult{
"ns1.example.com.": {Authoritative: true, SOA: mkSOA(10), Serial: 10},
"ns2.example.com.": {Authoritative: true, SOA: mkSOA(10), Serial: 10},
},
}
if got := collectSerialDrift(d); len(got) != 0 {
t.Errorf("expected no findings, got %v", got)
}
}
func TestCollectSerialDrift_Drift(t *testing.T) {
d := &ObservationData{
Probed: []string{"ns1.example.com.", "ns2.example.com.", "ns3.example.com."},
Results: map[string]*NSResult{
"ns1.example.com.": {Authoritative: true, SOA: mkSOA(10), Serial: 10},
"ns2.example.com.": {Authoritative: true, SOA: mkSOA(11), Serial: 11},
"ns3.example.com.": {Authoritative: false, SOA: mkSOA(99), Serial: 99}, // ignored
},
}
got := collectSerialDrift(d)
if len(got) != 1 || got[0].Code != CodeSerialDrift || got[0].Severity != SeverityCrit {
t.Fatalf("unexpected findings: %#v", got)
}
if !strings.Contains(got[0].Message, "serial 10") || !strings.Contains(got[0].Message, "serial 11") {
t.Errorf("message missing serials: %q", got[0].Message)
}
if strings.Contains(got[0].Message, "99") {
t.Errorf("non-authoritative server should not appear: %q", got[0].Message)
}
}
func TestCollectSerialVsSaved(t *testing.T) {
tests := []struct {
name string
saved uint32
nsSerials map[string]uint32
warn bool
wantCodes []string
wantSeverity []Severity
}{
{
name: "matches saved",
saved: 50,
nsSerials: map[string]uint32{"ns1.": 50, "ns2.": 50},
warn: true,
},
{
name: "saved newer than live -> stale",
saved: 50,
nsSerials: map[string]uint32{"ns1.": 49, "ns2.": 50},
warn: true,
wantCodes: []string{CodeSerialStaleVsSaved},
wantSeverity: []Severity{SeverityWarn},
},
{
name: "saved newer but warn disabled",
saved: 50,
nsSerials: map[string]uint32{"ns1.": 49},
warn: false,
},
{
name: "live ahead of saved -> info",
saved: 50,
nsSerials: map[string]uint32{"ns1.": 51},
warn: true,
wantCodes: []string{CodeSerialAheadOfSaved},
wantSeverity: []Severity{SeverityInfo},
},
{
name: "mixed",
saved: 50,
nsSerials: map[string]uint32{"ns1.": 49, "ns2.": 51},
warn: true,
wantCodes: []string{CodeSerialStaleVsSaved, CodeSerialAheadOfSaved},
wantSeverity: []Severity{SeverityWarn, SeverityInfo},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
d := &ObservationData{DeclaredSerial: tt.saved, Results: map[string]*NSResult{}}
for ns, s := range tt.nsSerials {
d.Probed = append(d.Probed, ns)
d.Results[ns] = &NSResult{Authoritative: true, SOA: mkSOA(s), Serial: s}
}
got := collectSerialVsSaved(d, tt.warn)
if len(got) != len(tt.wantCodes) {
t.Fatalf("got %d findings, want %d: %#v", len(got), len(tt.wantCodes), got)
}
codes := map[string]Severity{}
for _, f := range got {
codes[f.Code] = f.Severity
}
for i, c := range tt.wantCodes {
if sev, ok := codes[c]; !ok || sev != tt.wantSeverity[i] {
t.Errorf("missing or wrong-severity %s: got %v", c, codes)
}
}
})
}
}
func TestCollectSOAFieldsDrift(t *testing.T) {
soaA := mkSOA(10)
soaB := mkSOA(10)
soaB.Refresh = 9999 // different RDATA
soaC := mkSOA(11) // same RDATA as A but different serial; should NOT trigger this rule
d := &ObservationData{
Probed: []string{"ns1.", "ns2.", "ns3."},
Results: map[string]*NSResult{
"ns1.": {SOA: soaA},
"ns2.": {SOA: soaB},
"ns3.": {SOA: soaC},
},
}
got := collectSOAFieldsDrift(d)
if len(got) != 1 || got[0].Code != CodeSOAFieldsDrift {
t.Fatalf("expected one SOAFieldsDrift finding, got %#v", got)
}
// Two distinct RDATA buckets (A+C grouped, B alone).
if !strings.Contains(got[0].Message, "refresh=3600") || !strings.Contains(got[0].Message, "refresh=9999") {
t.Errorf("message missing refresh values: %q", got[0].Message)
}
}
func TestCollectSOAFieldsDrift_NoDriftWhenOnlySerialDiffers(t *testing.T) {
d := &ObservationData{
Probed: []string{"ns1.", "ns2."},
Results: map[string]*NSResult{
"ns1.": {SOA: mkSOA(10)},
"ns2.": {SOA: mkSOA(11)},
},
}
if got := collectSOAFieldsDrift(d); len(got) != 0 {
t.Errorf("serial-only difference should not be flagged here: %v", got)
}
}
func TestCollectNSRRsetDrift_Consistent(t *testing.T) {
rrset := []string{"ns1.example.com.", "ns2.example.com."}
d := &ObservationData{
Probed: []string{"ns1.example.com.", "ns2.example.com."},
DeclaredNS: rrset,
Results: map[string]*NSResult{
"ns1.example.com.": {Authoritative: true, NSRRset: rrset},
"ns2.example.com.": {Authoritative: true, NSRRset: rrset},
},
}
if got := collectNSRRsetDrift(d); len(got) != 0 {
t.Errorf("expected no findings, got %v", got)
}
}
func TestCollectNSRRsetDrift_Drift(t *testing.T) {
d := &ObservationData{
Probed: []string{"ns1.example.com.", "ns2.example.com."},
DeclaredNS: []string{"ns1.example.com.", "ns2.example.com."},
Results: map[string]*NSResult{
"ns1.example.com.": {Authoritative: true, NSRRset: []string{"ns1.example.com.", "ns2.example.com."}},
"ns2.example.com.": {Authoritative: true, NSRRset: []string{"ns1.example.com."}},
},
}
got := collectNSRRsetDrift(d)
codes := map[string]bool{}
for _, f := range got {
codes[f.Code] = true
}
if !codes[CodeNSRRsetDrift] {
t.Errorf("expected NSRRsetDrift, got %v", codes)
}
}
func TestCollectNSRRsetDrift_MismatchConfig(t *testing.T) {
d := &ObservationData{
Probed: []string{"ns1.example.com."},
DeclaredNS: []string{"ns1.example.com.", "ns2.example.com."},
Results: map[string]*NSResult{
"ns1.example.com.": {Authoritative: true, NSRRset: []string{"ns1.example.com.", "ns3.example.com."}},
},
}
got := collectNSRRsetDrift(d)
var found bool
for _, f := range got {
if f.Code == CodeNSRRsetMismatchConfig {
found = true
if !strings.Contains(f.Message, "ns2.example.com") || !strings.Contains(f.Message, "ns3.example.com") {
t.Errorf("message missing missing/extra entries: %q", f.Message)
}
}
}
if !found {
t.Errorf("expected NSRRsetMismatchConfig in %v", got)
}
}

100
checker/rules_discovery.go Normal file
View file

@ -0,0 +1,100 @@
package checker
import (
"context"
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
)
type nsDeclaredRule struct{}
func (r *nsDeclaredRule) Name() string { return "authoritative_consistency.ns_declared" }
func (r *nsDeclaredRule) Description() string {
return "Verifies the service declares at least the recommended number of name servers and that at least one name server could be discovered."
}
func (r *nsDeclaredRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadObservation(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
minNS := sdk.GetIntOption(opts, "minNameServers", 2)
useParentNS := sdk.GetBoolOption(opts, "useParentNS", true)
var findings []Finding
if len(data.DeclaredNS) == 0 && !useParentNS {
findings = append(findings, Finding{
Code: CodeNoNS,
Severity: SeverityCrit,
Message: "no name servers declared in the service and parent cross-check is disabled",
})
}
if len(data.Probed) == 0 {
findings = append(findings, Finding{
Code: CodeNoNS,
Severity: SeverityCrit,
Message: "no authoritative name servers could be discovered (declared list empty and parent query empty)",
})
}
if len(data.DeclaredNS) > 0 && len(data.DeclaredNS) < minNS {
findings = append(findings, Finding{
Code: CodeTooFewNS,
Severity: SeverityWarn,
Message: fmt.Sprintf("only %d name server(s) declared, RFC 1034 recommends at least %d", len(data.DeclaredNS), minNS),
})
}
if len(findings) == 0 {
return []sdk.CheckState{passState("authoritative_consistency.ns_declared.ok", fmt.Sprintf("%d name server(s) declared", len(data.DeclaredNS)))}
}
return findingsToStates(findings)
}
type parentDelegationRule struct{}
func (r *parentDelegationRule) Name() string { return "authoritative_consistency.parent_delegation" }
func (r *parentDelegationRule) Description() string {
return "Cross-checks the NS RRset returned by the parent zone's referral with the NS declared in the service."
}
func (r *parentDelegationRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
useParentNS := sdk.GetBoolOption(opts, "useParentNS", true)
if !useParentNS {
return []sdk.CheckState{notTestedState("authoritative_consistency.parent_delegation.skipped", "Parent delegation cross-check disabled by option.")}
}
data, errSt := loadObservation(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
var findings []Finding
if data.ParentQueryError != "" {
findings = append(findings, Finding{
Code: CodeParentQueryFailed,
Severity: SeverityWarn,
Message: fmt.Sprintf("parent delegation query failed: %s", data.ParentQueryError),
})
} else if len(data.DeclaredNS) > 0 && len(data.ParentNS) > 0 {
missing, extra := diffStringSets(data.DeclaredNS, data.ParentNS)
if len(missing) > 0 || len(extra) > 0 {
findings = append(findings, Finding{
Code: CodeParentDrift,
Severity: SeverityWarn,
Message: fmt.Sprintf(
"NS RRset at parent does not match declared service: missing=%v extra=%v",
missing, extra,
),
})
}
}
if len(findings) == 0 {
if len(data.ParentNS) == 0 {
return []sdk.CheckState{notTestedState("authoritative_consistency.parent_delegation.skipped", "No parent delegation observed.")}
}
return []sdk.CheckState{passState("authoritative_consistency.parent_delegation.ok", "Parent delegation matches the declared NS list.")}
}
return findingsToStates(findings)
}

View file

@ -0,0 +1,193 @@
package checker
import (
"context"
"encoding/json"
"maps"
"testing"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// stubObs implements the minimal subset of sdk.ObservationGetter the rules use.
type stubObs struct {
data *ObservationData
err error
}
func (s stubObs) Get(_ context.Context, _ sdk.ObservationKey, dst any) error {
if s.err != nil {
return s.err
}
b, err := json.Marshal(s.data)
if err != nil {
return err
}
return json.Unmarshal(b, dst)
}
func (s stubObs) GetRelated(_ context.Context, _ sdk.ObservationKey) ([]sdk.RelatedObservation, error) {
return nil, nil
}
func mkOpts(kv map[string]any) sdk.CheckerOptions {
out := sdk.CheckerOptions{}
maps.Copy(out, kv)
return out
}
func TestNSDeclaredRule(t *testing.T) {
rule := &nsDeclaredRule{}
t.Run("ok with two NS", func(t *testing.T) {
d := &ObservationData{
DeclaredNS: []string{"ns1.example.com.", "ns2.example.com."},
Probed: []string{"ns1.example.com.", "ns2.example.com."},
}
states := rule.Evaluate(context.Background(), stubObs{data: d}, mkOpts(nil))
if len(states) != 1 || states[0].Status != sdk.StatusOK {
t.Errorf("expected OK, got %#v", states)
}
})
t.Run("too few NS", func(t *testing.T) {
d := &ObservationData{
DeclaredNS: []string{"ns1.example.com."},
Probed: []string{"ns1.example.com."},
}
states := rule.Evaluate(context.Background(), stubObs{data: d}, mkOpts(map[string]any{"minNameServers": 2}))
if len(states) != 1 || states[0].Code != CodeTooFewNS {
t.Errorf("expected TooFewNS, got %#v", states)
}
})
t.Run("no NS at all", func(t *testing.T) {
d := &ObservationData{}
states := rule.Evaluate(context.Background(), stubObs{data: d}, mkOpts(map[string]any{"useParentNS": false}))
var hasNoNS bool
for _, st := range states {
if st.Code == CodeNoNS {
hasNoNS = true
}
}
if !hasNoNS {
t.Errorf("expected NoNS finding, got %#v", states)
}
})
}
func TestNSReachableRule(t *testing.T) {
rule := &nsReachableRule{}
t.Run("UDP fail is critical", func(t *testing.T) {
d := &ObservationData{
Probed: []string{"ns1."},
Results: map[string]*NSResult{
"ns1.": {UDPReachable: false, TCPReachable: false},
},
}
states := rule.Evaluate(context.Background(), stubObs{data: d}, mkOpts(nil))
if len(states) != 1 || states[0].Code != CodeNSUDPFailed || states[0].Status != sdk.StatusCrit {
t.Errorf("expected critical UDP fail, got %#v", states)
}
})
t.Run("TCP fail crit when requireTCP", func(t *testing.T) {
d := &ObservationData{
Probed: []string{"ns1."},
Results: map[string]*NSResult{
"ns1.": {UDPReachable: true, TCPReachable: false},
},
}
states := rule.Evaluate(context.Background(), stubObs{data: d}, mkOpts(map[string]any{"requireTCP": true}))
if len(states) != 1 || states[0].Code != CodeNSTCPFailed || states[0].Status != sdk.StatusCrit {
t.Errorf("got %#v", states)
}
})
t.Run("TCP fail warn when not required", func(t *testing.T) {
d := &ObservationData{
Probed: []string{"ns1."},
Results: map[string]*NSResult{
"ns1.": {UDPReachable: true, TCPReachable: false},
},
}
states := rule.Evaluate(context.Background(), stubObs{data: d}, mkOpts(map[string]any{"requireTCP": false}))
if len(states) != 1 || states[0].Status != sdk.StatusWarn {
t.Errorf("got %#v", states)
}
})
}
func TestAuthoritativeRule_Lame(t *testing.T) {
rule := &authoritativeRule{}
d := &ObservationData{
Zone: "example.com.",
HasSOA: true,
Probed: []string{"ns1."},
Results: map[string]*NSResult{
"ns1.": {UDPReachable: true, Authoritative: false},
},
}
states := rule.Evaluate(context.Background(), stubObs{data: d}, mkOpts(nil))
if len(states) != 1 || states[0].Code != CodeLame {
t.Errorf("expected lame finding, got %#v", states)
}
}
func TestLatencyRule(t *testing.T) {
rule := &latencyRule{}
d := &ObservationData{
Probed: []string{"fast.", "slow."},
Results: map[string]*NSResult{
"fast.": {UDPReachable: true, LatencyMs: 50},
"slow.": {UDPReachable: true, LatencyMs: 1000},
},
}
states := rule.Evaluate(context.Background(), stubObs{data: d}, mkOpts(map[string]any{"latencyThresholdMs": 500}))
if len(states) != 1 || states[0].Code != CodeSlowNS || states[0].Subject != "slow." {
t.Errorf("expected single slow finding for slow., got %#v", states)
}
}
func TestParentDelegationRule_Drift(t *testing.T) {
rule := &parentDelegationRule{}
d := &ObservationData{
DeclaredNS: []string{"ns1.example.com.", "ns2.example.com."},
ParentNS: []string{"ns1.example.com.", "ns3.example.com."},
}
states := rule.Evaluate(context.Background(), stubObs{data: d}, mkOpts(nil))
if len(states) != 1 || states[0].Code != CodeParentDrift {
t.Errorf("expected ParentDrift, got %#v", states)
}
}
func TestParentDelegationRule_QueryFailed(t *testing.T) {
rule := &parentDelegationRule{}
d := &ObservationData{ParentQueryError: "boom"}
states := rule.Evaluate(context.Background(), stubObs{data: d}, mkOpts(nil))
if len(states) != 1 || states[0].Code != CodeParentQueryFailed {
t.Errorf("expected ParentQueryFailed, got %#v", states)
}
}
func TestRulesRegistry(t *testing.T) {
rules := Rules()
if len(rules) == 0 {
t.Fatal("Rules() returned empty list")
}
seen := map[string]bool{}
for _, r := range rules {
name := r.Name()
if name == "" {
t.Error("rule with empty name")
}
if seen[name] {
t.Errorf("duplicate rule name: %s", name)
}
seen[name] = true
if r.Description() == "" {
t.Errorf("rule %s has empty description", name)
}
}
}

209
checker/rules_per_ns.go Normal file
View file

@ -0,0 +1,209 @@
package checker
import (
"context"
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
)
type nsResolvableRule struct{}
func (r *nsResolvableRule) Name() string { return "authoritative_consistency.ns_resolvable" }
func (r *nsResolvableRule) Description() string {
return "Verifies that every authoritative name server hostname resolves to at least one A or AAAA address."
}
func (r *nsResolvableRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadObservation(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
var findings []Finding
for _, ns := range data.Probed {
res := data.Results[ns]
if res == nil {
continue
}
if res.ResolveError != "" {
findings = append(findings, Finding{
Code: CodeNSUnresolvable,
Severity: SeverityCrit,
Message: fmt.Sprintf("cannot resolve %s: %s", ns, res.ResolveError),
Server: ns,
})
}
}
if len(findings) == 0 {
return []sdk.CheckState{passState("authoritative_consistency.ns_resolvable.ok", "Every probed name server resolves to at least one address.")}
}
return findingsToStates(findings)
}
type nsReachableRule struct{}
func (r *nsReachableRule) Name() string { return "authoritative_consistency.ns_reachable" }
func (r *nsReachableRule) Description() string {
return "Verifies that every authoritative name server answers over UDP/53 and TCP/53."
}
func (r *nsReachableRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadObservation(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
requireTCP := sdk.GetBoolOption(opts, "requireTCP", true)
var findings []Finding
for _, ns := range data.Probed {
res := data.Results[ns]
if res == nil || res.ResolveError != "" {
continue
}
if !res.UDPReachable {
findings = append(findings, Finding{
Code: CodeNSUDPFailed,
Severity: SeverityCrit,
Message: fmt.Sprintf("%s did not answer any SOA query over UDP/53", ns),
Server: ns,
})
continue
}
if !res.TCPReachable {
sev := SeverityWarn
msg := fmt.Sprintf("%s did not answer over TCP/53", ns)
if requireTCP {
sev = SeverityCrit
msg = fmt.Sprintf("%s did not answer over TCP/53 (required by RFC 7766 and DNSSEC)", ns)
}
findings = append(findings, Finding{
Code: CodeNSTCPFailed,
Severity: sev,
Message: msg,
Server: ns,
})
}
}
if len(findings) == 0 {
return []sdk.CheckState{passState("authoritative_consistency.ns_reachable.ok", "Every probed name server is reachable over UDP/53 and TCP/53.")}
}
return findingsToStates(findings)
}
type authoritativeRule struct{}
func (r *authoritativeRule) Name() string { return "authoritative_consistency.authoritative" }
func (r *authoritativeRule) Description() string {
return "Verifies that every reachable name server is authoritative for the zone (no lame delegation) and returns a SOA."
}
func (r *authoritativeRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadObservation(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
var findings []Finding
for _, ns := range data.Probed {
res := data.Results[ns]
if res == nil || !res.UDPReachable {
continue
}
if !res.Authoritative {
findings = append(findings, Finding{
Code: CodeLame,
Severity: SeverityCrit,
Message: fmt.Sprintf("%s is not authoritative for %s (lame delegation)", ns, data.Zone),
Server: ns,
})
continue
}
if data.HasSOA && res.SOA == nil {
findings = append(findings, Finding{
Code: CodeNoSOA,
Severity: SeverityCrit,
Message: fmt.Sprintf("%s is authoritative but returned no SOA for %s", ns, data.Zone),
Server: ns,
})
}
}
if len(findings) == 0 {
return []sdk.CheckState{passState("authoritative_consistency.authoritative.ok", "Every reachable name server is authoritative for the zone.")}
}
return findingsToStates(findings)
}
type ednsRule struct{}
func (r *ednsRule) Name() string { return "authoritative_consistency.edns" }
func (r *ednsRule) Description() string {
return "Verifies that every reachable name server correctly handles EDNS0 queries (required by DNSSEC and for large answers)."
}
func (r *ednsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
if !sdk.GetBoolOption(opts, "checkEDNS", true) {
return []sdk.CheckState{notTestedState("authoritative_consistency.edns.skipped", "EDNS0 check disabled by option.")}
}
data, errSt := loadObservation(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
var findings []Finding
for _, ns := range data.Probed {
res := data.Results[ns]
if res == nil || !res.UDPReachable {
continue
}
if !res.EDNSSupported {
findings = append(findings, Finding{
Code: CodeEDNSUnsupported,
Severity: SeverityWarn,
Message: fmt.Sprintf("%s does not correctly handle EDNS0 (breaks DNSSEC and large answers)", ns),
Server: ns,
})
}
}
if len(findings) == 0 {
return []sdk.CheckState{passState("authoritative_consistency.edns.ok", "Every reachable name server handles EDNS0 correctly.")}
}
return findingsToStates(findings)
}
type latencyRule struct{}
func (r *latencyRule) Name() string { return "authoritative_consistency.latency" }
func (r *latencyRule) Description() string {
return "Flags authoritative name servers whose response latency exceeds the configured threshold."
}
func (r *latencyRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
if !sdk.GetBoolOption(opts, "checkLatency", true) {
return []sdk.CheckState{notTestedState("authoritative_consistency.latency.skipped", "Latency check disabled by option.")}
}
data, errSt := loadObservation(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
threshold := int64(sdk.GetIntOption(opts, "latencyThresholdMs", 500))
var findings []Finding
for _, ns := range data.Probed {
res := data.Results[ns]
if res == nil || !res.UDPReachable {
continue
}
if res.LatencyMs > threshold {
findings = append(findings, Finding{
Code: CodeSlowNS,
Severity: SeverityInfo,
Message: fmt.Sprintf("%s responded in %d ms (above %d ms threshold)", ns, res.LatencyMs, threshold),
Server: ns,
})
}
}
if len(findings) == 0 {
return []sdk.CheckState{passState("authoritative_consistency.latency.ok", "Every reachable name server responded within the configured threshold.")}
}
return findingsToStates(findings)
}

127
checker/types.go Normal file
View file

@ -0,0 +1,127 @@
package checker
import (
"encoding/json"
"fmt"
"github.com/miekg/dns"
)
// Cap on per-NS error list so a flaky server with many addresses cannot
// bloat the JSON observation payload.
const maxNSResultErrors = 16
const ObservationKey = "authoritative-consistency"
type Severity string
const (
SeverityInfo Severity = "info"
SeverityWarn Severity = "warn"
SeverityCrit Severity = "crit"
)
// Stable identifiers; the UI keys translations and remediation docs off them.
const (
CodeSerialDrift = "authoritative_consistency_serial_drift"
CodeSerialStaleVsSaved = "authoritative_consistency_serial_stale_vs_saved"
CodeSerialAheadOfSaved = "authoritative_consistency_serial_ahead_of_saved"
CodeNSUnreachable = "authoritative_consistency_ns_unreachable"
CodeNSUDPFailed = "authoritative_consistency_ns_udp_failed"
CodeNSTCPFailed = "authoritative_consistency_ns_tcp_failed"
CodeNSUnresolvable = "authoritative_consistency_ns_unresolvable"
CodeLame = "authoritative_consistency_lame"
CodeNoSOA = "authoritative_consistency_no_soa"
CodeNSRRsetDrift = "authoritative_consistency_ns_rrset_drift"
CodeNSRRsetMismatchConfig = "authoritative_consistency_ns_rrset_mismatch_config"
CodeParentDrift = "authoritative_consistency_parent_drift"
CodeParentQueryFailed = "authoritative_consistency_parent_query_failed"
CodeSOAFieldsDrift = "authoritative_consistency_soa_fields_drift"
CodeSlowNS = "authoritative_consistency_slow_ns"
CodeEDNSUnsupported = "authoritative_consistency_edns_unsupported"
CodeTooFewNS = "authoritative_consistency_too_few_ns"
CodeNoNS = "authoritative_consistency_no_ns"
)
type Finding struct {
Code string `json:"code"`
Severity Severity `json:"severity"`
Message string `json:"message"`
// Server is empty for zone-wide findings.
Server string `json:"server,omitempty"`
// Addr disambiguates IPv4/IPv6 issues on a multi-homed NS.
Addr string `json:"addr,omitempty"`
}
type NSResult struct {
// Name is FQDN, lowercase.
Name string `json:"name"`
Addresses []string `json:"addresses,omitempty"`
ResolveError string `json:"resolve_error,omitempty"`
UDPReachable bool `json:"udp_reachable"`
TCPReachable bool `json:"tcp_reachable"`
Authoritative bool `json:"authoritative"`
// Zero when not reachable or the answer carries no SOA.
Serial uint32 `json:"serial,omitempty"`
// Full SOA RR kept for per-field comparison in the report.
SOA *dns.SOA `json:"soa,omitempty"`
NSRRset []string `json:"ns_rrset,omitempty"`
EDNSSupported bool `json:"edns_supported"`
// Zero when not reachable.
LatencyMs int64 `json:"latency_ms,omitempty"`
// Capped at maxNSResultErrors; appendError is the only intended writer.
Errors []string `json:"errors,omitempty"`
suppressedErrors int
}
// Dedupes identical messages and caps the list with a sentinel summary.
func (n *NSResult) appendError(format string, args ...any) {
msg := fmt.Sprintf(format, args...)
for _, e := range n.Errors {
if e == msg {
return
}
}
if len(n.Errors) >= maxNSResultErrors {
n.suppressedErrors++
sentinel := fmt.Sprintf("(%d more error(s) suppressed)", n.suppressedErrors)
if last := len(n.Errors) - 1; last >= 0 && len(n.Errors[last]) > 0 && n.Errors[last][0] == '(' {
n.Errors[last] = sentinel
return
}
n.Errors = append(n.Errors, sentinel)
return
}
n.Errors = append(n.Errors, msg)
}
type ObservationData struct {
Zone string `json:"zone"`
// HasSOA distinguishes Origin from NSOnlyOrigin and gates SOA-based rules.
HasSOA bool `json:"has_soa"`
// Zero when the service is an NSOnlyOrigin.
DeclaredSerial uint32 `json:"declared_serial,omitempty"`
DeclaredNS []string `json:"declared_ns,omitempty"`
// Empty when parent discovery is disabled or failed (see ParentQueryError).
ParentNS []string `json:"parent_ns,omitempty"`
ParentQueryError string `json:"parent_query_error,omitempty"`
// Union of DeclaredNS and ParentNS, de-duplicated.
Probed []string `json:"probed,omitempty"`
Results map[string]*NSResult `json:"results,omitempty"`
Findings []Finding `json:"findings"`
}
// Local mirror of happyDomain's services/abstract.Origin. Duplicated on
// purpose to avoid pulling the entire happyDomain server module just to
// decode the payload; miekg/dns marshals dns.SOA/dns.NS in the same shape.
type originService struct {
SOA *dns.SOA `json:"soa,omitempty"`
NameServers []*dns.NS `json:"ns"`
}
// Local mirror of happyDomain's ServiceMessage envelope.
type serviceMessage struct {
Type string `json:"_svctype"`
Domain string `json:"_domain"`
Service json.RawMessage `json:"Service"`
}

16
go.mod Normal file
View file

@ -0,0 +1,16 @@
module git.happydns.org/checker-authoritative-consistency
go 1.25.0
require (
git.happydns.org/checker-sdk-go v1.5.0
github.com/miekg/dns v1.1.72
)
require (
golang.org/x/mod v0.31.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/tools v0.40.0 // indirect
)

16
go.sum Normal file
View file

@ -0,0 +1,16 @@
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=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=

28
main.go Normal file
View file

@ -0,0 +1,28 @@
package main
import (
"flag"
"log"
chk "git.happydns.org/checker-authoritative-consistency/checker"
"git.happydns.org/checker-sdk-go/checker/server"
)
// Version is the standalone binary's version. It defaults to "custom-build"
// and is meant to be overridden by the CI at link time:
//
// go build -ldflags "-X main.Version=1.2.3" .
var Version = "custom-build"
var listenAddr = flag.String("listen", ":8080", "HTTP listen address")
func main() {
flag.Parse()
chk.Version = Version
srv := server.New(chk.Provider())
if err := srv.ListenAndServe(*listenAddr); err != nil {
log.Fatalf("server error: %v", err)
}
}

23
plugin/plugin.go Normal file
View file

@ -0,0 +1,23 @@
// Command plugin is the happyDomain plugin entrypoint for the authoritative-consistency
// checker. It is built as a Go plugin (`go build -buildmode=plugin`) and
// loaded at runtime by happyDomain.
package main
import (
chk "git.happydns.org/checker-authoritative-consistency/checker"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Version is the plugin's version. It defaults to "custom-build" and is
// meant to be overridden by the CI at link time:
//
// go build -buildmode=plugin -ldflags "-X main.Version=1.2.3" -o checker-chk.so ./plugin
var Version = "custom-build"
// NewCheckerPlugin is the symbol resolved by happyDomain when loading the
// .so file.
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
chk.Version = Version
prvd := chk.Provider()
return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil
}