Added:
- Server statistics: named's memory and CPU (from /proc), cache size,
sockets and client counts against their limits (statistics channel,
rndc status), host memory and load, and free disk space. Memory and
CPU are kept over time as gauges (sum and sample count, averaged in
timelines). Statistics page Server section, jarl stats server,
GET /stats/server.
- jarl record add|set|delete|disable|enable --view a,b | all: the same
change in each view, applied as one changeset.
- Blocking can be paused until resumed (jarl block pause indefinitely,
"Until I resume", {"indefinitely": true}).
Changed:
- List options (allow-transfer, forwarders, ...) get their braces added
when given without them.
- The dashboard lists named-checkconf's warnings about the running
configuration; the CLI prints them after an apply.
- Stats page: tile and card rows fill their width, and the queries and
failed answers charts share margins so their time axes line up.
- Dashboard: options have their own tile.
- 24-hour times in the web UI.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
387 lines
12 KiB
Go
387 lines
12 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"`
|
|
// RecursiveClients is "now/soft limit/hard limit" (the soft limit is
|
|
// where named starts dropping the oldest queries), TCPClients
|
|
// "now/limit".
|
|
RecursiveClients string `json:"recursive_clients,omitempty"`
|
|
TCPClients string `json:"tcp_clients,omitempty"`
|
|
CPUs string `json:"cpus,omitempty"`
|
|
WorkerThreads string `json:"worker_threads,omitempty"`
|
|
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 ok && k == "recursive clients":
|
|
st.RecursiveClients = v
|
|
case ok && k == "tcp clients":
|
|
st.TCPClients = v
|
|
case ok && k == "CPUs found":
|
|
st.CPUs = v
|
|
case ok && k == "worker threads":
|
|
st.WorkerThreads = v
|
|
case strings.Contains(line, "server is up and running"):
|
|
st.Running = true
|
|
}
|
|
}
|
|
return st, nil
|
|
}
|