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
@@ -113,6 +113,13 @@ All notable changes to Jarl are documented here. The format follows
|
||||
again" asks anew. A check keeps running if you leave the tab. Behind it,
|
||||
`GET /zones/{zone}/serials?stream=1` streams the queries as
|
||||
newline-delimited JSON (docs/api.md).
|
||||
- The delegation check streams its queries the same way, every one of
|
||||
them: finding the parent zone, its servers' addresses, the NS and DS
|
||||
queries to each parent server and the SOA from each delegated server.
|
||||
Each shows a short answer ("referral to katelyn.ns.cloudflare.com., …",
|
||||
"DS 2371", "NXDOMAIN"). The serials check now lists its address lookups
|
||||
as separate A and AAAA queries. `GET /zones/{zone}/delegation?stream=1`
|
||||
streams them.
|
||||
- Switching a zone's tabs (Records, Settings, …) no longer blanks the page
|
||||
while the tab loads: the page changes once the tab is ready, and
|
||||
"Loading…" appears only if a page takes a while.
|
||||
|
||||
+11
-9
@@ -220,15 +220,17 @@ when called ([operations.md](operations.md#checking-name-servers-and-the-delegat
|
||||
`error` (with `error`). `source` is `NS record`, `also-notify` or
|
||||
`primaries`; `port` is set for a non-default port.
|
||||
|
||||
`/serials?stream=1` answers with newline-delimited JSON
|
||||
(`application/x-ndjson`) while it works, which is how the web UI shows the
|
||||
check's progress. Each query is an event when it is sent and another when
|
||||
it is answered: `{"kind": "start", "id": 2, "query": "dig @192.0.2.1
|
||||
example.org. SOA +norec"}`, then `{"kind": "end", "id": 2, "result":
|
||||
{...}}`. The result is a `servers` entry, or `addresses` or `error` for a
|
||||
name server's address lookup. ID 0 is `rndc zonestatus`, which asks this
|
||||
server's named. The last line is `{"kind": "done", "response": {...}}`,
|
||||
the response above.
|
||||
`/serials?stream=1` and `/delegation?stream=1` answer with
|
||||
newline-delimited JSON (`application/x-ndjson`) while they work, which is
|
||||
how the web UI shows a check's progress. Each DNS query is an event when it
|
||||
is sent and another when it is answered: `{"kind": "start", "id": 2,
|
||||
"query": "dig @192.0.2.1 example.org. SOA +norec"}`, then `{"kind": "end",
|
||||
"id": 2, "answer": "serial 2026100900", "rtt_ms": 12}`. `answer` sums the
|
||||
answer up (addresses, `referral to …`, `NXDOMAIN`, `no such record`);
|
||||
`error` replaces it when there was none. The serials check's SOA queries
|
||||
end with `result`, the `servers` entry, instead. ID 0 is `rndc zonestatus`,
|
||||
which asks this server's named. The last line is `{"kind": "done",
|
||||
"response": {...}}`, the endpoint's usual response.
|
||||
|
||||
`/delegation` returns `parent`, `resolver`, `delegated`, `parent_ns`,
|
||||
`jarl_ns`, `parent_servers` (each parent server's `status`: `delegated`,
|
||||
|
||||
+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)
|
||||
}
|
||||
}
|
||||
@@ -60,30 +60,12 @@ func (s *Server) zoneSerials(w http.ResponseWriter, r *http.Request, p *auth.Pri
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
// ?stream=1: newline-delimited JSON events while the servers are
|
||||
// asked (nscheck.Event, kind start/end), then {"kind":"done",
|
||||
// "response":...}. The web UI shows the queries as they go.
|
||||
ck := s.checker()
|
||||
var emit func(any)
|
||||
if queryBool(r, "stream") {
|
||||
emit = s.streamJSON(w)
|
||||
ck.Watch = func(e nscheck.Event) { emit(e) }
|
||||
}
|
||||
cmd := s.eng.LoadedSerialCommand(st, z)
|
||||
if emit != nil && cmd != "" {
|
||||
emit(nscheck.Event{Kind: "start", ID: 0, Query: cmd})
|
||||
}
|
||||
resp.Serial, resp.SerialSource, err = s.localSerial(ctx, st, z)
|
||||
emit := s.watchChecks(w, r, ck)
|
||||
resp.Serial, resp.SerialSource, err = s.watchedLocalSerial(ctx, emit, st, z)
|
||||
if err != nil {
|
||||
resp.Notes = append(resp.Notes, "named on this server didn't report the zone's serial: "+err.Error())
|
||||
}
|
||||
if emit != nil && cmd != "" {
|
||||
e := nscheck.Event{Kind: "end", ID: 0}
|
||||
if err != nil {
|
||||
e.Error = err.Error()
|
||||
}
|
||||
emit(e)
|
||||
}
|
||||
if z.Signed() && resp.SerialSource == "jarl" {
|
||||
// named gives the signed zone its own serial, ahead of Jarl's.
|
||||
resp.Notes = append(resp.Notes, "The zone is signed and named wasn't asked: other servers serve the signed zone's serial, which named sets and which is usually ahead of Jarl's.")
|
||||
@@ -112,6 +94,40 @@ func (s *Server) zoneSerials(w http.ResponseWriter, r *http.Request, p *auth.Pri
|
||||
return nil
|
||||
}
|
||||
|
||||
// watchChecks: with ?stream=1, /serials and /delegation answer with
|
||||
// newline-delimited JSON events while they ask the servers (nscheck.Event,
|
||||
// kind start/end, one pair per DNS query), then {"kind":"done",
|
||||
// "response":...}. The web UI shows the queries as they go. It returns
|
||||
// the function that writes an event, or nil without ?stream=1.
|
||||
func (s *Server) watchChecks(w http.ResponseWriter, r *http.Request, ck *nscheck.Checker) func(any) {
|
||||
if !queryBool(r, "stream") {
|
||||
return nil
|
||||
}
|
||||
emit := s.streamJSON(w)
|
||||
ck.Watch = func(e nscheck.Event) { emit(e) }
|
||||
return emit
|
||||
}
|
||||
|
||||
// watchedLocalSerial is localSerial, reported as event 0 when streaming.
|
||||
func (s *Server) watchedLocalSerial(ctx context.Context, emit func(any), st *model.State, z *model.Zone) (*uint32, string, error) {
|
||||
cmd := s.eng.LoadedSerialCommand(st, z)
|
||||
if emit != nil && cmd != "" {
|
||||
emit(nscheck.Event{Kind: "start", ID: 0, Query: cmd})
|
||||
}
|
||||
serial, src, err := s.localSerial(ctx, st, z)
|
||||
if emit != nil && cmd != "" {
|
||||
e := nscheck.Event{Kind: "end", ID: 0}
|
||||
switch {
|
||||
case err != nil:
|
||||
e.Error = err.Error()
|
||||
case src == "named" && serial != nil:
|
||||
e.Answer = fmt.Sprintf("serial %d", *serial)
|
||||
}
|
||||
emit(e)
|
||||
}
|
||||
return serial, src, err
|
||||
}
|
||||
|
||||
// streamJSON starts a newline-delimited JSON response and returns a
|
||||
// function that writes one value and flushes it; it is safe for
|
||||
// concurrent use.
|
||||
@@ -166,11 +182,16 @@ func (s *Server) zoneDelegation(w http.ResponseWriter, r *http.Request, p *auth.
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
ref, _, _ := s.localSerial(ctx, st, z)
|
||||
emit := s.watchChecks(w, r, c)
|
||||
ref, _, _ := s.watchedLocalSerial(ctx, emit, st, z)
|
||||
resp := api.DelegationResponse{DelegationReport: c.Delegation(ctx, z.Name, ns, addrs, ref, s.signing(ctx, st, z)), View: st.ViewName(z), Notes: []string{}, CheckedAt: time.Now()}
|
||||
if resp.View != "" {
|
||||
resp.Notes = append(resp.Notes, fmt.Sprintf("Compared with the zone in view %s. Resolvers on the internet get the view that matches their address; make sure that's this one.", resp.View))
|
||||
}
|
||||
if emit != nil {
|
||||
emit(map[string]any{"kind": "done", "response": resp})
|
||||
return nil
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ function nsTable(servers, withSource) {
|
||||
// Name server checks ask other servers over the network, which can take
|
||||
// seconds. Results are kept for a while (per zone, for this page load),
|
||||
// so coming back to the tab shows them at once; "Check again" asks anew.
|
||||
const nsCache = new Map(); // zone id -> {serials: {at, resp, log} | {job}, delegation: {at, resolver, resp}}
|
||||
const nsCache = new Map(); // zone id -> {serials, delegation}: {at, resp, log, resolver} once done, {job, resolver} while running
|
||||
const nsFresh = 10 * 60 * 1000;
|
||||
|
||||
function nsEntry(zone) {
|
||||
@@ -100,32 +100,59 @@ function nsEntry(zone) {
|
||||
return nsCache.get(zone.id);
|
||||
}
|
||||
|
||||
// runSerials starts the serials check, streamed: the log fills with the
|
||||
// queries as they are sent and answered. Views subscribe to it.
|
||||
function runSerials(zone) {
|
||||
const job = { log: [], subs: new Set(), done: null, error: null, started: Date.now() };
|
||||
// runCheck starts a check ("serials" or "delegation"), streamed: its log
|
||||
// fills with the queries as they are sent and answered. Views subscribe
|
||||
// to the job; its result is kept in nsCache.
|
||||
function runCheck(zone, kind, query) {
|
||||
const job = { log: [], subs: new Set(), done: null, error: null };
|
||||
const entry = nsEntry(zone);
|
||||
entry.serials = { job };
|
||||
const resolver = query.resolver || "";
|
||||
entry[kind] = { job, resolver };
|
||||
const notify = () => job.subs.forEach((f) => f());
|
||||
stream("/zones/" + zone.id + "/serials", { stream: 1 }, (ev) => {
|
||||
stream("/zones/" + zone.id + "/" + kind, { ...query, stream: 1 }, (ev) => {
|
||||
if (ev.kind === "start") job.log.push({ id: ev.id, query: ev.query, started: Date.now() });
|
||||
else if (ev.kind === "end") {
|
||||
const q = job.log.find((q) => q.id === ev.id && !q.ended);
|
||||
if (q) Object.assign(q, { ended: Date.now(), result: ev.result, addresses: ev.addresses, error: ev.error });
|
||||
if (q) Object.assign(q, { ended: Date.now(), result: ev.result, answer: ev.answer, error: ev.error, rtt: ev.rtt_ms });
|
||||
} else if (ev.kind === "done") job.done = ev.response;
|
||||
notify();
|
||||
}).then(() => {
|
||||
if (job.done) entry.serials = { at: Date.now(), resp: job.done, log: job.log };
|
||||
if (job.done) entry[kind] = { at: Date.now(), resp: job.done, log: job.log, resolver };
|
||||
else job.error = new Error("the check ended without a result");
|
||||
notify();
|
||||
}).catch((e) => {
|
||||
job.error = e;
|
||||
delete entry.serials;
|
||||
if (entry[kind] && entry[kind].job === job) delete entry[kind];
|
||||
notify();
|
||||
});
|
||||
return job;
|
||||
}
|
||||
|
||||
// followCheck shows a running check in el, drawing progress(job) until
|
||||
// it's done and then result(response, log). It stops following once el
|
||||
// has been shown and is gone (the check goes on, and its result is kept).
|
||||
function followCheck(el, job, progress, result) {
|
||||
let seen = false, timer = null;
|
||||
const stop = () => { job.subs.delete(draw); clearInterval(timer); timer = null; };
|
||||
function draw() {
|
||||
if (el.isConnected) seen = true;
|
||||
else if (seen) { stop(); return; }
|
||||
if (job.error) mount(el, errorBox(job.error));
|
||||
else if (job.done) mount(el, result(job.done, job.log));
|
||||
else mount(el, progress(job));
|
||||
if (job.done || job.error) stop();
|
||||
}
|
||||
job.subs.add(draw);
|
||||
draw();
|
||||
if (!job.done && !job.error) {
|
||||
// Tick the "waiting n s" counters.
|
||||
timer = setInterval(() => {
|
||||
if (seen && !el.isConnected) { stop(); return; }
|
||||
for (const s of el.querySelectorAll(".elapsed")) s.textContent = "waiting " + ((Date.now() - Number(s.dataset.started)) / 1000).toFixed(1) + " s";
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
|
||||
// queryLog lists the queries of a check: what was sent, and the answer or
|
||||
// that it's still waiting.
|
||||
function queryLog(log) {
|
||||
@@ -133,50 +160,64 @@ function queryLog(log) {
|
||||
return h("ul", { class: "query-log" }, log.map((q) => {
|
||||
let outcome;
|
||||
if (!q.ended) outcome = [h("span", { class: "spinner", "aria-hidden": "true" }), h("span", { class: "muted elapsed", dataset: { started: q.started } }, "waiting " + secs(Date.now() - q.started))];
|
||||
else if (q.result) outcome = [nsBadge(q.result), q.result.rtt_ms ? h("span", { class: "muted" }, " " + q.result.rtt_ms + " ms") : h("span", { class: "muted" }, " " + secs(q.ended - q.started))];
|
||||
else if (q.addresses) outcome = h("span", { class: "muted mono" }, "→ " + q.addresses.join(", "));
|
||||
else if (q.error) outcome = h("span", { class: "err-text" }, q.error);
|
||||
else outcome = h("span", { class: "muted" }, "done, " + secs(q.ended - q.started));
|
||||
else {
|
||||
// The server's round-trip time when it has one; else how long the
|
||||
// answer took to arrive here (at least the timeouts, for errors).
|
||||
const ms = q.rtt || q.ended - q.started;
|
||||
const took = h("span", { class: "muted" }, ms < 1 ? "<1 ms" : ms < 1000 ? ms + " ms" : secs(ms));
|
||||
if (q.result) outcome = [nsBadge(q.result), took];
|
||||
else if (q.error) outcome = [h("span", { class: "err-text" }, q.error), took];
|
||||
else outcome = [h("span", { class: "mono" }, "→ " + (q.answer || "done")), took];
|
||||
}
|
||||
return h("li", { class: q.ended ? "" : "pending" }, h("code", null, q.query), " ", outcome);
|
||||
}));
|
||||
}
|
||||
|
||||
// serialsView fills el with the serials check: the cached result, or the
|
||||
// serialsView fills el with the serials check: the kept result, or the
|
||||
// running check's progress until its result is in.
|
||||
function serialsView(el, zone, again) {
|
||||
const entry = nsEntry(zone);
|
||||
const c = entry.serials;
|
||||
const c = nsEntry(zone).serials;
|
||||
if (!again && c && c.resp && Date.now() - c.at < nsFresh) {
|
||||
mount(el, serialsResult(c.resp, c.log, true));
|
||||
return;
|
||||
}
|
||||
const job = !again && c && c.job ? c.job : runSerials(zone);
|
||||
let seen = false, timer = null;
|
||||
const draw = () => {
|
||||
// Stop following the check once the tab is gone (the check goes on
|
||||
// and its result is kept).
|
||||
if (el.isConnected) seen = true;
|
||||
else if (seen) { job.subs.delete(draw); clearInterval(timer); return; }
|
||||
if (job.error) mount(el, errorBox(job.error));
|
||||
else if (job.done) mount(el, serialsResult(job.done, job.log, false));
|
||||
else {
|
||||
const local = job.log.find((q) => q.id === 0);
|
||||
mount(el,
|
||||
h("p", null, "This server: serial ", local && !local.ended ? [h("span", { class: "spinner", "aria-hidden": "true" }), h("span", { class: "muted" }, "asking named")] : h("span", { class: "muted" }, "…")),
|
||||
h("p", { class: "muted small" }, job.log.length ? "Waiting for the servers to answer:" : "Finding the servers to ask…"),
|
||||
queryLog(job.log));
|
||||
}
|
||||
if ((job.done || job.error) && timer) { clearInterval(timer); timer = null; }
|
||||
};
|
||||
job.subs.add(draw);
|
||||
draw();
|
||||
// Tick the "waiting n s" counters.
|
||||
if (!job.done && !job.error) {
|
||||
timer = setInterval(() => {
|
||||
if (seen && !el.isConnected) { clearInterval(timer); job.subs.delete(draw); return; }
|
||||
for (const s of el.querySelectorAll(".elapsed")) s.textContent = "waiting " + ((Date.now() - Number(s.dataset.started)) / 1000).toFixed(1) + " s";
|
||||
}, 200);
|
||||
const job = !again && c && c.job ? c.job : runCheck(zone, "serials", {});
|
||||
followCheck(el, job, (job) => {
|
||||
const local = job.log.find((q) => q.id === 0);
|
||||
return [
|
||||
h("p", null, "This server: serial ", local && !local.ended ? [h("span", { class: "spinner", "aria-hidden": "true" }), h("span", { class: "muted" }, "asking named")] : h("span", { class: "muted" }, "…")),
|
||||
h("p", { class: "muted small" }, job.log.length ? "Waiting for the servers to answer:" : "Finding the servers to ask…"),
|
||||
queryLog(job.log)];
|
||||
}, (resp, log) => serialsResult(resp, log, false));
|
||||
}
|
||||
|
||||
// delegationView fills el with the delegation check, like serialsView.
|
||||
// It starts a check only when asked (run), or follows one that's running.
|
||||
function delegationView(el, zone, resolver, run) {
|
||||
const c = nsEntry(zone).delegation;
|
||||
if (!run) {
|
||||
if (c && c.job) followCheck(el, c.job, delegationProgress, (resp, log) => delegationResult(resp, log, false));
|
||||
else if (c && c.resp && Date.now() - c.at < nsFresh) mount(el, delegationResult(c.resp, c.log, true, c.at));
|
||||
return;
|
||||
}
|
||||
const job = runCheck(zone, "delegation", resolver ? { resolver } : {});
|
||||
followCheck(el, job, delegationProgress, (resp, log) => delegationResult(resp, log, false));
|
||||
}
|
||||
|
||||
function delegationProgress(job) {
|
||||
return [
|
||||
h("p", { class: "muted small" }, job.log.length
|
||||
? "Finding the parent zone, asking its servers for the delegation and the DS records, then each delegated server for the zone:"
|
||||
: [h("span", { class: "spinner", "aria-hidden": "true" }), "Starting…"]),
|
||||
queryLog(job.log)];
|
||||
}
|
||||
|
||||
function delegationResult(r, log, cached, at) {
|
||||
return [
|
||||
delegationReport(r),
|
||||
log && log.length ? h("details", { class: "query-details" }, h("summary", { class: "small muted" }, "Queries sent (" + log.length + ")"), queryLog(log)) : null,
|
||||
h("p", { class: "muted small" }, "Checked ", time(r.checked_at || new Date(at).toISOString()), cached ? " (kept from earlier; Check delegation to ask anew)." : "."),
|
||||
];
|
||||
}
|
||||
|
||||
function serialsResult(r, log, cached) {
|
||||
@@ -205,22 +246,11 @@ async function serversTab(el, zone) {
|
||||
const out = h("div");
|
||||
const entry = nsEntry(zone);
|
||||
const resolver = input({ placeholder: "the server's own", value: entry.delegation ? entry.delegation.resolver : "" });
|
||||
const show = (d, cached) => mount(out, delegationReport(d.resp),
|
||||
h("p", { class: "muted small" }, "Checked ", time(new Date(d.at).toISOString()), cached ? " (kept from earlier; Check delegation to ask anew)." : "."));
|
||||
const run = async (e) => {
|
||||
const run = (e) => {
|
||||
e.preventDefault();
|
||||
const res = resolver.value.trim();
|
||||
mount(out, h("p", { class: "muted" }, h("span", { class: "spinner", "aria-hidden": "true" }),
|
||||
"Finding the parent zone through " + (res || "the server's resolver") + ", then asking its servers for the delegation and each delegated server for the zone…"));
|
||||
try {
|
||||
const d = { at: Date.now(), resolver: res, resp: await get("/zones/" + zone.id + "/delegation", res ? { resolver: res } : {}) };
|
||||
entry.delegation = d;
|
||||
if (out.isConnected) show(d, false);
|
||||
} catch (err) {
|
||||
mount(out, errorBox(err));
|
||||
}
|
||||
delegationView(out, zone, resolver.value.trim(), true);
|
||||
};
|
||||
if (entry.delegation && Date.now() - entry.delegation.at < nsFresh) show(entry.delegation, true);
|
||||
delegationView(out, zone, "", false);
|
||||
parts.push(h("div", { class: "panel" },
|
||||
h("h2", null, "Delegation from the parent zone"),
|
||||
h("p", { class: "muted small" }, "For zones on the internet: checks that the parent zone (for adbraun.lol, \"lol\") points to this zone's name servers with the right glue addresses, and that each of them serves the zone. A zone used only on your own network isn't delegated, and doesn't need this."),
|
||||
|
||||
Reference in new issue
Block a user