Files
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

372 lines
11 KiB
Go

// Package bind runs BIND's own tools: named-checkconf and named-checkzone
// to validate generated configuration, and rndc to reload named and query
// its state.
package bind
import (
"bytes"
"context"
"errors"
"fmt"
"os/exec"
"regexp"
"strconv"
"strings"
"time"
)
// Tools locates and runs the BIND utilities.
type Tools struct {
NamedCheckConf string // path to named-checkconf
NamedCheckZone string // path to named-checkzone
RNDC string // path to rndc
RNDCArgs []string // extra rndc arguments, e.g. -k /etc/bind/rndc.key -s 127.0.0.1 -p 953
Env []string // extra environment variables
Timeout time.Duration
}
// Message is one line of tool output.
type Message struct {
File string `json:"file,omitempty"`
Line int `json:"line,omitempty"`
Zone string `json:"zone,omitempty"` // zone name, when the message is about a zone
View string `json:"view,omitempty"`
Text string `json:"text"`
Error bool `json:"error"`
}
// Result is the outcome of a check.
type Result struct {
OK bool `json:"ok"`
Messages []Message `json:"messages"`
Output string `json:"output"`
}
func (t *Tools) timeout() time.Duration {
if t.Timeout > 0 {
return t.Timeout
}
return 60 * time.Second
}
func (t *Tools) run(ctx context.Context, bin string, args ...string) (string, int, error) {
ctx, cancel := context.WithTimeout(ctx, t.timeout())
defer cancel()
cmd := exec.CommandContext(ctx, bin, args...)
if len(t.Env) > 0 {
cmd.Env = append(cmd.Environ(), t.Env...)
}
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
err := cmd.Run()
code := 0
if err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
code = ee.ExitCode()
err = nil
} else {
return out.String(), -1, fmt.Errorf("running %s: %w", bin, err)
}
}
if ctx.Err() != nil {
return out.String(), -1, fmt.Errorf("%s timed out after %s", bin, t.timeout())
}
return out.String(), code, nil
}
var (
// /path/file:12: message
reFileLine = regexp.MustCompile(`^(?:dns_master_load: |dns_rdata_fromtext: )?(/[^:]+|[^:\s]+\.[A-Za-z0-9]+):(\d+): (.*)$`)
// zone example.com/IN: message or zone example.com/IN/internal: message
reZoneMsg = regexp.MustCompile(`^zone ([^/\s]+)/([A-Z]+)(?:/(\S+))?: (.*)$`)
// _default/example.com/IN: message (checkconf summary)
reViewZone = regexp.MustCompile(`^([^/\s]+)/([^/\s]+)/([A-Z]+): (.*)$`)
)
func isErrorText(s string) bool {
l := strings.ToLower(s)
switch {
case strings.HasPrefix(l, "warning"), strings.Contains(l, ": warning:"):
return false
case strings.HasPrefix(l, "loaded serial"), l == "ok":
return false
}
return true
}
// ParseOutput splits tool output into messages.
func ParseOutput(out string) []Message {
var msgs []Message
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
m := Message{Text: line}
if sm := reFileLine.FindStringSubmatch(line); sm != nil {
m.File = sm[1]
m.Line, _ = strconv.Atoi(sm[2])
m.Text = sm[3]
m.Error = isErrorText(sm[3])
} else if sm := reZoneMsg.FindStringSubmatch(line); sm != nil {
m.Zone, m.View, m.Text = sm[1], sm[3], sm[4]
m.Error = isErrorText(sm[4]) && !strings.HasPrefix(sm[4], "loaded serial")
if strings.Contains(sm[4], "is a CNAME (illegal)") || strings.Contains(sm[4], "has no address records") || strings.Contains(sm[4], "has no REQUIRED GLUE") {
// named loads the zone anyway; Jarl's own checks
// report these with more context.
m.Error = strings.Contains(sm[4], "NS '") && !strings.Contains(sm[4], "/NS '")
}
} else if sm := reViewZone.FindStringSubmatch(line); sm != nil {
m.View, m.Zone, m.Text = sm[1], sm[2], sm[4]
m.Error = true
} else {
m.Error = isErrorText(line) && line != "OK"
}
msgs = append(msgs, m)
}
return msgs
}
// CheckConfig runs named-checkconf on path. With loadZones, primary zones
// are test-loaded too (-z).
func (t *Tools) CheckConfig(ctx context.Context, path string, loadZones bool) (*Result, error) {
args := []string{}
if loadZones {
args = append(args, "-z")
}
args = append(args, path)
out, code, err := t.run(ctx, t.NamedCheckConf, args...)
if err != nil {
return nil, err
}
return newResult(out, code), nil
}
// newResult builds a checker result. The exit code is authoritative: when
// the checker succeeded, whatever it printed is a warning, even if the text
// doesn't say so (e.g. 'both "recursion no;" and "allow-recursion" active').
func newResult(out string, code int) *Result {
r := &Result{OK: code == 0, Messages: ParseOutput(out), Output: out}
if r.OK {
for i := range r.Messages {
r.Messages[i].Error = false
}
}
return r
}
// CheckZone runs named-checkzone on a zone file with the same strictness
// named applies to primary zones (check-names fail, local integrity
// checks).
func (t *Tools) CheckZone(ctx context.Context, zone, file string) (*Result, error) {
out, code, err := t.run(ctx, t.NamedCheckZone, "-k", "fail", "-i", "local", zone, file)
if err != nil {
return nil, err
}
return newResult(out, code), nil
}
// DumpZone loads a zone file and prints it in canonical form (used by the
// importer for files with $INCLUDE/$GENERATE).
func (t *Tools) DumpZone(ctx context.Context, zone, file string) (string, error) {
out, code, err := t.run(ctx, t.NamedCheckZone, "-q", "-D", "-s", "full", "-o", "-", zone, file)
if err != nil {
return "", err
}
if code != 0 {
return "", fmt.Errorf("named-checkzone failed for %s", zone)
}
return out, nil
}
// Version returns the version printed by named-checkconf -v.
func (t *Tools) Version(ctx context.Context) (string, error) {
out, _, err := t.run(ctx, t.NamedCheckConf, "-v")
return strings.TrimSpace(out), err
}
// RNDCError is returned when rndc reports a failure.
type RNDCError struct {
Command string
Output string
}
func (e *RNDCError) Error() string {
return fmt.Sprintf("rndc %s failed: %s", e.Command, strings.TrimSpace(e.Output))
}
// Run runs an rndc command.
func (t *Tools) RNDCRun(ctx context.Context, args ...string) (string, error) {
full := append(append([]string{}, t.RNDCArgs...), args...)
out, code, err := t.run(ctx, t.RNDC, full...)
if err != nil {
return out, err
}
if code != 0 {
return out, &RNDCError{Command: strings.Join(args, " "), Output: out}
}
return out, nil
}
// Reconfig makes named reread its configuration and load new zones.
func (t *Tools) Reconfig(ctx context.Context) error {
_, err := t.RNDCRun(ctx, "reconfig")
return err
}
// ReloadZone asks named to reload one zone. named queues the reload and
// does not report load errors; check the result with ZoneStatus.
func (t *Tools) ReloadZone(ctx context.Context, zone, view string) error {
_, err := t.RNDCRun(ctx, zoneArgs("reload", zone, view)...)
return err
}
// CheckDS tells named that the parent zone has published (or withdrawn)
// the DS record for key tag of a zone it signs (rndc dnssec -checkds).
func (t *Tools) CheckDS(ctx context.Context, zone, view string, tag uint16, alg uint8, published bool) (string, error) {
which := "withdrawn"
if published {
which = "published"
}
z := zoneArgs("", zone, view)[1:]
args := append([]string{"dnssec", "-checkds", "-key", strconv.Itoa(int(tag)), "-alg", strconv.Itoa(int(alg)), which}, z...)
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 == "" {
z = "."
}
if view == "" {
return []string{cmd, z}
}
return []string{cmd, z, "IN", view}
}
// ZoneStatus is the result of rndc zonestatus.
type ZoneStatus struct {
Name string `json:"name"`
Type string `json:"type"`
Files string `json:"files"`
// Serial is the serial of the zone file named loaded. For a zone
// named signs (inline signing) that is the unsigned zone Jarl wrote;
// the signed zone that named serves has its own serial, SignedSerial.
Serial uint32 `json:"serial"`
HasSerial bool `json:"-"`
SignedSerial uint32 `json:"signed_serial,omitempty"`
HasSignedSerial bool `json:"-"`
Nodes int `json:"nodes"`
LastLoaded string `json:"last_loaded"`
Secure bool `json:"secure"`
InlineSigning bool `json:"inline_signing"`
NextKeyEvent string `json:"next_key_event,omitempty"`
Dynamic bool `json:"dynamic"`
Raw string `json:"raw"`
}
// ServedSerial is the serial named answers queries with: the signed
// zone's for a zone it signs, otherwise the loaded one.
func (zs *ZoneStatus) ServedSerial() (uint32, bool) {
if zs.HasSignedSerial {
return zs.SignedSerial, true
}
return zs.Serial, zs.HasSerial
}
// ZoneStatus returns named's view of a zone.
func (t *Tools) ZoneStatus(ctx context.Context, zone, view string) (*ZoneStatus, error) {
out, err := t.RNDCRun(ctx, zoneArgs("zonestatus", zone, view)...)
if err != nil {
return nil, err
}
return ParseZoneStatus(out), nil
}
// ParseZoneStatus parses rndc zonestatus output.
func ParseZoneStatus(out string) *ZoneStatus {
zs := &ZoneStatus{Raw: out}
for _, line := range strings.Split(out, "\n") {
k, v, ok := strings.Cut(line, ": ")
if !ok {
continue
}
v = strings.TrimSpace(v)
switch strings.TrimSpace(k) {
case "name":
zs.Name = v
case "type":
zs.Type = v
case "files":
zs.Files = v
case "serial":
if n, err := strconv.ParseUint(v, 10, 32); err == nil {
zs.Serial, zs.HasSerial = uint32(n), true
}
case "signed serial":
if n, err := strconv.ParseUint(v, 10, 32); err == nil {
zs.SignedSerial, zs.HasSignedSerial = uint32(n), true
}
case "inline signing":
zs.InlineSigning = v == "yes"
case "next key event":
zs.NextKeyEvent = v
case "nodes":
zs.Nodes, _ = strconv.Atoi(v)
case "last loaded":
zs.LastLoaded = v
case "secure":
zs.Secure = v == "yes"
case "dynamic":
zs.Dynamic = v == "yes"
}
}
return zs
}
// ServerStatus is the parsed output of rndc status.
type ServerStatus struct {
Version string `json:"version"`
BootTime string `json:"boot_time"`
LastConfigured string `json:"last_configured"`
ConfigFile string `json:"config_file"`
Zones string `json:"zones"`
Running bool `json:"running"`
Raw string `json:"raw"`
}
// Status returns named's status.
func (t *Tools) Status(ctx context.Context) (*ServerStatus, error) {
out, err := t.RNDCRun(ctx, "status")
if err != nil {
return nil, err
}
st := &ServerStatus{Raw: out}
for _, line := range strings.Split(out, "\n") {
k, v, ok := strings.Cut(line, ": ")
switch {
case ok && k == "version":
st.Version = v
case ok && k == "boot time":
st.BootTime = v
case ok && k == "last configured":
st.LastConfigured = v
case ok && k == "configuration file":
st.ConfigFile = v
case ok && k == "number of zones":
st.Zones = v
case strings.Contains(line, "server is up and running"):
st.Running = true
}
}
return st, nil
}