diff --git a/Dockerfile b/Dockerfile index d1a96e3..c502d63 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,10 +6,9 @@ WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=0 go build -tags standalone -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-ns-restrictions . +RUN CGO_ENABLED=0 go build -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-ns-restrictions . FROM scratch COPY --from=builder /checker-ns-restrictions /checker-ns-restrictions -USER 65534:65534 EXPOSE 8080 ENTRYPOINT ["/checker-ns-restrictions"] diff --git a/Makefile b/Makefile index 2c0872e..7324d03 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 test clean +.PHONY: all plugin docker clean all: $(CHECKER_NAME) $(CHECKER_NAME): $(CHECKER_SOURCES) - go build -tags standalone -ldflags "$(GO_LDFLAGS)" -o $@ . + go build -ldflags "$(GO_LDFLAGS)" -o $@ . plugin: $(CHECKER_NAME).so @@ -21,8 +21,5 @@ $(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/README.md b/README.md index 8536d4c..8e02673 100644 --- a/README.md +++ b/README.md @@ -47,20 +47,6 @@ The plugin exposes a `NewCheckerPlugin` symbol returning the checker definition and observation provider, which happyDomain registers in its global registries at load time. -### Deployment - -The `/collect` endpoint has no built-in authentication and will issue -DNS queries (including AXFR/IXFR/ANY zone-transfer attempts) to whatever -addresses the supplied NS hostnames resolve to. A caller that controls -the input domain can publish NS records pointing at arbitrary IPs, -including private/internal ranges (RFC 1918, loopback, link-local) or -unrelated third-party hosts, and use this checker as an SSRF / probing -relay against them. It is meant to run on a trusted network, reachable -only by the happyDomain instance that drives it. Restrict access via a -reverse proxy with authentication, a network ACL, or by binding the -listener to a private interface; do not expose it directly to the -public internet. - ### Versioning The binary, plugin, and Docker image embed a version string overridable diff --git a/checker/abstract.go b/checker/abstract.go index 3a7e18c..c8de1e5 100644 --- a/checker/abstract.go +++ b/checker/abstract.go @@ -12,10 +12,16 @@ const ( serviceTypeNSOnlyOrigin = "abstract.NSOnlyOrigin" ) -// nsPayload is a minimal local copy of services/abstract.Origin and +// originPayload is a minimal local copy of services/abstract.Origin keeping +// only the field this checker reads. The JSON tag matches the upstream wire +// format ("ns"). +type originPayload struct { + NameServers []*dns.NS `json:"ns"` +} + +// nsOnlyOriginPayload is a minimal local copy of // services/abstract.NSOnlyOrigin keeping only the field this checker reads. -// The JSON tag matches the upstream wire format ("ns"). -type nsPayload struct { +type nsOnlyOriginPayload struct { NameServers []*dns.NS `json:"ns"` } @@ -23,8 +29,14 @@ type nsPayload struct { // NSOnlyOrigin service payload. func nsFromService(svc *serviceMessage) []*dns.NS { switch svc.Type { - case serviceTypeOrigin, serviceTypeNSOnlyOrigin: - var o nsPayload + case serviceTypeOrigin: + var o originPayload + if err := json.Unmarshal(svc.Service, &o); err != nil { + return nil + } + return o.NameServers + case serviceTypeNSOnlyOrigin: + var o nsOnlyOriginPayload if err := json.Unmarshal(svc.Service, &o); err != nil { return nil } diff --git a/checker/checks.go b/checker/checks.go index 2a60bd3..89bb952 100644 --- a/checker/checks.go +++ b/checker/checks.go @@ -4,153 +4,161 @@ import ( "context" "fmt" "net" - "sync" "time" "github.com/miekg/dns" ) -// dnsPort is the DNS service port used for every query made by this checker. -const dnsPort = "53" - -// defaultQueryTimeout bounds every UDP query this checker issues. -const defaultQueryTimeout = 5 * time.Second - -// exchangeUDP issues a single UDP DNS query, bound to ctx. -func exchangeUDP(ctx context.Context, msg *dns.Msg, addr string) (*dns.Msg, error) { - cl := &dns.Client{Net: "udp", Timeout: defaultQueryTimeout} - resp, _, err := cl.ExchangeContext(ctx, msg, net.JoinHostPort(addr, dnsPort)) - return resp, err -} - -// probeAXFR attempts a zone transfer and returns raw facts about it. -func probeAXFR(ctx context.Context, domain, addr string) AXFRProbe { +// checkAXFR returns (ok bool, detail string). +// ok=false means the server accepted the zone transfer (CRITICAL). +func checkAXFR(ctx context.Context, domain, addr string) (bool, string) { msg := new(dns.Msg) msg.SetAxfr(dns.Fqdn(domain)) - t := &dns.Transfer{ - DialTimeout: 5 * time.Second, - ReadTimeout: 10 * time.Second, + t := &dns.Transfer{} + t.DialTimeout = 5 * time.Second + t.ReadTimeout = 10 * time.Second + + ch, err := t.In(msg, net.JoinHostPort(addr, "53")) + if err != nil { + return true, fmt.Sprintf("transfer refused: %s", err) } - done := make(chan AXFRProbe, 1) - go func() { - ch, err := t.In(msg, net.JoinHostPort(addr, dnsPort)) - if err != nil { - done <- AXFRProbe{Accepted: false, Reason: fmt.Sprintf("transfer refused: %s", err)} - return + for env := range ch { + if env.Error != nil { + return true, fmt.Sprintf("transfer error: %s", env.Error) } - // Drain channel even after a verdict: stopping reads would - // block miekg/dns' sender goroutine on the TCP connection. - verdict := AXFRProbe{Accepted: false, Reason: "AXFR refused"} - for env := range ch { - if env.Error != nil { - // Don't downgrade an already-accepted verdict: - // a late transport error after the SOA arrived - // must not erase the fact that the zone was - // served. - if !verdict.Accepted { - verdict = AXFRProbe{Accepted: false, Reason: fmt.Sprintf("transfer error: %s", env.Error)} - } - continue - } - for _, rr := range env.RR { - if rr.Header().Rrtype == dns.TypeSOA { - verdict = AXFRProbe{Accepted: true} - } + for _, rr := range env.RR { + if rr.Header().Rrtype == dns.TypeSOA { + return false, "AXFR zone transfer accepted" } } - done <- verdict - }() - - select { - case <-ctx.Done(): - return AXFRProbe{Cancelled: true, Reason: fmt.Sprintf("AXFR check cancelled: %s", ctx.Err())} - case r := <-done: - return r } + + return true, "AXFR refused" } -// probeIXFR issues a single IXFR query and returns the raw response facts. -func probeIXFR(ctx context.Context, domain, addr string) IXFRProbe { +// checkIXFR returns (ok bool, detail string). +// ok=false means the server answered with records (WARN). +func checkIXFR(ctx context.Context, domain, addr string) (bool, string) { msg := new(dns.Msg) msg.SetIxfr(dns.Fqdn(domain), 0, "", "") - resp, err := exchangeUDP(ctx, msg, addr) + cl := &dns.Client{Net: "udp", Timeout: 5 * time.Second} + resp, _, err := cl.ExchangeContext(ctx, msg, net.JoinHostPort(addr, "53")) if err != nil { - return IXFRProbe{Error: err.Error()} + return true, fmt.Sprintf("query failed: %s", err) } - return IXFRProbe{ - Rcode: dns.RcodeToString[resp.Rcode], - AnswerCount: len(resp.Answer), + + if resp.Rcode != dns.RcodeSuccess { + return true, fmt.Sprintf("IXFR refused (rcode=%s)", dns.RcodeToString[resp.Rcode]) } + if len(resp.Answer) > 0 { + return false, fmt.Sprintf("IXFR accepted with %d answer(s)", len(resp.Answer)) + } + + return true, "IXFR refused or empty" } -// probeSOA issues a SOA query with RD=1 and captures the RA and AA bits. -func probeSOA(ctx context.Context, domain, addr string) SOAProbe { +// checkNoRecursion returns (ok bool, detail string). +// ok=false means the server offers recursion (WARN). +func checkNoRecursion(ctx context.Context, domain, addr string) (bool, string) { msg := new(dns.Msg) msg.SetQuestion(dns.Fqdn(domain), dns.TypeSOA) msg.RecursionDesired = true - resp, err := exchangeUDP(ctx, msg, addr) + cl := &dns.Client{Net: "udp", Timeout: 5 * time.Second} + resp, _, err := cl.ExchangeContext(ctx, msg, net.JoinHostPort(addr, "53")) if err != nil { - return SOAProbe{Error: err.Error()} + return true, fmt.Sprintf("query failed: %s", err) } - return SOAProbe{ - RecursionAvailable: resp.RecursionAvailable, - Authoritative: resp.Authoritative, + + if resp.RecursionAvailable { + return false, "recursion available (RA bit set)" } + return true, "recursion not available" } -// probeANY issues an ANY query and records raw facts about the answer. -func probeANY(ctx context.Context, domain, addr string) ANYProbe { +// checkANYHandled returns (ok bool, detail string). +// ok=false means the server returned a full record set for ANY (WARN). +// Per RFC 8482, servers should return HINFO or a minimal response. +func checkANYHandled(ctx context.Context, domain, addr string) (bool, string) { msg := new(dns.Msg) msg.SetQuestion(dns.Fqdn(domain), dns.TypeANY) - resp, err := exchangeUDP(ctx, msg, addr) + cl := &dns.Client{Net: "udp", Timeout: 5 * time.Second} + resp, _, err := cl.ExchangeContext(ctx, msg, net.JoinHostPort(addr, "53")) if err != nil { - return ANYProbe{Error: err.Error()} + return true, fmt.Sprintf("query failed: %s", err) } - out := ANYProbe{ - Rcode: dns.RcodeToString[resp.Rcode], - AnswerCount: len(resp.Answer), + + if resp.Rcode != dns.RcodeSuccess { + return true, fmt.Sprintf("ANY refused (rcode=%s)", dns.RcodeToString[resp.Rcode]) } - if len(resp.Answer) > 0 { - hinfoOnly := true - for _, rr := range resp.Answer { - if _, ok := rr.(*dns.HINFO); !ok { - hinfoOnly = false - break - } + + if len(resp.Answer) == 1 { + if _, ok := resp.Answer[0].(*dns.HINFO); ok { + return true, "RFC 8482 compliant HINFO response" } - out.HINFOOnly = hinfoOnly } - return out + + if len(resp.Answer) == 0 { + return true, "ANY returned empty answer" + } + + return false, fmt.Sprintf("ANY returned %d records (not RFC 8482 compliant)", len(resp.Answer)) } -// probeServerAddr runs every raw probe against a single IP address in parallel -// and returns a populated NSServerResult with no pass/fail judgment applied. -func probeServerAddr(ctx context.Context, domain, nsHost, addr string) NSServerResult { - var ( - wg sync.WaitGroup - axfr AXFRProbe - ixfr IXFRProbe - soa SOAProbe - any ANYProbe - ) - wg.Add(4) - go func() { defer wg.Done(); axfr = probeAXFR(ctx, domain, addr) }() - go func() { defer wg.Done(); ixfr = probeIXFR(ctx, domain, addr) }() - go func() { defer wg.Done(); soa = probeSOA(ctx, domain, addr) }() - go func() { defer wg.Done(); any = probeANY(ctx, domain, addr) }() - wg.Wait() +// checkIsAuthoritative returns (ok bool, detail string). +// ok=false means the server is not authoritative for the zone (INFO). +func checkIsAuthoritative(ctx context.Context, domain, addr string) (bool, string) { + msg := new(dns.Msg) + msg.SetQuestion(dns.Fqdn(domain), dns.TypeSOA) - return NSServerResult{ - Name: nsHost, - Address: addr, - AXFR: axfr, - IXFR: ixfr, - SOA: soa, - ANY: any, + cl := &dns.Client{Net: "udp", Timeout: 5 * time.Second} + resp, _, err := cl.ExchangeContext(ctx, msg, net.JoinHostPort(addr, "53")) + if err != nil { + return false, fmt.Sprintf("query failed: %s", err) } + + if resp.Authoritative { + return true, "server is authoritative (AA bit set)" + } + return false, "server is not authoritative (AA bit not set)" +} + +// Stable check names. They are part of the JSON wire format of +// NSRestrictionsReport and used by individual rules to look up their +// corresponding entry, so they MUST NOT change without coordinating with +// the rule definitions. +const ( + checkNameAXFR = "AXFR refused" + checkNameIXFR = "IXFR refused" + checkNameNoRecursion = "No recursion" + checkNameANYHandled = "ANY handled (RFC 8482)" + checkNameIsAuthoritative = "Is authoritative" +) + +// checkServerAddr runs all NS security checks against a single IP address. +func checkServerAddr(ctx context.Context, domain, nsHost, addr string) NSServerResult { + result := NSServerResult{Name: nsHost, Address: addr} + + type checkDef struct { + name string + fn func(context.Context, string, string) (bool, string) + } + checks := []checkDef{ + {checkNameAXFR, checkAXFR}, + {checkNameIXFR, checkIXFR}, + {checkNameNoRecursion, checkNoRecursion}, + {checkNameANYHandled, checkANYHandled}, + {checkNameIsAuthoritative, checkIsAuthoritative}, + } + + for _, ch := range checks { + ok, detail := ch.fn(ctx, domain, addr) + result.Checks = append(result.Checks, NSCheckItem{Name: ch.name, OK: ok, Detail: detail}) + } + + return result } diff --git a/checker/collect.go b/checker/collect.go index fa9288a..7ab9990 100644 --- a/checker/collect.go +++ b/checker/collect.go @@ -7,16 +7,14 @@ import ( "fmt" "net" "strings" - "sync" "syscall" "time" sdk "git.happydns.org/checker-sdk-go/checker" ) -// Collect gathers raw NS probe data for the configured service and returns an -// NSRestrictionsReport. It does not make any pass/fail judgment: rules derive -// status from the raw probe fields. +// Collect performs the NS security restriction checks for the configured +// service and returns an NSRestrictionsReport. func (p *nsProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) { svc, err := serviceFromOptions(opts) if err != nil { @@ -44,49 +42,28 @@ func (p *nsProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, return nil, fmt.Errorf("no nameservers found in service") } - ipv6Reachable := probeIPv6(ctx) - - all := make([][]NSServerResult, len(nameServers)) - var wg sync.WaitGroup - wg.Add(len(nameServers)) - for i, ns := range nameServers { - nsHost := buildNSHost(ns.Ns, svc.Domain, domainName) - go func() { - defer wg.Done() - all[i] = probeNameServer(ctx, domainName, nsHost, ipv6Reachable) - }() - } - wg.Wait() - - report := &NSRestrictionsReport{ - Domain: domainName, - IPv6Reachable: ipv6Reachable, - } - for _, r := range all { - report.Servers = append(report.Servers, r...) + report := &NSRestrictionsReport{} + for _, ns := range nameServers { + var nsHost string + if nsCut, ok := strings.CutSuffix(ns.Ns, "."); ok { + nsHost = nsCut + } else { + nsHost = ns.Ns + if svc.Domain != "" && svc.Domain != "@" { + nsHost += "." + strings.TrimSuffix(svc.Domain, ".") + } + nsHost += "." + strings.TrimSuffix(domainName, ".") + } + results := checkNameServer(ctx, domainName, nsHost) + report.Servers = append(report.Servers, results...) } return report, nil } -// buildNSHost resolves a possibly-relative NS record name against the service -// domain and the full domain name, returning an absolute host without a -// trailing dot. -func buildNSHost(ns, svcDomain, domainName string) string { - if absolute, ok := strings.CutSuffix(ns, "."); ok { - return absolute - } - host := ns - if svcDomain != "" && svcDomain != "@" { - host += "." + strings.TrimSuffix(svcDomain, ".") - } - host += "." + strings.TrimSuffix(domainName, ".") - return host -} - // serviceFromOptions extracts a *serviceMessage from the options. It accepts // either a direct value (in-process plugin path) or a JSON-decoded -// map[string]any (HTTP path), both are normalized via a JSON round-trip. +// map[string]any (HTTP path) — both are normalized via a JSON round-trip. func serviceFromOptions(opts sdk.CheckerOptions) (*serviceMessage, error) { v, ok := opts["service"] if !ok { @@ -105,56 +82,45 @@ func serviceFromOptions(opts sdk.CheckerOptions) (*serviceMessage, error) { return &svc, nil } -// probeIPv6 returns true if the host appears to have IPv6 connectivity. It -// dials a public DNS server over UDP once and treats ENETUNREACH as a signal -// that IPv6 is unusable on this machine. -func probeIPv6(ctx context.Context) bool { - var d net.Dialer - dialCtx, cancel := context.WithTimeout(ctx, 3*time.Second) - defer cancel() - conn, err := d.DialContext(dialCtx, "udp", net.JoinHostPort("2001:4860:4860::8888", dnsPort)) - if errors.Is(err, syscall.ENETUNREACH) { - return false - } - if conn != nil { - conn.Close() - } - return true -} - -// probeNameServer resolves nsHost and runs raw probes on each address in -// parallel. When resolution fails, it emits one NSServerResult carrying -// ResolutionError so the dedicated rule can surface the fact. -func probeNameServer(ctx context.Context, domain, nsHost string, ipv6Reachable bool) []NSServerResult { +// checkNameServer resolves nsHost and runs checks on each address. +func checkNameServer(ctx context.Context, domain, nsHost string) []NSServerResult { addrs, err := net.LookupHost(nsHost) if err != nil { return []NSServerResult{{ - Name: nsHost, - ResolutionError: err.Error(), + Name: nsHost, + Address: "", + Checks: []NSCheckItem{{ + Name: "DNS resolution", + OK: false, + Detail: fmt.Sprintf("lookup failed: %s", err), + }}, }} } - results := make([]NSServerResult, len(addrs)) - var wg sync.WaitGroup - wg.Add(len(addrs)) - for i, addr := range addrs { - go func() { - defer wg.Done() - if !ipv6Reachable { - if ip := net.ParseIP(addr); ip != nil && ip.To4() == nil { - results[i] = NSServerResult{ - Name: nsHost, - Address: addr, - AddressSkipped: true, - SkipReason: "host lacks IPv6 connectivity", - } - return - } + var results []NSServerResult + for _, addr := range addrs { + // Skip IPv6 addresses when there is no IPv6 connectivity. + if ip := net.ParseIP(addr); ip != nil && ip.To4() == nil { + conn, err := net.DialTimeout("udp", net.JoinHostPort(addr, "53"), 3*time.Second) + if errors.Is(err, syscall.ENETUNREACH) { + results = append(results, NSServerResult{ + Name: nsHost, + Address: addr, + Checks: []NSCheckItem{{ + Name: "IPv6 connectivity", + OK: true, + Detail: "unable to test due to the lack of IPv6 connectivity", + }}, + }) + continue } - results[i] = probeServerAddr(ctx, domain, nsHost, addr) - }() + if conn != nil { + conn.Close() + } + } + + results = append(results, checkServerAddr(ctx, domain, nsHost, addr)) } - wg.Wait() return results } diff --git a/checker/collect_test.go b/checker/collect_test.go deleted file mode 100644 index 790fb03..0000000 --- a/checker/collect_test.go +++ /dev/null @@ -1,132 +0,0 @@ -package checker - -import ( - "encoding/json" - "testing" - - sdk "git.happydns.org/checker-sdk-go/checker" - "github.com/miekg/dns" -) - -func TestBuildNSHost(t *testing.T) { - tests := []struct { - name string - ns string - svcDomain string - domainName string - want string - }{ - { - name: "absolute NS keeps name and drops trailing dot", - ns: "ns1.example.net.", - svcDomain: "ignored", - domainName: "example.com", - want: "ns1.example.net", - }, - { - name: "relative NS with empty service domain appends domain", - ns: "ns1", - svcDomain: "", - domainName: "example.com", - want: "ns1.example.com", - }, - { - name: "relative NS with @ service domain appends only domain", - ns: "ns1", - svcDomain: "@", - domainName: "example.com", - want: "ns1.example.com", - }, - { - name: "relative NS with subdomain service appends both", - ns: "ns1", - svcDomain: "sub", - domainName: "example.com", - want: "ns1.sub.example.com", - }, - { - name: "relative NS strips trailing dot from svc domain and domain", - ns: "ns1", - svcDomain: "sub.", - domainName: "example.com.", - want: "ns1.sub.example.com", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := buildNSHost(tt.ns, tt.svcDomain, tt.domainName) - if got != tt.want { - t.Errorf("buildNSHost(%q, %q, %q) = %q, want %q", - tt.ns, tt.svcDomain, tt.domainName, got, tt.want) - } - }) - } -} - -func TestServiceFromOptions(t *testing.T) { - t.Run("missing service option", func(t *testing.T) { - _, err := serviceFromOptions(sdk.CheckerOptions{}) - if err == nil { - t.Fatal("expected error for missing service option, got nil") - } - }) - - t.Run("direct value (in-process plugin)", func(t *testing.T) { - svc := serviceMessage{ - Type: serviceTypeOrigin, - Domain: "example.com", - Service: json.RawMessage(`{"ns":[]}`), - } - got, err := serviceFromOptions(sdk.CheckerOptions{"service": svc}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got.Type != serviceTypeOrigin || got.Domain != "example.com" { - t.Errorf("got %+v, want type=%s domain=example.com", got, serviceTypeOrigin) - } - }) - - t.Run("decoded JSON map (HTTP path)", func(t *testing.T) { - raw := map[string]any{ - "_svctype": serviceTypeNSOnlyOrigin, - "_domain": "sub", - "Service": map[string]any{"ns": []any{}}, - } - got, err := serviceFromOptions(sdk.CheckerOptions{"service": raw}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got.Type != serviceTypeNSOnlyOrigin || got.Domain != "sub" { - t.Errorf("got %+v, want type=%s domain=sub", got, serviceTypeNSOnlyOrigin) - } - }) -} - -func TestNSFromService(t *testing.T) { - t.Run("origin payload returns NS records", func(t *testing.T) { - payload, _ := json.Marshal(nsPayload{NameServers: []*dns.NS{ - {Ns: "ns1.example.com."}, - {Ns: "ns2.example.com."}, - }}) - svc := &serviceMessage{Type: serviceTypeOrigin, Service: payload} - - got := nsFromService(svc) - if len(got) != 2 || got[0].Ns != "ns1.example.com." { - t.Errorf("got %+v, want 2 NS records", got) - } - }) - - t.Run("unknown service type returns nil", func(t *testing.T) { - svc := &serviceMessage{Type: "abstract.NotAnOrigin", Service: json.RawMessage(`{}`)} - if got := nsFromService(svc); got != nil { - t.Errorf("got %+v, want nil", got) - } - }) - - t.Run("malformed payload returns nil", func(t *testing.T) { - svc := &serviceMessage{Type: serviceTypeOrigin, Service: json.RawMessage(`not json`)} - if got := nsFromService(svc); got != nil { - t.Errorf("got %+v, want nil", got) - } - }) -} diff --git a/checker/definition.go b/checker/definition.go index ba4f89c..1db8583 100644 --- a/checker/definition.go +++ b/checker/definition.go @@ -17,7 +17,7 @@ var Version = "built-in" // Definition returns the CheckerDefinition for the NS security restrictions // checker. -func (p *nsProvider) Definition() *sdk.CheckerDefinition { +func Definition() *sdk.CheckerDefinition { return &sdk.CheckerDefinition{ ID: "ns_restrictions", Name: "NS Security Restrictions", diff --git a/checker/interactive.go b/checker/interactive.go index 6469bbf..d7de7c8 100644 --- a/checker/interactive.go +++ b/checker/interactive.go @@ -1,26 +1,17 @@ -//go:build standalone - package checker import ( - "context" "encoding/json" "errors" "fmt" - "net" "net/http" "strings" - "time" sdk "git.happydns.org/checker-sdk-go/checker" "github.com/miekg/dns" ) -// resolveNSTimeout bounds the total time spent attempting NS resolution -// across all configured fallback resolvers. -const resolveNSTimeout = 15 * time.Second - -// RenderForm implements server.Interactive. It lists the minimal human +// RenderForm implements sdk.CheckerInteractive. It lists the minimal human // inputs needed to bootstrap a check when this checker runs standalone // (outside of a happyDomain host). func (p *nsProvider) RenderForm() []sdk.CheckerOptionField { @@ -36,9 +27,9 @@ func (p *nsProvider) RenderForm() []sdk.CheckerOptionField { } } -// ParseForm implements server.Interactive. It resolves the NS records +// ParseForm implements sdk.CheckerInteractive. It resolves the NS records // for the requested domain via DNS and assembles the CheckerOptions that -// Collect expects, replacing the AutoFill work that happyDomain would +// Collect expects — replacing the AutoFill work that happyDomain would // otherwise perform. func (p *nsProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) { domain := strings.TrimSpace(r.FormValue("domain")) @@ -55,7 +46,7 @@ func (p *nsProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) { return nil, fmt.Errorf("no NS records found for %s", domain) } - payload, err := json.Marshal(nsPayload{NameServers: nsRecords}) + payload, err := json.Marshal(originPayload{NameServers: nsRecords}) if err != nil { return nil, fmt.Errorf("failed to encode origin payload: %w", err) } @@ -76,28 +67,19 @@ func (p *nsProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) { // returns them as miekg *dns.NS records so they match the shape produced // by happyDomain's Origin service payload. func resolveNS(fqdn string) ([]*dns.NS, error) { - ctx, cancel := context.WithTimeout(context.Background(), resolveNSTimeout) - defer cancel() - - c := &dns.Client{Timeout: defaultQueryTimeout} + c := new(dns.Client) m := new(dns.Msg) m.SetQuestion(fqdn, dns.TypeNS) m.RecursionDesired = true config, err := dns.ClientConfigFromFile("/etc/resolv.conf") if err != nil || config == nil || len(config.Servers) == 0 { - config = &dns.ClientConfig{Servers: []string{"1.1.1.1", "8.8.8.8"}, Port: dnsPort} + config = &dns.ClientConfig{Servers: []string{"1.1.1.1", "8.8.8.8"}, Port: "53"} } var lastErr error for _, server := range config.Servers { - if err := ctx.Err(); err != nil { - if lastErr == nil { - lastErr = err - } - break - } - in, _, err := c.ExchangeContext(ctx, m, net.JoinHostPort(server, config.Port)) + in, _, err := c.Exchange(m, server+":"+config.Port) if err != nil { lastErr = err continue diff --git a/checker/provider.go b/checker/provider.go index 48e2dda..6dfe8a7 100644 --- a/checker/provider.go +++ b/checker/provider.go @@ -14,3 +14,8 @@ type nsProvider struct{} func (p *nsProvider) Key() sdk.ObservationKey { return ObservationKeyNSRestrictions } + +// Definition implements sdk.CheckerDefinitionProvider. +func (p *nsProvider) Definition() *sdk.CheckerDefinition { + return Definition() +} diff --git a/checker/rule.go b/checker/rule.go index 301c6b6..94abc35 100644 --- a/checker/rule.go +++ b/checker/rule.go @@ -7,74 +7,135 @@ import ( sdk "git.happydns.org/checker-sdk-go/checker" ) -// Rules returns one CheckRule per individual NS security concern. Every rule -// reads the same shared observation produced by Collect and only looks at -// its own facet of the raw probe data, so a single network round trip feeds -// every rule. +// Rules returns one rule per individual NS security check. Every rule +// reads the same shared observation produced by Collect and only looks +// at its own check entry, so a single network round trip feeds all rules. func Rules() []sdk.CheckRule { return []sdk.CheckRule{ - &resolutionRule{}, - &axfrRule{}, - &ixfrRule{}, - &noRecursionRule{}, - &anyRFC8482Rule{}, - &authoritativeRule{}, + &singleCheckRule{ + ruleName: "ns_axfr_refused", + description: "Verifies that AXFR zone transfers are refused by every authoritative nameserver", + checkName: checkNameAXFR, + failStatus: sdk.StatusCrit, + code: "ns_axfr", + }, + &singleCheckRule{ + ruleName: "ns_ixfr_refused", + description: "Verifies that IXFR zone transfers are refused by every authoritative nameserver", + checkName: checkNameIXFR, + failStatus: sdk.StatusWarn, + code: "ns_ixfr", + }, + &singleCheckRule{ + ruleName: "ns_no_recursion", + description: "Verifies that authoritative nameservers do not advertise recursion (RA bit unset)", + checkName: checkNameNoRecursion, + failStatus: sdk.StatusWarn, + code: "ns_recursion", + }, + &singleCheckRule{ + ruleName: "ns_any_handled", + description: "Verifies that ANY queries are handled per RFC 8482 (HINFO or minimal answer)", + checkName: checkNameANYHandled, + failStatus: sdk.StatusWarn, + code: "ns_any", + }, + &singleCheckRule{ + ruleName: "ns_is_authoritative", + description: "Verifies that nameservers answer authoritatively (AA bit set) for the zone", + checkName: checkNameIsAuthoritative, + failStatus: sdk.StatusInfo, + code: "ns_authoritative", + }, } } -// loadReport fetches the shared NS observation. On error it returns a -// CheckState the caller should emit verbatim to short-circuit its rule. -func loadReport(ctx context.Context, obs sdk.ObservationGetter, errCode string) (*NSRestrictionsReport, *sdk.CheckState) { +// singleCheckRule evaluates one named check across all servers in the +// shared NSRestrictionsReport observation. +type singleCheckRule struct { + ruleName string + description string + checkName string + failStatus sdk.Status + code string +} + +func (r *singleCheckRule) Name() string { return r.ruleName } +func (r *singleCheckRule) Description() string { return r.description } + +func (r *singleCheckRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { var report NSRestrictionsReport if err := obs.Get(ctx, ObservationKeyNSRestrictions, &report); err != nil { - return nil, &sdk.CheckState{ + return []sdk.CheckState{{ Status: sdk.StatusError, Message: fmt.Sprintf("Failed to get NS restrictions data: %v", err), - Code: errCode, - } + Code: r.code + "_error", + }} } - return &report, nil + + out := make([]sdk.CheckState, 0, len(report.Servers)) + for _, srv := range report.Servers { + meta := map[string]any{ + "check": r.checkName, + "name": srv.Name, + "address": srv.Address, + } + + item, found := findCheck(srv.Checks, r.checkName) + if !found { + message := "check not performed" + if len(srv.Checks) > 0 { + message = fmt.Sprintf("skipped: %s", srv.Checks[0].Detail) + } + out = append(out, sdk.CheckState{ + Status: sdk.StatusUnknown, + Message: message, + Code: r.code + "_skipped", + Subject: serverLabel(srv), + Meta: meta, + }) + continue + } + + state := sdk.CheckState{ + Code: r.code + "_result", + Subject: serverLabel(srv), + Meta: meta, + Message: item.Detail, + } + if item.OK { + state.Status = sdk.StatusOK + if state.Message == "" { + state.Message = "OK" + } + } else { + state.Status = r.failStatus + } + out = append(out, state) + } + + if len(out) == 0 { + return []sdk.CheckState{{ + Status: sdk.StatusUnknown, + Message: "no nameserver to evaluate", + Code: r.code + "_result", + }} + } + return out +} + +func findCheck(items []NSCheckItem, name string) (NSCheckItem, bool) { + for _, it := range items { + if it.Name == name { + return it, true + } + } + return NSCheckItem{}, false } -// serverLabel returns a human-friendly subject for a given server result. func serverLabel(srv NSServerResult) string { if srv.Address == "" { return srv.Name } return fmt.Sprintf("%s (%s)", srv.Name, srv.Address) } - -// serverMeta returns the per-server meta blob attached to every state a -// rule produces. -func serverMeta(srv NSServerResult) map[string]any { - return map[string]any{ - "name": srv.Name, - "address": srv.Address, - } -} - -// probedServers returns only the servers that were actually probed -// (i.e. DNS-resolved and not skipped). Rules that need to iterate over -// probe results should call this helper to transparently skip the -// resolution-error and address-skipped rows, which are the concern of -// the dedicated resolutionRule. -func probedServers(report *NSRestrictionsReport) []NSServerResult { - out := make([]NSServerResult, 0, len(report.Servers)) - for _, s := range report.Servers { - if s.ResolutionError != "" || s.AddressSkipped { - continue - } - out = append(out, s) - } - return out -} - -// noProbesState returns the default state emitted when a rule has nothing to -// evaluate (no server was successfully probed). -func noProbesState(code string) sdk.CheckState { - return sdk.CheckState{ - Status: sdk.StatusUnknown, - Message: "no nameserver could be probed", - Code: code, - } -} diff --git a/checker/rules_any.go b/checker/rules_any.go deleted file mode 100644 index 3f5cfb4..0000000 --- a/checker/rules_any.go +++ /dev/null @@ -1,62 +0,0 @@ -package checker - -import ( - "context" - "fmt" - - sdk "git.happydns.org/checker-sdk-go/checker" -) - -// anyRFC8482Rule flags nameservers that return the full record set for a -// qtype=ANY query, instead of the minimal HINFO response recommended by -// RFC 8482. -type anyRFC8482Rule struct{} - -func (r *anyRFC8482Rule) Name() string { return "ns_any_handled" } -func (r *anyRFC8482Rule) Description() string { - return "Verifies that ANY queries are handled per RFC 8482 (HINFO or minimal answer)" -} - -func (r *anyRFC8482Rule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { - report, errSt := loadReport(ctx, obs, "ns_any_error") - if errSt != nil { - return []sdk.CheckState{*errSt} - } - - servers := probedServers(report) - if len(servers) == 0 { - return []sdk.CheckState{noProbesState("ns_any_skipped")} - } - - out := make([]sdk.CheckState, 0, len(servers)) - for _, srv := range servers { - state := sdk.CheckState{ - Subject: serverLabel(srv), - Meta: serverMeta(srv), - } - switch { - case srv.ANY.Error != "": - state.Status = sdk.StatusUnknown - state.Code = "ns_any_skipped" - state.Message = fmt.Sprintf("query failed: %s", srv.ANY.Error) - case srv.ANY.Rcode != "NOERROR": - state.Status = sdk.StatusOK - state.Code = "ns_any_ok" - state.Message = fmt.Sprintf("ANY refused (rcode=%s)", srv.ANY.Rcode) - case srv.ANY.HINFOOnly: - state.Status = sdk.StatusOK - state.Code = "ns_any_ok" - state.Message = "RFC 8482 compliant HINFO response" - case srv.ANY.AnswerCount == 0: - state.Status = sdk.StatusOK - state.Code = "ns_any_ok" - state.Message = "ANY returned empty answer" - default: - state.Status = sdk.StatusWarn - state.Code = "ns_any_non_compliant" - state.Message = fmt.Sprintf("ANY returned %d records (not RFC 8482 compliant)", srv.ANY.AnswerCount) - } - out = append(out, state) - } - return out -} diff --git a/checker/rules_authoritative.go b/checker/rules_authoritative.go deleted file mode 100644 index 5c6b09a..0000000 --- a/checker/rules_authoritative.go +++ /dev/null @@ -1,54 +0,0 @@ -package checker - -import ( - "context" - "fmt" - - sdk "git.happydns.org/checker-sdk-go/checker" -) - -// authoritativeRule flags nameservers that answer the zone's SOA without -// setting the AA bit, i.e. they are not authoritative for the zone they -// are delegated to serve. -type authoritativeRule struct{} - -func (r *authoritativeRule) Name() string { return "ns_is_authoritative" } -func (r *authoritativeRule) Description() string { - return "Verifies that nameservers answer authoritatively (AA bit set) for the zone" -} - -func (r *authoritativeRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { - report, errSt := loadReport(ctx, obs, "ns_authoritative_error") - if errSt != nil { - return []sdk.CheckState{*errSt} - } - - servers := probedServers(report) - if len(servers) == 0 { - return []sdk.CheckState{noProbesState("ns_authoritative_skipped")} - } - - out := make([]sdk.CheckState, 0, len(servers)) - for _, srv := range servers { - state := sdk.CheckState{ - Subject: serverLabel(srv), - Meta: serverMeta(srv), - } - switch { - case srv.SOA.Error != "": - state.Status = sdk.StatusInfo - state.Code = "ns_authoritative_unknown" - state.Message = fmt.Sprintf("query failed: %s", srv.SOA.Error) - case srv.SOA.Authoritative: - state.Status = sdk.StatusOK - state.Code = "ns_authoritative_ok" - state.Message = "server is authoritative (AA bit set)" - default: - state.Status = sdk.StatusInfo - state.Code = "ns_authoritative_missing" - state.Message = "server is not authoritative (AA bit not set)" - } - out = append(out, state) - } - return out -} diff --git a/checker/rules_axfr.go b/checker/rules_axfr.go deleted file mode 100644 index 862197b..0000000 --- a/checker/rules_axfr.go +++ /dev/null @@ -1,54 +0,0 @@ -package checker - -import ( - "context" - - sdk "git.happydns.org/checker-sdk-go/checker" -) - -// axfrRule flags nameservers that accept full AXFR zone transfers from -// arbitrary clients, which leaks the entire zone content. -type axfrRule struct{} - -func (r *axfrRule) Name() string { return "ns_axfr_refused" } -func (r *axfrRule) Description() string { - return "Verifies that AXFR zone transfers are refused by every authoritative nameserver" -} - -func (r *axfrRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { - report, errSt := loadReport(ctx, obs, "ns_axfr_error") - if errSt != nil { - return []sdk.CheckState{*errSt} - } - - servers := probedServers(report) - if len(servers) == 0 { - return []sdk.CheckState{noProbesState("ns_axfr_skipped")} - } - - out := make([]sdk.CheckState, 0, len(servers)) - for _, srv := range servers { - state := sdk.CheckState{ - Subject: serverLabel(srv), - Meta: serverMeta(srv), - } - if srv.AXFR.Cancelled { - state.Status = sdk.StatusUnknown - state.Code = "ns_axfr_skipped" - state.Message = srv.AXFR.Reason - } else if srv.AXFR.Accepted { - state.Status = sdk.StatusCrit - state.Code = "ns_axfr_accepted" - state.Message = "AXFR zone transfer accepted" - } else { - state.Status = sdk.StatusOK - state.Code = "ns_axfr_ok" - state.Message = srv.AXFR.Reason - if state.Message == "" { - state.Message = "AXFR refused" - } - } - out = append(out, state) - } - return out -} diff --git a/checker/rules_ixfr.go b/checker/rules_ixfr.go deleted file mode 100644 index 8a886a3..0000000 --- a/checker/rules_ixfr.go +++ /dev/null @@ -1,57 +0,0 @@ -package checker - -import ( - "context" - "fmt" - - sdk "git.happydns.org/checker-sdk-go/checker" -) - -// ixfrRule flags nameservers that answer IXFR queries with records, which -// leaks incremental zone content to arbitrary clients. -type ixfrRule struct{} - -func (r *ixfrRule) Name() string { return "ns_ixfr_refused" } -func (r *ixfrRule) Description() string { - return "Verifies that IXFR zone transfers are refused by every authoritative nameserver" -} - -func (r *ixfrRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { - report, errSt := loadReport(ctx, obs, "ns_ixfr_error") - if errSt != nil { - return []sdk.CheckState{*errSt} - } - - servers := probedServers(report) - if len(servers) == 0 { - return []sdk.CheckState{noProbesState("ns_ixfr_skipped")} - } - - out := make([]sdk.CheckState, 0, len(servers)) - for _, srv := range servers { - state := sdk.CheckState{ - Subject: serverLabel(srv), - Meta: serverMeta(srv), - } - switch { - case srv.IXFR.Error != "": - state.Status = sdk.StatusOK - state.Code = "ns_ixfr_ok" - state.Message = fmt.Sprintf("query failed: %s", srv.IXFR.Error) - case srv.IXFR.Rcode != "NOERROR": - state.Status = sdk.StatusOK - state.Code = "ns_ixfr_ok" - state.Message = fmt.Sprintf("IXFR refused (rcode=%s)", srv.IXFR.Rcode) - case srv.IXFR.AnswerCount > 0: - state.Status = sdk.StatusWarn - state.Code = "ns_ixfr_accepted" - state.Message = fmt.Sprintf("IXFR accepted with %d answer(s)", srv.IXFR.AnswerCount) - default: - state.Status = sdk.StatusOK - state.Code = "ns_ixfr_ok" - state.Message = "IXFR refused or empty" - } - out = append(out, state) - } - return out -} diff --git a/checker/rules_recursion.go b/checker/rules_recursion.go deleted file mode 100644 index 7f93250..0000000 --- a/checker/rules_recursion.go +++ /dev/null @@ -1,53 +0,0 @@ -package checker - -import ( - "context" - "fmt" - - sdk "git.happydns.org/checker-sdk-go/checker" -) - -// noRecursionRule flags authoritative nameservers that still advertise -// recursion to the public (RA bit set), a classic open-resolver posture. -type noRecursionRule struct{} - -func (r *noRecursionRule) Name() string { return "ns_no_recursion" } -func (r *noRecursionRule) Description() string { - return "Verifies that authoritative nameservers do not advertise recursion (RA bit unset)" -} - -func (r *noRecursionRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { - report, errSt := loadReport(ctx, obs, "ns_recursion_error") - if errSt != nil { - return []sdk.CheckState{*errSt} - } - - servers := probedServers(report) - if len(servers) == 0 { - return []sdk.CheckState{noProbesState("ns_recursion_skipped")} - } - - out := make([]sdk.CheckState, 0, len(servers)) - for _, srv := range servers { - state := sdk.CheckState{ - Subject: serverLabel(srv), - Meta: serverMeta(srv), - } - switch { - case srv.SOA.Error != "": - state.Status = sdk.StatusUnknown - state.Code = "ns_recursion_skipped" - state.Message = fmt.Sprintf("query failed: %s", srv.SOA.Error) - case srv.SOA.RecursionAvailable: - state.Status = sdk.StatusWarn - state.Code = "ns_recursion_available" - state.Message = "recursion available (RA bit set)" - default: - state.Status = sdk.StatusOK - state.Code = "ns_recursion_ok" - state.Message = "recursion not available" - } - out = append(out, state) - } - return out -} diff --git a/checker/rules_resolution.go b/checker/rules_resolution.go deleted file mode 100644 index e54aa70..0000000 --- a/checker/rules_resolution.go +++ /dev/null @@ -1,48 +0,0 @@ -package checker - -import ( - "context" - "fmt" - - sdk "git.happydns.org/checker-sdk-go/checker" -) - -// resolutionRule flags nameservers whose host names could not be resolved -// to any IP address. An unresolvable NS is effectively dead weight in the -// delegation and is its own concern (distinct from any answer-posture check). -type resolutionRule struct{} - -func (r *resolutionRule) Name() string { return "ns_resolution" } -func (r *resolutionRule) Description() string { - return "Verifies that every nameserver host name declared in the delegation resolves to at least one IP address" -} - -func (r *resolutionRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { - report, errSt := loadReport(ctx, obs, "ns_resolution_error") - if errSt != nil { - return []sdk.CheckState{*errSt} - } - - var out []sdk.CheckState - for _, srv := range report.Servers { - if srv.ResolutionError == "" { - continue - } - out = append(out, sdk.CheckState{ - Status: sdk.StatusCrit, - Message: fmt.Sprintf("DNS resolution failed: %s", srv.ResolutionError), - Code: "ns_resolution_failed", - Subject: srv.Name, - Meta: map[string]any{"name": srv.Name}, - }) - } - - if len(out) == 0 { - return []sdk.CheckState{{ - Status: sdk.StatusOK, - Message: "every nameserver host name resolves to at least one IP address", - Code: "ns_resolution_ok", - }} - } - return out -} diff --git a/checker/rules_test.go b/checker/rules_test.go deleted file mode 100644 index 3bb5cce..0000000 --- a/checker/rules_test.go +++ /dev/null @@ -1,322 +0,0 @@ -package checker - -import ( - "context" - "encoding/json" - "errors" - "testing" - - sdk "git.happydns.org/checker-sdk-go/checker" -) - -// fakeObs is a synthetic ObservationGetter that returns a pre-built report -// (or a fixed error) when asked for ObservationKeyNSRestrictions. -type fakeObs struct { - report *NSRestrictionsReport - err error -} - -func (f *fakeObs) Get(_ context.Context, key sdk.ObservationKey, dest any) error { - if f.err != nil { - return f.err - } - if key != ObservationKeyNSRestrictions { - return errors.New("unexpected key: " + key) - } - raw, err := json.Marshal(f.report) - if err != nil { - return err - } - return json.Unmarshal(raw, dest) -} - -func (f *fakeObs) GetRelated(_ context.Context, _ sdk.ObservationKey) ([]sdk.RelatedObservation, error) { - return nil, nil -} - -func obsWith(report *NSRestrictionsReport) *fakeObs { return &fakeObs{report: report} } -func obsErr(err error) *fakeObs { return &fakeObs{err: err} } - -func evalOne(t *testing.T, r sdk.CheckRule, obs sdk.ObservationGetter) []sdk.CheckState { - t.Helper() - return r.Evaluate(context.Background(), obs, sdk.CheckerOptions{}) -} - -func mustOne(t *testing.T, states []sdk.CheckState) sdk.CheckState { - t.Helper() - if len(states) != 1 { - t.Fatalf("expected 1 state, got %d: %+v", len(states), states) - } - return states[0] -} - -// --- Generic preamble: load error + no probes --------------------------- - -// rulesUnderTest enumerates every rule registered by Rules() with the -// expected error and skipped codes per rule. Keep in sync with Rules(). -var rulesUnderTest = []struct { - rule sdk.CheckRule - errCode string - skippedCode string - // resolutionRule emits a single OK state when there is no failure, - // not the "no probes" sentinel — it is the rule that owns the - // resolution-error rows. Skip its no-probes test. - skipNoProbes bool -}{ - {rule: &resolutionRule{}, errCode: "ns_resolution_error", skippedCode: "ns_resolution_skipped", skipNoProbes: true}, - {rule: &axfrRule{}, errCode: "ns_axfr_error", skippedCode: "ns_axfr_skipped"}, - {rule: &ixfrRule{}, errCode: "ns_ixfr_error", skippedCode: "ns_ixfr_skipped"}, - {rule: &noRecursionRule{}, errCode: "ns_recursion_error", skippedCode: "ns_recursion_skipped"}, - {rule: &anyRFC8482Rule{}, errCode: "ns_any_error", skippedCode: "ns_any_skipped"}, - {rule: &authoritativeRule{}, errCode: "ns_authoritative_error", skippedCode: "ns_authoritative_skipped"}, -} - -func TestRules_LoadErrorPropagated(t *testing.T) { - for _, tt := range rulesUnderTest { - t.Run(tt.rule.Name(), func(t *testing.T) { - st := mustOne(t, evalOne(t, tt.rule, obsErr(errors.New("boom")))) - if st.Status != sdk.StatusError { - t.Errorf("status = %v, want StatusError", st.Status) - } - if st.Code != tt.errCode { - t.Errorf("code = %q, want %q", st.Code, tt.errCode) - } - }) - } -} - -func TestRules_NoProbesEmitsSkipped(t *testing.T) { - // All resolution-failed servers: probedServers() returns empty. - report := &NSRestrictionsReport{ - Servers: []NSServerResult{{Name: "ns1.example.com", ResolutionError: "nxdomain"}}, - } - for _, tt := range rulesUnderTest { - if tt.skipNoProbes { - continue - } - t.Run(tt.rule.Name(), func(t *testing.T) { - st := mustOne(t, evalOne(t, tt.rule, obsWith(report))) - if st.Status != sdk.StatusUnknown { - t.Errorf("status = %v, want StatusUnknown", st.Status) - } - if st.Code != tt.skippedCode { - t.Errorf("code = %q, want %q", st.Code, tt.skippedCode) - } - }) - } -} - -// --- resolutionRule ------------------------------------------------------ - -func TestResolutionRule(t *testing.T) { - t.Run("all resolved -> single OK", func(t *testing.T) { - report := &NSRestrictionsReport{ - Servers: []NSServerResult{ - {Name: "ns1.example.com", Address: "192.0.2.1"}, - {Name: "ns2.example.com", Address: "192.0.2.2"}, - }, - } - st := mustOne(t, evalOne(t, &resolutionRule{}, obsWith(report))) - if st.Status != sdk.StatusOK || st.Code != "ns_resolution_ok" { - t.Errorf("got status=%v code=%q, want OK ns_resolution_ok", st.Status, st.Code) - } - }) - - t.Run("one failure -> Crit per failed NS, no OK", func(t *testing.T) { - report := &NSRestrictionsReport{ - Servers: []NSServerResult{ - {Name: "ns1.example.com", Address: "192.0.2.1"}, - {Name: "broken.example.com", ResolutionError: "no such host"}, - }, - } - states := evalOne(t, &resolutionRule{}, obsWith(report)) - if len(states) != 1 { - t.Fatalf("got %d states, want 1", len(states)) - } - if states[0].Status != sdk.StatusCrit || states[0].Code != "ns_resolution_failed" { - t.Errorf("got status=%v code=%q, want Crit ns_resolution_failed", states[0].Status, states[0].Code) - } - if states[0].Subject != "broken.example.com" { - t.Errorf("subject = %q, want broken.example.com", states[0].Subject) - } - }) -} - -// --- axfrRule ------------------------------------------------------------ - -func TestAxfrRule(t *testing.T) { - srv := func(axfr AXFRProbe) NSServerResult { - return NSServerResult{Name: "ns1.example.com", Address: "192.0.2.1", AXFR: axfr} - } - tests := []struct { - name string - probe AXFRProbe - status sdk.Status - code string - }{ - {"refused -> OK with reason", AXFRProbe{Reason: "transfer refused: REFUSED"}, sdk.StatusOK, "ns_axfr_ok"}, - {"refused with empty reason -> OK with default message", - AXFRProbe{}, sdk.StatusOK, "ns_axfr_ok"}, - {"accepted -> Crit", AXFRProbe{Accepted: true}, sdk.StatusCrit, "ns_axfr_accepted"}, - {"cancelled -> Unknown", AXFRProbe{Cancelled: true, Reason: "ctx cancelled"}, sdk.StatusUnknown, "ns_axfr_skipped"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - report := &NSRestrictionsReport{Servers: []NSServerResult{srv(tt.probe)}} - st := mustOne(t, evalOne(t, &axfrRule{}, obsWith(report))) - if st.Status != tt.status || st.Code != tt.code { - t.Errorf("got status=%v code=%q, want %v %q", st.Status, st.Code, tt.status, tt.code) - } - if st.Message == "" { - t.Error("empty message") - } - }) - } -} - -// --- ixfrRule ------------------------------------------------------------ - -func TestIxfrRule(t *testing.T) { - srv := func(p IXFRProbe) NSServerResult { - return NSServerResult{Name: "ns1.example.com", Address: "192.0.2.1", IXFR: p} - } - tests := []struct { - name string - probe IXFRProbe - status sdk.Status - code string - }{ - {"transport error -> OK", IXFRProbe{Error: "i/o timeout"}, sdk.StatusOK, "ns_ixfr_ok"}, - {"refused rcode -> OK", IXFRProbe{Rcode: "REFUSED"}, sdk.StatusOK, "ns_ixfr_ok"}, - {"NOERROR with answers -> Warn", IXFRProbe{Rcode: "NOERROR", AnswerCount: 3}, sdk.StatusWarn, "ns_ixfr_accepted"}, - {"NOERROR empty -> OK", IXFRProbe{Rcode: "NOERROR"}, sdk.StatusOK, "ns_ixfr_ok"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - report := &NSRestrictionsReport{Servers: []NSServerResult{srv(tt.probe)}} - st := mustOne(t, evalOne(t, &ixfrRule{}, obsWith(report))) - if st.Status != tt.status || st.Code != tt.code { - t.Errorf("got status=%v code=%q, want %v %q", st.Status, st.Code, tt.status, tt.code) - } - }) - } -} - -// --- noRecursionRule ----------------------------------------------------- - -func TestNoRecursionRule(t *testing.T) { - srv := func(p SOAProbe) NSServerResult { - return NSServerResult{Name: "ns1.example.com", Address: "192.0.2.1", SOA: p} - } - tests := []struct { - name string - probe SOAProbe - status sdk.Status - code string - }{ - {"transport error -> Unknown", SOAProbe{Error: "timeout"}, sdk.StatusUnknown, "ns_recursion_skipped"}, - {"RA set -> Warn", SOAProbe{RecursionAvailable: true}, sdk.StatusWarn, "ns_recursion_available"}, - {"RA unset -> OK", SOAProbe{}, sdk.StatusOK, "ns_recursion_ok"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - report := &NSRestrictionsReport{Servers: []NSServerResult{srv(tt.probe)}} - st := mustOne(t, evalOne(t, &noRecursionRule{}, obsWith(report))) - if st.Status != tt.status || st.Code != tt.code { - t.Errorf("got status=%v code=%q, want %v %q", st.Status, st.Code, tt.status, tt.code) - } - }) - } -} - -// --- anyRFC8482Rule ------------------------------------------------------ - -func TestAnyRule(t *testing.T) { - srv := func(p ANYProbe) NSServerResult { - return NSServerResult{Name: "ns1.example.com", Address: "192.0.2.1", ANY: p} - } - tests := []struct { - name string - probe ANYProbe - status sdk.Status - code string - }{ - {"transport error -> Unknown", ANYProbe{Error: "timeout"}, sdk.StatusUnknown, "ns_any_skipped"}, - {"refused -> OK", ANYProbe{Rcode: "REFUSED"}, sdk.StatusOK, "ns_any_ok"}, - {"HINFO only -> OK", ANYProbe{Rcode: "NOERROR", AnswerCount: 1, HINFOOnly: true}, sdk.StatusOK, "ns_any_ok"}, - {"empty answer -> OK", ANYProbe{Rcode: "NOERROR"}, sdk.StatusOK, "ns_any_ok"}, - {"full answer -> Warn", ANYProbe{Rcode: "NOERROR", AnswerCount: 5}, sdk.StatusWarn, "ns_any_non_compliant"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - report := &NSRestrictionsReport{Servers: []NSServerResult{srv(tt.probe)}} - st := mustOne(t, evalOne(t, &anyRFC8482Rule{}, obsWith(report))) - if st.Status != tt.status || st.Code != tt.code { - t.Errorf("got status=%v code=%q, want %v %q", st.Status, st.Code, tt.status, tt.code) - } - }) - } -} - -// --- authoritativeRule --------------------------------------------------- - -func TestAuthoritativeRule(t *testing.T) { - srv := func(p SOAProbe) NSServerResult { - return NSServerResult{Name: "ns1.example.com", Address: "192.0.2.1", SOA: p} - } - tests := []struct { - name string - probe SOAProbe - status sdk.Status - code string - }{ - {"transport error -> Info", SOAProbe{Error: "timeout"}, sdk.StatusInfo, "ns_authoritative_unknown"}, - {"AA set -> OK", SOAProbe{Authoritative: true}, sdk.StatusOK, "ns_authoritative_ok"}, - {"AA unset -> Info", SOAProbe{}, sdk.StatusInfo, "ns_authoritative_missing"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - report := &NSRestrictionsReport{Servers: []NSServerResult{srv(tt.probe)}} - st := mustOne(t, evalOne(t, &authoritativeRule{}, obsWith(report))) - if st.Status != tt.status || st.Code != tt.code { - t.Errorf("got status=%v code=%q, want %v %q", st.Status, st.Code, tt.status, tt.code) - } - }) - } -} - -// --- multi-server fan-out ------------------------------------------------ - -// Sanity check: a rule that has 3 probed servers must return 3 states, -// each with the per-server subject. This covers the loop in every -// per-server rule and would catch a regression where the boilerplate gets -// factored incorrectly. -func TestRules_OneStatePerProbedServer(t *testing.T) { - report := &NSRestrictionsReport{ - Servers: []NSServerResult{ - {Name: "ns1.example.com", Address: "192.0.2.1"}, // probed - {Name: "ns2.example.com", Address: "192.0.2.2"}, // probed - {Name: "ns3.example.com", AddressSkipped: true}, // skipped - {Name: "ns4.example.com", ResolutionError: "x"}, // resolution failed - }, - } - perServer := []sdk.CheckRule{ - &axfrRule{}, &ixfrRule{}, &noRecursionRule{}, - &anyRFC8482Rule{}, &authoritativeRule{}, - } - for _, r := range perServer { - t.Run(r.Name(), func(t *testing.T) { - states := evalOne(t, r, obsWith(report)) - if len(states) != 2 { - t.Fatalf("got %d states, want 2 (one per probed server): %+v", len(states), states) - } - subjects := map[string]bool{} - for _, st := range states { - subjects[st.Subject] = true - } - if !subjects["ns1.example.com (192.0.2.1)"] || !subjects["ns2.example.com (192.0.2.2)"] { - t.Errorf("subjects = %v, want both probed servers", subjects) - } - }) - } -} diff --git a/checker/types.go b/checker/types.go index ebef320..d84f327 100644 --- a/checker/types.go +++ b/checker/types.go @@ -6,107 +6,23 @@ import "encoding/json" // restrictions data. const ObservationKeyNSRestrictions = "ns_restrictions" -// NSRestrictionsReport contains the raw probe results from every discovered -// nameserver address. It carries facts (answer rcodes, flag bits, record -// counts, errors, …) and does not make any pass/fail judgment; rules derive -// status from these fields. +// NSRestrictionsReport contains the results of NS security restriction checks. type NSRestrictionsReport struct { - // Domain is the zone that was probed. - Domain string `json:"domain"` - - // IPv6Reachable reflects whether the host running the checker could - // reach the public IPv6 internet at collection time. When false, - // probes against IPv6 addresses are skipped (AddressSkipped=true). - IPv6Reachable bool `json:"ipv6Reachable"` - - // Servers holds one entry per (NS host, resolved address) pair, - // plus one entry per NS host that failed DNS resolution (with - // ResolutionError set and Address empty). Servers []NSServerResult `json:"servers"` } -// NSServerResult holds raw probe results for a single nameserver address. +// NSServerResult holds the check results for a single nameserver IP. type NSServerResult struct { - // Name is the authoritative NS host name being probed. - Name string `json:"name"` - - // Address is the resolved IP address (may be empty when DNS - // resolution failed or when the address was skipped). - Address string `json:"address,omitempty"` - - // ResolutionError is set when resolving Name to any IP failed. - // Other per-probe fields are not populated in that case. - ResolutionError string `json:"resolutionError,omitempty"` - - // AddressSkipped is true when Address was not probed, e.g. an - // IPv6 address on a host without IPv6 connectivity. Per-probe - // fields are not populated. - AddressSkipped bool `json:"addressSkipped,omitempty"` - - // SkipReason describes why AddressSkipped was set. - SkipReason string `json:"skipReason,omitempty"` - - // AXFR carries the raw AXFR probe result. - AXFR AXFRProbe `json:"axfr"` - - // IXFR carries the raw IXFR probe result. - IXFR IXFRProbe `json:"ixfr"` - - // SOA carries the SOA/RD query used for the recursion and - // authoritative probes. - SOA SOAProbe `json:"soa"` - - // ANY carries the raw ANY-query probe result. - ANY ANYProbe `json:"any"` + Name string `json:"name"` + Address string `json:"address"` + Checks []NSCheckItem `json:"checks"` } -// AXFRProbe describes what happened when an AXFR zone transfer was attempted. -type AXFRProbe struct { - // Accepted is true when the server served a full zone transfer - // (emitted at least a SOA envelope). - Accepted bool `json:"accepted"` - // Reason is a human-readable description of the outcome when - // Accepted is false: either the refusal reason returned by the - // server or the transport error encountered. Empty when Accepted - // is true. - Reason string `json:"reason,omitempty"` - // Cancelled is true when the probe was cut short by context cancel. - Cancelled bool `json:"cancelled,omitempty"` -} - -// IXFRProbe describes what happened when an IXFR query was issued. -type IXFRProbe struct { - // Error is non-empty when the UDP query itself failed. - Error string `json:"error,omitempty"` - // Rcode is the DNS rcode string of the response ("" on error). - Rcode string `json:"rcode,omitempty"` - // AnswerCount is the number of answer records returned. - AnswerCount int `json:"answerCount"` -} - -// SOAProbe describes the SOA/RD=1 query used by the recursion and -// authoritative rules. -type SOAProbe struct { - // Error is non-empty when the UDP query itself failed. - Error string `json:"error,omitempty"` - // RecursionAvailable reflects the RA bit in the response header. - RecursionAvailable bool `json:"recursionAvailable"` - // Authoritative reflects the AA bit in the response header. - Authoritative bool `json:"authoritative"` -} - -// ANYProbe describes the outcome of a qtype=ANY query, used to judge RFC -// 8482 compliance. -type ANYProbe struct { - // Error is non-empty when the UDP query itself failed. - Error string `json:"error,omitempty"` - // Rcode is the DNS rcode string of the response ("" on error). - Rcode string `json:"rcode,omitempty"` - // AnswerCount is the number of answer records in the response. - AnswerCount int `json:"answerCount"` - // HINFOOnly is true when the answer section is exactly a single - // HINFO record, i.e. the RFC 8482 minimal response. - HINFOOnly bool `json:"hinfoOnly"` +// NSCheckItem represents one security check for an NS server. +type NSCheckItem struct { + Name string `json:"name"` + OK bool `json:"ok"` + Detail string `json:"detail,omitempty"` } // serviceMessage is a minimal local copy of happydns.ServiceMessage matching diff --git a/go.mod b/go.mod index 63525b0..906fafe 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module git.happydns.org/checker-ns-restrictions go 1.25.0 require ( - git.happydns.org/checker-sdk-go v1.3.0 + git.happydns.org/checker-sdk-go v1.2.0 github.com/miekg/dns v1.1.72 ) diff --git a/go.sum b/go.sum index 092728a..d64ca7c 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -git.happydns.org/checker-sdk-go v1.3.0 h1:FG2kIhlJCzI0m35EhxSgn4UWc9M4ha6aZTeoChu4l7A= -git.happydns.org/checker-sdk-go v1.3.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= +git.happydns.org/checker-sdk-go v1.2.0 h1:v4MpKAz0W3PwP+bxx3pya8w893sVH5xTD1of1cc0TV8= +git.happydns.org/checker-sdk-go v1.2.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 1b3419f..2ea54aa 100644 --- a/main.go +++ b/main.go @@ -5,7 +5,7 @@ import ( "log" nsr "git.happydns.org/checker-ns-restrictions/checker" - "git.happydns.org/checker-sdk-go/checker/server" + sdk "git.happydns.org/checker-sdk-go/checker" ) // Version is the standalone binary's version. It defaults to "custom-build" @@ -23,8 +23,8 @@ func main() { // CheckerDefinition.Version. nsr.Version = Version - srv := server.New(nsr.Provider()) - if err := srv.ListenAndServe(*listenAddr); err != nil { + server := sdk.NewServer(nsr.Provider()) + if err := server.ListenAndServe(*listenAddr); err != nil { log.Fatalf("server error: %v", err) } } diff --git a/plugin/plugin.go b/plugin/plugin.go index 71c04f2..9551878 100644 --- a/plugin/plugin.go +++ b/plugin/plugin.go @@ -6,8 +6,6 @@ package main import ( - "fmt" - nsr "git.happydns.org/checker-ns-restrictions/checker" sdk "git.happydns.org/checker-sdk-go/checker" ) @@ -25,10 +23,5 @@ func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) // Propagate the plugin's version to the checker package so it shows up // in CheckerDefinition.Version. nsr.Version = Version - prvd := nsr.Provider() - defProvider, ok := prvd.(sdk.CheckerDefinitionProvider) - if !ok { - return nil, nil, fmt.Errorf("provider %T does not implement sdk.CheckerDefinitionProvider", prvd) - } - return defProvider.Definition(), prvd, nil + return nsr.Definition(), nsr.Provider(), nil }