Zones get a DNSSEC setting: named signs them with BIND's default (or a custom) dnssec-policy and inline signing, with keys in a per-view key directory. Jarl shows the keys, the DS record and when it is safe to publish it, checks the parent's DS records (check-ds, which also tells named), and refuses steps that would break a signed domain: switching straight to unsigned, finishing a turn-off too early, deleting or moving a signed zone, TTLs above max-zone-ttl. Turning off goes through the insecure policy; wipe starts over once the parent has no DS. Backups store the DNSSEC keys and restore puts them back for named; the backup unit gets CAP_DAC_READ_SEARCH to read them. Jarl raises a zone's serial above named's whenever it writes an older one (after a restore). The serials check compares the signed serial. With a Porkbun API key in jarl.yaml, the registrar's name servers, glue records and DS records can be shown and changed, each change checked first (dry run, force to override). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
366 lines
11 KiB
Go
366 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...)
|
|
}
|
|
|
|
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
|
|
}
|