From bb6b4ab64d007b839b28b756adb4f47e5ad34d6f Mon Sep 17 00:00:00 2001 From: adam Date: Fri, 9 Oct 2026 16:00:39 +0000 Subject: [PATCH] Stream the delegation check's queries too 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 --- CHANGELOG.md | 7 + docs/api.md | 20 +-- internal/nscheck/nscheck.go | 166 +++++++++++++++++------ internal/nscheck/nscheck_test.go | 62 +++++++++ internal/server/h_nscheck.go | 63 ++++++--- internal/web/static/assets/pages/zone.js | 144 ++++++++++++-------- 6 files changed, 334 insertions(+), 128 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76d93ef..07d9522 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/api.md b/docs/api.md index 8a508de..8587d85 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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`, diff --git a/internal/nscheck/nscheck.go b/internal/nscheck/nscheck.go index 748f108..7a9b5f3 100644 --- a/internal/nscheck/nscheck.go +++ b/internal/nscheck/nscheck.go @@ -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) } diff --git a/internal/nscheck/nscheck_test.go b/internal/nscheck/nscheck_test.go index 4d280bc..f99eff5 100644 --- a/internal/nscheck/nscheck_test.go +++ b/internal/nscheck/nscheck_test.go @@ -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) + } +} diff --git a/internal/server/h_nscheck.go b/internal/server/h_nscheck.go index 2079a97..9dae2fd 100644 --- a/internal/server/h_nscheck.go +++ b/internal/server/h_nscheck.go @@ -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 } diff --git a/internal/web/static/assets/pages/zone.js b/internal/web/static/assets/pages/zone.js index d7789f2..221a5ec 100644 --- a/internal/web/static/assets/pages/zone.js +++ b/internal/web/static/assets/pages/zone.js @@ -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."),