checker: split monolithic rule into per-concern rules
This commit is contained in:
parent
d9a92ad576
commit
e8b38fac59
18 changed files with 1159 additions and 308 deletions
14
README.md
14
README.md
|
|
@ -47,6 +47,20 @@ The plugin exposes a `NewCheckerPlugin` symbol returning the checker
|
|||
definition and observation provider, which happyDomain registers in its
|
||||
global registries at load time.
|
||||
|
||||
### Deployment
|
||||
|
||||
The `/collect` endpoint has no built-in authentication and will issue
|
||||
DNS queries (including AXFR/IXFR/ANY zone-transfer attempts) to whatever
|
||||
addresses the supplied NS hostnames resolve to. A caller that controls
|
||||
the input domain can publish NS records pointing at arbitrary IPs,
|
||||
including private/internal ranges (RFC 1918, loopback, link-local) or
|
||||
unrelated third-party hosts, and use this checker as an SSRF / probing
|
||||
relay against them. It is meant to run on a trusted network, reachable
|
||||
only by the happyDomain instance that drives it. Restrict access via a
|
||||
reverse proxy with authentication, a network ACL, or by binding the
|
||||
listener to a private interface; do not expose it directly to the
|
||||
public internet.
|
||||
|
||||
### Versioning
|
||||
|
||||
The binary, plugin, and Docker image embed a version string overridable
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
132
checker/collect_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
167
checker/rule.go
167
checker/rule.go
|
|
@ -7,135 +7,74 @@ import (
|
|||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Rules returns one rule per individual NS security check. Every rule
|
||||
// reads the same shared observation produced by Collect and only looks
|
||||
// at its own check entry, so a single network round trip feeds all rules.
|
||||
// Rules returns one CheckRule per individual NS security concern. Every rule
|
||||
// reads the same shared observation produced by Collect and only looks at
|
||||
// its own facet of the raw probe data, so a single network round trip feeds
|
||||
// every rule.
|
||||
func Rules() []sdk.CheckRule {
|
||||
return []sdk.CheckRule{
|
||||
&singleCheckRule{
|
||||
ruleName: "ns_axfr_refused",
|
||||
description: "Verifies that AXFR zone transfers are refused by every authoritative nameserver",
|
||||
checkName: checkNameAXFR,
|
||||
failStatus: sdk.StatusCrit,
|
||||
code: "ns_axfr",
|
||||
},
|
||||
&singleCheckRule{
|
||||
ruleName: "ns_ixfr_refused",
|
||||
description: "Verifies that IXFR zone transfers are refused by every authoritative nameserver",
|
||||
checkName: checkNameIXFR,
|
||||
failStatus: sdk.StatusWarn,
|
||||
code: "ns_ixfr",
|
||||
},
|
||||
&singleCheckRule{
|
||||
ruleName: "ns_no_recursion",
|
||||
description: "Verifies that authoritative nameservers do not advertise recursion (RA bit unset)",
|
||||
checkName: checkNameNoRecursion,
|
||||
failStatus: sdk.StatusWarn,
|
||||
code: "ns_recursion",
|
||||
},
|
||||
&singleCheckRule{
|
||||
ruleName: "ns_any_handled",
|
||||
description: "Verifies that ANY queries are handled per RFC 8482 (HINFO or minimal answer)",
|
||||
checkName: checkNameANYHandled,
|
||||
failStatus: sdk.StatusWarn,
|
||||
code: "ns_any",
|
||||
},
|
||||
&singleCheckRule{
|
||||
ruleName: "ns_is_authoritative",
|
||||
description: "Verifies that nameservers answer authoritatively (AA bit set) for the zone",
|
||||
checkName: checkNameIsAuthoritative,
|
||||
failStatus: sdk.StatusInfo,
|
||||
code: "ns_authoritative",
|
||||
},
|
||||
&resolutionRule{},
|
||||
&axfrRule{},
|
||||
&ixfrRule{},
|
||||
&noRecursionRule{},
|
||||
&anyRFC8482Rule{},
|
||||
&authoritativeRule{},
|
||||
}
|
||||
}
|
||||
|
||||
// singleCheckRule evaluates one named check across all servers in the
|
||||
// shared NSRestrictionsReport observation.
|
||||
type singleCheckRule struct {
|
||||
ruleName string
|
||||
description string
|
||||
checkName string
|
||||
failStatus sdk.Status
|
||||
code string
|
||||
}
|
||||
|
||||
func (r *singleCheckRule) Name() string { return r.ruleName }
|
||||
func (r *singleCheckRule) Description() string { return r.description }
|
||||
|
||||
func (r *singleCheckRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
// loadReport fetches the shared NS observation. On error it returns a
|
||||
// CheckState the caller should emit verbatim to short-circuit its rule.
|
||||
func loadReport(ctx context.Context, obs sdk.ObservationGetter, errCode string) (*NSRestrictionsReport, *sdk.CheckState) {
|
||||
var report NSRestrictionsReport
|
||||
if err := obs.Get(ctx, ObservationKeyNSRestrictions, &report); err != nil {
|
||||
return []sdk.CheckState{{
|
||||
return nil, &sdk.CheckState{
|
||||
Status: sdk.StatusError,
|
||||
Message: fmt.Sprintf("Failed to get NS restrictions data: %v", err),
|
||||
Code: r.code + "_error",
|
||||
}}
|
||||
Code: errCode,
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]sdk.CheckState, 0, len(report.Servers))
|
||||
for _, srv := range report.Servers {
|
||||
meta := map[string]any{
|
||||
"check": r.checkName,
|
||||
"name": srv.Name,
|
||||
"address": srv.Address,
|
||||
}
|
||||
|
||||
item, found := findCheck(srv.Checks, r.checkName)
|
||||
if !found {
|
||||
message := "check not performed"
|
||||
if len(srv.Checks) > 0 {
|
||||
message = fmt.Sprintf("skipped: %s", srv.Checks[0].Detail)
|
||||
}
|
||||
out = append(out, sdk.CheckState{
|
||||
Status: sdk.StatusUnknown,
|
||||
Message: message,
|
||||
Code: r.code + "_skipped",
|
||||
Subject: serverLabel(srv),
|
||||
Meta: meta,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
state := sdk.CheckState{
|
||||
Code: r.code + "_result",
|
||||
Subject: serverLabel(srv),
|
||||
Meta: meta,
|
||||
Message: item.Detail,
|
||||
}
|
||||
if item.OK {
|
||||
state.Status = sdk.StatusOK
|
||||
if state.Message == "" {
|
||||
state.Message = "OK"
|
||||
}
|
||||
} else {
|
||||
state.Status = r.failStatus
|
||||
}
|
||||
out = append(out, state)
|
||||
}
|
||||
|
||||
if len(out) == 0 {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusUnknown,
|
||||
Message: "no nameserver to evaluate",
|
||||
Code: r.code + "_result",
|
||||
}}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func findCheck(items []NSCheckItem, name string) (NSCheckItem, bool) {
|
||||
for _, it := range items {
|
||||
if it.Name == name {
|
||||
return it, true
|
||||
}
|
||||
}
|
||||
return NSCheckItem{}, false
|
||||
return &report, nil
|
||||
}
|
||||
|
||||
// serverLabel returns a human-friendly subject for a given server result.
|
||||
func serverLabel(srv NSServerResult) string {
|
||||
if srv.Address == "" {
|
||||
return srv.Name
|
||||
}
|
||||
return fmt.Sprintf("%s (%s)", srv.Name, srv.Address)
|
||||
}
|
||||
|
||||
// serverMeta returns the per-server meta blob attached to every state a
|
||||
// rule produces.
|
||||
func serverMeta(srv NSServerResult) map[string]any {
|
||||
return map[string]any{
|
||||
"name": srv.Name,
|
||||
"address": srv.Address,
|
||||
}
|
||||
}
|
||||
|
||||
// probedServers returns only the servers that were actually probed
|
||||
// (i.e. DNS-resolved and not skipped). Rules that need to iterate over
|
||||
// probe results should call this helper to transparently skip the
|
||||
// resolution-error and address-skipped rows, which are the concern of
|
||||
// the dedicated resolutionRule.
|
||||
func probedServers(report *NSRestrictionsReport) []NSServerResult {
|
||||
out := make([]NSServerResult, 0, len(report.Servers))
|
||||
for _, s := range report.Servers {
|
||||
if s.ResolutionError != "" || s.AddressSkipped {
|
||||
continue
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// noProbesState returns the default state emitted when a rule has nothing to
|
||||
// evaluate (no server was successfully probed).
|
||||
func noProbesState(code string) sdk.CheckState {
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusUnknown,
|
||||
Message: "no nameserver could be probed",
|
||||
Code: code,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
62
checker/rules_any.go
Normal file
62
checker/rules_any.go
Normal 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
|
||||
}
|
||||
54
checker/rules_authoritative.go
Normal file
54
checker/rules_authoritative.go
Normal 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
54
checker/rules_axfr.go
Normal 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
57
checker/rules_ixfr.go
Normal 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
|
||||
}
|
||||
53
checker/rules_recursion.go
Normal file
53
checker/rules_recursion.go
Normal 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
|
||||
}
|
||||
48
checker/rules_resolution.go
Normal file
48
checker/rules_resolution.go
Normal 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
322
checker/rules_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
104
checker/types.go
104
checker/types.go
|
|
@ -6,23 +6,107 @@ import "encoding/json"
|
|||
// restrictions data.
|
||||
const ObservationKeyNSRestrictions = "ns_restrictions"
|
||||
|
||||
// NSRestrictionsReport contains the results of NS security restriction checks.
|
||||
// NSRestrictionsReport contains the raw probe results from every discovered
|
||||
// nameserver address. It carries facts (answer rcodes, flag bits, record
|
||||
// counts, errors, …) and does not make any pass/fail judgment; rules derive
|
||||
// status from these fields.
|
||||
type NSRestrictionsReport struct {
|
||||
// Domain is the zone that was probed.
|
||||
Domain string `json:"domain"`
|
||||
|
||||
// IPv6Reachable reflects whether the host running the checker could
|
||||
// reach the public IPv6 internet at collection time. When false,
|
||||
// probes against IPv6 addresses are skipped (AddressSkipped=true).
|
||||
IPv6Reachable bool `json:"ipv6Reachable"`
|
||||
|
||||
// Servers holds one entry per (NS host, resolved address) pair,
|
||||
// plus one entry per NS host that failed DNS resolution (with
|
||||
// ResolutionError set and Address empty).
|
||||
Servers []NSServerResult `json:"servers"`
|
||||
}
|
||||
|
||||
// NSServerResult holds the check results for a single nameserver IP.
|
||||
// NSServerResult holds raw probe results for a single nameserver address.
|
||||
type NSServerResult struct {
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address"`
|
||||
Checks []NSCheckItem `json:"checks"`
|
||||
// Name is the authoritative NS host name being probed.
|
||||
Name string `json:"name"`
|
||||
|
||||
// Address is the resolved IP address (may be empty when DNS
|
||||
// resolution failed or when the address was skipped).
|
||||
Address string `json:"address,omitempty"`
|
||||
|
||||
// ResolutionError is set when resolving Name to any IP failed.
|
||||
// Other per-probe fields are not populated in that case.
|
||||
ResolutionError string `json:"resolutionError,omitempty"`
|
||||
|
||||
// AddressSkipped is true when Address was not probed, e.g. an
|
||||
// IPv6 address on a host without IPv6 connectivity. Per-probe
|
||||
// fields are not populated.
|
||||
AddressSkipped bool `json:"addressSkipped,omitempty"`
|
||||
|
||||
// SkipReason describes why AddressSkipped was set.
|
||||
SkipReason string `json:"skipReason,omitempty"`
|
||||
|
||||
// AXFR carries the raw AXFR probe result.
|
||||
AXFR AXFRProbe `json:"axfr"`
|
||||
|
||||
// IXFR carries the raw IXFR probe result.
|
||||
IXFR IXFRProbe `json:"ixfr"`
|
||||
|
||||
// SOA carries the SOA/RD query used for the recursion and
|
||||
// authoritative probes.
|
||||
SOA SOAProbe `json:"soa"`
|
||||
|
||||
// ANY carries the raw ANY-query probe result.
|
||||
ANY ANYProbe `json:"any"`
|
||||
}
|
||||
|
||||
// NSCheckItem represents one security check for an NS server.
|
||||
type NSCheckItem struct {
|
||||
Name string `json:"name"`
|
||||
OK bool `json:"ok"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
// AXFRProbe describes what happened when an AXFR zone transfer was attempted.
|
||||
type AXFRProbe struct {
|
||||
// Accepted is true when the server served a full zone transfer
|
||||
// (emitted at least a SOA envelope).
|
||||
Accepted bool `json:"accepted"`
|
||||
// Reason is a human-readable description of the outcome when
|
||||
// Accepted is false: either the refusal reason returned by the
|
||||
// server or the transport error encountered. Empty when Accepted
|
||||
// is true.
|
||||
Reason string `json:"reason,omitempty"`
|
||||
// Cancelled is true when the probe was cut short by context cancel.
|
||||
Cancelled bool `json:"cancelled,omitempty"`
|
||||
}
|
||||
|
||||
// IXFRProbe describes what happened when an IXFR query was issued.
|
||||
type IXFRProbe struct {
|
||||
// Error is non-empty when the UDP query itself failed.
|
||||
Error string `json:"error,omitempty"`
|
||||
// Rcode is the DNS rcode string of the response ("" on error).
|
||||
Rcode string `json:"rcode,omitempty"`
|
||||
// AnswerCount is the number of answer records returned.
|
||||
AnswerCount int `json:"answerCount"`
|
||||
}
|
||||
|
||||
// SOAProbe describes the SOA/RD=1 query used by the recursion and
|
||||
// authoritative rules.
|
||||
type SOAProbe struct {
|
||||
// Error is non-empty when the UDP query itself failed.
|
||||
Error string `json:"error,omitempty"`
|
||||
// RecursionAvailable reflects the RA bit in the response header.
|
||||
RecursionAvailable bool `json:"recursionAvailable"`
|
||||
// Authoritative reflects the AA bit in the response header.
|
||||
Authoritative bool `json:"authoritative"`
|
||||
}
|
||||
|
||||
// ANYProbe describes the outcome of a qtype=ANY query, used to judge RFC
|
||||
// 8482 compliance.
|
||||
type ANYProbe struct {
|
||||
// Error is non-empty when the UDP query itself failed.
|
||||
Error string `json:"error,omitempty"`
|
||||
// Rcode is the DNS rcode string of the response ("" on error).
|
||||
Rcode string `json:"rcode,omitempty"`
|
||||
// AnswerCount is the number of answer records in the response.
|
||||
AnswerCount int `json:"answerCount"`
|
||||
// HINFOOnly is true when the answer section is exactly a single
|
||||
// HINFO record, i.e. the RFC 8482 minimal response.
|
||||
HINFOOnly bool `json:"hinfoOnly"`
|
||||
}
|
||||
|
||||
// serviceMessage is a minimal local copy of happydns.ServiceMessage matching
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue