From e8b38fac590ff42f237c06f6c9b2b9ac4d443638 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sun, 26 Apr 2026 10:20:35 +0700 Subject: [PATCH] checker: split monolithic rule into per-concern rules --- README.md | 14 ++ checker/abstract.go | 22 +-- checker/checks.go | 216 +++++++++++----------- checker/collect.go | 128 ++++++++----- checker/collect_test.go | 132 ++++++++++++++ checker/definition.go | 2 +- checker/interactive.go | 24 ++- checker/provider.go | 5 - checker/rule.go | 167 ++++++----------- checker/rules_any.go | 62 +++++++ checker/rules_authoritative.go | 54 ++++++ checker/rules_axfr.go | 54 ++++++ checker/rules_ixfr.go | 57 ++++++ checker/rules_recursion.go | 53 ++++++ checker/rules_resolution.go | 48 +++++ checker/rules_test.go | 322 +++++++++++++++++++++++++++++++++ checker/types.go | 104 ++++++++++- plugin/plugin.go | 9 +- 18 files changed, 1162 insertions(+), 311 deletions(-) create mode 100644 checker/collect_test.go create mode 100644 checker/rules_any.go create mode 100644 checker/rules_authoritative.go create mode 100644 checker/rules_axfr.go create mode 100644 checker/rules_ixfr.go create mode 100644 checker/rules_recursion.go create mode 100644 checker/rules_resolution.go create mode 100644 checker/rules_test.go diff --git a/README.md b/README.md index 8e02673..8536d4c 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,20 @@ 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 c8de1e5..3a7e18c 100644 --- a/checker/abstract.go +++ b/checker/abstract.go @@ -12,16 +12,10 @@ const ( serviceTypeNSOnlyOrigin = "abstract.NSOnlyOrigin" ) -// 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 +// nsPayload is a minimal local copy of services/abstract.Origin and // services/abstract.NSOnlyOrigin keeping only the field this checker reads. -type nsOnlyOriginPayload struct { +// The JSON tag matches the upstream wire format ("ns"). +type nsPayload struct { NameServers []*dns.NS `json:"ns"` } @@ -29,14 +23,8 @@ type nsOnlyOriginPayload struct { // NSOnlyOrigin service payload. func nsFromService(svc *serviceMessage) []*dns.NS { switch svc.Type { - case serviceTypeOrigin: - var o originPayload - if err := json.Unmarshal(svc.Service, &o); err != nil { - return nil - } - return o.NameServers - case serviceTypeNSOnlyOrigin: - var o nsOnlyOriginPayload + case serviceTypeOrigin, serviceTypeNSOnlyOrigin: + var o nsPayload if err := json.Unmarshal(svc.Service, &o); err != nil { return nil } diff --git a/checker/checks.go b/checker/checks.go index 89bb952..2a60bd3 100644 --- a/checker/checks.go +++ b/checker/checks.go @@ -4,161 +4,153 @@ import ( "context" "fmt" "net" + "sync" "time" "github.com/miekg/dns" ) -// 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) { +// 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 { msg := new(dns.Msg) msg.SetAxfr(dns.Fqdn(domain)) - 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) + t := &dns.Transfer{ + DialTimeout: 5 * time.Second, + ReadTimeout: 10 * time.Second, } - for env := range ch { - if env.Error != nil { - return true, fmt.Sprintf("transfer error: %s", env.Error) + 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 _, rr := range env.RR { - if rr.Header().Rrtype == dns.TypeSOA { - return false, "AXFR zone transfer accepted" + // 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} + } } } - } + done <- verdict + }() - return true, "AXFR refused" + select { + case <-ctx.Done(): + return AXFRProbe{Cancelled: true, Reason: fmt.Sprintf("AXFR check cancelled: %s", ctx.Err())} + case r := <-done: + return r + } } -// 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) { +// probeIXFR issues a single IXFR query and returns the raw response facts. +func probeIXFR(ctx context.Context, domain, addr string) IXFRProbe { msg := new(dns.Msg) msg.SetIxfr(dns.Fqdn(domain), 0, "", "") - cl := &dns.Client{Net: "udp", Timeout: 5 * time.Second} - resp, _, err := cl.ExchangeContext(ctx, msg, net.JoinHostPort(addr, "53")) + resp, err := exchangeUDP(ctx, msg, addr) if err != nil { - return true, fmt.Sprintf("query failed: %s", err) + return IXFRProbe{Error: err.Error()} } - - if resp.Rcode != dns.RcodeSuccess { - return true, fmt.Sprintf("IXFR refused (rcode=%s)", dns.RcodeToString[resp.Rcode]) + return IXFRProbe{ + Rcode: dns.RcodeToString[resp.Rcode], + AnswerCount: len(resp.Answer), } - if len(resp.Answer) > 0 { - return false, fmt.Sprintf("IXFR accepted with %d answer(s)", len(resp.Answer)) - } - - return true, "IXFR refused or empty" } -// checkNoRecursion returns (ok bool, detail string). -// ok=false means the server offers recursion (WARN). -func checkNoRecursion(ctx context.Context, domain, addr string) (bool, string) { +// probeSOA issues a SOA query with RD=1 and captures the RA and AA bits. +func probeSOA(ctx context.Context, domain, addr string) SOAProbe { msg := new(dns.Msg) msg.SetQuestion(dns.Fqdn(domain), dns.TypeSOA) msg.RecursionDesired = true - cl := &dns.Client{Net: "udp", Timeout: 5 * time.Second} - resp, _, err := cl.ExchangeContext(ctx, msg, net.JoinHostPort(addr, "53")) + resp, err := exchangeUDP(ctx, msg, addr) if err != nil { - return true, fmt.Sprintf("query failed: %s", err) + return SOAProbe{Error: err.Error()} } - - if resp.RecursionAvailable { - return false, "recursion available (RA bit set)" + return SOAProbe{ + RecursionAvailable: resp.RecursionAvailable, + Authoritative: resp.Authoritative, } - return true, "recursion not available" } -// 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) { +// probeANY issues an ANY query and records raw facts about the answer. +func probeANY(ctx context.Context, domain, addr string) ANYProbe { msg := new(dns.Msg) msg.SetQuestion(dns.Fqdn(domain), dns.TypeANY) - cl := &dns.Client{Net: "udp", Timeout: 5 * time.Second} - resp, _, err := cl.ExchangeContext(ctx, msg, net.JoinHostPort(addr, "53")) + resp, err := exchangeUDP(ctx, msg, addr) if err != nil { - return true, fmt.Sprintf("query failed: %s", err) + return ANYProbe{Error: err.Error()} } - - if resp.Rcode != dns.RcodeSuccess { - return true, fmt.Sprintf("ANY refused (rcode=%s)", dns.RcodeToString[resp.Rcode]) + out := ANYProbe{ + Rcode: dns.RcodeToString[resp.Rcode], + AnswerCount: len(resp.Answer), } - - if len(resp.Answer) == 1 { - if _, ok := resp.Answer[0].(*dns.HINFO); ok { - return true, "RFC 8482 compliant HINFO response" + if len(resp.Answer) > 0 { + hinfoOnly := true + for _, rr := range resp.Answer { + if _, ok := rr.(*dns.HINFO); !ok { + hinfoOnly = false + break + } } + out.HINFOOnly = hinfoOnly } - - 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)) + return out } -// 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) +// 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() - 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) + return NSServerResult{ + Name: nsHost, + Address: addr, + AXFR: axfr, + IXFR: ixfr, + SOA: soa, + ANY: any, } - - 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 0a81567..fa9288a 100644 --- a/checker/collect.go +++ b/checker/collect.go @@ -7,14 +7,16 @@ import ( "fmt" "net" "strings" + "sync" "syscall" "time" sdk "git.happydns.org/checker-sdk-go/checker" ) -// Collect performs the NS security restriction checks for the configured -// service and returns an NSRestrictionsReport. +// 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. func (p *nsProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) { svc, err := serviceFromOptions(opts) if err != nil { @@ -42,25 +44,46 @@ func (p *nsProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, return nil, fmt.Errorf("no nameservers found in service") } - 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...) + 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...) } 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. @@ -82,45 +105,56 @@ func serviceFromOptions(opts sdk.CheckerOptions) (*serviceMessage, error) { return &svc, nil } -// checkNameServer resolves nsHost and runs checks on each address. -func checkNameServer(ctx context.Context, domain, nsHost string) []NSServerResult { +// 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 { addrs, err := net.LookupHost(nsHost) if err != nil { return []NSServerResult{{ - Name: nsHost, - Address: "", - Checks: []NSCheckItem{{ - Name: "DNS resolution", - OK: false, - Detail: fmt.Sprintf("lookup failed: %s", err), - }}, + Name: nsHost, + ResolutionError: err.Error(), }} } - 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 := 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 + } } - if conn != nil { - conn.Close() - } - } - - results = append(results, checkServerAddr(ctx, domain, nsHost, addr)) + results[i] = probeServerAddr(ctx, domain, nsHost, addr) + }() } + wg.Wait() return results } diff --git a/checker/collect_test.go b/checker/collect_test.go new file mode 100644 index 0000000..790fb03 --- /dev/null +++ b/checker/collect_test.go @@ -0,0 +1,132 @@ +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 1db8583..ba4f89c 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 Definition() *sdk.CheckerDefinition { +func (p *nsProvider) Definition() *sdk.CheckerDefinition { return &sdk.CheckerDefinition{ ID: "ns_restrictions", Name: "NS Security Restrictions", diff --git a/checker/interactive.go b/checker/interactive.go index 3506d22..6469bbf 100644 --- a/checker/interactive.go +++ b/checker/interactive.go @@ -3,16 +3,23 @@ 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 // inputs needed to bootstrap a check when this checker runs standalone // (outside of a happyDomain host). @@ -48,7 +55,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(originPayload{NameServers: nsRecords}) + payload, err := json.Marshal(nsPayload{NameServers: nsRecords}) if err != nil { return nil, fmt.Errorf("failed to encode origin payload: %w", err) } @@ -69,19 +76,28 @@ 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) { - c := new(dns.Client) + ctx, cancel := context.WithTimeout(context.Background(), resolveNSTimeout) + defer cancel() + + c := &dns.Client{Timeout: defaultQueryTimeout} 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: "53"} + config = &dns.ClientConfig{Servers: []string{"1.1.1.1", "8.8.8.8"}, Port: dnsPort} } var lastErr error for _, server := range config.Servers { - in, _, err := c.Exchange(m, server+":"+config.Port) + if err := ctx.Err(); err != nil { + if lastErr == nil { + lastErr = err + } + break + } + in, _, err := c.ExchangeContext(ctx, m, net.JoinHostPort(server, config.Port)) if err != nil { lastErr = err continue diff --git a/checker/provider.go b/checker/provider.go index 6dfe8a7..48e2dda 100644 --- a/checker/provider.go +++ b/checker/provider.go @@ -14,8 +14,3 @@ 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 94abc35..301c6b6 100644 --- a/checker/rule.go +++ b/checker/rule.go @@ -7,135 +7,74 @@ import ( sdk "git.happydns.org/checker-sdk-go/checker" ) -// 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. +// 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. func Rules() []sdk.CheckRule { return []sdk.CheckRule{ - &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", - }, + &resolutionRule{}, + &axfrRule{}, + &ixfrRule{}, + &noRecursionRule{}, + &anyRFC8482Rule{}, + &authoritativeRule{}, } } -// 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 { +// 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) { var report NSRestrictionsReport if err := obs.Get(ctx, ObservationKeyNSRestrictions, &report); err != nil { - return []sdk.CheckState{{ + return nil, &sdk.CheckState{ Status: sdk.StatusError, Message: fmt.Sprintf("Failed to get NS restrictions data: %v", err), - Code: r.code + "_error", - }} + Code: errCode, + } } - - 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 + return &report, nil } +// 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 new file mode 100644 index 0000000..3f5cfb4 --- /dev/null +++ b/checker/rules_any.go @@ -0,0 +1,62 @@ +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 new file mode 100644 index 0000000..5c6b09a --- /dev/null +++ b/checker/rules_authoritative.go @@ -0,0 +1,54 @@ +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 new file mode 100644 index 0000000..862197b --- /dev/null +++ b/checker/rules_axfr.go @@ -0,0 +1,54 @@ +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 new file mode 100644 index 0000000..8a886a3 --- /dev/null +++ b/checker/rules_ixfr.go @@ -0,0 +1,57 @@ +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 new file mode 100644 index 0000000..7f93250 --- /dev/null +++ b/checker/rules_recursion.go @@ -0,0 +1,53 @@ +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 new file mode 100644 index 0000000..e54aa70 --- /dev/null +++ b/checker/rules_resolution.go @@ -0,0 +1,48 @@ +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 new file mode 100644 index 0000000..04e0a07 --- /dev/null +++ b/checker/rules_test.go @@ -0,0 +1,322 @@ +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 d84f327..ebef320 100644 --- a/checker/types.go +++ b/checker/types.go @@ -6,23 +6,107 @@ import "encoding/json" // restrictions data. const ObservationKeyNSRestrictions = "ns_restrictions" -// NSRestrictionsReport contains the results of NS security restriction checks. +// 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. 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 the check results for a single nameserver IP. +// NSServerResult holds raw probe results for a single nameserver address. type NSServerResult struct { - Name string `json:"name"` - Address string `json:"address"` - Checks []NSCheckItem `json:"checks"` + // 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"` } -// 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"` +// 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"` } // serviceMessage is a minimal local copy of happydns.ServiceMessage matching diff --git a/plugin/plugin.go b/plugin/plugin.go index 9551878..71c04f2 100644 --- a/plugin/plugin.go +++ b/plugin/plugin.go @@ -6,6 +6,8 @@ package main import ( + "fmt" + nsr "git.happydns.org/checker-ns-restrictions/checker" sdk "git.happydns.org/checker-sdk-go/checker" ) @@ -23,5 +25,10 @@ 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 - return nsr.Definition(), nsr.Provider(), nil + 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 }