Stream the delegation check's queries too
release / release (push) Successful in 1m42s

Every DNS query the name server checks send is now reported from
exchangeHostPort as a dig command line with a short answer (addresses,
referral, DS, NXDOMAIN) and its round-trip time; the serials check's SOA
queries keep their own events with the server's result. The delegation
check streams with ?stream=1 like /serials, and the Name servers tab
shows its queries as they happen, keeps its result and follows a running
check when you come back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adamandClaude Opus 5.5 committed 2026-10-09 16:00:39 +00:00
1 parent 1d6c28ba69
commit bb6b4ab64d
6 files changed
+334 -128

No files matched your search

+125 -41
View File
@@ -20,8 +20,10 @@ import (
"net"
"net/netip"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
@@ -93,24 +95,118 @@ type Checker struct {
// 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, 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 Serials starts or finishes (Checker.Watch).
// 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 int `json:"id"`
ID int64 `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"`
// 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).
@@ -196,16 +292,25 @@ func (c *Checker) exchange(ctx context.Context, m *dns.Msg, addr string, key *Ke
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) {
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())
}
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 {
@@ -262,7 +367,7 @@ func (c *Checker) soa(ctx context.Context, zone string, key *Key, ref *uint32, r
if port == "" {
port = c.port()
}
resp, rtt, err := c.exchangeHostPort(ctx, m, net.JoinHostPort(r.Address, port), key)
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 {
@@ -322,42 +427,19 @@ func (c *Checker) Serials(ctx context.Context, zone string, ref *uint32, targets
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)
add(Result{Name: t.Name, Source: t.Source, Status: "no_address", Error: msg})
continue
}
end(Event{ID: id, Addresses: found})
addrs = found
}
for _, a := range addrs {
@@ -381,11 +463,13 @@ func (c *Checker) Serials(ctx context.Context, zone string, ref *uint32, targets
if port == "" {
port = c.port()
}
id := start(digLine(r.Address, port, zone, "SOA", true, t.Key))
// Reported here rather than by exchange: the end carries the
// server's status.
id := c.startEvent(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})
c.endEvent(Event{ID: id, Result: &r, RTTMs: r.RTTMs})
add(r)
}(r, t.Key)
}