Files
jarl/internal/nscheck/nscheck.go
T
abrown38andClaude Opus 5.5 293e6ddd33 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>
2026-10-08 19:02:34 +00:00

339 lines
9.3 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
}
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
}