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>
333 lines
10 KiB
Go
333 lines
10 KiB
Go
package server
|
|
|
|
// Name server checks (internal/nscheck): GET /zones/{zone}/serials and
|
|
// GET /zones/{zone}/delegation. Both only run when asked for (the zone
|
|
// page's Name servers tab, 'jarl zone serials' and 'jarl zone
|
|
// delegation'); nothing checks in the background.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"jarl/internal/api"
|
|
"jarl/internal/auth"
|
|
"jarl/internal/dnsname"
|
|
"jarl/internal/dnssec"
|
|
"jarl/internal/model"
|
|
"jarl/internal/namedconf"
|
|
"jarl/internal/nscheck"
|
|
)
|
|
|
|
// checker is the nscheck.Checker the handlers use (tests replace it).
|
|
func (s *Server) checker() *nscheck.Checker {
|
|
if s.nsChecker != nil {
|
|
c := *s.nsChecker
|
|
return &c
|
|
}
|
|
return &nscheck.Checker{}
|
|
}
|
|
|
|
func (s *Server) zoneSerials(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
st, err := s.state(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
z, err := s.zoneByPath(r, st, p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resp := api.SerialsResponse{Zone: z.Name, View: st.ViewName(z), Type: z.Type, Servers: []nscheck.Result{}, Notes: []string{}}
|
|
var targets []nscheck.Target
|
|
switch z.Type {
|
|
case model.ZonePrimary:
|
|
targets = append(targets, nsTargets(st, z)...)
|
|
an, notes := remoteTargets(st, inheritedOption(st, z, "also-notify"), "also-notify")
|
|
targets = append(targets, an...)
|
|
resp.Notes = append(resp.Notes, notes...)
|
|
case model.ZoneSecondary, model.ZoneMirror:
|
|
pr, notes := remoteTargets(st, st.FindOption(model.ScopeZone, z.ID, "primaries"), "primaries")
|
|
targets = append(targets, pr...)
|
|
resp.Notes = append(resp.Notes, notes...)
|
|
default:
|
|
return badRequest("only primary, secondary and mirror zones have serials to compare (this is a %s zone)", z.Type)
|
|
}
|
|
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.")
|
|
resp.Serial = nil
|
|
}
|
|
if len(targets) == 0 {
|
|
if z.Type == model.ZonePrimary {
|
|
resp.Notes = append(resp.Notes, "The zone has no NS records or also-notify targets to check.")
|
|
} else {
|
|
resp.Notes = append(resp.Notes, "The zone has no primaries to check.")
|
|
}
|
|
}
|
|
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 = 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 {
|
|
return err
|
|
}
|
|
z, err := s.zoneByPath(r, st, p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if z.Type != model.ZonePrimary {
|
|
return badRequest("the delegation check compares the parent's delegation with the zone's NS records, so it needs a primary zone (this is a %s zone; check it where its primary is)", z.Type)
|
|
}
|
|
c := s.checker()
|
|
if v := r.URL.Query().Get("resolver"); v != "" {
|
|
// An address only, always port 53: this is a DNS resolver.
|
|
a, err := netip.ParseAddr(v)
|
|
if err != nil {
|
|
return badRequest("resolver must be an IP address, e.g. 1.1.1.1 or 9.9.9.9")
|
|
}
|
|
c.Resolver = net.JoinHostPort(a.String(), "53")
|
|
}
|
|
var ns []string
|
|
for _, rec := range st.ZoneRecords(z.ID) {
|
|
if rec.Type == "NS" && rec.Name == z.Name {
|
|
ns = append(ns, rec.Data)
|
|
}
|
|
}
|
|
addrs := map[string][]string{}
|
|
for _, n := range ns {
|
|
if a := jarlAddresses(st, z.ViewID, dnsname.Fqdn(n)); len(a) > 0 {
|
|
addrs[dnsname.Fqdn(n)] = a
|
|
}
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
|
defer cancel()
|
|
ref, _, _ := s.localSerial(ctx, 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))
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
return nil
|
|
}
|
|
|
|
// signing describes how Jarl signs z, for comparing with the parent's DS
|
|
// records: the DS of every key-signing key named still publishes.
|
|
func (s *Server) signing(ctx context.Context, st *model.State, z *model.Zone) *nscheck.Signing {
|
|
sig := &nscheck.Signing{Policy: z.DNSSECPolicy}
|
|
for _, k := range s.eng.DNSSEC(ctx, st, z).Keys {
|
|
if k.DS != nil && k.DNSKEYState != dnssec.Hidden {
|
|
sig.DS = append(sig.DS, *k.DS)
|
|
}
|
|
}
|
|
return sig
|
|
}
|
|
|
|
// localSerial is the serial this server serves for z: named's, or for a
|
|
// primary zone Jarl's when named isn't asked.
|
|
func (s *Server) localSerial(ctx context.Context, st *model.State, z *model.Zone) (*uint32, string, error) {
|
|
serial, ok, err := s.eng.LoadedSerial(ctx, st, z)
|
|
if ok {
|
|
return &serial, "named", nil
|
|
}
|
|
if z.Type == model.ZonePrimary {
|
|
v := z.SOA.Serial
|
|
return &v, "jarl", err
|
|
}
|
|
return nil, "", err
|
|
}
|
|
|
|
// nsTargets are the name servers in the zone's apex NS records, with the
|
|
// addresses Jarl has for them in the same view (or nil, to be looked up).
|
|
func nsTargets(st *model.State, z *model.Zone) []nscheck.Target {
|
|
var out []nscheck.Target
|
|
for _, rec := range st.ZoneRecords(z.ID) {
|
|
if rec.Type != "NS" || rec.Name != z.Name {
|
|
continue
|
|
}
|
|
name := dnsname.Fqdn(rec.Data)
|
|
out = append(out, nscheck.Target{Name: name, Addresses: jarlAddresses(st, z.ViewID, name), Source: "NS record"})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// jarlAddresses returns the A and AAAA records for name in the primary
|
|
// zones of a view (nil when Jarl has none).
|
|
func jarlAddresses(st *model.State, viewID int64, name string) []string {
|
|
var out []string
|
|
for _, z := range st.ZonesInView(viewID) {
|
|
if !z.HasRecords() || !dnsname.IsSubdomain(name, z.Name) {
|
|
continue
|
|
}
|
|
for _, rec := range st.ZoneRecords(z.ID) {
|
|
if rec.Name == name && (rec.Type == "A" || rec.Type == "AAAA") {
|
|
out = append(out, rec.Data)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// inheritedOption finds an option for z as named does: the zone's own,
|
|
// else its view's, else the server's.
|
|
func inheritedOption(st *model.State, z *model.Zone, name string) *model.Option {
|
|
if o := st.FindOption(model.ScopeZone, z.ID, name); o != nil {
|
|
return o
|
|
}
|
|
if z.ViewID != 0 {
|
|
if o := st.FindOption(model.ScopeView, z.ViewID, name); o != nil {
|
|
return o
|
|
}
|
|
}
|
|
return st.FindOption(model.ScopeOptions, 0, name)
|
|
}
|
|
|
|
// remoteTargets turns an also-notify or primaries option into targets:
|
|
// addresses with their port and key, following named remote-servers
|
|
// lists.
|
|
func remoteTargets(st *model.State, o *model.Option, source string) ([]nscheck.Target, []string) {
|
|
if o == nil {
|
|
return nil, nil
|
|
}
|
|
var out []nscheck.Target
|
|
var notes []string
|
|
var walk func(name, value string, port string, depth int)
|
|
walk = func(name, value, port string, depth int) {
|
|
stmt, err := namedconf.ParseOptionValue(name, value)
|
|
if err != nil {
|
|
notes = append(notes, fmt.Sprintf("couldn't read %s: %v", name, err))
|
|
return
|
|
}
|
|
vals := stmt.Values()
|
|
for i := 0; i+1 < len(vals); i++ {
|
|
if strings.EqualFold(vals[i], "port") {
|
|
port = vals[i+1]
|
|
}
|
|
}
|
|
blk := stmt.FirstBlock()
|
|
if blk == nil {
|
|
return
|
|
}
|
|
for _, el := range blk.Stmts {
|
|
if len(el.Items) == 0 || el.Items[0].Value == nil {
|
|
continue
|
|
}
|
|
first := el.Items[0].Value.Text
|
|
t := nscheck.Target{Source: source, Port: port}
|
|
words := el.Values()
|
|
for i := 0; i+1 < len(words); i++ {
|
|
switch strings.ToLower(words[i]) {
|
|
case "port":
|
|
t.Port = words[i+1]
|
|
case "key":
|
|
if k := st.KeyByName(words[i+1]); k != nil {
|
|
t.Key = &nscheck.Key{Name: k.Name, Algorithm: k.Algorithm, Secret: k.Secret}
|
|
}
|
|
}
|
|
}
|
|
if a, err := netip.ParseAddr(first); err == nil {
|
|
t.Addresses = []string{a.String()}
|
|
out = append(out, t)
|
|
continue
|
|
}
|
|
// A named list of servers.
|
|
if depth >= 5 {
|
|
continue
|
|
}
|
|
if lo := remoteList(st, first); lo != nil {
|
|
walk(lo.Name, lo.Value, port, depth+1)
|
|
} else {
|
|
notes = append(notes, fmt.Sprintf("%s refers to %q, which isn't an address or a server list Jarl knows", source, first))
|
|
}
|
|
}
|
|
}
|
|
walk(o.Name, o.Value, "", 0)
|
|
return out, notes
|
|
}
|
|
|
|
// remoteList finds a top-level remote-servers (or primaries) list.
|
|
func remoteList(st *model.State, name string) *model.Option {
|
|
for _, o := range st.ScopedOptions(model.ScopeTopLevel, 0) {
|
|
switch o.Name {
|
|
case "remote-servers", "primaries", "masters":
|
|
if f := strings.Fields(o.Value); len(f) > 0 && strings.Trim(f[0], `"`) == name {
|
|
return o
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func hasOther(rs []nscheck.Result) bool {
|
|
for _, r := range rs {
|
|
if r.Status != "self" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|