From 57148a56faefa65ed46c122c3691a7ca4a85cb9c Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 8 Apr 2026 04:18:58 +0700 Subject: [PATCH 1/3] Initial commit --- .gitignore | 2 + Dockerfile | 14 ++ LICENSE | 21 ++ Makefile | 25 ++ checker/collect.go | 517 ++++++++++++++++++++++++++++++++++++++++++ checker/definition.go | 104 +++++++++ checker/dns.go | 317 ++++++++++++++++++++++++++ checker/evaluate.go | 74 ++++++ checker/provider.go | 43 ++++ checker/rule.go | 67 ++++++ checker/types.go | 110 +++++++++ go.mod | 16 ++ go.sum | 16 ++ main.go | 49 ++++ plugin/plugin.go | 43 ++++ 15 files changed, 1418 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 checker/collect.go create mode 100644 checker/definition.go create mode 100644 checker/dns.go create mode 100644 checker/evaluate.go create mode 100644 checker/provider.go create mode 100644 checker/rule.go create mode 100644 checker/types.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 plugin/plugin.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d214794 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +checker-delegation +*.so diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..64f0039 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +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 -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-delegation . + +FROM scratch +COPY --from=builder /checker-delegation /checker-delegation +EXPOSE 8080 +ENTRYPOINT ["/checker-delegation"] 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..3b94d28 --- /dev/null +++ b/Makefile @@ -0,0 +1,25 @@ +CHECKER_NAME := checker-delegation +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 clean + +all: $(CHECKER_NAME) + +$(CHECKER_NAME): $(CHECKER_SOURCES) + go build -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) . + +clean: + rm -f $(CHECKER_NAME) $(CHECKER_NAME).so diff --git a/checker/collect.go b/checker/collect.go new file mode 100644 index 0000000..7d25897 --- /dev/null +++ b/checker/collect.go @@ -0,0 +1,517 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + "github.com/miekg/dns" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Collect runs the delegation testsuite and returns a *DelegationData +// populated with findings. +// +// The collector resolves the parent zone's authoritative servers, asks each +// of them for the delegation of the target FQDN, then turns around and +// queries every delegated server using ONLY the NS names + glue learned +// from the parent. The child zone is never used as a source of truth. +func (p *delegationProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) { + svc, err := loadService(opts) + if err != nil { + return nil, err + } + + parentZone, subdomain := loadNames(opts) + if subdomain == "" { + return nil, fmt.Errorf("missing 'subdomain' option") + } + if parentZone == "" { + return nil, fmt.Errorf("missing 'domain_name' option") + } + + delegatedFQDN := dns.Fqdn(strings.TrimSuffix(subdomain, ".") + "." + strings.TrimSuffix(parentZone, ".") + ".") + + data := &DelegationData{ + DelegatedFQDN: delegatedFQDN, + ParentZone: dns.Fqdn(parentZone), + } + + requireDS := sdk.GetBoolOption(opts, "requireDS", false) + requireTCP := sdk.GetBoolOption(opts, "requireTCP", true) + minNS := sdk.GetIntOption(opts, "minNameServers", 2) + allowGlueMismatch := sdk.GetBoolOption(opts, "allowGlueMismatch", false) + + // Declared NS / DS from the service. + declaredNS := normalizeNSList(svc.NameServers) + if len(declaredNS) < minNS { + data.Findings = append(data.Findings, DelegationFinding{ + Code: "delegation_too_few_ns", + Severity: SeverityWarn, + Message: fmt.Sprintf("only %d name server(s) declared, RFC 1034 recommends at least %d", len(declaredNS), minNS), + }) + } + + // Resolve parent's authoritative servers. + _, parentServers, err := findParentZone(ctx, delegatedFQDN, parentZone) + if err != nil { + data.Findings = append(data.Findings, DelegationFinding{ + Code: "delegation_no_parent_ns", + Severity: SeverityCrit, + Message: err.Error(), + }) + return data, nil + } + data.ParentNS = parentServers + + // Phase A: query every parent server. + type parentView struct { + server string + ns []string + glue map[string][]string + ds []*dns.DS + } + var views []parentView + + for _, ps := range parentServers { + ns, glue, _, qerr := queryDelegation(ctx, ps, delegatedFQDN) + if qerr != nil { + data.Findings = append(data.Findings, DelegationFinding{ + Code: "delegation_parent_query_failed", + Severity: SeverityCrit, + Message: fmt.Sprintf("parent NS query failed: %v", qerr), + Server: ps, + }) + continue + } + if len(ns) == 0 { + data.Findings = append(data.Findings, DelegationFinding{ + Code: "delegation_no_parent_ns", + Severity: SeverityCrit, + Message: "parent returned an empty NS RRset", + Server: ps, + }) + continue + } + + // TCP reachability of the parent for the same query. + if _, _, _, terr := queryDelegationTCP(ctx, ps, delegatedFQDN); terr != nil { + sev := SeverityCrit + if !requireTCP { + sev = SeverityWarn + } + data.Findings = append(data.Findings, DelegationFinding{ + Code: "delegation_parent_tcp_failed", + Severity: sev, + Message: fmt.Sprintf("parent NS query over TCP failed: %v", terr), + Server: ps, + }) + } + + // Compare NS to the declared list. + missing, extra := diffStringSets(declaredNS, ns) + if len(missing) > 0 || len(extra) > 0 { + data.Findings = append(data.Findings, DelegationFinding{ + Code: "delegation_ns_mismatch", + Severity: SeverityCrit, + Message: fmt.Sprintf("NS RRset at parent does not match declared service: missing=%v extra=%v", missing, extra), + Server: ps, + }) + } + + // Glue sanity: in-bailiwick NS must have glue, out-of-bailiwick NS must not. + for _, n := range ns { + inBailiwick := strings.HasSuffix(n, "."+delegatedFQDN) || strings.HasSuffix(n, delegatedFQDN) + if inBailiwick { + if len(glue[n]) == 0 { + data.Findings = append(data.Findings, DelegationFinding{ + Code: "delegation_missing_glue", + Severity: SeverityCrit, + Message: fmt.Sprintf("in-bailiwick NS %s has no glue", n), + Server: ps, + }) + } + } else { + if len(glue[n]) > 0 { + data.Findings = append(data.Findings, DelegationFinding{ + Code: "delegation_unnecessary_glue", + Severity: SeverityWarn, + Message: fmt.Sprintf("out-of-bailiwick NS %s has glue records, which the parent should not return", n), + Server: ps, + }) + } + } + } + + // DS at parent. + ds, sigs, dserr := queryDS(ctx, ps, delegatedFQDN) + if dserr != nil { + data.Findings = append(data.Findings, DelegationFinding{ + Code: "delegation_ds_query_failed", + Severity: SeverityWarn, + Message: fmt.Sprintf("DS query failed: %v", dserr), + Server: ps, + }) + } else { + // Compare DS with declared service DS. + declaredDS := svc.DS + if len(declaredDS) > 0 || len(ds) > 0 { + dsMissing, dsExtra := diffDS(declaredDS, ds) + if len(dsMissing) > 0 || len(dsExtra) > 0 { + sev := SeverityCrit + if len(declaredDS) == 0 { + // Service does not declare any DS but parent has some — warn only. + sev = SeverityWarn + } + data.Findings = append(data.Findings, DelegationFinding{ + Code: "delegation_ds_mismatch", + Severity: sev, + Message: fmt.Sprintf("DS RRset at parent does not match declared service: missing=%d extra=%d", len(dsMissing), len(dsExtra)), + Server: ps, + }) + } + } + + if len(declaredDS) > 0 && len(ds) == 0 { + sev := SeverityInfo + if requireDS { + sev = SeverityCrit + } + data.Findings = append(data.Findings, DelegationFinding{ + Code: "delegation_ds_missing", + Severity: sev, + Message: "service declares DS records but parent serves none", + Server: ps, + }) + } + + // Validate DS RRSIG validity period if a signature is present. + for _, sig := range sigs { + if !sig.ValidityPeriod(time.Now()) { + data.Findings = append(data.Findings, DelegationFinding{ + Code: "delegation_ds_rrsig_invalid", + Severity: SeverityCrit, + Message: fmt.Sprintf("DS RRSIG: %s", validityWindow(sig)), + Server: ps, + }) + } + } + + if len(ds) > 0 { + dsTexts := make([]string, len(ds)) + for i, d := range ds { + dsTexts[i] = d.String() + } + data.ParentDS = dsTexts + } + } + + views = append(views, parentView{server: ps, ns: ns, glue: glue, ds: ds}) + } + + if len(views) == 0 { + // All parent servers failed; no point in continuing. + return data, nil + } + + // Pick the first successful parent view as the source of truth for + // Phase B. We rely on the per-parent NS_mismatch findings already + // emitted above to flag inconsistencies between parents. + parent := views[0] + data.AdvertisedNS = parent.ns + data.AdvertisedGlue = parent.glue + + // Phase B: query each child name server using only parent-supplied data. + data.ChildSerials = map[string]uint32{} + for _, nsName := range parent.ns { + addrs := parent.glue[nsName] + if len(addrs) == 0 { + // Out-of-bailiwick: resolve via the system resolver. + resolved, rerr := resolveHost(ctx, nsName) + if rerr != nil { + data.Findings = append(data.Findings, DelegationFinding{ + Code: "delegation_ns_unresolvable", + Severity: SeverityCrit, + Message: fmt.Sprintf("cannot resolve NS %s: %v", nsName, rerr), + Server: nsName, + }) + continue + } + addrs = resolved + } + + var lastSerial uint32 + var sawAA bool + for _, addr := range addrs { + srv := hostPort(addr, "53") + + // UDP reachability + AA check. + soa, aa, qerr := querySOA(ctx, "", srv, delegatedFQDN) + if qerr != nil { + data.Findings = append(data.Findings, DelegationFinding{ + Code: "delegation_unreachable", + Severity: SeverityCrit, + Message: fmt.Sprintf("UDP SOA query failed at %s (%s): %v", nsName, addr, qerr), + Server: srv, + }) + continue + } + if !aa { + data.Findings = append(data.Findings, DelegationFinding{ + Code: "delegation_lame", + Severity: SeverityCrit, + Message: fmt.Sprintf("server %s (%s) is not authoritative for %s", nsName, addr, delegatedFQDN), + Server: srv, + }) + continue + } + sawAA = true + if soa != nil { + if lastSerial != 0 && lastSerial != soa.Serial { + data.Findings = append(data.Findings, DelegationFinding{ + Code: "delegation_soa_serial_drift", + Severity: SeverityWarn, + Message: fmt.Sprintf("SOA serial drift on %s: %d vs %d", nsName, lastSerial, soa.Serial), + Server: srv, + }) + } + lastSerial = soa.Serial + data.ChildSerials[srv] = soa.Serial + } + + // TCP reachability. + if _, _, terr := querySOA(ctx, "tcp", srv, delegatedFQDN); terr != nil { + sev := SeverityCrit + if !requireTCP { + sev = SeverityWarn + } + data.Findings = append(data.Findings, DelegationFinding{ + Code: "delegation_tcp_failed", + Severity: sev, + Message: fmt.Sprintf("TCP SOA query failed at %s (%s): %v", nsName, addr, terr), + Server: srv, + }) + } + + // NS RRset agreement with parent. + childNS, nerr := queryNSAt(ctx, srv, delegatedFQDN) + if nerr == nil { + missing, extra := diffStringSets(parent.ns, childNS) + if len(missing) > 0 || len(extra) > 0 { + data.Findings = append(data.Findings, DelegationFinding{ + Code: "delegation_ns_drift", + Severity: SeverityWarn, + Message: fmt.Sprintf("child NS RRset differs from parent: missing=%v extra=%v", missing, extra), + Server: srv, + }) + } + } + + // In-bailiwick glue agreement. + if isInBailiwick(nsName, delegatedFQDN) { + childAddrs, _ := queryAddrsAt(ctx, srv, nsName) + missing, _ := diffStringSets(parent.glue[nsName], childAddrs) + if len(missing) > 0 { + sev := SeverityCrit + if allowGlueMismatch { + sev = SeverityWarn + } + data.Findings = append(data.Findings, DelegationFinding{ + Code: "delegation_glue_mismatch", + Severity: sev, + Message: fmt.Sprintf("addresses served by child for %s differ from parent glue: missing=%v", nsName, missing), + Server: srv, + }) + } + } + + // DNSKEY hand-off, only if the parent has DS records. + if len(parent.ds) > 0 { + keys, kerr := queryDNSKEY(ctx, srv, delegatedFQDN) + if kerr != nil { + data.Findings = append(data.Findings, DelegationFinding{ + Code: "delegation_dnskey_query_failed", + Severity: SeverityWarn, + Message: fmt.Sprintf("DNSKEY query failed at %s: %v", nsName, kerr), + Server: srv, + }) + } else if !dsMatchesAnyKey(parent.ds, keys) { + data.Findings = append(data.Findings, DelegationFinding{ + Code: "delegation_dnskey_no_match", + Severity: SeverityCrit, + Message: fmt.Sprintf("none of the DNSKEY records served by %s match the DS published by the parent", nsName), + Server: srv, + }) + } + } + } + if !sawAA && len(addrs) > 0 { + // At least record we tried. + data.Findings = append(data.Findings, DelegationFinding{ + Code: "delegation_no_authoritative_answer", + Severity: SeverityCrit, + Message: fmt.Sprintf("no authoritative answer obtained from any address of %s", nsName), + Server: nsName, + }) + } + } + + return data, nil +} + +// queryDelegationTCP is the TCP variant of queryDelegation. It is split out +// so the per-server findings keep their UDP/TCP roles distinct. +func queryDelegationTCP(ctx context.Context, parentServer, fqdn string) (ns []string, glue map[string][]string, msg *dns.Msg, err error) { + q := dns.Question{Name: dns.Fqdn(fqdn), Qtype: dns.TypeNS, Qclass: dns.ClassINET} + msg, err = dnsExchange(ctx, "tcp", parentServer, q, true) + if err != nil { + return nil, nil, nil, err + } + if msg.Rcode != dns.RcodeSuccess { + return nil, nil, msg, fmt.Errorf("parent answered %s", dns.RcodeToString[msg.Rcode]) + } + return +} + +// loadService extracts the abstract.Delegation payload from the auto-filled +// "service" option. We parse it into our local minimal type so this checker +// does not have to import the full happyDomain server module. +func loadService(opts sdk.CheckerOptions) (*delegationService, error) { + svc, ok := sdk.GetOption[serviceMessage](opts, "service") + if !ok { + return nil, fmt.Errorf("missing 'service' option") + } + if svc.Type != "" && svc.Type != "abstract.Delegation" { + return nil, fmt.Errorf("service is %s, expected abstract.Delegation", svc.Type) + } + var d delegationService + if err := json.Unmarshal(svc.Service, &d); err != nil { + return nil, fmt.Errorf("decoding delegation service: %w", err) + } + return &d, nil +} + +func loadNames(opts sdk.CheckerOptions) (parentZone, subdomain string) { + if v, ok := sdk.GetOption[string](opts, "domain_name"); ok { + parentZone = v + } + if v, ok := sdk.GetOption[string](opts, "subdomain"); ok { + subdomain = v + } + return +} + +// normalizeNSList lowercases and FQDN-normalizes a list of NS records. +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 +} + +// diffStringSets returns the elements of "want" missing from "got" and the +// elements of "got" not present in "want". +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 +} + +// diffDS returns the DS records present in "want" but missing from "got" +// and vice-versa. +func diffDS(want, got []*dns.DS) (missing, extra []*dns.DS) { + for _, w := range want { + found := false + for _, g := range got { + if dsEqual(w, g) { + found = true + break + } + } + if !found { + missing = append(missing, w) + } + } + for _, g := range got { + found := false + for _, w := range want { + if dsEqual(w, g) { + found = true + break + } + } + if !found { + extra = append(extra, g) + } + } + return +} + +// isInBailiwick reports whether host sits inside zone. +func isInBailiwick(host, zone string) bool { + host = strings.ToLower(dns.Fqdn(host)) + zone = strings.ToLower(dns.Fqdn(zone)) + return host == zone || strings.HasSuffix(host, "."+zone) +} + +// dsMatchesAnyKey reports whether at least one of the DNSKEY records hashes +// to one of the DS records. +func dsMatchesAnyKey(ds []*dns.DS, keys []*dns.DNSKEY) bool { + for _, k := range keys { + for _, d := range ds { + expected := k.ToDS(d.DigestType) + if expected != nil && dsEqual(expected, d) { + return true + } + } + } + return false +} diff --git a/checker/definition.go b/checker/definition.go new file mode 100644 index 0000000..e400932 --- /dev/null +++ b/checker/definition.go @@ -0,0 +1,104 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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" +) + +// Version is the checker version reported in CheckerDefinition.Version. +var Version = "built-in" + +// Definition returns the CheckerDefinition for the delegation checker. +func Definition() *sdk.CheckerDefinition { + return &sdk.CheckerDefinition{ + ID: "delegation", + Name: "DNS delegation", + Version: Version, + Availability: sdk.CheckerAvailability{ + ApplyToService: true, + LimitToServices: []string{"abstract.Delegation"}, + }, + ObservationKeys: []sdk.ObservationKey{ObservationKeyDelegation}, + Options: sdk.CheckerOptionsDocumentation{ + UserOpts: []sdk.CheckerOptionDocumentation{ + { + Id: "requireDS", + Type: "bool", + Label: "Require DS at parent", + Description: "When enabled, missing DS records at the parent are treated as a critical issue (otherwise informational).", + Default: false, + }, + { + Id: "requireTCP", + Type: "bool", + Label: "Require DNS over TCP", + Description: "When enabled, name servers that fail to answer over TCP are reported as critical (otherwise as warning).", + Default: true, + }, + { + Id: "minNameServers", + Type: "uint", + Label: "Minimum number of name servers", + Description: "Below this count, the delegation is reported as a warning (RFC 1034 recommends at least 2).", + Default: float64(2), + }, + { + Id: "allowGlueMismatch", + Type: "bool", + Label: "Allow glue mismatches", + Description: "When disabled, glue/address mismatches between parent and child are reported as critical.", + Default: false, + }, + }, + DomainOpts: []sdk.CheckerOptionDocumentation{ + { + Id: "domain_name", + Label: "Parent domain name", + AutoFill: sdk.AutoFillDomainName, + }, + { + Id: "subdomain", + Label: "Subdomain", + AutoFill: sdk.AutoFillSubdomain, + }, + }, + ServiceOpts: []sdk.CheckerOptionDocumentation{ + { + Id: "service", + Label: "Service", + AutoFill: sdk.AutoFillService, + }, + }, + }, + Rules: []sdk.CheckRule{ + Rule(), + }, + Interval: &sdk.CheckIntervalSpec{ + Min: 5 * time.Minute, + Max: 24 * time.Hour, + Default: 1 * time.Hour, + }, + } +} diff --git a/checker/dns.go b/checker/dns.go new file mode 100644 index 0000000..9b68043 --- /dev/null +++ b/checker/dns.go @@ -0,0 +1,317 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 ( + "context" + "fmt" + "net" + "strings" + "time" + + "github.com/miekg/dns" +) + +// year68 mirrors the constant from miekg/dns used to wrap RRSIG validity +// periods around 2^32 seconds (≈68 years), as in the adlin checker. +const year68 = int64(1 << 31) + +// dnsTimeout is the per-query deadline used by every helper here. +const dnsTimeout = 5 * time.Second + +// dnsExchange sends a single query to the given server using the requested +// transport ("" for UDP, "tcp"). The server address must already include a +// port. RecursionDesired 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, 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) + } + + deadline, ok := ctx.Deadline() + if ok { + if d := time.Until(deadline); d > 0 && d < client.Timeout { + client.Timeout = d + } + } + + r, _, err := client.Exchange(m, server) + if err != nil { + return nil, err + } + if r == nil { + return nil, fmt.Errorf("nil response from %s", server) + } + return r, nil +} + +// hostPort returns "host:port", correctly bracketing IPv6 literals. +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 +} + +// resolveHost resolves an NS hostname to its A and AAAA addresses using the +// system resolver. It is used as a fallback when no glue is provided by the +// parent for an out-of-bailiwick NS. +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 +} + +// findParentZone walks up the labels of fqdn until it finds the closest +// enclosing zone (the one that has its own SOA), and returns the FQDN of +// that zone along with its authoritative server addresses (resolved from +// its NS RRset). The walk stops as soon as a SOA query at the system +// resolver returns NOERROR with an answer. +// +// If hintParent is non-empty, it is used as the assumed parent and we only +// resolve its NS — this matches happyDomain's data model where the parent +// zone is known. +func findParentZone(ctx context.Context, fqdn, hintParent string) (zone string, servers []string, err error) { + zone = dns.Fqdn(hintParent) + if zone == "" || zone == "." { + // Walk up. + labels := dns.SplitDomainName(fqdn) + if len(labels) == 0 { + return "", nil, fmt.Errorf("cannot derive parent of %q", fqdn) + } + zone = dns.Fqdn(strings.Join(labels[1:], ".")) + } + + servers, err = resolveZoneNSAddrs(ctx, zone) + if err != nil { + return "", nil, fmt.Errorf("resolving NS of parent zone %q: %w", zone, err) + } + if len(servers) == 0 { + return "", nil, fmt.Errorf("parent zone %q has no resolvable NS", zone) + } + return zone, servers, nil +} + +// resolveZoneNSAddrs returns the list of "host:53" entries for every NS of +// the given zone, as seen by the system resolver. It is used to discover the +// parent's authoritative servers. +func resolveZoneNSAddrs(ctx context.Context, zone string) ([]string, error) { + var resolver net.Resolver + nss, err := resolver.LookupNS(ctx, strings.TrimSuffix(zone, ".")) + if err != nil { + return nil, err + } + + var out []string + for _, ns := range nss { + addrs, err := resolver.LookupHost(ctx, strings.TrimSuffix(ns.Host, ".")) + if err != nil || len(addrs) == 0 { + continue + } + for _, a := range addrs { + out = append(out, hostPort(a, "53")) + } + } + return out, nil +} + +// queryDelegation queries the given parent server for the NS RRset of fqdn +// and extracts the advertised NS names plus any glue records found in the +// Additional section. The query is sent without RD; the response is the +// classical "referral" packet. +func queryDelegation(ctx context.Context, parentServer, fqdn string) (ns []string, glue map[string][]string, msg *dns.Msg, err error) { + q := dns.Question{Name: dns.Fqdn(fqdn), Qtype: dns.TypeNS, Qclass: dns.ClassINET} + + msg, err = dnsExchange(ctx, "", parentServer, q, true) + if err != nil { + return nil, nil, nil, err + } + if msg.Rcode != dns.RcodeSuccess { + return nil, nil, msg, fmt.Errorf("parent answered %s", dns.RcodeToString[msg.Rcode]) + } + + glue = map[string][]string{} + + collect := func(records []dns.RR) { + for _, rr := range records { + switch t := rr.(type) { + case *dns.NS: + if strings.EqualFold(strings.TrimSuffix(t.Header().Name, "."), strings.TrimSuffix(fqdn, ".")) { + ns = append(ns, strings.ToLower(dns.Fqdn(t.Ns))) + } + case *dns.A: + name := strings.ToLower(dns.Fqdn(t.Header().Name)) + glue[name] = append(glue[name], t.A.String()) + case *dns.AAAA: + name := strings.ToLower(dns.Fqdn(t.Header().Name)) + glue[name] = append(glue[name], t.AAAA.String()) + } + } + } + collect(msg.Answer) + collect(msg.Ns) + collect(msg.Extra) + return +} + +// queryDS asks the parent server for the DS RRset of fqdn and returns the +// DS records plus any RRSIGs found in the same section. +func queryDS(ctx context.Context, parentServer, fqdn string) (ds []*dns.DS, sigs []*dns.RRSIG, err error) { + q := dns.Question{Name: dns.Fqdn(fqdn), Qtype: dns.TypeDS, Qclass: dns.ClassINET} + + r, err := dnsExchange(ctx, "tcp", parentServer, q, true) + if err != nil { + return nil, nil, err + } + if r.Rcode != dns.RcodeSuccess { + return nil, nil, fmt.Errorf("parent answered %s for DS", dns.RcodeToString[r.Rcode]) + } + + for _, rr := range r.Answer { + switch t := rr.(type) { + case *dns.DS: + ds = append(ds, t) + case *dns.RRSIG: + sigs = append(sigs, t) + } + } + return +} + +// querySOA asks the given authoritative server for the SOA of fqdn and +// returns the SOA record plus the AA flag from the response header. +func querySOA(ctx context.Context, proto, server, fqdn string) (soa *dns.SOA, aa bool, err error) { + q := dns.Question{Name: dns.Fqdn(fqdn), Qtype: dns.TypeSOA, Qclass: dns.ClassINET} + r, err := dnsExchange(ctx, proto, server, q, false) + if err != nil { + return nil, false, err + } + if r.Rcode != dns.RcodeSuccess { + return nil, r.Authoritative, 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, nil + } + } + return nil, r.Authoritative, fmt.Errorf("no SOA in answer section") +} + +// queryNSAt asks the given authoritative server for the NS RRset of fqdn. +func queryNSAt(ctx context.Context, server, fqdn string) ([]string, error) { + q := dns.Question{Name: dns.Fqdn(fqdn), 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 +} + +// queryAddrsAt asks an authoritative server for the A and AAAA records of +// host (typically an in-bailiwick NS hostname). +func queryAddrsAt(ctx context.Context, server, host string) ([]string, error) { + var out []string + for _, qt := range []uint16{dns.TypeA, dns.TypeAAAA} { + r, err := dnsExchange(ctx, "", server, dns.Question{Name: dns.Fqdn(host), Qtype: qt, Qclass: dns.ClassINET}, false) + if err != nil { + continue + } + if r.Rcode != dns.RcodeSuccess { + continue + } + for _, rr := range r.Answer { + switch t := rr.(type) { + case *dns.A: + out = append(out, t.A.String()) + case *dns.AAAA: + out = append(out, t.AAAA.String()) + } + } + } + return out, nil +} + +// queryDNSKEY asks the given child server for the DNSKEY RRset of fqdn. +func queryDNSKEY(ctx context.Context, server, fqdn string) ([]*dns.DNSKEY, error) { + q := dns.Question{Name: dns.Fqdn(fqdn), Qtype: dns.TypeDNSKEY, Qclass: dns.ClassINET} + r, err := dnsExchange(ctx, "tcp", server, q, true) + if err != nil { + return nil, err + } + if r.Rcode != dns.RcodeSuccess { + return nil, fmt.Errorf("server answered %s for DNSKEY", dns.RcodeToString[r.Rcode]) + } + var out []*dns.DNSKEY + for _, rr := range r.Answer { + if t, ok := rr.(*dns.DNSKEY); ok { + out = append(out, t) + } + } + return out, nil +} + +// dsEqual returns true when two DS records refer to the same key material. +func dsEqual(a, b *dns.DS) bool { + return a.KeyTag == b.KeyTag && + a.Algorithm == b.Algorithm && + a.DigestType == b.DigestType && + strings.EqualFold(a.Digest, b.Digest) +} + +// validityWindow returns a human-readable explanation of why a signature is +// outside its validity period, mirroring the year68 logic from the adlin +// checker. +func validityWindow(sig *dns.RRSIG) string { + utc := time.Now().UTC().Unix() + + modi := (int64(sig.Inception) - utc) / year68 + ti := int64(sig.Inception) + modi*year68 + + mode := (int64(sig.Expiration) - utc) / year68 + te := int64(sig.Expiration) + mode*year68 + + if ti > utc { + return "signature not yet valid" + } else if utc > te { + return "signature expired" + } + return "signature outside its validity window" +} diff --git a/checker/evaluate.go b/checker/evaluate.go new file mode 100644 index 0000000..761d841 --- /dev/null +++ b/checker/evaluate.go @@ -0,0 +1,74 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 ( + "fmt" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Evaluate folds findings into a single CheckState. The status is the +// highest severity observed: any Crit makes the whole result Crit, any Warn +// makes it Warn, otherwise OK. +func Evaluate(data *DelegationData) sdk.CheckState { + status := sdk.StatusOK + var crit, warn, info int + for _, f := range data.Findings { + switch f.Severity { + case SeverityCrit: + crit++ + status = sdk.StatusCrit + case SeverityWarn: + warn++ + if status != sdk.StatusCrit { + status = sdk.StatusWarn + } + case SeverityInfo: + info++ + if status == sdk.StatusOK { + status = sdk.StatusInfo + } + } + } + + var msg string + if len(data.Findings) == 0 { + msg = fmt.Sprintf("Delegation of %s is healthy", data.DelegatedFQDN) + } else { + msg = fmt.Sprintf("Delegation of %s: %d critical, %d warning, %d info", data.DelegatedFQDN, crit, warn, info) + } + + return sdk.CheckState{ + Status: status, + Message: msg, + Code: "delegation_result", + Meta: map[string]any{ + "findings": data.Findings, + "delegated_fqdn": data.DelegatedFQDN, + "parent_zone": data.ParentZone, + "advertised_ns": data.AdvertisedNS, + "parent_ds": data.ParentDS, + "child_serials": data.ChildSerials, + }, + } +} diff --git a/checker/provider.go b/checker/provider.go new file mode 100644 index 0000000..c6a6e9c --- /dev/null +++ b/checker/provider.go @@ -0,0 +1,43 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 ( + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Provider returns a new delegation observation provider. +func Provider() sdk.ObservationProvider { + return &delegationProvider{} +} + +type delegationProvider struct{} + +func (p *delegationProvider) Key() sdk.ObservationKey { + return ObservationKeyDelegation +} + +// Definition implements sdk.CheckerDefinitionProvider so the SDK server can +// expose /definition without an extra argument. +func (p *delegationProvider) Definition() *sdk.CheckerDefinition { + return Definition() +} diff --git a/checker/rule.go b/checker/rule.go new file mode 100644 index 0000000..4200d09 --- /dev/null +++ b/checker/rule.go @@ -0,0 +1,67 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 ( + "context" + "fmt" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Rule returns the delegation check rule. +func Rule() sdk.CheckRule { + return &delegationRule{} +} + +type delegationRule struct{} + +func (r *delegationRule) Name() string { return "delegation_check" } + +func (r *delegationRule) Description() string { + return "Verifies a DNS delegation against its parent zone and the delegated name servers" +} + +func (r *delegationRule) ValidateOptions(opts sdk.CheckerOptions) error { + if v, ok := opts["minNameServers"]; ok { + f, ok := v.(float64) + if !ok { + return fmt.Errorf("minNameServers must be a number") + } + if f < 1 { + return fmt.Errorf("minNameServers must be >= 1") + } + } + return nil +} + +func (r *delegationRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) sdk.CheckState { + var data DelegationData + if err := obs.Get(ctx, ObservationKeyDelegation, &data); err != nil { + return sdk.CheckState{ + Status: sdk.StatusError, + Message: fmt.Sprintf("Failed to get delegation data: %v", err), + Code: "delegation_error", + } + } + return Evaluate(&data) +} diff --git a/checker/types.go b/checker/types.go new file mode 100644 index 0000000..d164212 --- /dev/null +++ b/checker/types.go @@ -0,0 +1,110 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 ( + "encoding/json" + + "github.com/miekg/dns" +) + +// ObservationKeyDelegation is the observation key for delegation data. +const ObservationKeyDelegation = "delegation" + +// Severity classifies a finding emitted by the delegation checker. +type Severity string + +const ( + SeverityInfo Severity = "info" + SeverityWarn Severity = "warn" + SeverityCrit Severity = "crit" +) + +// DelegationFinding describes a single observation produced while running +// the delegation testsuite. +type DelegationFinding struct { + // Code is a stable machine-readable identifier (e.g. "delegation_ns_mismatch"). + Code string `json:"code"` + + // Severity grades the finding. + Severity Severity `json:"severity"` + + // Message is a human-readable explanation. + Message string `json:"message"` + + // Server is the DNS server that exhibited the finding (parent or child), + // when applicable. Empty for findings tied to the service definition itself. + Server string `json:"server,omitempty"` +} + +// DelegationData is the observation payload stored by the checker. It carries +// every finding emitted by the testsuite plus the raw observed state from the +// parent and from each delegated server. +type DelegationData struct { + // DelegatedFQDN is the FQDN of the delegated zone (subdomain + parent). + DelegatedFQDN string `json:"delegated_fqdn"` + + // ParentZone is the FQDN of the parent zone that delegates DelegatedFQDN. + ParentZone string `json:"parent_zone"` + + // ParentNS lists the parent zone's authoritative servers that were + // queried (FQDNs of NS records). + ParentNS []string `json:"parent_ns,omitempty"` + + // AdvertisedNS holds the NS RRset returned by the parent for the + // delegated FQDN, normalized as lowercase FQDNs. + AdvertisedNS []string `json:"advertised_ns,omitempty"` + + // AdvertisedGlue maps an in-bailiwick NS hostname to the glue addresses + // returned by the parent for that name. + AdvertisedGlue map[string][]string `json:"advertised_glue,omitempty"` + + // ParentDS lists the DS records returned by the parent for the + // delegated FQDN, in their textual presentation form. + ParentDS []string `json:"parent_ds,omitempty"` + + // ChildSerials maps an NS hostname to the SOA serial it returns for + // the delegated FQDN. + ChildSerials map[string]uint32 `json:"child_serials,omitempty"` + + // Findings is the list of issues / observations produced by the run. + Findings []DelegationFinding `json:"findings"` +} + +// delegationService is the minimal local mirror of happyDomain's +// `services/abstract.Delegation` type. It is duplicated on purpose so that +// this checker does not have to import the (heavy) happyDomain server module +// just to decode the service payload. github.com/miekg/dns marshals +// dns.NS / dns.DS to JSON in the same shape happyDomain uses. +type delegationService struct { + NameServers []*dns.NS `json:"ns"` + DS []*dns.DS `json:"ds"` +} + +// serviceMessage is the minimal local mirror of happyDomain's ServiceMessage +// envelope. We only need the embedded service JSON; the rest of the meta +// fields are ignored. +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..ebea955 --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module git.happydns.org/checker-delegation + +go 1.25.0 + +require ( + git.happydns.org/checker-sdk-go v0.0.1 + 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..eab57b3 --- /dev/null +++ b/go.sum @@ -0,0 +1,16 @@ +git.happydns.org/checker-sdk-go v0.0.1 h1:4RxCJr73HWKxjOyU/6NJMO8lXJmH0gMLA68EzTqLbQI= +git.happydns.org/checker-sdk-go v0.0.1/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..492a553 --- /dev/null +++ b/main.go @@ -0,0 +1,49 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 main + +import ( + "flag" + "log" + + delegation "git.happydns.org/checker-delegation/checker" + sdk "git.happydns.org/checker-sdk-go/checker" +) + +var listenAddr = flag.String("listen", ":8080", "HTTP listen address") + +// 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" + +func main() { + flag.Parse() + + delegation.Version = Version + + server := sdk.NewServer(delegation.Provider()) + if err := server.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..5fde216 --- /dev/null +++ b/plugin/plugin.go @@ -0,0 +1,43 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 . + +// Command plugin is the happyDomain plugin entrypoint for the delegation +// checker. It is built as a Go plugin (`go build -buildmode=plugin`) and +// loaded at runtime by happyDomain. +package main + +import ( + delegation "git.happydns.org/checker-delegation/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-delegation.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) { + delegation.Version = Version + return delegation.Definition(), delegation.Provider(), nil +} From 00de0f9780d97781075c823acf2d2ca979eea596 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 8 Apr 2026 04:18:58 +0700 Subject: [PATCH 2/3] Initial commit --- .gitignore | 2 + Dockerfile | 14 ++ LICENSE | 21 ++ Makefile | 25 +++ README.md | 122 +++++++++++ checker/collect.go | 496 ++++++++++++++++++++++++++++++++++++++++++ checker/definition.go | 83 +++++++ checker/dns.go | 296 +++++++++++++++++++++++++ checker/evaluate.go | 53 +++++ checker/provider.go | 22 ++ checker/rule.go | 46 ++++ checker/types.go | 89 ++++++++ go.mod | 16 ++ go.sum | 16 ++ main.go | 28 +++ plugin/plugin.go | 22 ++ 16 files changed, 1351 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 checker/collect.go create mode 100644 checker/definition.go create mode 100644 checker/dns.go create mode 100644 checker/evaluate.go create mode 100644 checker/provider.go create mode 100644 checker/rule.go create mode 100644 checker/types.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 plugin/plugin.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d214794 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +checker-delegation +*.so diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..64f0039 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +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 -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-delegation . + +FROM scratch +COPY --from=builder /checker-delegation /checker-delegation +EXPOSE 8080 +ENTRYPOINT ["/checker-delegation"] 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..3b94d28 --- /dev/null +++ b/Makefile @@ -0,0 +1,25 @@ +CHECKER_NAME := checker-delegation +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 clean + +all: $(CHECKER_NAME) + +$(CHECKER_NAME): $(CHECKER_SOURCES) + go build -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) . + +clean: + rm -f $(CHECKER_NAME) $(CHECKER_NAME).so diff --git a/README.md b/README.md new file mode 100644 index 0000000..baa57dc --- /dev/null +++ b/README.md @@ -0,0 +1,122 @@ +# checker-delegation + +DNS delegation checker for [happyDomain](https://www.happydomain.org/). + +Audits the delegation of a zone: NS consistency between parent and child, +glue correctness, DS / DNSKEY hand-off, TCP reachability, SOA serial drift, +and authoritativeness of each delegated server. Applies to services of type +`abstract.Delegation`. + +## Usage + +### Standalone HTTP server + +```bash +# Build and run +make +./checker-delegation -listen :8080 +``` + +The server exposes: + +- `GET /health`, health check +- `POST /collect`, collect delegation observations (happyDomain external checker protocol) + +### Docker + +```bash +make docker +docker run -p 8080:8080 happydomain/checker-delegation +``` + +### happyDomain plugin + +```bash +make plugin +# produces checker-delegation.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 delegation checker to the URL of +the running checker-delegation server (e.g., +`http://checker-delegation:8080`). happyDomain will delegate observation +collection to this endpoint. + +## Options + +| Option | Type | Default | Description | +|---------------------|------|---------|---------------------------------------------------------------------------------------------------| +| `requireDS` | bool | `false` | When enabled, missing DS records at the parent are treated as critical (otherwise informational). | +| `requireTCP` | bool | `true` | When enabled, name servers that fail to answer over TCP are reported as critical (otherwise warning). | +| `minNameServers` | uint | `2` | Below this count, the delegation is reported as a warning (RFC 1034 recommends at least 2). | +| `allowGlueMismatch` | bool | `false` | When disabled, glue/address mismatches between parent and child are reported as critical. | + +## Protocol + +### POST /collect + +Request: +```json +{ + "key": "delegation", + "target": {"userId": "...", "domainId": "..."}, + "options": { + "domain_name": "example.com.", + "subdomain": "www", + "service": { "_svctype": "abstract.Delegation", "Service": { "ns": [...], "ds": [...] } } + } +} +``` + +Response: +```json +{ + "data": { + "delegated_fqdn": "www.example.com.", + "parent_zone": "example.com.", + "parent_ns": ["a.iana-servers.net.", "b.iana-servers.net."], + "advertised_ns": ["ns1.example.net.", "ns2.example.net."], + "advertised_glue": {}, + "parent_ds": [], + "child_serials": {"ns1.example.net.:53": 2026042401}, + "findings": [ + { + "code": "delegation_ns_mismatch", + "severity": "crit", + "message": "NS RRset at parent does not match declared service: missing=[ns3.example.net] extra=[]", + "server": "a.iana-servers.net.:53" + } + ] + } +} +``` + +Findings carry a stable `code` (e.g. `delegation_lame`, +`delegation_missing_glue`, `delegation_ds_mismatch`, +`delegation_soa_serial_drift`, `delegation_dnskey_no_match`, …) so that +downstream rules can match on them deterministically. + +## License + +This project is licensed under the **MIT License** (see `LICENSE`), in +line with the rest of the happyDomain checker ecosystem. + +The third-party Apache-2.0 attributions for `checker-sdk-go` are recorded +in `NOTICE` and must accompany any binary or source redistribution of this +project. diff --git a/checker/collect.go b/checker/collect.go new file mode 100644 index 0000000..027dce0 --- /dev/null +++ b/checker/collect.go @@ -0,0 +1,496 @@ +package checker + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + "github.com/miekg/dns" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Collect runs the delegation testsuite and returns a *DelegationData +// populated with findings. +// +// The collector resolves the parent zone's authoritative servers, asks each +// of them for the delegation of the target FQDN, then turns around and +// queries every delegated server using ONLY the NS names + glue learned +// from the parent. The child zone is never used as a source of truth. +func (p *delegationProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) { + svc, err := loadService(opts) + if err != nil { + return nil, err + } + + parentZone, subdomain := loadNames(opts) + if subdomain == "" { + return nil, fmt.Errorf("missing 'subdomain' option") + } + if parentZone == "" { + return nil, fmt.Errorf("missing 'domain_name' option") + } + + delegatedFQDN := dns.Fqdn(strings.TrimSuffix(subdomain, ".") + "." + strings.TrimSuffix(parentZone, ".") + ".") + + data := &DelegationData{ + DelegatedFQDN: delegatedFQDN, + ParentZone: dns.Fqdn(parentZone), + } + + requireDS := sdk.GetBoolOption(opts, "requireDS", false) + requireTCP := sdk.GetBoolOption(opts, "requireTCP", true) + minNS := sdk.GetIntOption(opts, "minNameServers", 2) + allowGlueMismatch := sdk.GetBoolOption(opts, "allowGlueMismatch", false) + + // Declared NS / DS from the service. + declaredNS := normalizeNSList(svc.NameServers) + if len(declaredNS) < minNS { + data.Findings = append(data.Findings, DelegationFinding{ + Code: "delegation_too_few_ns", + Severity: SeverityWarn, + Message: fmt.Sprintf("only %d name server(s) declared, RFC 1034 recommends at least %d", len(declaredNS), minNS), + }) + } + + // Resolve parent's authoritative servers. + _, parentServers, err := findParentZone(ctx, delegatedFQDN, parentZone) + if err != nil { + data.Findings = append(data.Findings, DelegationFinding{ + Code: "delegation_no_parent_ns", + Severity: SeverityCrit, + Message: err.Error(), + }) + return data, nil + } + data.ParentNS = parentServers + + // Phase A: query every parent server. + type parentView struct { + server string + ns []string + glue map[string][]string + ds []*dns.DS + } + var views []parentView + + for _, ps := range parentServers { + ns, glue, _, qerr := queryDelegation(ctx, ps, delegatedFQDN) + if qerr != nil { + data.Findings = append(data.Findings, DelegationFinding{ + Code: "delegation_parent_query_failed", + Severity: SeverityCrit, + Message: fmt.Sprintf("parent NS query failed: %v", qerr), + Server: ps, + }) + continue + } + if len(ns) == 0 { + data.Findings = append(data.Findings, DelegationFinding{ + Code: "delegation_no_parent_ns", + Severity: SeverityCrit, + Message: "parent returned an empty NS RRset", + Server: ps, + }) + continue + } + + // TCP reachability of the parent for the same query. + if _, _, _, terr := queryDelegationTCP(ctx, ps, delegatedFQDN); terr != nil { + sev := SeverityCrit + if !requireTCP { + sev = SeverityWarn + } + data.Findings = append(data.Findings, DelegationFinding{ + Code: "delegation_parent_tcp_failed", + Severity: sev, + Message: fmt.Sprintf("parent NS query over TCP failed: %v", terr), + Server: ps, + }) + } + + // Compare NS to the declared list. + missing, extra := diffStringSets(declaredNS, ns) + if len(missing) > 0 || len(extra) > 0 { + data.Findings = append(data.Findings, DelegationFinding{ + Code: "delegation_ns_mismatch", + Severity: SeverityCrit, + Message: fmt.Sprintf("NS RRset at parent does not match declared service: missing=%v extra=%v", missing, extra), + Server: ps, + }) + } + + // Glue sanity: in-bailiwick NS must have glue, out-of-bailiwick NS must not. + for _, n := range ns { + inBailiwick := strings.HasSuffix(n, "."+delegatedFQDN) || strings.HasSuffix(n, delegatedFQDN) + if inBailiwick { + if len(glue[n]) == 0 { + data.Findings = append(data.Findings, DelegationFinding{ + Code: "delegation_missing_glue", + Severity: SeverityCrit, + Message: fmt.Sprintf("in-bailiwick NS %s has no glue", n), + Server: ps, + }) + } + } else { + if len(glue[n]) > 0 { + data.Findings = append(data.Findings, DelegationFinding{ + Code: "delegation_unnecessary_glue", + Severity: SeverityWarn, + Message: fmt.Sprintf("out-of-bailiwick NS %s has glue records, which the parent should not return", n), + Server: ps, + }) + } + } + } + + // DS at parent. + ds, sigs, dserr := queryDS(ctx, ps, delegatedFQDN) + if dserr != nil { + data.Findings = append(data.Findings, DelegationFinding{ + Code: "delegation_ds_query_failed", + Severity: SeverityWarn, + Message: fmt.Sprintf("DS query failed: %v", dserr), + Server: ps, + }) + } else { + // Compare DS with declared service DS. + declaredDS := svc.DS + if len(declaredDS) > 0 || len(ds) > 0 { + dsMissing, dsExtra := diffDS(declaredDS, ds) + if len(dsMissing) > 0 || len(dsExtra) > 0 { + sev := SeverityCrit + if len(declaredDS) == 0 { + // Service does not declare any DS but parent has some — warn only. + sev = SeverityWarn + } + data.Findings = append(data.Findings, DelegationFinding{ + Code: "delegation_ds_mismatch", + Severity: sev, + Message: fmt.Sprintf("DS RRset at parent does not match declared service: missing=%d extra=%d", len(dsMissing), len(dsExtra)), + Server: ps, + }) + } + } + + if len(declaredDS) > 0 && len(ds) == 0 { + sev := SeverityInfo + if requireDS { + sev = SeverityCrit + } + data.Findings = append(data.Findings, DelegationFinding{ + Code: "delegation_ds_missing", + Severity: sev, + Message: "service declares DS records but parent serves none", + Server: ps, + }) + } + + // Validate DS RRSIG validity period if a signature is present. + for _, sig := range sigs { + if !sig.ValidityPeriod(time.Now()) { + data.Findings = append(data.Findings, DelegationFinding{ + Code: "delegation_ds_rrsig_invalid", + Severity: SeverityCrit, + Message: fmt.Sprintf("DS RRSIG: %s", validityWindow(sig)), + Server: ps, + }) + } + } + + if len(ds) > 0 { + dsTexts := make([]string, len(ds)) + for i, d := range ds { + dsTexts[i] = d.String() + } + data.ParentDS = dsTexts + } + } + + views = append(views, parentView{server: ps, ns: ns, glue: glue, ds: ds}) + } + + if len(views) == 0 { + // All parent servers failed; no point in continuing. + return data, nil + } + + // Pick the first successful parent view as the source of truth for + // Phase B. We rely on the per-parent NS_mismatch findings already + // emitted above to flag inconsistencies between parents. + parent := views[0] + data.AdvertisedNS = parent.ns + data.AdvertisedGlue = parent.glue + + // Phase B: query each child name server using only parent-supplied data. + data.ChildSerials = map[string]uint32{} + for _, nsName := range parent.ns { + addrs := parent.glue[nsName] + if len(addrs) == 0 { + // Out-of-bailiwick: resolve via the system resolver. + resolved, rerr := resolveHost(ctx, nsName) + if rerr != nil { + data.Findings = append(data.Findings, DelegationFinding{ + Code: "delegation_ns_unresolvable", + Severity: SeverityCrit, + Message: fmt.Sprintf("cannot resolve NS %s: %v", nsName, rerr), + Server: nsName, + }) + continue + } + addrs = resolved + } + + var lastSerial uint32 + var sawAA bool + for _, addr := range addrs { + srv := hostPort(addr, "53") + + // UDP reachability + AA check. + soa, aa, qerr := querySOA(ctx, "", srv, delegatedFQDN) + if qerr != nil { + data.Findings = append(data.Findings, DelegationFinding{ + Code: "delegation_unreachable", + Severity: SeverityCrit, + Message: fmt.Sprintf("UDP SOA query failed at %s (%s): %v", nsName, addr, qerr), + Server: srv, + }) + continue + } + if !aa { + data.Findings = append(data.Findings, DelegationFinding{ + Code: "delegation_lame", + Severity: SeverityCrit, + Message: fmt.Sprintf("server %s (%s) is not authoritative for %s", nsName, addr, delegatedFQDN), + Server: srv, + }) + continue + } + sawAA = true + if soa != nil { + if lastSerial != 0 && lastSerial != soa.Serial { + data.Findings = append(data.Findings, DelegationFinding{ + Code: "delegation_soa_serial_drift", + Severity: SeverityWarn, + Message: fmt.Sprintf("SOA serial drift on %s: %d vs %d", nsName, lastSerial, soa.Serial), + Server: srv, + }) + } + lastSerial = soa.Serial + data.ChildSerials[srv] = soa.Serial + } + + // TCP reachability. + if _, _, terr := querySOA(ctx, "tcp", srv, delegatedFQDN); terr != nil { + sev := SeverityCrit + if !requireTCP { + sev = SeverityWarn + } + data.Findings = append(data.Findings, DelegationFinding{ + Code: "delegation_tcp_failed", + Severity: sev, + Message: fmt.Sprintf("TCP SOA query failed at %s (%s): %v", nsName, addr, terr), + Server: srv, + }) + } + + // NS RRset agreement with parent. + childNS, nerr := queryNSAt(ctx, srv, delegatedFQDN) + if nerr == nil { + missing, extra := diffStringSets(parent.ns, childNS) + if len(missing) > 0 || len(extra) > 0 { + data.Findings = append(data.Findings, DelegationFinding{ + Code: "delegation_ns_drift", + Severity: SeverityWarn, + Message: fmt.Sprintf("child NS RRset differs from parent: missing=%v extra=%v", missing, extra), + Server: srv, + }) + } + } + + // In-bailiwick glue agreement. + if isInBailiwick(nsName, delegatedFQDN) { + childAddrs, _ := queryAddrsAt(ctx, srv, nsName) + missing, _ := diffStringSets(parent.glue[nsName], childAddrs) + if len(missing) > 0 { + sev := SeverityCrit + if allowGlueMismatch { + sev = SeverityWarn + } + data.Findings = append(data.Findings, DelegationFinding{ + Code: "delegation_glue_mismatch", + Severity: sev, + Message: fmt.Sprintf("addresses served by child for %s differ from parent glue: missing=%v", nsName, missing), + Server: srv, + }) + } + } + + // DNSKEY hand-off, only if the parent has DS records. + if len(parent.ds) > 0 { + keys, kerr := queryDNSKEY(ctx, srv, delegatedFQDN) + if kerr != nil { + data.Findings = append(data.Findings, DelegationFinding{ + Code: "delegation_dnskey_query_failed", + Severity: SeverityWarn, + Message: fmt.Sprintf("DNSKEY query failed at %s: %v", nsName, kerr), + Server: srv, + }) + } else if !dsMatchesAnyKey(parent.ds, keys) { + data.Findings = append(data.Findings, DelegationFinding{ + Code: "delegation_dnskey_no_match", + Severity: SeverityCrit, + Message: fmt.Sprintf("none of the DNSKEY records served by %s match the DS published by the parent", nsName), + Server: srv, + }) + } + } + } + if !sawAA && len(addrs) > 0 { + // At least record we tried. + data.Findings = append(data.Findings, DelegationFinding{ + Code: "delegation_no_authoritative_answer", + Severity: SeverityCrit, + Message: fmt.Sprintf("no authoritative answer obtained from any address of %s", nsName), + Server: nsName, + }) + } + } + + return data, nil +} + +// queryDelegationTCP is the TCP variant of queryDelegation. It is split out +// so the per-server findings keep their UDP/TCP roles distinct. +func queryDelegationTCP(ctx context.Context, parentServer, fqdn string) (ns []string, glue map[string][]string, msg *dns.Msg, err error) { + q := dns.Question{Name: dns.Fqdn(fqdn), Qtype: dns.TypeNS, Qclass: dns.ClassINET} + msg, err = dnsExchange(ctx, "tcp", parentServer, q, true) + if err != nil { + return nil, nil, nil, err + } + if msg.Rcode != dns.RcodeSuccess { + return nil, nil, msg, fmt.Errorf("parent answered %s", dns.RcodeToString[msg.Rcode]) + } + return +} + +// loadService extracts the abstract.Delegation payload from the auto-filled +// "service" option. We parse it into our local minimal type so this checker +// does not have to import the full happyDomain server module. +func loadService(opts sdk.CheckerOptions) (*delegationService, error) { + svc, ok := sdk.GetOption[serviceMessage](opts, "service") + if !ok { + return nil, fmt.Errorf("missing 'service' option") + } + if svc.Type != "" && svc.Type != "abstract.Delegation" { + return nil, fmt.Errorf("service is %s, expected abstract.Delegation", svc.Type) + } + var d delegationService + if err := json.Unmarshal(svc.Service, &d); err != nil { + return nil, fmt.Errorf("decoding delegation service: %w", err) + } + return &d, nil +} + +func loadNames(opts sdk.CheckerOptions) (parentZone, subdomain string) { + if v, ok := sdk.GetOption[string](opts, "domain_name"); ok { + parentZone = v + } + if v, ok := sdk.GetOption[string](opts, "subdomain"); ok { + subdomain = v + } + return +} + +// normalizeNSList lowercases and FQDN-normalizes a list of NS records. +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 +} + +// diffStringSets returns the elements of "want" missing from "got" and the +// elements of "got" not present in "want". +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 +} + +// diffDS returns the DS records present in "want" but missing from "got" +// and vice-versa. +func diffDS(want, got []*dns.DS) (missing, extra []*dns.DS) { + for _, w := range want { + found := false + for _, g := range got { + if dsEqual(w, g) { + found = true + break + } + } + if !found { + missing = append(missing, w) + } + } + for _, g := range got { + found := false + for _, w := range want { + if dsEqual(w, g) { + found = true + break + } + } + if !found { + extra = append(extra, g) + } + } + return +} + +// isInBailiwick reports whether host sits inside zone. +func isInBailiwick(host, zone string) bool { + host = strings.ToLower(dns.Fqdn(host)) + zone = strings.ToLower(dns.Fqdn(zone)) + return host == zone || strings.HasSuffix(host, "."+zone) +} + +// dsMatchesAnyKey reports whether at least one of the DNSKEY records hashes +// to one of the DS records. +func dsMatchesAnyKey(ds []*dns.DS, keys []*dns.DNSKEY) bool { + for _, k := range keys { + for _, d := range ds { + expected := k.ToDS(d.DigestType) + if expected != nil && dsEqual(expected, d) { + return true + } + } + } + return false +} diff --git a/checker/definition.go b/checker/definition.go new file mode 100644 index 0000000..e6b9c37 --- /dev/null +++ b/checker/definition.go @@ -0,0 +1,83 @@ +package checker + +import ( + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Version is the checker version reported in CheckerDefinition.Version. +var Version = "built-in" + +// Definition returns the CheckerDefinition for the delegation checker. +func Definition() *sdk.CheckerDefinition { + return &sdk.CheckerDefinition{ + ID: "delegation", + Name: "DNS delegation", + Version: Version, + Availability: sdk.CheckerAvailability{ + ApplyToService: true, + LimitToServices: []string{"abstract.Delegation"}, + }, + ObservationKeys: []sdk.ObservationKey{ObservationKeyDelegation}, + Options: sdk.CheckerOptionsDocumentation{ + UserOpts: []sdk.CheckerOptionDocumentation{ + { + Id: "requireDS", + Type: "bool", + Label: "Require DS at parent", + Description: "When enabled, missing DS records at the parent are treated as a critical issue (otherwise informational).", + Default: false, + }, + { + Id: "requireTCP", + Type: "bool", + Label: "Require DNS over TCP", + Description: "When enabled, name servers that fail to answer over TCP are reported as critical (otherwise as warning).", + Default: true, + }, + { + Id: "minNameServers", + Type: "uint", + Label: "Minimum number of name servers", + Description: "Below this count, the delegation is reported as a warning (RFC 1034 recommends at least 2).", + Default: float64(2), + }, + { + Id: "allowGlueMismatch", + Type: "bool", + Label: "Allow glue mismatches", + Description: "When disabled, glue/address mismatches between parent and child are reported as critical.", + Default: false, + }, + }, + DomainOpts: []sdk.CheckerOptionDocumentation{ + { + Id: "domain_name", + Label: "Parent domain name", + AutoFill: sdk.AutoFillDomainName, + }, + { + Id: "subdomain", + Label: "Subdomain", + AutoFill: sdk.AutoFillSubdomain, + }, + }, + ServiceOpts: []sdk.CheckerOptionDocumentation{ + { + Id: "service", + Label: "Service", + AutoFill: sdk.AutoFillService, + }, + }, + }, + Rules: []sdk.CheckRule{ + Rule(), + }, + Interval: &sdk.CheckIntervalSpec{ + Min: 5 * time.Minute, + Max: 24 * time.Hour, + Default: 1 * time.Hour, + }, + } +} diff --git a/checker/dns.go b/checker/dns.go new file mode 100644 index 0000000..3930e14 --- /dev/null +++ b/checker/dns.go @@ -0,0 +1,296 @@ +package checker + +import ( + "context" + "fmt" + "net" + "strings" + "time" + + "github.com/miekg/dns" +) + +// year68 mirrors the constant from miekg/dns used to wrap RRSIG validity +// periods around 2^32 seconds (≈68 years), as in the adlin checker. +const year68 = int64(1 << 31) + +// dnsTimeout is the per-query deadline used by every helper here. +const dnsTimeout = 5 * time.Second + +// dnsExchange sends a single query to the given server using the requested +// transport ("" for UDP, "tcp"). The server address must already include a +// port. RecursionDesired 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, 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) + } + + deadline, ok := ctx.Deadline() + if ok { + if d := time.Until(deadline); d > 0 && d < client.Timeout { + client.Timeout = d + } + } + + r, _, err := client.Exchange(m, server) + if err != nil { + return nil, err + } + if r == nil { + return nil, fmt.Errorf("nil response from %s", server) + } + return r, nil +} + +// hostPort returns "host:port", correctly bracketing IPv6 literals. +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 +} + +// resolveHost resolves an NS hostname to its A and AAAA addresses using the +// system resolver. It is used as a fallback when no glue is provided by the +// parent for an out-of-bailiwick NS. +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 +} + +// findParentZone walks up the labels of fqdn until it finds the closest +// enclosing zone (the one that has its own SOA), and returns the FQDN of +// that zone along with its authoritative server addresses (resolved from +// its NS RRset). The walk stops as soon as a SOA query at the system +// resolver returns NOERROR with an answer. +// +// If hintParent is non-empty, it is used as the assumed parent and we only +// resolve its NS — this matches happyDomain's data model where the parent +// zone is known. +func findParentZone(ctx context.Context, fqdn, hintParent string) (zone string, servers []string, err error) { + zone = dns.Fqdn(hintParent) + if zone == "" || zone == "." { + // Walk up. + labels := dns.SplitDomainName(fqdn) + if len(labels) == 0 { + return "", nil, fmt.Errorf("cannot derive parent of %q", fqdn) + } + zone = dns.Fqdn(strings.Join(labels[1:], ".")) + } + + servers, err = resolveZoneNSAddrs(ctx, zone) + if err != nil { + return "", nil, fmt.Errorf("resolving NS of parent zone %q: %w", zone, err) + } + if len(servers) == 0 { + return "", nil, fmt.Errorf("parent zone %q has no resolvable NS", zone) + } + return zone, servers, nil +} + +// resolveZoneNSAddrs returns the list of "host:53" entries for every NS of +// the given zone, as seen by the system resolver. It is used to discover the +// parent's authoritative servers. +func resolveZoneNSAddrs(ctx context.Context, zone string) ([]string, error) { + var resolver net.Resolver + nss, err := resolver.LookupNS(ctx, strings.TrimSuffix(zone, ".")) + if err != nil { + return nil, err + } + + var out []string + for _, ns := range nss { + addrs, err := resolver.LookupHost(ctx, strings.TrimSuffix(ns.Host, ".")) + if err != nil || len(addrs) == 0 { + continue + } + for _, a := range addrs { + out = append(out, hostPort(a, "53")) + } + } + return out, nil +} + +// queryDelegation queries the given parent server for the NS RRset of fqdn +// and extracts the advertised NS names plus any glue records found in the +// Additional section. The query is sent without RD; the response is the +// classical "referral" packet. +func queryDelegation(ctx context.Context, parentServer, fqdn string) (ns []string, glue map[string][]string, msg *dns.Msg, err error) { + q := dns.Question{Name: dns.Fqdn(fqdn), Qtype: dns.TypeNS, Qclass: dns.ClassINET} + + msg, err = dnsExchange(ctx, "", parentServer, q, true) + if err != nil { + return nil, nil, nil, err + } + if msg.Rcode != dns.RcodeSuccess { + return nil, nil, msg, fmt.Errorf("parent answered %s", dns.RcodeToString[msg.Rcode]) + } + + glue = map[string][]string{} + + collect := func(records []dns.RR) { + for _, rr := range records { + switch t := rr.(type) { + case *dns.NS: + if strings.EqualFold(strings.TrimSuffix(t.Header().Name, "."), strings.TrimSuffix(fqdn, ".")) { + ns = append(ns, strings.ToLower(dns.Fqdn(t.Ns))) + } + case *dns.A: + name := strings.ToLower(dns.Fqdn(t.Header().Name)) + glue[name] = append(glue[name], t.A.String()) + case *dns.AAAA: + name := strings.ToLower(dns.Fqdn(t.Header().Name)) + glue[name] = append(glue[name], t.AAAA.String()) + } + } + } + collect(msg.Answer) + collect(msg.Ns) + collect(msg.Extra) + return +} + +// queryDS asks the parent server for the DS RRset of fqdn and returns the +// DS records plus any RRSIGs found in the same section. +func queryDS(ctx context.Context, parentServer, fqdn string) (ds []*dns.DS, sigs []*dns.RRSIG, err error) { + q := dns.Question{Name: dns.Fqdn(fqdn), Qtype: dns.TypeDS, Qclass: dns.ClassINET} + + r, err := dnsExchange(ctx, "tcp", parentServer, q, true) + if err != nil { + return nil, nil, err + } + if r.Rcode != dns.RcodeSuccess { + return nil, nil, fmt.Errorf("parent answered %s for DS", dns.RcodeToString[r.Rcode]) + } + + for _, rr := range r.Answer { + switch t := rr.(type) { + case *dns.DS: + ds = append(ds, t) + case *dns.RRSIG: + sigs = append(sigs, t) + } + } + return +} + +// querySOA asks the given authoritative server for the SOA of fqdn and +// returns the SOA record plus the AA flag from the response header. +func querySOA(ctx context.Context, proto, server, fqdn string) (soa *dns.SOA, aa bool, err error) { + q := dns.Question{Name: dns.Fqdn(fqdn), Qtype: dns.TypeSOA, Qclass: dns.ClassINET} + r, err := dnsExchange(ctx, proto, server, q, false) + if err != nil { + return nil, false, err + } + if r.Rcode != dns.RcodeSuccess { + return nil, r.Authoritative, 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, nil + } + } + return nil, r.Authoritative, fmt.Errorf("no SOA in answer section") +} + +// queryNSAt asks the given authoritative server for the NS RRset of fqdn. +func queryNSAt(ctx context.Context, server, fqdn string) ([]string, error) { + q := dns.Question{Name: dns.Fqdn(fqdn), 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 +} + +// queryAddrsAt asks an authoritative server for the A and AAAA records of +// host (typically an in-bailiwick NS hostname). +func queryAddrsAt(ctx context.Context, server, host string) ([]string, error) { + var out []string + for _, qt := range []uint16{dns.TypeA, dns.TypeAAAA} { + r, err := dnsExchange(ctx, "", server, dns.Question{Name: dns.Fqdn(host), Qtype: qt, Qclass: dns.ClassINET}, false) + if err != nil { + continue + } + if r.Rcode != dns.RcodeSuccess { + continue + } + for _, rr := range r.Answer { + switch t := rr.(type) { + case *dns.A: + out = append(out, t.A.String()) + case *dns.AAAA: + out = append(out, t.AAAA.String()) + } + } + } + return out, nil +} + +// queryDNSKEY asks the given child server for the DNSKEY RRset of fqdn. +func queryDNSKEY(ctx context.Context, server, fqdn string) ([]*dns.DNSKEY, error) { + q := dns.Question{Name: dns.Fqdn(fqdn), Qtype: dns.TypeDNSKEY, Qclass: dns.ClassINET} + r, err := dnsExchange(ctx, "tcp", server, q, true) + if err != nil { + return nil, err + } + if r.Rcode != dns.RcodeSuccess { + return nil, fmt.Errorf("server answered %s for DNSKEY", dns.RcodeToString[r.Rcode]) + } + var out []*dns.DNSKEY + for _, rr := range r.Answer { + if t, ok := rr.(*dns.DNSKEY); ok { + out = append(out, t) + } + } + return out, nil +} + +// dsEqual returns true when two DS records refer to the same key material. +func dsEqual(a, b *dns.DS) bool { + return a.KeyTag == b.KeyTag && + a.Algorithm == b.Algorithm && + a.DigestType == b.DigestType && + strings.EqualFold(a.Digest, b.Digest) +} + +// validityWindow returns a human-readable explanation of why a signature is +// outside its validity period, mirroring the year68 logic from the adlin +// checker. +func validityWindow(sig *dns.RRSIG) string { + utc := time.Now().UTC().Unix() + + modi := (int64(sig.Inception) - utc) / year68 + ti := int64(sig.Inception) + modi*year68 + + mode := (int64(sig.Expiration) - utc) / year68 + te := int64(sig.Expiration) + mode*year68 + + if ti > utc { + return "signature not yet valid" + } else if utc > te { + return "signature expired" + } + return "signature outside its validity window" +} diff --git a/checker/evaluate.go b/checker/evaluate.go new file mode 100644 index 0000000..b62675d --- /dev/null +++ b/checker/evaluate.go @@ -0,0 +1,53 @@ +package checker + +import ( + "fmt" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Evaluate folds findings into a single CheckState. The status is the +// highest severity observed: any Crit makes the whole result Crit, any Warn +// makes it Warn, otherwise OK. +func Evaluate(data *DelegationData) sdk.CheckState { + status := sdk.StatusOK + var crit, warn, info int + for _, f := range data.Findings { + switch f.Severity { + case SeverityCrit: + crit++ + status = sdk.StatusCrit + case SeverityWarn: + warn++ + if status != sdk.StatusCrit { + status = sdk.StatusWarn + } + case SeverityInfo: + info++ + if status == sdk.StatusOK { + status = sdk.StatusInfo + } + } + } + + var msg string + if len(data.Findings) == 0 { + msg = fmt.Sprintf("Delegation of %s is healthy", data.DelegatedFQDN) + } else { + msg = fmt.Sprintf("Delegation of %s: %d critical, %d warning, %d info", data.DelegatedFQDN, crit, warn, info) + } + + return sdk.CheckState{ + Status: status, + Message: msg, + Code: "delegation_result", + Meta: map[string]any{ + "findings": data.Findings, + "delegated_fqdn": data.DelegatedFQDN, + "parent_zone": data.ParentZone, + "advertised_ns": data.AdvertisedNS, + "parent_ds": data.ParentDS, + "child_serials": data.ChildSerials, + }, + } +} diff --git a/checker/provider.go b/checker/provider.go new file mode 100644 index 0000000..c612fa3 --- /dev/null +++ b/checker/provider.go @@ -0,0 +1,22 @@ +package checker + +import ( + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Provider returns a new delegation observation provider. +func Provider() sdk.ObservationProvider { + return &delegationProvider{} +} + +type delegationProvider struct{} + +func (p *delegationProvider) Key() sdk.ObservationKey { + return ObservationKeyDelegation +} + +// Definition implements sdk.CheckerDefinitionProvider so the SDK server can +// expose /definition without an extra argument. +func (p *delegationProvider) Definition() *sdk.CheckerDefinition { + return Definition() +} diff --git a/checker/rule.go b/checker/rule.go new file mode 100644 index 0000000..753d7b7 --- /dev/null +++ b/checker/rule.go @@ -0,0 +1,46 @@ +package checker + +import ( + "context" + "fmt" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Rule returns the delegation check rule. +func Rule() sdk.CheckRule { + return &delegationRule{} +} + +type delegationRule struct{} + +func (r *delegationRule) Name() string { return "delegation_check" } + +func (r *delegationRule) Description() string { + return "Verifies a DNS delegation against its parent zone and the delegated name servers" +} + +func (r *delegationRule) ValidateOptions(opts sdk.CheckerOptions) error { + if v, ok := opts["minNameServers"]; ok { + f, ok := v.(float64) + if !ok { + return fmt.Errorf("minNameServers must be a number") + } + if f < 1 { + return fmt.Errorf("minNameServers must be >= 1") + } + } + return nil +} + +func (r *delegationRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) sdk.CheckState { + var data DelegationData + if err := obs.Get(ctx, ObservationKeyDelegation, &data); err != nil { + return sdk.CheckState{ + Status: sdk.StatusError, + Message: fmt.Sprintf("Failed to get delegation data: %v", err), + Code: "delegation_error", + } + } + return Evaluate(&data) +} diff --git a/checker/types.go b/checker/types.go new file mode 100644 index 0000000..a6c76e5 --- /dev/null +++ b/checker/types.go @@ -0,0 +1,89 @@ +package checker + +import ( + "encoding/json" + + "github.com/miekg/dns" +) + +// ObservationKeyDelegation is the observation key for delegation data. +const ObservationKeyDelegation = "delegation" + +// Severity classifies a finding emitted by the delegation checker. +type Severity string + +const ( + SeverityInfo Severity = "info" + SeverityWarn Severity = "warn" + SeverityCrit Severity = "crit" +) + +// DelegationFinding describes a single observation produced while running +// the delegation testsuite. +type DelegationFinding struct { + // Code is a stable machine-readable identifier (e.g. "delegation_ns_mismatch"). + Code string `json:"code"` + + // Severity grades the finding. + Severity Severity `json:"severity"` + + // Message is a human-readable explanation. + Message string `json:"message"` + + // Server is the DNS server that exhibited the finding (parent or child), + // when applicable. Empty for findings tied to the service definition itself. + Server string `json:"server,omitempty"` +} + +// DelegationData is the observation payload stored by the checker. It carries +// every finding emitted by the testsuite plus the raw observed state from the +// parent and from each delegated server. +type DelegationData struct { + // DelegatedFQDN is the FQDN of the delegated zone (subdomain + parent). + DelegatedFQDN string `json:"delegated_fqdn"` + + // ParentZone is the FQDN of the parent zone that delegates DelegatedFQDN. + ParentZone string `json:"parent_zone"` + + // ParentNS lists the parent zone's authoritative servers that were + // queried (FQDNs of NS records). + ParentNS []string `json:"parent_ns,omitempty"` + + // AdvertisedNS holds the NS RRset returned by the parent for the + // delegated FQDN, normalized as lowercase FQDNs. + AdvertisedNS []string `json:"advertised_ns,omitempty"` + + // AdvertisedGlue maps an in-bailiwick NS hostname to the glue addresses + // returned by the parent for that name. + AdvertisedGlue map[string][]string `json:"advertised_glue,omitempty"` + + // ParentDS lists the DS records returned by the parent for the + // delegated FQDN, in their textual presentation form. + ParentDS []string `json:"parent_ds,omitempty"` + + // ChildSerials maps an NS hostname to the SOA serial it returns for + // the delegated FQDN. + ChildSerials map[string]uint32 `json:"child_serials,omitempty"` + + // Findings is the list of issues / observations produced by the run. + Findings []DelegationFinding `json:"findings"` +} + +// delegationService is the minimal local mirror of happyDomain's +// `services/abstract.Delegation` type. It is duplicated on purpose so that +// this checker does not have to import the (heavy) happyDomain server module +// just to decode the service payload. github.com/miekg/dns marshals +// dns.NS / dns.DS to JSON in the same shape happyDomain uses. +type delegationService struct { + NameServers []*dns.NS `json:"ns"` + DS []*dns.DS `json:"ds"` +} + +// serviceMessage is the minimal local mirror of happyDomain's ServiceMessage +// envelope. We only need the embedded service JSON; the rest of the meta +// fields are ignored. +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..ebea955 --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module git.happydns.org/checker-delegation + +go 1.25.0 + +require ( + git.happydns.org/checker-sdk-go v0.0.1 + 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..eab57b3 --- /dev/null +++ b/go.sum @@ -0,0 +1,16 @@ +git.happydns.org/checker-sdk-go v0.0.1 h1:4RxCJr73HWKxjOyU/6NJMO8lXJmH0gMLA68EzTqLbQI= +git.happydns.org/checker-sdk-go v0.0.1/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..615fea3 --- /dev/null +++ b/main.go @@ -0,0 +1,28 @@ +package main + +import ( + "flag" + "log" + + delegation "git.happydns.org/checker-delegation/checker" + sdk "git.happydns.org/checker-sdk-go/checker" +) + +var listenAddr = flag.String("listen", ":8080", "HTTP listen address") + +// 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" + +func main() { + flag.Parse() + + delegation.Version = Version + + server := sdk.NewServer(delegation.Provider()) + if err := server.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..7d2e37d --- /dev/null +++ b/plugin/plugin.go @@ -0,0 +1,22 @@ +// Command plugin is the happyDomain plugin entrypoint for the delegation +// checker. It is built as a Go plugin (`go build -buildmode=plugin`) and +// loaded at runtime by happyDomain. +package main + +import ( + delegation "git.happydns.org/checker-delegation/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-delegation.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) { + delegation.Version = Version + return delegation.Definition(), delegation.Provider(), nil +} From 715af92c55ecfe696ad29806c4e2cf3e26160f40 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 24 Apr 2026 12:48:30 +0700 Subject: [PATCH 3/3] Migrate to checker-sdk-go v1.3.0 with standalone build tag The SDK split the HTTP server scaffolding into the new checker-sdk-go/checker/server subpackage and CheckRule.Evaluate now returns []CheckState. Update main.go to import server and call server.New, switch the rule and the package-level Evaluate helper to the new slice return type, and isolate the interactive form code behind the standalone build tag so plugin/builtin builds skip net/http and html/template entirely. --- Dockerfile | 2 +- Makefile | 7 ++- checker/collect.go | 2 +- checker/dns.go | 4 +- checker/evaluate.go | 6 +- checker/interactive.go | 139 +++++++++++++++++++++++++++++++++++++++++ checker/rule.go | 6 +- go.mod | 2 +- go.sum | 4 +- main.go | 6 +- 10 files changed, 160 insertions(+), 18 deletions(-) create mode 100644 checker/interactive.go diff --git a/Dockerfile b/Dockerfile index 64f0039..591dc3d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,7 @@ WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=0 go build -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-delegation . +RUN CGO_ENABLED=0 go build -tags standalone -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-delegation . FROM scratch COPY --from=builder /checker-delegation /checker-delegation diff --git a/Makefile b/Makefile index 3b94d28..a257412 100644 --- a/Makefile +++ b/Makefile @@ -6,12 +6,12 @@ CHECKER_SOURCES := main.go $(wildcard checker/*.go) GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION) -.PHONY: all plugin docker clean +.PHONY: all plugin docker test clean all: $(CHECKER_NAME) $(CHECKER_NAME): $(CHECKER_SOURCES) - go build -ldflags "$(GO_LDFLAGS)" -o $@ . + go build -tags standalone -ldflags "$(GO_LDFLAGS)" -o $@ . plugin: $(CHECKER_NAME).so @@ -21,5 +21,8 @@ $(CHECKER_NAME).so: $(CHECKER_SOURCES) $(wildcard plugin/*.go) 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/checker/collect.go b/checker/collect.go index 027dce0..925ecb2 100644 --- a/checker/collect.go +++ b/checker/collect.go @@ -164,7 +164,7 @@ func (p *delegationProvider) Collect(ctx context.Context, opts sdk.CheckerOption if len(dsMissing) > 0 || len(dsExtra) > 0 { sev := SeverityCrit if len(declaredDS) == 0 { - // Service does not declare any DS but parent has some — warn only. + // Service does not declare any DS but parent has some, warn only. sev = SeverityWarn } data.Findings = append(data.Findings, DelegationFinding{ diff --git a/checker/dns.go b/checker/dns.go index 3930e14..e810eed 100644 --- a/checker/dns.go +++ b/checker/dns.go @@ -19,7 +19,7 @@ const dnsTimeout = 5 * time.Second // dnsExchange sends a single query to the given server using the requested // transport ("" for UDP, "tcp"). The server address must already include a -// port. RecursionDesired is forced off — this checker only talks to +// port. RecursionDesired 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, error) { client := dns.Client{Net: proto, Timeout: dnsTimeout} @@ -77,7 +77,7 @@ func resolveHost(ctx context.Context, host string) ([]string, error) { // resolver returns NOERROR with an answer. // // If hintParent is non-empty, it is used as the assumed parent and we only -// resolve its NS — this matches happyDomain's data model where the parent +// resolve its NS, this matches happyDomain's data model where the parent // zone is known. func findParentZone(ctx context.Context, fqdn, hintParent string) (zone string, servers []string, err error) { zone = dns.Fqdn(hintParent) diff --git a/checker/evaluate.go b/checker/evaluate.go index b62675d..b8cf56d 100644 --- a/checker/evaluate.go +++ b/checker/evaluate.go @@ -9,7 +9,7 @@ import ( // Evaluate folds findings into a single CheckState. The status is the // highest severity observed: any Crit makes the whole result Crit, any Warn // makes it Warn, otherwise OK. -func Evaluate(data *DelegationData) sdk.CheckState { +func Evaluate(data *DelegationData) []sdk.CheckState { status := sdk.StatusOK var crit, warn, info int for _, f := range data.Findings { @@ -37,7 +37,7 @@ func Evaluate(data *DelegationData) sdk.CheckState { msg = fmt.Sprintf("Delegation of %s: %d critical, %d warning, %d info", data.DelegatedFQDN, crit, warn, info) } - return sdk.CheckState{ + return []sdk.CheckState{{ Status: status, Message: msg, Code: "delegation_result", @@ -49,5 +49,5 @@ func Evaluate(data *DelegationData) sdk.CheckState { "parent_ds": data.ParentDS, "child_serials": data.ChildSerials, }, - } + }} } diff --git a/checker/interactive.go b/checker/interactive.go new file mode 100644 index 0000000..ec46c23 --- /dev/null +++ b/checker/interactive.go @@ -0,0 +1,139 @@ +//go:build standalone + +package checker + +import ( + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "strings" + + "github.com/miekg/dns" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// RenderForm exposes a single "delegated domain" input for the standalone +// /check route. The parent zone is derived by stripping the left-most label +// and the declared NS / DS sets are resolved via the system resolver. +func (p *delegationProvider) RenderForm() []sdk.CheckerOptionField { + return []sdk.CheckerOptionField{ + { + Id: "domain", + Type: "string", + Label: "Delegated domain", + Placeholder: "sub.example.com", + Required: true, + Description: "Fully-qualified name of the delegated zone to check.", + }, + } +} + +// ParseForm turns the submitted domain into the CheckerOptions that Collect +// expects: the parent zone, the sub-label, and a synthesized +// abstract.Delegation service populated with the NS / DS records resolved +// via the system resolver. +func (p *delegationProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) { + domain := strings.TrimSpace(r.FormValue("domain")) + if domain == "" { + return nil, errors.New("domain is required") + } + fqdn := dns.Fqdn(domain) + labels := dns.SplitDomainName(fqdn) + if len(labels) < 2 { + return nil, fmt.Errorf("%q has no parent zone", domain) + } + parentZone := strings.Join(labels[1:], ".") + subdomain := labels[0] + + resolver, err := interactiveResolver() + if err != nil { + return nil, err + } + + nsRecords, err := lookupDelegationNS(resolver, fqdn) + if err != nil { + return nil, fmt.Errorf("NS lookup for %s: %w", domain, err) + } + if len(nsRecords) == 0 { + return nil, fmt.Errorf("no NS records found for %s", domain) + } + dsRecords, err := lookupDelegationDS(resolver, fqdn) + if err != nil { + return nil, fmt.Errorf("DS lookup for %s: %w", domain, err) + } + + body, err := json.Marshal(delegationService{NameServers: nsRecords, DS: dsRecords}) + if err != nil { + return nil, fmt.Errorf("marshal delegation service: %w", err) + } + + svc := serviceMessage{ + Type: "abstract.Delegation", + Domain: parentZone, + Service: body, + } + + return sdk.CheckerOptions{ + "domain_name": parentZone, + "subdomain": subdomain, + "service": svc, + }, nil +} + +func interactiveResolver() (string, error) { + cfg, err := dns.ClientConfigFromFile("/etc/resolv.conf") + if err != nil || len(cfg.Servers) == 0 { + return net.JoinHostPort("1.1.1.1", "53"), nil + } + return net.JoinHostPort(cfg.Servers[0], cfg.Port), nil +} + +func lookupDelegationNS(resolver, fqdn string) ([]*dns.NS, error) { + msg := new(dns.Msg) + msg.SetQuestion(fqdn, dns.TypeNS) + msg.RecursionDesired = true + + c := new(dns.Client) + in, _, err := c.Exchange(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 []*dns.NS + for _, rr := range in.Answer { + if ns, ok := rr.(*dns.NS); ok { + out = append(out, ns) + } + } + return out, nil +} + +func lookupDelegationDS(resolver, fqdn string) ([]*dns.DS, error) { + msg := new(dns.Msg) + msg.SetQuestion(fqdn, dns.TypeDS) + msg.RecursionDesired = true + msg.SetEdns0(4096, true) + + c := new(dns.Client) + in, _, err := c.Exchange(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 []*dns.DS + for _, rr := range in.Answer { + if ds, ok := rr.(*dns.DS); ok { + out = append(out, ds) + } + } + return out, nil +} diff --git a/checker/rule.go b/checker/rule.go index 753d7b7..444d421 100644 --- a/checker/rule.go +++ b/checker/rule.go @@ -33,14 +33,14 @@ func (r *delegationRule) ValidateOptions(opts sdk.CheckerOptions) error { return nil } -func (r *delegationRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) sdk.CheckState { +func (r *delegationRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { var data DelegationData if err := obs.Get(ctx, ObservationKeyDelegation, &data); err != nil { - return sdk.CheckState{ + return []sdk.CheckState{{ Status: sdk.StatusError, Message: fmt.Sprintf("Failed to get delegation data: %v", err), Code: "delegation_error", - } + }} } return Evaluate(&data) } diff --git a/go.mod b/go.mod index ebea955..3ec3aab 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module git.happydns.org/checker-delegation go 1.25.0 require ( - git.happydns.org/checker-sdk-go v0.0.1 + git.happydns.org/checker-sdk-go v1.3.0 github.com/miekg/dns v1.1.72 ) diff --git a/go.sum b/go.sum index eab57b3..092728a 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -git.happydns.org/checker-sdk-go v0.0.1 h1:4RxCJr73HWKxjOyU/6NJMO8lXJmH0gMLA68EzTqLbQI= -git.happydns.org/checker-sdk-go v0.0.1/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= +git.happydns.org/checker-sdk-go v1.3.0 h1:FG2kIhlJCzI0m35EhxSgn4UWc9M4ha6aZTeoChu4l7A= +git.happydns.org/checker-sdk-go v1.3.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= diff --git a/main.go b/main.go index 615fea3..465e3cf 100644 --- a/main.go +++ b/main.go @@ -5,7 +5,7 @@ import ( "log" delegation "git.happydns.org/checker-delegation/checker" - sdk "git.happydns.org/checker-sdk-go/checker" + "git.happydns.org/checker-sdk-go/checker/server" ) var listenAddr = flag.String("listen", ":8080", "HTTP listen address") @@ -21,8 +21,8 @@ func main() { delegation.Version = Version - server := sdk.NewServer(delegation.Provider()) - if err := server.ListenAndServe(*listenAddr); err != nil { + srv := server.New(delegation.Provider()) + if err := srv.ListenAndServe(*listenAddr); err != nil { log.Fatalf("server error: %v", err) } }