Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb6b4ab64d | ||
|
|
1d6c28ba69 | ||
|
|
020223d1a5 |
No files matched your search
@@ -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,
|
- 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
|
and the views page highlights it. It used to be plain text, and the zone
|
||||||
page linked to the views page as a whole.
|
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
|
- 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
|
while the tab loads: the page changes once the tab is ready, and
|
||||||
"Loading…" appears only if a page takes a while.
|
"Loading…" appears only if a page takes a while.
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ review step.
|
|||||||
|
|
||||||
## Status
|
## 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
|
0.2.0 adds DNSSEC signing, Porkbun support, two-factor login and view
|
||||||
colours. Jarl is feature-complete for one server's configuration.
|
colours. Jarl is feature-complete for one server's configuration.
|
||||||
Managing several servers from one jarld and dynamic updates are not
|
Managing several servers from one jarld and dynamic updates are not
|
||||||
|
|||||||
+12
@@ -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
|
`error` (with `error`). `source` is `NS record`, `also-notify` or
|
||||||
`primaries`; `port` is set for a non-default port.
|
`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`,
|
`/delegation` returns `parent`, `resolver`, `delegated`, `parent_ns`,
|
||||||
`jarl_ns`, `parent_servers` (each parent server's `status`: `delegated`,
|
`jarl_ns`, `parent_servers` (each parent server's `status`: `delegated`,
|
||||||
`not_delegated`, `unreachable`, `error`, with `ns` and `glue`), `glue`
|
`not_delegated`, `unreachable`, `error`, with `ns` and `glue`), `glue`
|
||||||
|
|||||||
@@ -235,6 +235,12 @@ func (t *Tools) CheckDS(ctx context.Context, zone, view string, tag uint16, alg
|
|||||||
return t.RNDCRun(ctx, args...)
|
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 {
|
func zoneArgs(cmd, zone, view string) []string {
|
||||||
z := strings.TrimSuffix(zone, ".")
|
z := strings.TrimSuffix(zone, ".")
|
||||||
if z == "" {
|
if z == "" {
|
||||||
|
|||||||
@@ -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
|
// 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
|
// Jarl wrote. ok is false when named isn't asked (no_reload) or doesn't
|
||||||
// report a serial (the zone isn't loaded yet).
|
// 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) {
|
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 {
|
if e.cfg.NoReload || e.tools == nil {
|
||||||
return 0, false, nil
|
return 0, false, nil
|
||||||
|
|||||||
+152
-5
@@ -20,8 +20,10 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -93,6 +95,134 @@ type Checker struct {
|
|||||||
// IsLocal reports whether an address belongs to this host (default:
|
// IsLocal reports whether an address belongs to this host (default:
|
||||||
// its interface addresses and loopback).
|
// its interface addresses and loopback).
|
||||||
IsLocal func(netip.Addr) bool
|
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 {
|
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)
|
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()}
|
cl := &dns.Client{Timeout: c.timeout()}
|
||||||
if key != nil {
|
if key != nil {
|
||||||
name := dns.Fqdn(key.Name)
|
name := dns.Fqdn(key.Name)
|
||||||
cl.TsigSecret = map[string]string{name: key.Secret}
|
cl.TsigSecret = map[string]string{name: key.Secret}
|
||||||
m.SetTsig(name, dns.Fqdn(key.Algorithm), 300, time.Now().Unix())
|
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++ {
|
for try := 0; try < 2; try++ {
|
||||||
resp, rtt, err = cl.ExchangeContext(ctx, m, hostport)
|
resp, rtt, err = cl.ExchangeContext(ctx, m, hostport)
|
||||||
if err == nil || ctx.Err() != nil {
|
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 == "" {
|
if port == "" {
|
||||||
port = c.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 {
|
if err != nil {
|
||||||
var ne net.Error
|
var ne net.Error
|
||||||
switch {
|
switch {
|
||||||
@@ -320,9 +459,17 @@ func (c *Checker) Serials(ctx context.Context, zone string, ref *uint32, targets
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
wg.Add(1)
|
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) {
|
go func(r Result, key *Key) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
c.soa(ctx, zone, key, ref, &r)
|
c.soa(ctx, zone, key, ref, &r)
|
||||||
|
c.endEvent(Event{ID: id, Result: &r, RTTMs: r.RTTMs})
|
||||||
add(r)
|
add(r)
|
||||||
}(r, t.Key)
|
}(r, t.Key)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,11 +7,13 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"jarl/internal/api"
|
"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)
|
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||||
defer cancel()
|
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 {
|
if err != nil {
|
||||||
resp.Notes = append(resp.Notes, "named on this server didn't report the zone's serial: "+err.Error())
|
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 != "" {
|
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.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) {
|
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.Notes = append(resp.Notes, "Only this server is listed, so there is nothing to compare with.")
|
||||||
}
|
}
|
||||||
resp.CheckedAt = time.Now()
|
resp.CheckedAt = time.Now()
|
||||||
|
if emit != nil {
|
||||||
|
emit(map[string]any{"kind": "done", "response": resp})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, resp)
|
writeJSON(w, http.StatusOK, resp)
|
||||||
return nil
|
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 {
|
func (s *Server) zoneDelegation(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
st, err := s.state(r)
|
st, err := s.state(r)
|
||||||
if err != nil {
|
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)
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||||
defer cancel()
|
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()}
|
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 != "" {
|
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))
|
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)
|
writeJSON(w, http.StatusOK, resp)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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/copy.example/delegation", e.token, nil, 400)
|
||||||
e.do("GET", "/zones/example.com/delegation?resolver=not-an-ip", 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)
|
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) {
|
func (w *statusWriter) Write(b []byte) (int, error) {
|
||||||
if w.status == 0 {
|
if w.status == 0 {
|
||||||
w.status = http.StatusOK
|
w.status = http.StatusOK
|
||||||
|
|||||||
@@ -73,6 +73,34 @@ async function call(method, path, opts) {
|
|||||||
|
|
||||||
export const get = (path, query) => api("GET", path, { query });
|
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
|
// draftQuery adds the draft changeset to a query, so reads show the
|
||||||
// configuration as it will be once the pending changes are applied.
|
// configuration as it will be once the pending changes are applied.
|
||||||
export function draftQuery(query = {}) {
|
export function draftQuery(query = {}) {
|
||||||
|
|||||||
@@ -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 { 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 { rerender } from "../app.js";
|
||||||
import { optionsEditor } from "./options.js";
|
import { optionsEditor } from "./options.js";
|
||||||
import { emailOf, pendingBadge } from "./zones.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." });
|
].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) {
|
async function serversTab(el, zone) {
|
||||||
const serials = h("div");
|
const serials = h("div");
|
||||||
const loadSerials = async () => {
|
const again = h("button", { class: "small", onclick: () => serialsView(serials, zone, true) }, "Check again");
|
||||||
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 parts = [h("div", { class: "panel" },
|
const parts = [h("div", { class: "panel" },
|
||||||
h("div", { class: "page-head" },
|
h("div", { class: "page-head" },
|
||||||
h("h2", { class: "grow" }, zone.type === "primary" ? "Serials on the secondaries" : "Serials on the primaries"),
|
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"
|
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 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."),
|
: "Asks the zone's primaries which serial they serve, to see whether this server's copy is up to date."),
|
||||||
serials)];
|
serials)];
|
||||||
if (zone.type === "primary") {
|
if (zone.type === "primary") {
|
||||||
const out = h("div");
|
const out = h("div");
|
||||||
const resolver = input({ placeholder: "the server's own" });
|
const entry = nsEntry(zone);
|
||||||
const run = async (e) => {
|
const resolver = input({ placeholder: "the server's own", value: entry.delegation ? entry.delegation.resolver : "" });
|
||||||
|
const run = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
mount(out, h("div", { class: "muted" }, "Asking the parent zone's servers…"));
|
delegationView(out, zone, resolver.value.trim(), true);
|
||||||
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, "", false);
|
||||||
parts.push(h("div", { class: "panel" },
|
parts.push(h("div", { class: "panel" },
|
||||||
h("h2", null, "Delegation from the parent zone"),
|
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."),
|
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));
|
out));
|
||||||
}
|
}
|
||||||
mount(el, parts);
|
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) {
|
function delegationReport(r) {
|
||||||
|
|||||||
@@ -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 { 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 { 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; }
|
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; }
|
||||||
Reference in new issue
Block a user