The zone's Name servers tab no longer waits for the other servers before
showing: it lists each query as a dig command line with a waiting
counter, fills in the answers as they arrive, then shows the result with
the queries under "Queries sent". Results (serials and delegation) are
kept for 10 minutes so revisiting the tab is instant; "Check again" asks
anew, and a check keeps running when the tab is left.
GET /zones/{zone}/serials?stream=1 streams the queries as
newline-delimited JSON (nscheck.Checker.Watch); without it the endpoint
is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
402 lines
11 KiB
Go
402 lines
11 KiB
Go
// 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
|
|
// Watch, if set, is told about each query Serials sends and its
|
|
// outcome as it happens (the web UI shows them while it waits). It is
|
|
// called from several goroutines at once.
|
|
Watch func(Event)
|
|
}
|
|
|
|
// Event is a query Serials starts or finishes (Checker.Watch).
|
|
type Event struct {
|
|
// Kind is "start" or "end" (of query ID).
|
|
Kind string `json:"kind"`
|
|
ID int `json:"id"`
|
|
// Query describes what is asked, as a dig command line.
|
|
Query string `json:"query,omitempty"`
|
|
// End: the server's result (SOA queries), or the addresses found or
|
|
// the error (address lookups).
|
|
Result *Result `json:"result,omitempty"`
|
|
Addresses []string `json:"addresses,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// digLine is a dig command line for a query (no TSIG secret).
|
|
func digLine(server, port, name, types string, norec bool, key *Key) string {
|
|
s := "dig @" + server
|
|
if port != "" && port != "53" {
|
|
s += " -p " + port
|
|
}
|
|
s += " " + name + " " + types
|
|
if norec {
|
|
s += " +norec"
|
|
}
|
|
if key != nil {
|
|
s += " -y " + key.Algorithm + ":" + strings.TrimSuffix(key.Name, ".") + ":…"
|
|
}
|
|
return s
|
|
}
|
|
|
|
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()
|
|
}
|
|
var ids int
|
|
start := func(query string) int {
|
|
if c.Watch == nil {
|
|
return 0
|
|
}
|
|
mu.Lock()
|
|
ids++
|
|
id := ids
|
|
mu.Unlock()
|
|
c.Watch(Event{Kind: "start", ID: id, Query: query})
|
|
return id
|
|
}
|
|
end := func(e Event) {
|
|
if c.Watch != nil {
|
|
e.Kind = "end"
|
|
c.Watch(e)
|
|
}
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, t := range targets {
|
|
addrs := t.Addresses
|
|
if addrs == nil && t.Name != "" {
|
|
res, _ := c.resolver()
|
|
id := start(digLine(res, "", t.Name, "A, AAAA", false, nil))
|
|
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()
|
|
}
|
|
r := Result{Name: t.Name, Source: t.Source, Status: "no_address", Error: msg}
|
|
end(Event{ID: id, Error: msg, Result: &r})
|
|
add(r)
|
|
continue
|
|
}
|
|
end(Event{ID: id, Addresses: found})
|
|
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)
|
|
port := r.Port
|
|
if port == "" {
|
|
port = c.port()
|
|
}
|
|
id := start(digLine(r.Address, port, zone, "SOA", true, t.Key))
|
|
go func(r Result, key *Key) {
|
|
defer wg.Done()
|
|
c.soa(ctx, zone, key, ref, &r)
|
|
end(Event{ID: id, Result: &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
|
|
}
|