release / release (push) Successful in 1m58s
The unit's RestrictAddressFamilies lacked AF_NETLINK, which listing the host's addresses needs, so the name server checks didn't know this server's own addresses: the Name servers tab queried them like another server's, and the delegation check didn't sign them with the view key. Allow AF_NETLINK, and fall back to /proc/net without it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
545 lines
15 KiB
Go
545 lines
15 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"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/netip"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"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
|
|
// LocalKey, when set, signs the queries sent to this host's own
|
|
// addresses (when they are asked): with views, the key that selects
|
|
// the zone's view, so the answer comes from that view and not from
|
|
// whichever view matches this host's address.
|
|
LocalKey *Key
|
|
// Watch, if set, is told about each DNS query the checks send 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 a check starts or finishes (Checker.Watch).
|
|
type Event struct {
|
|
// Kind is "start" or "end" (of query ID).
|
|
Kind string `json:"kind"`
|
|
ID int64 `json:"id"`
|
|
// Query describes what is asked, as a dig command line.
|
|
Query string `json:"query,omitempty"`
|
|
// End: Answer sums up the answer ("192.0.2.1, 2001:db8::1",
|
|
// "NXDOMAIN", "referral to a.nic.lol., …"), or Error says why there
|
|
// is none. Serials' SOA queries give the server's Result instead.
|
|
Answer string `json:"answer,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
RTTMs int64 `json:"rtt_ms,omitempty"`
|
|
Result *Result `json:"result,omitempty"`
|
|
}
|
|
|
|
// eventIDs numbers events; 0 is left for the caller's own steps.
|
|
var eventIDs atomic.Int64
|
|
|
|
func (c *Checker) startEvent(query string) int64 {
|
|
if c.Watch == nil {
|
|
return 0
|
|
}
|
|
id := eventIDs.Add(1)
|
|
c.Watch(Event{Kind: "start", ID: id, Query: query})
|
|
return id
|
|
}
|
|
|
|
func (c *Checker) endEvent(e Event) {
|
|
if c.Watch != nil {
|
|
e.Kind = "end"
|
|
c.Watch(e)
|
|
}
|
|
}
|
|
|
|
// quietKey in a context stops exchangeHostPort from reporting the query:
|
|
// the caller reports it with more to say (Serials' SOA queries).
|
|
type quietKey struct{}
|
|
|
|
// digQuery is the dig command line for m sent to hostport.
|
|
func digQuery(m *dns.Msg, hostport string, key *Key) string {
|
|
host, port, err := net.SplitHostPort(hostport)
|
|
if err != nil {
|
|
host, port = hostport, ""
|
|
}
|
|
if len(m.Question) == 0 {
|
|
return "dig @" + host
|
|
}
|
|
q := m.Question[0]
|
|
s := digLine(host, port, q.Name, dns.TypeToString[q.Qtype], !m.RecursionDesired, key)
|
|
if o := m.IsEdns0(); o != nil && o.Do() {
|
|
s += " +dnssec"
|
|
}
|
|
return s
|
|
}
|
|
|
|
// sumAnswer sums up a DNS answer in a few words.
|
|
func sumAnswer(resp *dns.Msg) string {
|
|
if resp.Rcode != dns.RcodeSuccess {
|
|
return dns.RcodeToString[resp.Rcode]
|
|
}
|
|
var vals []string
|
|
for _, rr := range resp.Answer {
|
|
switch v := rr.(type) {
|
|
case *dns.A:
|
|
vals = append(vals, v.A.String())
|
|
case *dns.AAAA:
|
|
vals = append(vals, v.AAAA.String())
|
|
case *dns.NS:
|
|
vals = append(vals, v.Ns)
|
|
case *dns.SOA:
|
|
vals = append(vals, "serial "+strconv.FormatUint(uint64(v.Serial), 10))
|
|
case *dns.DS:
|
|
vals = append(vals, "DS "+strconv.Itoa(int(v.KeyTag)))
|
|
case *dns.RRSIG:
|
|
default:
|
|
vals = append(vals, dns.TypeToString[rr.Header().Rrtype])
|
|
}
|
|
}
|
|
if len(vals) == 0 {
|
|
var ns []string
|
|
for _, rr := range resp.Ns {
|
|
switch v := rr.(type) {
|
|
case *dns.NS:
|
|
ns = append(ns, v.Ns)
|
|
case *dns.SOA:
|
|
return "no such record"
|
|
}
|
|
}
|
|
if len(ns) > 0 {
|
|
return "referral to " + shorten(ns)
|
|
}
|
|
return "empty answer"
|
|
}
|
|
s := shorten(vals)
|
|
if !resp.Authoritative && !resp.RecursionAvailable {
|
|
s += " (not authoritative)"
|
|
}
|
|
return s
|
|
}
|
|
|
|
func shorten(vals []string) string {
|
|
if len(vals) > 3 {
|
|
return strings.Join(vals[:3], ", ") + " and " + strconv.Itoa(len(vals)-3) + " more"
|
|
}
|
|
return strings.Join(vals, ", ")
|
|
}
|
|
|
|
// 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
|
|
}
|
|
for _, b := range LocalAddrs() {
|
|
if b == a.Unmap() {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// LocalAddrs returns this host's interface addresses. Listing them needs a
|
|
// netlink socket; where that is not allowed (a systemd unit without
|
|
// AF_NETLINK in RestrictAddressFamilies), they are read from /proc.
|
|
func LocalAddrs() []netip.Addr {
|
|
var out []netip.Addr
|
|
if addrs, err := net.InterfaceAddrs(); err == nil {
|
|
for _, ia := range addrs {
|
|
if n, ok := ia.(*net.IPNet); ok {
|
|
if b, ok := netip.AddrFromSlice(n.IP); ok {
|
|
out = append(out, b.Unmap())
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
return procAddrs("/proc/net")
|
|
}
|
|
|
|
// procAddrs reads the host's addresses from /proc/net: IPv6 from
|
|
// if_inet6, IPv4 from the local (/32 host LOCAL) entries of fib_trie.
|
|
func procAddrs(dir string) []netip.Addr {
|
|
var out []netip.Addr
|
|
if b, err := os.ReadFile(filepath.Join(dir, "if_inet6")); err == nil {
|
|
for _, line := range strings.Split(string(b), "\n") {
|
|
f := strings.Fields(line)
|
|
if len(f) == 0 || len(f[0]) != 32 {
|
|
continue
|
|
}
|
|
raw, err := hex.DecodeString(f[0])
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if a, ok := netip.AddrFromSlice(raw); ok {
|
|
out = append(out, a)
|
|
}
|
|
}
|
|
}
|
|
if b, err := os.ReadFile(filepath.Join(dir, "fib_trie")); err == nil {
|
|
seen := map[netip.Addr]bool{}
|
|
var last netip.Addr
|
|
for _, line := range strings.Split(string(b), "\n") {
|
|
t := strings.TrimSpace(line)
|
|
if strings.HasPrefix(t, "|-- ") {
|
|
last, _ = netip.ParseAddr(strings.TrimPrefix(t, "|-- "))
|
|
} else if strings.HasPrefix(t, "/32 host LOCAL") && last.IsValid() && !seen[last] {
|
|
seen[last] = true
|
|
out = append(out, last)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// 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) (resp *dns.Msg, rtt time.Duration, err error) {
|
|
if c.Watch != nil && ctx.Value(quietKey{}) == nil {
|
|
id := c.startEvent(digQuery(m, hostport, key))
|
|
defer func() {
|
|
e := Event{ID: id, RTTMs: rtt.Milliseconds()}
|
|
if err != nil {
|
|
e.Error = err.Error()
|
|
} else {
|
|
e.Answer = sumAnswer(resp)
|
|
}
|
|
c.endEvent(e)
|
|
}()
|
|
}
|
|
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())
|
|
}
|
|
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(context.WithValue(ctx, quietKey{}, true), 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)
|
|
port := r.Port
|
|
if port == "" {
|
|
port = c.port()
|
|
}
|
|
// Reported here rather than by exchange: the end carries the
|
|
// server's status.
|
|
key := t.Key
|
|
if key == nil && c.LocalKey != nil && IsLocalAddr(ip) {
|
|
key = c.LocalKey
|
|
}
|
|
id := c.startEvent(digLine(r.Address, port, zone, "SOA", true, key))
|
|
go func(r Result, key *Key) {
|
|
defer wg.Done()
|
|
c.soa(ctx, zone, key, ref, &r)
|
|
c.endEvent(Event{ID: id, Result: &r, RTTMs: r.RTTMs})
|
|
add(r)
|
|
}(r, 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
|
|
}
|