Jarl keeps a BIND 9.20 server's whole configuration (views, ACLs, TSIG keys, zones of every type, records, named.conf options) in SQLite and renders named.conf, keys.conf and zone files from it. Every change goes through one changeset pipeline: whole-configuration validation, named-checkconf -z and named-checkzone in a staging directory, atomic install, rndc reconfig/reload, serial verification with rndc zonestatus, and rollback if named does not load the change. Changes apply one at a time by default or are bundled and applied together. Applied changesets can be reverted. - jarld: the daemon with the REST API and an embedded web UI, plus import, adopt, check, resync, backup and local user recovery commands - jarl: the CLI, including ACME DNS-01 hooks for certbot and acme.sh - users with argon2id passwords and CSRF-protected sessions; hashed API tokens with granular grants and roles; an audit log - systemd unit, sysusers.d/tmpfiles.d files, Makefile - unit tests, integration tests against a real named, scripts/e2e.sh - docs/ guides, README and CHANGELOG Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
317 lines
9.0 KiB
Go
317 lines
9.0 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 &Result{OK: code == 0, Messages: ParseOutput(out), Output: out}, nil
|
|
}
|
|
|
|
// 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 &Result{OK: code == 0, Messages: ParseOutput(out), Output: out}, 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
|
|
}
|