checker: split monolithic rule into per-concern rules

This commit is contained in:
nemunaire 2026-04-26 10:20:35 +07:00
commit e8b38fac59
18 changed files with 1159 additions and 308 deletions

View file

@ -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

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

132
checker/collect_test.go Normal file
View file

@ -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)
}
})
}

View file

@ -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",

View file

@ -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

View file

@ -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()
}

View file

@ -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,
}
}

62
checker/rules_any.go Normal file
View file

@ -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
}

View file

@ -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
}

54
checker/rules_axfr.go Normal file
View file

@ -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
}

57
checker/rules_ixfr.go Normal file
View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

322
checker/rules_test.go Normal file
View file

@ -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)
}
})
}
}

View file

@ -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

View file

@ -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
}