Add backups, token expiry warnings and name server checks
- Daily database backups: jarl-backup.timer runs 'jarld backup', which writes jarl-<UTC time>.db into backup.dir, checks it before naming it, and prunes by the time in the name while always keeping the newest keep_at_least. New backup: section in jarl.yaml; the dashboard and 'jarl status' show the newest backup and warn when it is stale or failed. The Debian package enables the timer. - 'jarld restore [FILE | latest]': checks the backup, shows it next to the current database, keeps the current one aside and records the restore. 'jarld serve' holds a database lock, so a restore can't run under a live jarld and two jarlds can't share a database. - Token expiry warnings from 14 days ahead: dashboard, token lists, a daily log line, and a CLI warning via the Jarl-Token-Expires header; 'jarl token list --expiring'. - 'jarl zone serials' and the zone page's Name servers tab: compare the serial on a primary zone's secondaries (NS records, also-notify) or a secondary zone's primaries with this server's. - 'jarl zone delegation': for zones on the internet, check the parent's delegation and glue against the zone; an undelegated zone gets a note. - Server: Jarl/<version> header; CLI exit code 2 for failed checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1afed741bf
commit
293e6ddd33
47 files changed
+3717
-95
No files matched your search
@@ -0,0 +1,449 @@
|
||||
package nscheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// Finding is one conclusion of a delegation check. Level is "ok",
|
||||
// "info", "warning" or "error".
|
||||
type Finding struct {
|
||||
Level string `json:"level"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// ParentAnswer is what one of the parent zone's servers says.
|
||||
type ParentAnswer struct {
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address,omitempty"`
|
||||
// Status: "delegated", "not_delegated" (NXDOMAIN, or the name exists
|
||||
// without a delegation), "unreachable" or "error".
|
||||
Status string `json:"status"`
|
||||
NS []string `json:"ns,omitempty"`
|
||||
// Glue is the addresses the parent hands out for in-zone name
|
||||
// servers.
|
||||
Glue map[string][]string `json:"glue,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// GlueCheck compares the parent's glue for an in-zone name server with
|
||||
// Jarl's addresses for it.
|
||||
type GlueCheck struct {
|
||||
Name string `json:"name"`
|
||||
Parent []string `json:"parent"`
|
||||
Jarl []string `json:"jarl"`
|
||||
Match bool `json:"match"`
|
||||
}
|
||||
|
||||
// DelegationReport is the result of Delegation.
|
||||
type DelegationReport struct {
|
||||
Zone string `json:"zone"`
|
||||
Parent string `json:"parent,omitempty"`
|
||||
Resolver string `json:"resolver"`
|
||||
// Delegated: at least one parent server delegates the zone.
|
||||
Delegated bool `json:"delegated"`
|
||||
ParentNS []string `json:"parent_ns"`
|
||||
JarlNS []string `json:"jarl_ns"`
|
||||
Parents []ParentAnswer `json:"parent_servers"`
|
||||
Glue []GlueCheck `json:"glue"`
|
||||
// Servers are the delegated name servers' SOA answers, as resolvers
|
||||
// on the internet would reach them.
|
||||
Servers []Result `json:"servers"`
|
||||
Findings []Finding `json:"findings"`
|
||||
}
|
||||
|
||||
// Delegation checks how the parent zone delegates zone: it finds the
|
||||
// parent through the resolver, asks each of the parent's servers for the
|
||||
// zone's NS records and glue, compares those with jarlNS and jarlAddrs
|
||||
// (Jarl's NS records and its addresses for in-zone name servers), and
|
||||
// asks every delegated server for the zone's serial (compared with ref).
|
||||
func (c *Checker) Delegation(ctx context.Context, zone string, jarlNS []string, jarlAddrs map[string][]string, ref *uint32) *DelegationReport {
|
||||
zone = dns.CanonicalName(zone)
|
||||
rep := &DelegationReport{Zone: zone, JarlNS: canonAll(jarlNS), ParentNS: []string{}, Parents: []ParentAnswer{}, Glue: []GlueCheck{}, Servers: []Result{}, Findings: []Finding{}}
|
||||
sort.Strings(rep.JarlNS)
|
||||
add := func(level, format string, args ...any) {
|
||||
for i, a := range args {
|
||||
switch v := a.(type) {
|
||||
case string:
|
||||
args[i] = show(v)
|
||||
case []string:
|
||||
args[i] = showAll(v)
|
||||
}
|
||||
}
|
||||
rep.Findings = append(rep.Findings, Finding{level, fmt.Sprintf(format, args...)})
|
||||
}
|
||||
res, err := c.resolver()
|
||||
if err != nil {
|
||||
add("error", "No resolver to find the parent zone with: %v", err)
|
||||
return rep
|
||||
}
|
||||
rep.Resolver = res
|
||||
if zone == "." {
|
||||
add("info", "The root zone has no parent to delegate it.")
|
||||
return rep
|
||||
}
|
||||
|
||||
// The parent zone: the zone that holds the name just above ours.
|
||||
parent, err := c.enclosingZone(ctx, res, parentName(zone))
|
||||
if err != nil {
|
||||
add("error", "Couldn't find the parent zone of %s through %s: %v", zone, res, err)
|
||||
return rep
|
||||
}
|
||||
rep.Parent = parent
|
||||
pns, err := c.nsNames(ctx, res, parent)
|
||||
if err != nil || len(pns) == 0 {
|
||||
add("error", "Couldn't find the name servers of the parent zone %s through %s: %v", parent, res, errOr(err, "none listed"))
|
||||
return rep
|
||||
}
|
||||
|
||||
// Ask every parent server, in parallel.
|
||||
var wg sync.WaitGroup
|
||||
answers := make([]ParentAnswer, len(pns))
|
||||
for i, name := range pns {
|
||||
wg.Add(1)
|
||||
go func(i int, name string) {
|
||||
defer wg.Done()
|
||||
answers[i] = c.askParent(ctx, zone, name)
|
||||
}(i, name)
|
||||
}
|
||||
wg.Wait()
|
||||
rep.Parents = answers
|
||||
|
||||
parentNS := map[string]bool{}
|
||||
glue := map[string]map[string]bool{}
|
||||
reached, delegated, notDelegated := 0, 0, 0
|
||||
nsSets := map[string]bool{}
|
||||
for _, a := range answers {
|
||||
switch a.Status {
|
||||
case "delegated":
|
||||
reached++
|
||||
delegated++
|
||||
nsSets[strings.Join(a.NS, " ")] = true
|
||||
for _, n := range a.NS {
|
||||
parentNS[n] = true
|
||||
}
|
||||
for n, addrs := range a.Glue {
|
||||
if glue[n] == nil {
|
||||
glue[n] = map[string]bool{}
|
||||
}
|
||||
for _, ad := range addrs {
|
||||
glue[n][ad] = true
|
||||
}
|
||||
}
|
||||
case "not_delegated":
|
||||
reached++
|
||||
notDelegated++
|
||||
}
|
||||
}
|
||||
if reached == 0 {
|
||||
add("error", "None of the parent zone's servers (%s) answered; this server may not be able to reach them.", pns)
|
||||
return rep
|
||||
}
|
||||
if delegated == 0 {
|
||||
rep.Delegated = false
|
||||
where := "the parent zone " + show(parent)
|
||||
if parent == "." {
|
||||
where = "the root zone"
|
||||
}
|
||||
add("info", "%s isn't delegated from %s, so the internet can't see it. That's expected for a zone used only on your own network. To publish it, register the domain and give your registrar these name servers: %s.", zone, where, rep.JarlNS)
|
||||
return rep
|
||||
}
|
||||
rep.Delegated = true
|
||||
if notDelegated > 0 {
|
||||
add("warning", "%d of the parent's %d servers don't know the delegation yet (a recent change at the registrar can take a while to reach them all).", notDelegated, reached)
|
||||
}
|
||||
if len(nsSets) > 1 {
|
||||
add("warning", "The parent's servers give different name server lists for %s.", zone)
|
||||
}
|
||||
rep.ParentNS = keys(parentNS)
|
||||
|
||||
// The NS lists.
|
||||
jarlSet := map[string]bool{}
|
||||
for _, n := range rep.JarlNS {
|
||||
jarlSet[n] = true
|
||||
}
|
||||
var missing, extra []string
|
||||
for _, n := range rep.JarlNS {
|
||||
if !parentNS[n] {
|
||||
missing = append(missing, n)
|
||||
}
|
||||
}
|
||||
for _, n := range rep.ParentNS {
|
||||
if !jarlSet[n] {
|
||||
extra = append(extra, n)
|
||||
}
|
||||
}
|
||||
if len(missing) == 0 && len(extra) == 0 {
|
||||
add("ok", "The parent delegates %s to the same name servers as its NS records: %s.", zone, rep.ParentNS)
|
||||
}
|
||||
if len(extra) > 0 {
|
||||
add("warning", "The parent delegates to %s, which the zone's NS records don't list. Resolvers ask them too; remove them at the registrar if they no longer serve the zone, or add NS records for them.", extra)
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
add("warning", "The zone's NS records list %s, which the parent doesn't delegate to. Resolvers that start from the parent won't use them; add them at the registrar.", missing)
|
||||
}
|
||||
|
||||
// Glue for name servers inside the zone.
|
||||
for _, n := range rep.ParentNS {
|
||||
if !dns.IsSubDomain(zone, n) {
|
||||
continue
|
||||
}
|
||||
g := GlueCheck{Name: n, Parent: keys(glue[n]), Jarl: canonAddrs(jarlAddrs[n])}
|
||||
g.Match = len(g.Parent) > 0 && strings.Join(g.Parent, " ") == strings.Join(g.Jarl, " ")
|
||||
rep.Glue = append(rep.Glue, g)
|
||||
switch {
|
||||
case len(g.Parent) == 0:
|
||||
add("error", "The parent has no glue address for %s, which is inside the zone, so resolvers can't find it. Add its address at the registrar (%s).", n, strings.Join(g.Jarl, ", "))
|
||||
case len(g.Jarl) == 0:
|
||||
add("warning", "The parent's glue for %s is %s, but the zone has no address record for it.", n, strings.Join(g.Parent, ", "))
|
||||
case !g.Match:
|
||||
add("warning", "The parent's glue for %s is %s, but the zone says %s. Update the glue at the registrar.", n, strings.Join(g.Parent, ", "), strings.Join(g.Jarl, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
// Every delegated server, reached the way resolvers reach it: by its
|
||||
// glue, or by looking its name up.
|
||||
var targets []Target
|
||||
for _, n := range rep.ParentNS {
|
||||
t := Target{Name: n, Source: "delegation"}
|
||||
if g := keys(glue[n]); len(g) > 0 {
|
||||
t.Addresses = g
|
||||
}
|
||||
targets = append(targets, t)
|
||||
}
|
||||
// Here every address is asked, this server's too: what matters is
|
||||
// what it answers from the outside.
|
||||
pub := *c
|
||||
pub.IsLocal = func(netip.Addr) bool { return false }
|
||||
rep.Servers = pub.Serials(ctx, zone, ref, targets)
|
||||
// One finding per server and outcome, however many addresses it has.
|
||||
type outcome struct{ name, status, detail string }
|
||||
var order []outcome
|
||||
addrs := map[outcome][]string{}
|
||||
bad := 0
|
||||
for _, r := range rep.Servers {
|
||||
var o outcome
|
||||
switch r.Status {
|
||||
case "in_sync", "answered", "no_route":
|
||||
continue
|
||||
case "behind", "ahead":
|
||||
o = outcome{r.Name, r.Status, fmt.Sprintf("serves serial %d, not %d", *r.Serial, *ref)}
|
||||
case "not_authoritative":
|
||||
o = outcome{r.Name, r.Status, ""}
|
||||
default:
|
||||
o = outcome{r.Name, r.Status, describe(r)}
|
||||
}
|
||||
bad++
|
||||
if addrs[o] == nil {
|
||||
order = append(order, o)
|
||||
}
|
||||
addrs[o] = append(addrs[o], nonEmpty(r.Address, "no address"))
|
||||
}
|
||||
for _, o := range order {
|
||||
at := strings.Join(addrs[o], ", ")
|
||||
if len(addrs[o]) > 2 {
|
||||
at = fmt.Sprintf("all %d addresses", len(addrs[o]))
|
||||
}
|
||||
if o.status == "not_authoritative" {
|
||||
add("error", "%s (%s) is delegated the zone but doesn't serve it (a lame delegation).", o.name, at)
|
||||
} else {
|
||||
add("warning", "%s (%s): %s.", o.name, at, o.detail)
|
||||
}
|
||||
}
|
||||
if bad == 0 && len(rep.Servers) > 0 {
|
||||
add("ok", "Every delegated name server answers for the zone with the current serial.")
|
||||
}
|
||||
return rep
|
||||
}
|
||||
|
||||
// askParent asks one of the parent's servers (by name) for zone's NS.
|
||||
func (c *Checker) askParent(ctx context.Context, zone, server string) ParentAnswer {
|
||||
pa := ParentAnswer{Name: server}
|
||||
addrs, err := c.lookup(ctx, server)
|
||||
if err != nil || len(addrs) == 0 {
|
||||
pa.Status, pa.Error = "error", "looking up its address: "+errOr(err, "no address").Error()
|
||||
return pa
|
||||
}
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion(zone, dns.TypeNS)
|
||||
m.RecursionDesired = false
|
||||
// IPv4 first: try each address until one answers.
|
||||
sort.SliceStable(addrs, func(i, j int) bool { return !strings.Contains(addrs[i], ":") && strings.Contains(addrs[j], ":") })
|
||||
var resp *dns.Msg
|
||||
for _, a := range addrs {
|
||||
pa.Address = a
|
||||
resp, _, err = c.exchange(ctx, m.Copy(), a, nil)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
pa.Status, pa.Error = "unreachable", err.Error()
|
||||
return pa
|
||||
}
|
||||
if resp.Rcode == dns.RcodeNameError {
|
||||
pa.Status = "not_delegated"
|
||||
return pa
|
||||
}
|
||||
if resp.Rcode != dns.RcodeSuccess {
|
||||
pa.Status, pa.Error = "error", "answered "+dns.RcodeToString[resp.Rcode]
|
||||
return pa
|
||||
}
|
||||
// A referral puts the NS set in the authority section; a server that
|
||||
// also serves the child zone answers it directly.
|
||||
ns := map[string]bool{}
|
||||
for _, sec := range [][]dns.RR{resp.Ns, resp.Answer} {
|
||||
for _, rr := range sec {
|
||||
if n, ok := rr.(*dns.NS); ok && dns.CanonicalName(n.Hdr.Name) == zone {
|
||||
ns[dns.CanonicalName(n.Ns)] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(ns) == 0 {
|
||||
pa.Status = "not_delegated"
|
||||
return pa
|
||||
}
|
||||
pa.Status, pa.NS = "delegated", keys(ns)
|
||||
for _, rr := range resp.Extra {
|
||||
var name, addr string
|
||||
switch v := rr.(type) {
|
||||
case *dns.A:
|
||||
name, addr = v.Hdr.Name, v.A.String()
|
||||
case *dns.AAAA:
|
||||
name, addr = v.Hdr.Name, v.AAAA.String()
|
||||
default:
|
||||
continue
|
||||
}
|
||||
name = dns.CanonicalName(name)
|
||||
if ns[name] {
|
||||
if pa.Glue == nil {
|
||||
pa.Glue = map[string][]string{}
|
||||
}
|
||||
pa.Glue[name] = append(pa.Glue[name], addr)
|
||||
}
|
||||
}
|
||||
for n := range pa.Glue {
|
||||
pa.Glue[n] = canonAddrs(pa.Glue[n])
|
||||
}
|
||||
return pa
|
||||
}
|
||||
|
||||
// enclosingZone finds the apex of the zone that holds name, from the
|
||||
// owner of the SOA record the resolver returns (in the answer for an apex,
|
||||
// in the authority section otherwise).
|
||||
func (c *Checker) enclosingZone(ctx context.Context, res, name string) (string, error) {
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion(name, dns.TypeSOA)
|
||||
resp, _, err := c.exchangeHostPort(ctx, m, res, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, sec := range [][]dns.RR{resp.Answer, resp.Ns} {
|
||||
for _, rr := range sec {
|
||||
if s, ok := rr.(*dns.SOA); ok {
|
||||
return dns.CanonicalName(s.Hdr.Name), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no SOA record in the answer for %s (%s)", name, dns.RcodeToString[resp.Rcode])
|
||||
}
|
||||
|
||||
// nsNames asks the resolver for a zone's NS names.
|
||||
func (c *Checker) nsNames(ctx context.Context, res, zone string) ([]string, error) {
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion(zone, dns.TypeNS)
|
||||
resp, _, err := c.exchangeHostPort(ctx, m, res, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
set := map[string]bool{}
|
||||
for _, rr := range resp.Answer {
|
||||
if n, ok := rr.(*dns.NS); ok {
|
||||
set[dns.CanonicalName(n.Ns)] = true
|
||||
}
|
||||
}
|
||||
return keys(set), nil
|
||||
}
|
||||
|
||||
// show is a name as people write it, without the trailing dot.
|
||||
func show(name string) string {
|
||||
if name == "." || !strings.HasSuffix(name, ".") {
|
||||
return name
|
||||
}
|
||||
return strings.TrimSuffix(name, ".")
|
||||
}
|
||||
|
||||
func showAll(names []string) string {
|
||||
out := make([]string, len(names))
|
||||
for i, n := range names {
|
||||
out[i] = show(n)
|
||||
}
|
||||
return strings.Join(out, ", ")
|
||||
}
|
||||
|
||||
func parentName(zone string) string {
|
||||
labels := dns.SplitDomainName(zone)
|
||||
if len(labels) <= 1 {
|
||||
return "."
|
||||
}
|
||||
return dns.Fqdn(strings.Join(labels[1:], "."))
|
||||
}
|
||||
|
||||
func describe(r Result) string {
|
||||
switch r.Status {
|
||||
case "refused":
|
||||
return "refuses queries for the zone"
|
||||
case "unreachable":
|
||||
return "no answer (" + r.Error + ")"
|
||||
case "no_address":
|
||||
return r.Error
|
||||
}
|
||||
return nonEmpty(r.Error, r.Status)
|
||||
}
|
||||
|
||||
func keys(m map[string]bool) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func canonAll(names []string) []string {
|
||||
out := make([]string, 0, len(names))
|
||||
for _, n := range names {
|
||||
out = append(out, dns.CanonicalName(n))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func canonAddrs(addrs []string) []string {
|
||||
set := map[string]bool{}
|
||||
for _, a := range addrs {
|
||||
if ip, err := netip.ParseAddr(a); err == nil {
|
||||
set[ip.String()] = true
|
||||
}
|
||||
}
|
||||
return keys(set)
|
||||
}
|
||||
|
||||
func errOr(err error, msg string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("%s", msg)
|
||||
}
|
||||
|
||||
func nonEmpty(s, def string) string {
|
||||
if s != "" {
|
||||
return s
|
||||
}
|
||||
return def
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
// Package nscheck asks other name servers about a zone over DNS:
|
||||
//
|
||||
// - Serials: is every name server of a zone serving the serial this
|
||||
// server serves? For a primary zone those are its secondaries (the NS
|
||||
// records and also-notify targets); for a secondary zone, its
|
||||
// primaries.
|
||||
// - Delegation: does the parent zone (e.g. "lol." for adbraun.lol)
|
||||
// delegate the zone to the name servers, with the glue addresses, that
|
||||
// Jarl has? Only meaningful for zones on the internet, so it only runs
|
||||
// when asked and reports a missing delegation as information, not as a
|
||||
// problem.
|
||||
//
|
||||
// It only sends ordinary non-recursive SOA and NS queries.
|
||||
package nscheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// Key is a TSIG key to sign queries with, for servers that pick the view
|
||||
// (or allow the query) by key.
|
||||
type Key struct {
|
||||
Name string
|
||||
Algorithm string // e.g. hmac-sha256
|
||||
Secret string // base64
|
||||
}
|
||||
|
||||
// Target is a server to ask.
|
||||
type Target struct {
|
||||
Name string // host name (FQDN), "" when only an address is known
|
||||
Addresses []string // known addresses; nil: look the name up
|
||||
Source string // why it is asked: "NS record", "also-notify", "primaries", ...
|
||||
Port string // "" for the default
|
||||
Key *Key
|
||||
}
|
||||
|
||||
// Result is one server's answer.
|
||||
type Result struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Port string `json:"port,omitempty"`
|
||||
Source string `json:"source"`
|
||||
// Status is one of:
|
||||
// self this server (not queried)
|
||||
// in_sync serves the same serial
|
||||
// answered serves the zone (no serial here to compare with)
|
||||
// behind, ahead serves an older or newer serial (RFC 1982)
|
||||
// not_authoritative answers, but doesn't serve the zone
|
||||
// refused refuses the query (allow-query, or its view
|
||||
// doesn't have the zone for this server)
|
||||
// unreachable no answer in time
|
||||
// no_route this host has no route to the address (e.g.
|
||||
// IPv6 on a host without it); not a problem
|
||||
// no_address the name has no address
|
||||
// error anything else (see Error)
|
||||
Status string `json:"status"`
|
||||
Serial *uint32 `json:"serial,omitempty"`
|
||||
// Diff is Serial minus the reference serial (negative: behind).
|
||||
Diff int64 `json:"diff,omitempty"`
|
||||
RTTMs int64 `json:"rtt_ms,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// OK reports whether the result is good news.
|
||||
func (r *Result) OK() bool {
|
||||
switch r.Status {
|
||||
case "in_sync", "answered", "self", "no_route":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Checker sends the queries.
|
||||
type Checker struct {
|
||||
// Timeout per query (default 2s; two tries per address).
|
||||
Timeout time.Duration
|
||||
// Port is the DNS port (default 53; tests use another).
|
||||
Port string
|
||||
// Resolver is the recursive resolver used to look up names
|
||||
// (host:port); default: the first nameserver in /etc/resolv.conf.
|
||||
Resolver string
|
||||
// IsLocal reports whether an address belongs to this host (default:
|
||||
// its interface addresses and loopback).
|
||||
IsLocal func(netip.Addr) bool
|
||||
}
|
||||
|
||||
func (c *Checker) timeout() time.Duration {
|
||||
if c.Timeout > 0 {
|
||||
return c.Timeout
|
||||
}
|
||||
return 2 * time.Second
|
||||
}
|
||||
|
||||
func (c *Checker) port() string {
|
||||
if c.Port != "" {
|
||||
return c.Port
|
||||
}
|
||||
return "53"
|
||||
}
|
||||
|
||||
func (c *Checker) isLocal(a netip.Addr) bool {
|
||||
if c.IsLocal != nil {
|
||||
return c.IsLocal(a)
|
||||
}
|
||||
return IsLocalAddr(a)
|
||||
}
|
||||
|
||||
// IsLocalAddr reports whether a is a loopback address or one of this
|
||||
// host's interface addresses.
|
||||
func IsLocalAddr(a netip.Addr) bool {
|
||||
if a.IsLoopback() {
|
||||
return true
|
||||
}
|
||||
addrs, err := net.InterfaceAddrs()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, ia := range addrs {
|
||||
if n, ok := ia.(*net.IPNet); ok {
|
||||
if b, ok := netip.AddrFromSlice(n.IP); ok && b.Unmap() == a.Unmap() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SystemResolver returns the first nameserver in /etc/resolv.conf as
|
||||
// host:port.
|
||||
func SystemResolver() (string, error) {
|
||||
cc, err := dns.ClientConfigFromFile("/etc/resolv.conf")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reading /etc/resolv.conf: %w", err)
|
||||
}
|
||||
if len(cc.Servers) == 0 {
|
||||
return "", errors.New("no nameserver in /etc/resolv.conf")
|
||||
}
|
||||
return net.JoinHostPort(cc.Servers[0], cc.Port), nil
|
||||
}
|
||||
|
||||
func (c *Checker) resolver() (string, error) {
|
||||
if c.Resolver != "" {
|
||||
return c.Resolver, nil
|
||||
}
|
||||
return SystemResolver()
|
||||
}
|
||||
|
||||
// exchange sends one query to addr (an IP address) with a retry, and
|
||||
// over TCP when the UDP answer is truncated.
|
||||
func (c *Checker) exchange(ctx context.Context, m *dns.Msg, addr string, key *Key) (*dns.Msg, time.Duration, error) {
|
||||
return c.exchangeHostPort(ctx, m, net.JoinHostPort(addr, c.port()), key)
|
||||
}
|
||||
|
||||
func (c *Checker) exchangeHostPort(ctx context.Context, m *dns.Msg, hostport string, key *Key) (*dns.Msg, time.Duration, error) {
|
||||
cl := &dns.Client{Timeout: c.timeout()}
|
||||
if key != nil {
|
||||
name := dns.Fqdn(key.Name)
|
||||
cl.TsigSecret = map[string]string{name: key.Secret}
|
||||
m.SetTsig(name, dns.Fqdn(key.Algorithm), 300, time.Now().Unix())
|
||||
}
|
||||
var resp *dns.Msg
|
||||
var rtt time.Duration
|
||||
var err error
|
||||
for try := 0; try < 2; try++ {
|
||||
resp, rtt, err = cl.ExchangeContext(ctx, m, hostport)
|
||||
if err == nil || ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err == nil && resp.Truncated {
|
||||
cl.Net = "tcp"
|
||||
resp, rtt, err = cl.ExchangeContext(ctx, m, hostport)
|
||||
}
|
||||
return resp, rtt, err
|
||||
}
|
||||
|
||||
// lookup returns a name's IPv4 and IPv6 addresses through the resolver.
|
||||
func (c *Checker) lookup(ctx context.Context, name string) ([]string, error) {
|
||||
res, err := c.resolver()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []string
|
||||
var firstErr error
|
||||
for _, t := range []uint16{dns.TypeA, dns.TypeAAAA} {
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion(dns.Fqdn(name), t)
|
||||
resp, _, err := c.exchangeHostPort(ctx, m, res, nil)
|
||||
if err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, rr := range resp.Answer {
|
||||
switch v := rr.(type) {
|
||||
case *dns.A:
|
||||
out = append(out, v.A.String())
|
||||
case *dns.AAAA:
|
||||
out = append(out, v.AAAA.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out) == 0 && firstErr != nil {
|
||||
return nil, firstErr
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// soa asks addr for zone's SOA and fills in r (whose Name, Address and
|
||||
// Source are set).
|
||||
func (c *Checker) soa(ctx context.Context, zone string, key *Key, ref *uint32, r *Result) {
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion(dns.Fqdn(zone), dns.TypeSOA)
|
||||
m.RecursionDesired = false
|
||||
port := r.Port
|
||||
if port == "" {
|
||||
port = c.port()
|
||||
}
|
||||
resp, rtt, err := c.exchangeHostPort(ctx, m, net.JoinHostPort(r.Address, port), key)
|
||||
if err != nil {
|
||||
var ne net.Error
|
||||
switch {
|
||||
case errors.Is(err, syscall.ENETUNREACH):
|
||||
// Not the server's fault: typically IPv6 on a host without it.
|
||||
r.Status, r.Error = "no_route", "this server has no route to that address"
|
||||
case errors.As(err, &ne) && ne.Timeout(), errors.Is(err, context.DeadlineExceeded):
|
||||
r.Status, r.Error = "unreachable", "no answer within "+c.timeout().String()
|
||||
default:
|
||||
r.Status, r.Error = "unreachable", err.Error()
|
||||
}
|
||||
return
|
||||
}
|
||||
r.RTTMs = rtt.Milliseconds()
|
||||
switch {
|
||||
case resp.Rcode == dns.RcodeRefused:
|
||||
r.Status = "refused"
|
||||
return
|
||||
case resp.Rcode != dns.RcodeSuccess:
|
||||
r.Status, r.Error = "error", "answered "+dns.RcodeToString[resp.Rcode]
|
||||
return
|
||||
case !resp.Authoritative:
|
||||
r.Status = "not_authoritative"
|
||||
return
|
||||
}
|
||||
for _, rr := range resp.Answer {
|
||||
if s, ok := rr.(*dns.SOA); ok && strings.EqualFold(s.Hdr.Name, dns.Fqdn(zone)) {
|
||||
serial := s.Serial
|
||||
r.Serial = &serial
|
||||
r.Status = "answered"
|
||||
if ref != nil {
|
||||
r.Status = "in_sync"
|
||||
r.Diff = int64(int32(serial - *ref))
|
||||
switch {
|
||||
case r.Diff < 0:
|
||||
r.Status = "behind"
|
||||
case r.Diff > 0:
|
||||
r.Status = "ahead"
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
r.Status, r.Error = "error", "the answer has no SOA record"
|
||||
}
|
||||
|
||||
// Serials asks each target for zone's SOA serial and compares it with
|
||||
// ref, the serial this server serves (nil when unknown). Targets with
|
||||
// local addresses are this server and aren't asked. Results are sorted by
|
||||
// name and address.
|
||||
func (c *Checker) Serials(ctx context.Context, zone string, ref *uint32, targets []Target) []Result {
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
var out []Result
|
||||
add := func(r Result) {
|
||||
mu.Lock()
|
||||
out = append(out, r)
|
||||
mu.Unlock()
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, t := range targets {
|
||||
addrs := t.Addresses
|
||||
if addrs == nil && t.Name != "" {
|
||||
found, err := c.lookup(ctx, t.Name)
|
||||
if err != nil || len(found) == 0 {
|
||||
msg := "no A or AAAA record"
|
||||
if err != nil {
|
||||
msg = "looking up its address: " + err.Error()
|
||||
}
|
||||
add(Result{Name: t.Name, Source: t.Source, Status: "no_address", Error: msg})
|
||||
continue
|
||||
}
|
||||
addrs = found
|
||||
}
|
||||
for _, a := range addrs {
|
||||
ip, err := netip.ParseAddr(a)
|
||||
if err != nil || seen[ip.String()+"#"+t.Port] {
|
||||
continue
|
||||
}
|
||||
seen[ip.String()+"#"+t.Port] = true
|
||||
r := Result{Name: t.Name, Address: ip.String(), Port: t.Port, Source: t.Source}
|
||||
if c.isLocal(ip) {
|
||||
r.Status = "self"
|
||||
if ref != nil {
|
||||
s := *ref
|
||||
r.Serial = &s
|
||||
}
|
||||
add(r)
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(r Result, key *Key) {
|
||||
defer wg.Done()
|
||||
c.soa(ctx, zone, key, ref, &r)
|
||||
add(r)
|
||||
}(r, t.Key)
|
||||
}
|
||||
}
|
||||
wg.Wait()
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Name != out[j].Name {
|
||||
return out[i].Name < out[j].Name
|
||||
}
|
||||
return out[i].Address < out[j].Address
|
||||
})
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package nscheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// fake is a DNS server on one loopback address that answers with fn.
|
||||
type fake func(q dns.Question) *dns.Msg
|
||||
|
||||
// serve starts fake servers on 127.0.0.x addresses, all on the same port,
|
||||
// and returns the port.
|
||||
func serve(t *testing.T, servers map[string]fake) string {
|
||||
t.Helper()
|
||||
port := ""
|
||||
for addr, fn := range servers {
|
||||
hp := net.JoinHostPort(addr, "0")
|
||||
if port != "" {
|
||||
hp = net.JoinHostPort(addr, port)
|
||||
}
|
||||
pc, err := net.ListenPacket("udp", hp)
|
||||
if err != nil {
|
||||
t.Skipf("can't listen on %s: %v", hp, err)
|
||||
}
|
||||
if port == "" {
|
||||
_, port, _ = net.SplitHostPort(pc.LocalAddr().String())
|
||||
}
|
||||
fn := fn
|
||||
srv := &dns.Server{PacketConn: pc, Handler: dns.HandlerFunc(func(w dns.ResponseWriter, req *dns.Msg) {
|
||||
resp := fn(req.Question[0])
|
||||
if resp == nil {
|
||||
return // no answer: a timeout
|
||||
}
|
||||
rcode, aa := resp.Rcode, resp.Authoritative
|
||||
resp.SetReply(req) // resets both
|
||||
resp.Rcode, resp.Authoritative = rcode, aa
|
||||
w.WriteMsg(resp)
|
||||
})}
|
||||
go srv.ActivateAndServe()
|
||||
t.Cleanup(func() { srv.Shutdown() })
|
||||
}
|
||||
return port
|
||||
}
|
||||
|
||||
func rr(s string) dns.RR {
|
||||
r, err := dns.NewRR(s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// authSOA answers SOA queries for zone authoritatively with serial.
|
||||
func authSOA(zone string, serial uint32) fake {
|
||||
return func(q dns.Question) *dns.Msg {
|
||||
m := new(dns.Msg)
|
||||
m.Authoritative = true
|
||||
if q.Qtype == dns.TypeSOA && strings.EqualFold(q.Name, zone) {
|
||||
m.Answer = []dns.RR{rr(zone + " 3600 IN SOA ns1." + zone + " admin." + zone + " " + itoa(serial) + " 3600 900 1209600 300")}
|
||||
}
|
||||
return m
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(n uint32) string { return strconv.FormatUint(uint64(n), 10) }
|
||||
|
||||
func checker(port, resolver string) *Checker {
|
||||
return &Checker{
|
||||
Timeout: 300 * time.Millisecond,
|
||||
Port: port,
|
||||
Resolver: resolver,
|
||||
IsLocal: func(a netip.Addr) bool { return a.String() == "127.0.0.9" },
|
||||
}
|
||||
}
|
||||
|
||||
func TestSerials(t *testing.T) {
|
||||
zone := "example.com."
|
||||
port := serve(t, map[string]fake{
|
||||
"127.0.0.2": authSOA(zone, 2026100805),
|
||||
"127.0.0.3": authSOA(zone, 2026100803),
|
||||
"127.0.0.4": func(dns.Question) *dns.Msg { return new(dns.Msg) }, // answers, not authoritative
|
||||
"127.0.0.5": func(dns.Question) *dns.Msg { m := new(dns.Msg); m.Rcode = dns.RcodeRefused; return m },
|
||||
"127.0.0.6": func(dns.Question) *dns.Msg { return nil }, // never answers
|
||||
})
|
||||
ref := uint32(2026100805)
|
||||
res := checker(port, "").Serials(context.Background(), zone, &ref, []Target{
|
||||
{Name: "ns1.example.com.", Addresses: []string{"127.0.0.9"}, Source: "NS record"},
|
||||
{Name: "ns2.example.com.", Addresses: []string{"127.0.0.2"}, Source: "NS record"},
|
||||
{Name: "ns3.example.com.", Addresses: []string{"127.0.0.3"}, Source: "NS record"},
|
||||
{Name: "ns4.example.com.", Addresses: []string{"127.0.0.4"}, Source: "NS record"},
|
||||
{Addresses: []string{"127.0.0.5"}, Source: "also-notify"},
|
||||
{Addresses: []string{"127.0.0.6", "127.0.0.2"}, Source: "also-notify"}, // 127.0.0.2 again: asked once
|
||||
})
|
||||
got := map[string]Result{}
|
||||
for _, r := range res {
|
||||
got[r.Address] = r
|
||||
}
|
||||
if len(res) != 6 {
|
||||
t.Errorf("%d results: %+v", len(res), res)
|
||||
}
|
||||
check := func(addr, status string) {
|
||||
t.Helper()
|
||||
if got[addr].Status != status {
|
||||
t.Errorf("%s: %+v, want %s", addr, got[addr], status)
|
||||
}
|
||||
}
|
||||
check("127.0.0.9", "self")
|
||||
check("127.0.0.2", "in_sync")
|
||||
check("127.0.0.3", "behind")
|
||||
check("127.0.0.4", "not_authoritative")
|
||||
check("127.0.0.5", "refused")
|
||||
check("127.0.0.6", "unreachable")
|
||||
if got["127.0.0.3"].Diff != -2 || *got["127.0.0.3"].Serial != 2026100803 {
|
||||
t.Errorf("behind: %+v", got["127.0.0.3"])
|
||||
}
|
||||
// Without a serial to compare with, an answer is just an answer.
|
||||
res = checker(port, "").Serials(context.Background(), zone, nil, []Target{{Addresses: []string{"127.0.0.2"}}})
|
||||
if res[0].Status != "answered" || !res[0].OK() {
|
||||
t.Errorf("no reference: %+v", res[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSerialWraparound(t *testing.T) {
|
||||
// RFC 1982: 1 is ahead of 4294967295.
|
||||
zone := "example.com."
|
||||
port := serve(t, map[string]fake{"127.0.0.2": authSOA(zone, 1)})
|
||||
ref := uint32(4294967295)
|
||||
res := checker(port, "").Serials(context.Background(), zone, &ref, []Target{{Addresses: []string{"127.0.0.2"}}})
|
||||
if res[0].Status != "ahead" || res[0].Diff != 2 {
|
||||
t.Errorf("%+v", res[0])
|
||||
}
|
||||
}
|
||||
|
||||
// internet builds a small fake DNS tree: a resolver, a parent server for
|
||||
// "lol." and the child's servers.
|
||||
func internet(t *testing.T, parent fake) (port, resolver string) {
|
||||
port = serve(t, map[string]fake{
|
||||
// The recursive resolver.
|
||||
"127.0.0.10": func(q dns.Question) *dns.Msg {
|
||||
m := new(dns.Msg)
|
||||
m.RecursionAvailable = true
|
||||
switch {
|
||||
case q.Qtype == dns.TypeSOA && q.Name == "lol.":
|
||||
m.Answer = []dns.RR{rr("lol. 300 IN SOA ns.nic.lol. hostmaster.lol. 1 3600 900 1209600 300")}
|
||||
case q.Qtype == dns.TypeNS && q.Name == "lol.":
|
||||
m.Answer = []dns.RR{rr("lol. 300 IN NS ns.nic.lol.")}
|
||||
case q.Qtype == dns.TypeA && q.Name == "ns.nic.lol.":
|
||||
m.Answer = []dns.RR{rr("ns.nic.lol. 300 IN A 127.0.0.11")}
|
||||
case q.Qtype == dns.TypeA && q.Name == "ns.elsewhere.net.":
|
||||
m.Answer = []dns.RR{rr("ns.elsewhere.net. 300 IN A 127.0.0.13")}
|
||||
}
|
||||
return m
|
||||
},
|
||||
"127.0.0.11": parent,
|
||||
// ns1.adbraun.lol: serves the zone.
|
||||
"127.0.0.12": authSOA("adbraun.lol.", 7),
|
||||
// ns.elsewhere.net: doesn't serve it (lame).
|
||||
"127.0.0.13": func(dns.Question) *dns.Msg { m := new(dns.Msg); m.Rcode = dns.RcodeRefused; return m },
|
||||
})
|
||||
return port, net.JoinHostPort("127.0.0.10", port)
|
||||
}
|
||||
|
||||
func levels(rep *DelegationReport) map[string][]string {
|
||||
out := map[string][]string{}
|
||||
for _, f := range rep.Findings {
|
||||
out[f.Level] = append(out[f.Level], f.Message)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestDelegationMismatch(t *testing.T) {
|
||||
port, res := internet(t, func(q dns.Question) *dns.Msg {
|
||||
m := new(dns.Msg)
|
||||
if q.Qtype == dns.TypeNS && q.Name == "adbraun.lol." {
|
||||
m.Ns = []dns.RR{rr("adbraun.lol. 3600 IN NS ns1.adbraun.lol."), rr("adbraun.lol. 3600 IN NS ns.elsewhere.net.")}
|
||||
m.Extra = []dns.RR{rr("ns1.adbraun.lol. 3600 IN A 127.0.0.12")}
|
||||
}
|
||||
return m
|
||||
})
|
||||
ref := uint32(7)
|
||||
rep := checker(port, res).Delegation(context.Background(), "adbraun.lol.",
|
||||
[]string{"ns1.adbraun.lol.", "ns2.adbraun.lol."},
|
||||
map[string][]string{"ns1.adbraun.lol.": {"127.0.0.99"}, "ns2.adbraun.lol.": {"127.0.0.14"}}, &ref)
|
||||
if rep.Parent != "lol." || !rep.Delegated {
|
||||
t.Fatalf("report: %+v", rep)
|
||||
}
|
||||
l := levels(rep)
|
||||
all := strings.Join(append(append(l["error"], l["warning"]...), l["info"]...), "\n")
|
||||
for _, want := range []string{
|
||||
"delegates to ns.elsewhere.net, which the zone's NS records don't list",
|
||||
"list ns2.adbraun.lol, which the parent doesn't delegate to",
|
||||
"glue for ns1.adbraun.lol is 127.0.0.12, but the zone says 127.0.0.99",
|
||||
"ns.elsewhere.net (127.0.0.13): refuses queries",
|
||||
} {
|
||||
if !strings.Contains(all, want) {
|
||||
t.Errorf("missing finding %q in:\n%s", want, all)
|
||||
}
|
||||
}
|
||||
if len(rep.Glue) != 1 || rep.Glue[0].Match {
|
||||
t.Errorf("glue: %+v", rep.Glue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelegationGood(t *testing.T) {
|
||||
port, res := internet(t, func(q dns.Question) *dns.Msg {
|
||||
m := new(dns.Msg)
|
||||
if q.Qtype == dns.TypeNS && q.Name == "adbraun.lol." {
|
||||
m.Ns = []dns.RR{rr("adbraun.lol. 3600 IN NS ns1.adbraun.lol.")}
|
||||
m.Extra = []dns.RR{rr("ns1.adbraun.lol. 3600 IN A 127.0.0.12")}
|
||||
}
|
||||
return m
|
||||
})
|
||||
ref := uint32(7)
|
||||
rep := checker(port, res).Delegation(context.Background(), "adbraun.lol.", []string{"ns1.adbraun.lol."}, map[string][]string{"ns1.adbraun.lol.": {"127.0.0.12"}}, &ref)
|
||||
l := levels(rep)
|
||||
if len(l["error"])+len(l["warning"])+len(l["info"]) != 0 || len(l["ok"]) != 2 {
|
||||
t.Errorf("findings: %v", l)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotDelegated(t *testing.T) {
|
||||
// An internal-only zone: one calm note, no warnings.
|
||||
port, res := internet(t, func(q dns.Question) *dns.Msg {
|
||||
m := new(dns.Msg)
|
||||
m.Authoritative = true
|
||||
m.Rcode = dns.RcodeNameError
|
||||
m.Ns = []dns.RR{rr("lol. 300 IN SOA ns.nic.lol. hostmaster.lol. 1 3600 900 1209600 300")}
|
||||
return m
|
||||
})
|
||||
rep := checker(port, res).Delegation(context.Background(), "adbraun.lol.", []string{"ns1.adbraun.lol."}, nil, nil)
|
||||
l := levels(rep)
|
||||
if rep.Delegated || len(l["info"]) != 1 || len(l["warning"])+len(l["error"]) != 0 {
|
||||
t.Errorf("findings: %v", l)
|
||||
}
|
||||
if !strings.Contains(l["info"][0], "adbraun.lol isn't delegated from the parent zone lol, so the internet can't see it. That's expected for a zone used only on your own network") {
|
||||
t.Errorf("note: %s", l["info"][0])
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user