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
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue