Compare commits

...
Author SHA1 Message Date
adamandClaude Opus 5.5 bb6b4ab64d 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>
2026-10-09 16:00:39 +00:00
adamandClaude Opus 5.5 1d6c28ba69 Release 0.2.0-rc.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:49:11 +00:00
adamandClaude Opus 5.5 020223d1a5 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>
2026-10-09 15:49:11 +00:00
14 changed files with 583 additions and 36 deletions

No files matched your search

+16
View File
@@ -104,6 +104,22 @@ All notable changes to Jarl are documented here. The format follows
- The view in the zone list, and on the zone page, links to that view,
and the views page highlights it. It used to be plain text, and the zone
page linked to the views page as a whole.
- The zone's Name servers tab shows at once and follows the check as it
happens. Each query is listed as a dig command line (`dig @192.0.2.1
example.org. SOA +norec`) with a "waiting 1.8 s" counter until it is
answered. The finished result then replaces the progress, and the queries
stay under "Queries sent". Results, the delegation check's included, are
kept for 10 minutes, so returning to the tab shows them at once; "Check
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.
+1 -1
View File
@@ -166,7 +166,7 @@ review step.
## Status
Version 0.2.0 is in testing (0.2.0-rc.1); 0.1.0 is the current release.
Version 0.2.0 is in testing (0.2.0-rc.2); 0.1.0 is the current release.
0.2.0 adds DNSSEC signing, Porkbun support, two-factor login and view
colours. Jarl is feature-complete for one server's configuration.
Managing several servers from one jarld and dynamic updates are not
+1 -1
View File
@@ -1 +1 @@
0.2.0-rc.1
0.2.0-rc.2
+12
View File
@@ -220,6 +220,18 @@ 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` 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`,
`not_delegated`, `unreachable`, `error`, with `ns` and `glue`), `glue`
+6
View File
@@ -235,6 +235,12 @@ func (t *Tools) CheckDS(ctx context.Context, zone, view string, tag uint16, alg
return t.RNDCRun(ctx, args...)
}
// ZoneCommand is the rndc command line for a zone command, as shown to
// users ("rndc zonestatus example.org IN internal").
func ZoneCommand(cmd, zone, view string) string {
return "rndc " + strings.Join(zoneArgs(cmd, zone, view), " ")
}
func zoneArgs(cmd, zone, view string) []string {
z := strings.TrimSuffix(zone, ".")
if z == "" {
+9
View File
@@ -1222,6 +1222,15 @@ func (e *Engine) WorkingState(ctx context.Context, csID int64, p *auth.Principal
// secondaries and the rest of the world see), not that of the zone file
// Jarl wrote. ok is false when named isn't asked (no_reload) or doesn't
// report a serial (the zone isn't loaded yet).
// LoadedSerialCommand is the command LoadedSerial runs, or "" when it
// doesn't ask named.
func (e *Engine) LoadedSerialCommand(st *model.State, z *model.Zone) string {
if e.cfg.NoReload || e.tools == nil {
return ""
}
return bind.ZoneCommand("zonestatus", z.Name, e.namedView(st, z))
}
func (e *Engine) LoadedSerial(ctx context.Context, st *model.State, z *model.Zone) (serial uint32, ok bool, err error) {
if e.cfg.NoReload || e.tools == nil {
return 0, false, nil
+152 -5
View File
@@ -20,8 +20,10 @@ import (
"net"
"net/netip"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
@@ -93,6 +95,134 @@ 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 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 a check starts or finishes (Checker.Watch).
type Event struct {
// Kind is "start" or "end" (of query ID).
Kind string `json:"kind"`
ID int64 `json:"id"`
// Query describes what is asked, as a dig command line.
Query string `json:"query,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).
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 {
@@ -162,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 {
@@ -228,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 {
@@ -320,9 +459,17 @@ func (c *Checker) Serials(ctx context.Context, zone string, ref *uint32, targets
continue
}
wg.Add(1)
port := r.Port
if port == "" {
port = c.port()
}
// 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)
c.endEvent(Event{ID: id, Result: &r, RTTMs: r.RTTMs})
add(r)
}(r, t.Key)
}
+62
View File
@@ -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)
}
}
+69 -3
View File
@@ -7,11 +7,13 @@ package server
import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"net/netip"
"strings"
"sync"
"time"
"jarl/internal/api"
@@ -58,7 +60,9 @@ func (s *Server) zoneSerials(w http.ResponseWriter, r *http.Request, p *auth.Pri
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
resp.Serial, resp.SerialSource, err = s.localSerial(ctx, st, z)
ck := s.checker()
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())
}
@@ -77,15 +81,72 @@ func (s *Server) zoneSerials(w http.ResponseWriter, r *http.Request, p *auth.Pri
if resp.View != "" {
resp.Notes = append(resp.Notes, fmt.Sprintf("The zone is in view %s. Other servers answer from the view that matches this server's address (or key), as they do for its zone transfers and NOTIFYs.", resp.View))
}
resp.Servers = s.checker().Serials(ctx, z.Name, resp.Serial, targets)
resp.Servers = ck.Serials(ctx, z.Name, resp.Serial, targets)
if len(resp.Servers) > 0 && !hasOther(resp.Servers) {
resp.Notes = append(resp.Notes, "Only this server is listed, so there is nothing to compare with.")
}
resp.CheckedAt = time.Now()
if emit != nil {
emit(map[string]any{"kind": "done", "response": resp})
return nil
}
writeJSON(w, http.StatusOK, resp)
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.
func (s *Server) streamJSON(w http.ResponseWriter) func(any) {
w.Header().Set("Content-Type", "application/x-ndjson")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Accel-Buffering", "no") // nginx: don't buffer
w.WriteHeader(http.StatusOK)
rc := http.NewResponseController(w)
enc := json.NewEncoder(w)
var mu sync.Mutex
return func(v any) {
mu.Lock()
defer mu.Unlock()
enc.Encode(v)
rc.Flush()
}
}
func (s *Server) zoneDelegation(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
st, err := s.state(r)
if err != nil {
@@ -121,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
}
+58
View File
@@ -1,7 +1,9 @@
package server
import (
"encoding/json"
"net/netip"
"strings"
"testing"
"time"
@@ -69,3 +71,59 @@ func TestZoneSerialsTargets(t *testing.T) {
e.do("GET", "/zones/copy.example/delegation", e.token, nil, 400)
e.do("GET", "/zones/example.com/delegation?resolver=not-an-ip", e.token, nil, 400)
}
func TestZoneSerialsStream(t *testing.T) {
e := newAPIEnv(t)
e.srv.nsChecker = &nscheck.Checker{Timeout: 200 * time.Millisecond, Port: "1", IsLocal: func(a netip.Addr) bool { return a.String() == "127.0.0.21" }}
e.do("POST", "/tsig-keys", e.token, map[string]any{"name": "xfer"}, 201)
e.do("POST", "/zones", e.token, map[string]any{
"name": "example.com",
"nameservers": []string{"ns1.example.com.", "ns2.example.com."},
"records": []map[string]any{{"name": "ns1", "type": "A", "data": "127.0.0.21"}, {"name": "ns2", "type": "A", "data": "127.0.0.22"}},
"options": []map[string]any{{"name": "also-notify", "value": "{ 127.0.0.24 port 5300 key xfer; }"}},
}, 201)
body, status := e.req("GET", "/zones/example.com/serials?stream=1", e.token, nil)
if status != 200 {
t.Fatalf("stream = %d: %s", status, body)
}
var starts, ends []map[string]any
var done map[string]any
for _, line := range strings.Split(strings.TrimSpace(string(body)), "\n") {
var ev map[string]any
if err := json.Unmarshal([]byte(line), &ev); err != nil {
t.Fatalf("line %q: %v", line, err)
}
switch ev["kind"] {
case "start":
starts = append(starts, ev)
case "end":
ends = append(ends, ev)
case "done":
done = ev
}
}
// .21 is this server (not asked); .22 and .24 are.
queries := map[string]bool{}
for _, s := range starts {
queries[s["query"].(string)] = true
}
for _, q := range []string{
"dig @127.0.0.22 -p 1 example.com. SOA +norec",
"dig @127.0.0.24 -p 5300 example.com. SOA +norec -y hmac-sha256:xfer:…",
} {
if !queries[q] {
t.Errorf("no query %q in %v", q, queries)
}
}
if len(starts) != 2 || len(ends) != 2 {
t.Errorf("starts %d, ends %d", len(starts), len(ends))
}
for _, e := range ends {
if r, _ := e["result"].(map[string]any); r == nil || r["status"] != "unreachable" {
t.Errorf("end event %v", e)
}
}
if done == nil || len(done["response"].(map[string]any)["servers"].([]any)) != 3 {
t.Errorf("done = %v", done)
}
}
+4
View File
@@ -384,6 +384,10 @@ func (w *statusWriter) WriteHeader(code int) {
w.ResponseWriter.WriteHeader(code)
}
// Unwrap lets http.ResponseController reach the connection (to flush a
// streamed response).
func (w *statusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
func (w *statusWriter) Write(b []byte) (int, error) {
if w.status == 0 {
w.status = http.StatusOK
+28
View File
@@ -73,6 +73,34 @@ async function call(method, path, opts) {
export const get = (path, query) => api("GET", path, { query });
// stream GETs a newline-delimited JSON response (?stream=1 endpoints) and
// calls onEvent with each value as it arrives. It resolves at the end.
export async function stream(path, query, onEvent) {
const q = new URLSearchParams(query || {});
const resp = await fetch("/api/v1" + path + (q.toString() ? "?" + q : ""), { headers: { "Accept": "application/x-ndjson" }, credentials: "same-origin" });
if (!resp.ok) {
const ct = resp.headers.get("Content-Type") || "";
const data = ct.includes("application/json") ? await resp.json() : await resp.text();
if (resp.status === 401) onUnauthorized();
throw new APIError(resp.status, data);
}
const reader = resp.body.getReader();
const dec = new TextDecoder();
let buf = "";
for (;;) {
const { value, done } = await reader.read();
if (value) buf += dec.decode(value, { stream: true });
let nl;
while ((nl = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
if (line) onEvent(JSON.parse(line));
}
if (done) break;
}
if (buf.trim()) onEvent(JSON.parse(buf));
}
// draftQuery adds the draft changeset to a query, so reads show the
// configuration as it will be once the pending changes are applied.
export function draftQuery(query = {}) {
+152 -26
View File
@@ -1,5 +1,5 @@
import { h, mount, table, field, input, select, textarea, toast, errorBox, display, badge, confirmDialog, modal, checkbox, time, diffView, backLink, viewLink, lastList } from "../ui.js";
import { api, get, change, describeResult, rrtypes, state, draftQuery } from "../api.js";
import { api, get, stream, change, describeResult, rrtypes, state, draftQuery } from "../api.js";
import { rerender } from "../app.js";
import { optionsEditor } from "./options.js";
import { emailOf, pendingBadge } from "./zones.js";
@@ -89,43 +89,168 @@ function nsTable(servers, withSource) {
].filter(Boolean), servers, { empty: "No other name servers to ask." });
}
// 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, delegation}: {at, resp, log, resolver} once done, {job, resolver} while running
const nsFresh = 10 * 60 * 1000;
function nsEntry(zone) {
if (!nsCache.has(zone.id)) nsCache.set(zone.id, {});
return nsCache.get(zone.id);
}
// 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);
const resolver = query.resolver || "";
entry[kind] = { job, resolver };
const notify = () => job.subs.forEach((f) => f());
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, 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[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;
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) {
const secs = (ms) => (ms / 1000).toFixed(1) + " s";
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 {
// 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 kept result, or the
// running check's progress until its result is in.
function serialsView(el, zone, again) {
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 : 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) {
const src = { named: "served by named here", jarl: "Jarl's; named wasn't asked" }[r.serial_source];
return [
h("p", null, "This server: serial ", h("strong", { class: "mono" }, r.serial ?? "unknown"), src ? h("span", { class: "muted" }, " (" + src + ")") : null),
nsTable(r.servers, true),
r.notes.map((n) => h("p", { class: "muted small" }, n)),
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), cached ? " (kept from earlier; Check again to ask anew)." : "."),
];
}
async function serversTab(el, zone) {
const serials = h("div");
const loadSerials = async () => {
mount(serials, h("div", { class: "muted" }, "Asking the name servers…"));
try {
const r = await get("/zones/" + zone.id + "/serials");
const src = { named: "served by named here", jarl: "Jarl's; named wasn't asked" }[r.serial_source];
mount(serials,
h("p", null, "This server: serial ", h("strong", { class: "mono" }, r.serial ?? "unknown"), src ? h("span", { class: "muted" }, " (" + src + ")") : null),
nsTable(r.servers, true),
r.notes.map((n) => h("p", { class: "muted small" }, n)),
h("p", { class: "muted small" }, "Checked ", time(r.checked_at), "."));
} catch (err) {
mount(serials, errorBox(err));
}
};
const again = h("button", { class: "small", onclick: () => serialsView(serials, zone, true) }, "Check again");
const parts = [h("div", { class: "panel" },
h("div", { class: "page-head" },
h("h2", { class: "grow" }, zone.type === "primary" ? "Serials on the secondaries" : "Serials on the primaries"),
h("button", { class: "small", onclick: loadSerials }, "Check again")),
again),
h("p", { class: "muted small" }, zone.type === "primary"
? "Asks the servers in the zone's NS records and its also-notify targets which serial they serve. A secondary that is behind hasn't picked up the latest change yet."
: "Asks the zone's primaries which serial they serve, to see whether this server's copy is up to date."),
serials)];
if (zone.type === "primary") {
const out = h("div");
const resolver = input({ placeholder: "the server's own" });
const run = async (e) => {
const entry = nsEntry(zone);
const resolver = input({ placeholder: "the server's own", value: entry.delegation ? entry.delegation.resolver : "" });
const run = (e) => {
e.preventDefault();
mount(out, h("div", { class: "muted" }, "Asking the parent zone's servers…"));
try {
const q = resolver.value.trim() ? { resolver: resolver.value.trim() } : {};
mount(out, delegationReport(await get("/zones/" + zone.id + "/delegation", q)));
} catch (err) {
mount(out, errorBox(err));
}
delegationView(out, zone, resolver.value.trim(), 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."),
@@ -135,7 +260,8 @@ async function serversTab(el, zone) {
out));
}
mount(el, parts);
await loadSerials();
// Don't wait for the answers: the tab shows the check's progress.
serialsView(serials, zone, false);
}
function delegationReport(r) {
+13
View File
@@ -426,3 +426,16 @@ tr.selected td { background: var(--accent-soft); }
footer.site { padding: 12px 16px 16px; text-align: center; font-size: 12px; color: var(--muted); }
footer.site a { color: var(--muted); text-decoration: underline; text-decoration-color: var(--border); text-underline-offset: 2px; }
footer.site a:hover { color: var(--accent); text-decoration-color: currentColor; }
/* a request in progress */
.spinner { display: inline-block; width: 11px; height: 11px; margin-right: 6px; vertical-align: -1px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; border-top-color: var(--border); background: var(--accent-soft); } }
/* the queries a name server check sends */
.query-log { list-style: none; margin: 6px 0; padding: 0; font-size: 13px; }
.query-log li { padding: 3px 0; display: flex; flex-wrap: wrap; gap: 6px; align-items: baseline; }
.query-log code { background: var(--code-bg); padding: 1px 6px; border-radius: 4px; overflow-wrap: anywhere; }
.query-log li.pending code { opacity: 0.85; }
.err-text { color: var(--err); }
.query-details { margin: 8px 0; }