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 definition and observation provider, which happyDomain registers in its
global registries at load time. 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 ### Versioning
The binary, plugin, and Docker image embed a version string overridable The binary, plugin, and Docker image embed a version string overridable

View file

@ -12,16 +12,10 @@ const (
serviceTypeNSOnlyOrigin = "abstract.NSOnlyOrigin" serviceTypeNSOnlyOrigin = "abstract.NSOnlyOrigin"
) )
// originPayload is a minimal local copy of services/abstract.Origin keeping // nsPayload is a minimal local copy of services/abstract.Origin and
// only the field this checker reads. The JSON tag matches the upstream wire
// format ("ns").
type originPayload struct {
NameServers []*dns.NS `json:"ns"`
}
// nsOnlyOriginPayload is a minimal local copy of
// services/abstract.NSOnlyOrigin keeping only the field this checker reads. // 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"` NameServers []*dns.NS `json:"ns"`
} }
@ -29,14 +23,8 @@ type nsOnlyOriginPayload struct {
// NSOnlyOrigin service payload. // NSOnlyOrigin service payload.
func nsFromService(svc *serviceMessage) []*dns.NS { func nsFromService(svc *serviceMessage) []*dns.NS {
switch svc.Type { switch svc.Type {
case serviceTypeOrigin: case serviceTypeOrigin, serviceTypeNSOnlyOrigin:
var o originPayload var o nsPayload
if err := json.Unmarshal(svc.Service, &o); err != nil {
return nil
}
return o.NameServers
case serviceTypeNSOnlyOrigin:
var o nsOnlyOriginPayload
if err := json.Unmarshal(svc.Service, &o); err != nil { if err := json.Unmarshal(svc.Service, &o); err != nil {
return nil return nil
} }

View file

@ -4,161 +4,153 @@ import (
"context" "context"
"fmt" "fmt"
"net" "net"
"sync"
"time" "time"
"github.com/miekg/dns" "github.com/miekg/dns"
) )
// checkAXFR returns (ok bool, detail string). // dnsPort is the DNS service port used for every query made by this checker.
// ok=false means the server accepted the zone transfer (CRITICAL). const dnsPort = "53"
func checkAXFR(ctx context.Context, domain, addr string) (bool, string) {
// 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 := new(dns.Msg)
msg.SetAxfr(dns.Fqdn(domain)) msg.SetAxfr(dns.Fqdn(domain))
t := &dns.Transfer{} t := &dns.Transfer{
t.DialTimeout = 5 * time.Second DialTimeout: 5 * time.Second,
t.ReadTimeout = 10 * time.Second ReadTimeout: 10 * time.Second,
ch, err := t.In(msg, net.JoinHostPort(addr, "53"))
if err != nil {
return true, fmt.Sprintf("transfer refused: %s", err)
} }
for env := range ch { done := make(chan AXFRProbe, 1)
if env.Error != nil { go func() {
return true, fmt.Sprintf("transfer error: %s", env.Error) 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 { // Drain channel even after a verdict: stopping reads would
if rr.Header().Rrtype == dns.TypeSOA { // block miekg/dns' sender goroutine on the TCP connection.
return false, "AXFR zone transfer accepted" 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). // probeIXFR issues a single IXFR query and returns the raw response facts.
// ok=false means the server answered with records (WARN). func probeIXFR(ctx context.Context, domain, addr string) IXFRProbe {
func checkIXFR(ctx context.Context, domain, addr string) (bool, string) {
msg := new(dns.Msg) msg := new(dns.Msg)
msg.SetIxfr(dns.Fqdn(domain), 0, "", "") msg.SetIxfr(dns.Fqdn(domain), 0, "", "")
cl := &dns.Client{Net: "udp", Timeout: 5 * time.Second} resp, err := exchangeUDP(ctx, msg, addr)
resp, _, err := cl.ExchangeContext(ctx, msg, net.JoinHostPort(addr, "53"))
if err != nil { if err != nil {
return true, fmt.Sprintf("query failed: %s", err) return IXFRProbe{Error: err.Error()}
} }
return IXFRProbe{
if resp.Rcode != dns.RcodeSuccess { Rcode: dns.RcodeToString[resp.Rcode],
return true, fmt.Sprintf("IXFR refused (rcode=%s)", 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). // probeSOA issues a SOA query with RD=1 and captures the RA and AA bits.
// ok=false means the server offers recursion (WARN). func probeSOA(ctx context.Context, domain, addr string) SOAProbe {
func checkNoRecursion(ctx context.Context, domain, addr string) (bool, string) {
msg := new(dns.Msg) msg := new(dns.Msg)
msg.SetQuestion(dns.Fqdn(domain), dns.TypeSOA) msg.SetQuestion(dns.Fqdn(domain), dns.TypeSOA)
msg.RecursionDesired = true msg.RecursionDesired = true
cl := &dns.Client{Net: "udp", Timeout: 5 * time.Second} resp, err := exchangeUDP(ctx, msg, addr)
resp, _, err := cl.ExchangeContext(ctx, msg, net.JoinHostPort(addr, "53"))
if err != nil { if err != nil {
return true, fmt.Sprintf("query failed: %s", err) return SOAProbe{Error: err.Error()}
} }
return SOAProbe{
if resp.RecursionAvailable { RecursionAvailable: resp.RecursionAvailable,
return false, "recursion available (RA bit set)" Authoritative: resp.Authoritative,
} }
return true, "recursion not available"
} }
// checkANYHandled returns (ok bool, detail string). // probeANY issues an ANY query and records raw facts about the answer.
// ok=false means the server returned a full record set for ANY (WARN). func probeANY(ctx context.Context, domain, addr string) ANYProbe {
// Per RFC 8482, servers should return HINFO or a minimal response.
func checkANYHandled(ctx context.Context, domain, addr string) (bool, string) {
msg := new(dns.Msg) msg := new(dns.Msg)
msg.SetQuestion(dns.Fqdn(domain), dns.TypeANY) msg.SetQuestion(dns.Fqdn(domain), dns.TypeANY)
cl := &dns.Client{Net: "udp", Timeout: 5 * time.Second} resp, err := exchangeUDP(ctx, msg, addr)
resp, _, err := cl.ExchangeContext(ctx, msg, net.JoinHostPort(addr, "53"))
if err != nil { if err != nil {
return true, fmt.Sprintf("query failed: %s", err) return ANYProbe{Error: err.Error()}
} }
out := ANYProbe{
if resp.Rcode != dns.RcodeSuccess { Rcode: dns.RcodeToString[resp.Rcode],
return true, fmt.Sprintf("ANY refused (rcode=%s)", dns.RcodeToString[resp.Rcode]) AnswerCount: len(resp.Answer),
} }
if len(resp.Answer) > 0 {
if len(resp.Answer) == 1 { hinfoOnly := true
if _, ok := resp.Answer[0].(*dns.HINFO); ok { for _, rr := range resp.Answer {
return true, "RFC 8482 compliant HINFO response" if _, ok := rr.(*dns.HINFO); !ok {
hinfoOnly = false
break
}
} }
out.HINFOOnly = hinfoOnly
} }
return out
if len(resp.Answer) == 0 {
return true, "ANY returned empty answer"
}
return false, fmt.Sprintf("ANY returned %d records (not RFC 8482 compliant)", len(resp.Answer))
} }
// checkIsAuthoritative returns (ok bool, detail string). // probeServerAddr runs every raw probe against a single IP address in parallel
// ok=false means the server is not authoritative for the zone (INFO). // and returns a populated NSServerResult with no pass/fail judgment applied.
func checkIsAuthoritative(ctx context.Context, domain, addr string) (bool, string) { func probeServerAddr(ctx context.Context, domain, nsHost, addr string) NSServerResult {
msg := new(dns.Msg) var (
msg.SetQuestion(dns.Fqdn(domain), dns.TypeSOA) 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} return NSServerResult{
resp, _, err := cl.ExchangeContext(ctx, msg, net.JoinHostPort(addr, "53")) Name: nsHost,
if err != nil { Address: addr,
return false, fmt.Sprintf("query failed: %s", err) 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" "fmt"
"net" "net"
"strings" "strings"
"sync"
"syscall" "syscall"
"time" "time"
sdk "git.happydns.org/checker-sdk-go/checker" sdk "git.happydns.org/checker-sdk-go/checker"
) )
// Collect performs the NS security restriction checks for the configured // Collect gathers raw NS probe data for the configured service and returns an
// service and returns an NSRestrictionsReport. // 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) { func (p *nsProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
svc, err := serviceFromOptions(opts) svc, err := serviceFromOptions(opts)
if err != nil { 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") return nil, fmt.Errorf("no nameservers found in service")
} }
report := &NSRestrictionsReport{} ipv6Reachable := probeIPv6(ctx)
for _, ns := range nameServers {
var nsHost string all := make([][]NSServerResult, len(nameServers))
if nsCut, ok := strings.CutSuffix(ns.Ns, "."); ok { var wg sync.WaitGroup
nsHost = nsCut wg.Add(len(nameServers))
} else { for i, ns := range nameServers {
nsHost = ns.Ns nsHost := buildNSHost(ns.Ns, svc.Domain, domainName)
if svc.Domain != "" && svc.Domain != "@" { go func() {
nsHost += "." + strings.TrimSuffix(svc.Domain, ".") defer wg.Done()
} all[i] = probeNameServer(ctx, domainName, nsHost, ipv6Reachable)
nsHost += "." + strings.TrimSuffix(domainName, ".") }()
} }
results := checkNameServer(ctx, domainName, nsHost) wg.Wait()
report.Servers = append(report.Servers, results...)
report := &NSRestrictionsReport{
Domain: domainName,
IPv6Reachable: ipv6Reachable,
}
for _, r := range all {
report.Servers = append(report.Servers, r...)
} }
return report, nil 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 // serviceFromOptions extracts a *serviceMessage from the options. It accepts
// either a direct value (in-process plugin path) or a JSON-decoded // either a direct value (in-process plugin path) or a JSON-decoded
// map[string]any (HTTP path), both are normalized via a JSON round-trip. // map[string]any (HTTP path), both are normalized via a JSON round-trip.
@ -82,45 +105,56 @@ func serviceFromOptions(opts sdk.CheckerOptions) (*serviceMessage, error) {
return &svc, nil return &svc, nil
} }
// checkNameServer resolves nsHost and runs checks on each address. // probeIPv6 returns true if the host appears to have IPv6 connectivity. It
func checkNameServer(ctx context.Context, domain, nsHost string) []NSServerResult { // 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) addrs, err := net.LookupHost(nsHost)
if err != nil { if err != nil {
return []NSServerResult{{ return []NSServerResult{{
Name: nsHost, Name: nsHost,
Address: "", ResolutionError: err.Error(),
Checks: []NSCheckItem{{
Name: "DNS resolution",
OK: false,
Detail: fmt.Sprintf("lookup failed: %s", err),
}},
}} }}
} }
var results []NSServerResult results := make([]NSServerResult, len(addrs))
for _, addr := range addrs { var wg sync.WaitGroup
// Skip IPv6 addresses when there is no IPv6 connectivity. wg.Add(len(addrs))
if ip := net.ParseIP(addr); ip != nil && ip.To4() == nil { for i, addr := range addrs {
conn, err := net.DialTimeout("udp", net.JoinHostPort(addr, "53"), 3*time.Second) go func() {
if errors.Is(err, syscall.ENETUNREACH) { defer wg.Done()
results = append(results, NSServerResult{ if !ipv6Reachable {
Name: nsHost, if ip := net.ParseIP(addr); ip != nil && ip.To4() == nil {
Address: addr, results[i] = NSServerResult{
Checks: []NSCheckItem{{ Name: nsHost,
Name: "IPv6 connectivity", Address: addr,
OK: true, AddressSkipped: true,
Detail: "unable to test due to the lack of IPv6 connectivity", SkipReason: "host lacks IPv6 connectivity",
}}, }
}) return
continue }
} }
if conn != nil { results[i] = probeServerAddr(ctx, domain, nsHost, addr)
conn.Close() }()
}
}
results = append(results, checkServerAddr(ctx, domain, nsHost, addr))
} }
wg.Wait()
return results 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 // Definition returns the CheckerDefinition for the NS security restrictions
// checker. // checker.
func Definition() *sdk.CheckerDefinition { func (p *nsProvider) Definition() *sdk.CheckerDefinition {
return &sdk.CheckerDefinition{ return &sdk.CheckerDefinition{
ID: "ns_restrictions", ID: "ns_restrictions",
Name: "NS Security Restrictions", Name: "NS Security Restrictions",

View file

@ -3,16 +3,23 @@
package checker package checker
import ( import (
"context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"net"
"net/http" "net/http"
"strings" "strings"
"time"
sdk "git.happydns.org/checker-sdk-go/checker" sdk "git.happydns.org/checker-sdk-go/checker"
"github.com/miekg/dns" "github.com/miekg/dns"
) )
// resolveNSTimeout bounds the total time spent attempting NS resolution
// across all configured fallback resolvers.
const resolveNSTimeout = 15 * time.Second
// RenderForm implements server.Interactive. It lists the minimal human // RenderForm implements server.Interactive. It lists the minimal human
// inputs needed to bootstrap a check when this checker runs standalone // inputs needed to bootstrap a check when this checker runs standalone
// (outside of a happyDomain host). // (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) 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 { if err != nil {
return nil, fmt.Errorf("failed to encode origin payload: %w", err) 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 // returns them as miekg *dns.NS records so they match the shape produced
// by happyDomain's Origin service payload. // by happyDomain's Origin service payload.
func resolveNS(fqdn string) ([]*dns.NS, error) { 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 := new(dns.Msg)
m.SetQuestion(fqdn, dns.TypeNS) m.SetQuestion(fqdn, dns.TypeNS)
m.RecursionDesired = true m.RecursionDesired = true
config, err := dns.ClientConfigFromFile("/etc/resolv.conf") config, err := dns.ClientConfigFromFile("/etc/resolv.conf")
if err != nil || config == nil || len(config.Servers) == 0 { 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 var lastErr error
for _, server := range config.Servers { 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 { if err != nil {
lastErr = err lastErr = err
continue continue

View file

@ -14,8 +14,3 @@ type nsProvider struct{}
func (p *nsProvider) Key() sdk.ObservationKey { func (p *nsProvider) Key() sdk.ObservationKey {
return ObservationKeyNSRestrictions 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" sdk "git.happydns.org/checker-sdk-go/checker"
) )
// Rules returns one rule per individual NS security check. Every rule // Rules returns one CheckRule per individual NS security concern. Every rule
// reads the same shared observation produced by Collect and only looks // reads the same shared observation produced by Collect and only looks at
// at its own check entry, so a single network round trip feeds all rules. // its own facet of the raw probe data, so a single network round trip feeds
// every rule.
func Rules() []sdk.CheckRule { func Rules() []sdk.CheckRule {
return []sdk.CheckRule{ return []sdk.CheckRule{
&singleCheckRule{ &resolutionRule{},
ruleName: "ns_axfr_refused", &axfrRule{},
description: "Verifies that AXFR zone transfers are refused by every authoritative nameserver", &ixfrRule{},
checkName: checkNameAXFR, &noRecursionRule{},
failStatus: sdk.StatusCrit, &anyRFC8482Rule{},
code: "ns_axfr", &authoritativeRule{},
},
&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",
},
} }
} }
// singleCheckRule evaluates one named check across all servers in the // loadReport fetches the shared NS observation. On error it returns a
// shared NSRestrictionsReport observation. // CheckState the caller should emit verbatim to short-circuit its rule.
type singleCheckRule struct { func loadReport(ctx context.Context, obs sdk.ObservationGetter, errCode string) (*NSRestrictionsReport, *sdk.CheckState) {
ruleName string
description string
checkName string
failStatus sdk.Status
code string
}
func (r *singleCheckRule) Name() string { return r.ruleName }
func (r *singleCheckRule) Description() string { return r.description }
func (r *singleCheckRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
var report NSRestrictionsReport var report NSRestrictionsReport
if err := obs.Get(ctx, ObservationKeyNSRestrictions, &report); err != nil { if err := obs.Get(ctx, ObservationKeyNSRestrictions, &report); err != nil {
return []sdk.CheckState{{ return nil, &sdk.CheckState{
Status: sdk.StatusError, Status: sdk.StatusError,
Message: fmt.Sprintf("Failed to get NS restrictions data: %v", err), Message: fmt.Sprintf("Failed to get NS restrictions data: %v", err),
Code: r.code + "_error", Code: errCode,
}} }
} }
return &report, nil
out := make([]sdk.CheckState, 0, len(report.Servers))
for _, srv := range report.Servers {
meta := map[string]any{
"check": r.checkName,
"name": srv.Name,
"address": srv.Address,
}
item, found := findCheck(srv.Checks, r.checkName)
if !found {
message := "check not performed"
if len(srv.Checks) > 0 {
message = fmt.Sprintf("skipped: %s", srv.Checks[0].Detail)
}
out = append(out, sdk.CheckState{
Status: sdk.StatusUnknown,
Message: message,
Code: r.code + "_skipped",
Subject: serverLabel(srv),
Meta: meta,
})
continue
}
state := sdk.CheckState{
Code: r.code + "_result",
Subject: serverLabel(srv),
Meta: meta,
Message: item.Detail,
}
if item.OK {
state.Status = sdk.StatusOK
if state.Message == "" {
state.Message = "OK"
}
} else {
state.Status = r.failStatus
}
out = append(out, state)
}
if len(out) == 0 {
return []sdk.CheckState{{
Status: sdk.StatusUnknown,
Message: "no nameserver to evaluate",
Code: r.code + "_result",
}}
}
return out
}
func findCheck(items []NSCheckItem, name string) (NSCheckItem, bool) {
for _, it := range items {
if it.Name == name {
return it, true
}
}
return NSCheckItem{}, false
} }
// serverLabel returns a human-friendly subject for a given server result.
func serverLabel(srv NSServerResult) string { func serverLabel(srv NSServerResult) string {
if srv.Address == "" { if srv.Address == "" {
return srv.Name return srv.Name
} }
return fmt.Sprintf("%s (%s)", srv.Name, srv.Address) 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. // restrictions data.
const ObservationKeyNSRestrictions = "ns_restrictions" 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 { 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"` 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 { type NSServerResult struct {
Name string `json:"name"` // Name is the authoritative NS host name being probed.
Address string `json:"address"` Name string `json:"name"`
Checks []NSCheckItem `json:"checks"`
// 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. // AXFRProbe describes what happened when an AXFR zone transfer was attempted.
type NSCheckItem struct { type AXFRProbe struct {
Name string `json:"name"` // Accepted is true when the server served a full zone transfer
OK bool `json:"ok"` // (emitted at least a SOA envelope).
Detail string `json:"detail,omitempty"` 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 // serviceMessage is a minimal local copy of happydns.ServiceMessage matching

View file

@ -6,6 +6,8 @@
package main package main
import ( import (
"fmt"
nsr "git.happydns.org/checker-ns-restrictions/checker" nsr "git.happydns.org/checker-ns-restrictions/checker"
sdk "git.happydns.org/checker-sdk-go/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 // Propagate the plugin's version to the checker package so it shows up
// in CheckerDefinition.Version. // in CheckerDefinition.Version.
nsr.Version = 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
} }