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:
1 parent
1d6c28ba69
commit
bb6b4ab64d
6 files changed
+334
-128
No files matched your search
+125
-41
@@ -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)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -307,3 +308,64 @@ func TestDelegationDS(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWatch: with Watch set, every query is reported as it starts and
|
||||
// ends, as a dig command line with a summary of the answer.
|
||||
func TestWatch(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
|
||||
})
|
||||
var mu sync.Mutex
|
||||
var events []Event
|
||||
c := checker(port, res)
|
||||
c.Watch = func(e Event) {
|
||||
mu.Lock()
|
||||
events = append(events, e)
|
||||
mu.Unlock()
|
||||
}
|
||||
ref := uint32(7)
|
||||
c.Delegation(context.Background(), "adbraun.lol.", []string{"ns1.adbraun.lol."}, map[string][]string{"ns1.adbraun.lol.": {"127.0.0.12"}}, &ref, nil)
|
||||
open := map[int64]string{}
|
||||
var queries, answers []string
|
||||
for _, e := range events {
|
||||
switch e.Kind {
|
||||
case "start":
|
||||
if !strings.HasPrefix(e.Query, "dig @") {
|
||||
t.Errorf("query %q", e.Query)
|
||||
}
|
||||
open[e.ID] = e.Query
|
||||
queries = append(queries, e.Query)
|
||||
case "end":
|
||||
q, ok := open[e.ID]
|
||||
if !ok {
|
||||
t.Errorf("end without start: %+v", e)
|
||||
}
|
||||
delete(open, e.ID)
|
||||
a := e.Answer + e.Error
|
||||
if e.Result != nil {
|
||||
a = e.Result.Status
|
||||
}
|
||||
answers = append(answers, q+" => "+a)
|
||||
}
|
||||
}
|
||||
if len(open) != 0 {
|
||||
t.Errorf("never ended: %v", open)
|
||||
}
|
||||
all := strings.Join(answers, "\n")
|
||||
for _, want := range []string{
|
||||
" adbraun.lol. NS +norec => referral to ns1.adbraun.lol.", // asking a parent server
|
||||
"@127.0.0.12 -p " + port + " adbraun.lol. SOA +norec => in_sync", // asking the delegated server
|
||||
} {
|
||||
if !strings.Contains(all, want) {
|
||||
t.Errorf("no %q in\n%s", want, all)
|
||||
}
|
||||
}
|
||||
if len(queries) < 4 {
|
||||
t.Errorf("only %d queries:\n%s", len(queries), all)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user