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:
1 parent
d961cb8792
commit
020223d1a5
11 files changed
+364
-23
No files matched your search
@@ -7,11 +7,13 @@ package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"jarl/internal/api"
|
||||
@@ -58,10 +60,30 @@ 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)
|
||||
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.")
|
||||
@@ -77,15 +99,38 @@ 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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user