commit 2b72492a593ef8295090fce26bf58653755fa418 Author: Pierre-Olivier Mercier Date: Sun Apr 26 11:06:47 2026 +0700 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ff04a8d --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +checker-authoritative-consistency +checker-authoritative-consistency.so diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ebd84cf --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..07d44d8 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..42ea97d --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..824b3c1 --- /dev/null +++ b/README.md @@ -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`). diff --git a/checker/collect.go b/checker/collect.go new file mode 100644 index 0000000..05134da --- /dev/null +++ b/checker/collect.go @@ -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) +} diff --git a/checker/collect_test.go b/checker/collect_test.go new file mode 100644 index 0000000..94db089 --- /dev/null +++ b/checker/collect_test.go @@ -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}, + {"ab small", 200, 100, false}, + {"wrap b ahead", 0xFFFFFFFE, 1, true}, + {"wrap a ahead", 1, 0xFFFFFFFE, false}, + {"zero. +// +// 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 . + +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, + }, + } +} diff --git a/checker/dns.go b/checker/dns.go new file mode 100644 index 0000000..fdf3703 --- /dev/null +++ b/checker/dns.go @@ -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) +} diff --git a/checker/evaluate.go b/checker/evaluate.go new file mode 100644 index 0000000..59ad220 --- /dev/null +++ b/checker/evaluate.go @@ -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 +} diff --git a/checker/interactive.go b/checker/interactive.go new file mode 100644 index 0000000..d79dac8 --- /dev/null +++ b/checker/interactive.go @@ -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 +} diff --git a/checker/provider.go b/checker/provider.go new file mode 100644 index 0000000..e25b07a --- /dev/null +++ b/checker/provider.go @@ -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 +} diff --git a/checker/report.go b/checker/report.go new file mode 100644 index 0000000..2e171e9 --- /dev/null +++ b/checker/report.go @@ -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(`OK`) + } + return template.HTML(`KO`) + }, + "naBadge": func(ok bool, relevant bool) template.HTML { + if !relevant { + return template.HTML(``) + } + if ok { + return template.HTML(`OK`) + } + return template.HTML(`KO`) + }, + }). + Parse(` + + + + +Authoritative consistency: {{.Zone}} + + + + +
+

{{.Headline}}

+
{{.Zone}}{{if .HasSOA}}, saved SOA serial {{.DeclaredSerial}}{{end}}
+
+ {{- range $lvl, $n := .Totals}}{{if $n}} + {{$lvl}} {{$n}} + {{end}}{{end}} +
+ {{if .HeadlineHint}}
{{.HeadlineHint}}
{{end}} +
+ +{{if .ShowSerialTable}} +
+

Serial consistency

+ + + + + + {{- range .SerialGroups}} + + + + + {{- end}} + +
SOA serialServers
+ {{.Serial}} + {{if .Majority}} ← consensus{{end}} + {{join .Servers}}
+ {{if .DeclaredSerial}}
Saved in happyDomain: {{.DeclaredSerial}}
{{end}} +
+{{end}} + +
+

Per-server probe

+ + + + + + + + + + + + + + {{- range .NS}} + + + + + + + + + + {{- end}} + +
Name serverUDP/53TCP/53AuthoritativeSerialLatencyEDNS0
+
{{.Name}}
+ {{if .Addresses}}
{{.Addresses}}
{{end}} + {{- range .Errors}}
⚠ {{.}}
{{end}} +
{{boolBadge .UDP}}{{boolBadge .TCP}}{{boolBadge .AA}}{{if .Serial}}{{.Serial}}{{else}}{{end}}{{if .Latency}}{{.Latency}} ms{{else}}{{end}}{{naBadge .EDNS .UDP}}
+
+ +{{if .DeclaredNS}} +
+

Declared vs observed NS

+ + + + {{if .ParentNS}}{{end}} + {{if .ParentError}}{{end}} + +
Declared in service{{join .DeclaredNS}}
Parent delegation{{join .ParentNS}}
Parent query{{.ParentError}}
+
+{{end}} + +
+

Findings

+ {{if .Findings}} + {{- range .Findings}} +
+
+ {{.Severity}} + {{.Message}} +
+ {{if .Server}}
on {{.Server}}
{{end}} + {{if .Hint}}
💡 {{.Hint}}
{{end}} +
+ {{- end}} + {{else}} +
No issue detected. Every authoritative server agrees on the zone.
+ {{end}} +
+ + +`), +) + +// 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 +} diff --git a/checker/report_test.go b/checker/report_test.go new file mode 100644 index 0000000..53f895f --- /dev/null +++ b/checker/report_test.go @@ -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) + } +} diff --git a/checker/rule.go b/checker/rule.go new file mode 100644 index 0000000..4f7ca5d --- /dev/null +++ b/checker/rule.go @@ -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, + } +} diff --git a/checker/rules_consistency.go b/checker/rules_consistency.go new file mode 100644 index 0000000..8927843 --- /dev/null +++ b/checker/rules_consistency.go @@ -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 +} diff --git a/checker/rules_consistency_test.go b/checker/rules_consistency_test.go new file mode 100644 index 0000000..2dc13e1 --- /dev/null +++ b/checker/rules_consistency_test.go @@ -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) + } +} diff --git a/checker/rules_discovery.go b/checker/rules_discovery.go new file mode 100644 index 0000000..bdf906e --- /dev/null +++ b/checker/rules_discovery.go @@ -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) +} diff --git a/checker/rules_discovery_test.go b/checker/rules_discovery_test.go new file mode 100644 index 0000000..a2077d7 --- /dev/null +++ b/checker/rules_discovery_test.go @@ -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) + } + } +} diff --git a/checker/rules_per_ns.go b/checker/rules_per_ns.go new file mode 100644 index 0000000..62c1d04 --- /dev/null +++ b/checker/rules_per_ns.go @@ -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) +} diff --git a/checker/types.go b/checker/types.go new file mode 100644 index 0000000..c4d7bdc --- /dev/null +++ b/checker/types.go @@ -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"` +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5d1e4bd --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2a80023 --- /dev/null +++ b/go.sum @@ -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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..81494a0 --- /dev/null +++ b/main.go @@ -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) + } +} diff --git a/plugin/plugin.go b/plugin/plugin.go new file mode 100644 index 0000000..1425d5a --- /dev/null +++ b/plugin/plugin.go @@ -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 +}