164 lines
4.9 KiB
Go
164 lines
4.9 KiB
Go
package checker
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"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) {
|
|
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)
|
|
}
|
|
|
|
for env := range ch {
|
|
if env.Error != nil {
|
|
return true, fmt.Sprintf("transfer error: %s", env.Error)
|
|
}
|
|
for _, rr := range env.RR {
|
|
if rr.Header().Rrtype == dns.TypeSOA {
|
|
return false, "AXFR zone transfer accepted"
|
|
}
|
|
}
|
|
}
|
|
|
|
return true, "AXFR refused"
|
|
}
|
|
|
|
// 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) {
|
|
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"))
|
|
if err != nil {
|
|
return true, fmt.Sprintf("query failed: %s", err)
|
|
}
|
|
|
|
if resp.Rcode != dns.RcodeSuccess {
|
|
return true, fmt.Sprintf("IXFR refused (rcode=%s)", dns.RcodeToString[resp.Rcode])
|
|
}
|
|
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) {
|
|
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"))
|
|
if err != nil {
|
|
return true, fmt.Sprintf("query failed: %s", err)
|
|
}
|
|
|
|
if resp.RecursionAvailable {
|
|
return false, "recursion available (RA bit set)"
|
|
}
|
|
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) {
|
|
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"))
|
|
if err != nil {
|
|
return true, fmt.Sprintf("query failed: %s", err)
|
|
}
|
|
|
|
if resp.Rcode != dns.RcodeSuccess {
|
|
return true, fmt.Sprintf("ANY refused (rcode=%s)", dns.RcodeToString[resp.Rcode])
|
|
}
|
|
|
|
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 {
|
|
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).
|
|
// 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)
|
|
|
|
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)
|
|
}
|
|
|
|
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
|
|
}
|