Show the Name servers check as it happens, and keep its results

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>
This commit is contained in:
adamandClaude Opus 5.5 committed 2026-10-09 15:49:11 +00:00
1 parent d961cb8792
commit 020223d1a5
11 files changed
+364 -23

No files matched your search

+64 -1
View File
@@ -93,6 +93,40 @@ 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 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 {
@@ -288,19 +322,42 @@ 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()
}
add(Result{Name: t.Name, Source: t.Source, Status: "no_address", Error: msg})
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 {
@@ -320,9 +377,15 @@ func (c *Checker) Serials(ctx context.Context, zone string, ref *uint32, targets
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)
}