Files
jarl/internal/bind/bind.go
T
abrown38andClaude Opus 5.5 0795a1001e Fix zone list filtering and BIND warning display; add a views guide
- jarl zone list showed only primary zones. zone create binds its --type
  flag (default "primary") to the same variable as list's --type, so the
  default leaked into the list filter and hid hint, secondary and forward
  zones. list now has its own variable. TestListQueries runs list
  commands against a fake server and checks the filters they send.
- named-checkconf/named-checkzone messages are classified by the tool's
  exit code: on success, everything printed is a warning. Warnings such
  as 'both "recursion no;" and "allow-recursion" active' were shown as
  errors. The CLI, jarld check and the web UI now label them as warnings
  and skip the routine "loaded serial" lines.
- The zone_outside_view error points at --adopt-zones.
- docs/views.md: step-by-step split DNS for a server that has no views
  yet, tested on a copy of a stock Ubuntu 26.04 setup. Linked from the
  README, concepts and validation docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:36:24 +00:00

330 lines
9.3 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
}
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 uint32 `json:"serial"`
HasSerial bool `json:"-"`
Nodes int `json:"nodes"`
LastLoaded string `json:"last_loaded"`
Secure bool `json:"secure"`
Dynamic bool `json:"dynamic"`
Raw string `json:"raw"`
}
// 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 "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
}