feat(agent): DNS role applies managed records of dynamic zones (ADR 0026)

Revisions for targets serving dynamic zones as primary carry
ddi-suite-update.key and dynamic-records.json. They stay in the revision
directory. Validate checks both strictly (one key matching the records
file and defined in ddi-suite-keys.conf; zones must be dynamic primary
zones of the revision; names inside their zone; types A/AAAA/PTR/CNAME/
MX/TXT/SRV/NS with presentation data on one line). After the reload,
Activate deletes exactly the records the previous revision managed and
the new one does not, adds all new ones with nsupdate -k (new option
dns.nsupdate), retries while a zone is not loaded yet and fails the
activation otherwise, so that the core rolls back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sir_melonchen
2026-10-17 05:47:33 +02:00
co-authored by Claude Opus 5.5
parent 3e957d7b33
commit b461fedab3
6 changed files with 1428 additions and 16 deletions
+7 -2
View File
@@ -27,9 +27,13 @@ type Config struct {
NamedCheckconf string `yaml:"named_checkconf"`
NamedCheckzone string `yaml:"named_checkzone"`
Rndc string `yaml:"rndc"`
// Nsupdate applies the DDI-managed records of dynamic zones (ADR 0026)
// as RFC 2136 updates sent to DNSAddress.
Nsupdate string `yaml:"nsupdate"`
// RndcArgs come before every rndc command, e.g. ["-k", "/etc/bind/rndc.key"].
RndcArgs []string `yaml:"rndc_args"`
// DNSAddress is where the health check sends SOA queries (ip:port).
// DNSAddress is where the health check sends SOA queries and nsupdate
// sends dynamic updates (ip:port).
DNSAddress string `yaml:"dns_address"`
// CommandTimeout bounds every program run.
CommandTimeout time.Duration `yaml:"command_timeout"`
@@ -45,6 +49,7 @@ func DefaultConfig() Config {
NamedCheckconf: "named-checkconf",
NamedCheckzone: "named-checkzone",
Rndc: "rndc",
Nsupdate: "nsupdate",
DNSAddress: "127.0.0.1:53",
CommandTimeout: 60 * time.Second,
}
@@ -62,7 +67,7 @@ func (c *Config) Validate() error {
}
for _, p := range []struct{ name, value string }{
{"named", c.Named}, {"named_checkconf", c.NamedCheckconf},
{"named_checkzone", c.NamedCheckzone}, {"rndc", c.Rndc},
{"named_checkzone", c.NamedCheckzone}, {"rndc", c.Rndc}, {"nsupdate", c.Nsupdate},
} {
if strings.TrimSpace(p.value) == "" || strings.ContainsAny(p.value, "\x00\n") {
errs = append(errs, fmt.Errorf("dns.%s: program name is empty or invalid", p.name))
+64 -10
View File
@@ -15,6 +15,11 @@
// creates (with the mode and owner of zone_dir) and whose content it never
// touches. Revisions without keys file (before M5) get a keys file without
// keys, so that named.conf can always include it.
//
// A target that serves dynamic zones as primary also gets
// ddi-suite-update.key and dynamic-records.json (ADR 0026). They stay in
// the revision directory; after the reload the role applies the managed
// records of those zones with nsupdate (see dynamic.go).
package dns
import (
@@ -22,12 +27,14 @@ import (
"errors"
"fmt"
"io/fs"
"log/slog"
"os"
"path"
"path/filepath"
"regexp"
"slices"
"strings"
"time"
"git.beugsiud.de/beug.k/DDI/agent/core"
"git.beugsiud.de/beug.k/DDI/agent/role/internal/command"
@@ -57,12 +64,19 @@ const (
// Role manages BIND.
type Role struct {
cfg Config
run command.Runner
run command.InputRunner
// dial is used for SOA queries (replaced in tests).
dial dialFunc
log *slog.Logger
// updateWait bounds the retries of dynamic updates while zones load,
// updateRetry is the pause between two attempts (replaced in tests).
updateWait, updateRetry time.Duration
}
var _ core.Role = (*Role)(nil)
var (
_ core.Role = (*Role)(nil)
_ core.LogSetter = (*Role)(nil)
)
// New creates the role from the "dns:" section of the agent configuration;
// it matches core.RoleFactory. decode may be nil (no section).
@@ -76,11 +90,21 @@ func New(decode func(v any) error) (core.Role, error) {
return newRole(cfg, command.Exec{Timeout: cfg.CommandTimeout})
}
func newRole(cfg Config, run command.Runner) (*Role, error) {
func newRole(cfg Config, run command.InputRunner) (*Role, error) {
if err := cfg.Validate(); err != nil {
return nil, err
}
return &Role{cfg: cfg, run: run, dial: defaultDial}, nil
return &Role{
cfg: cfg, run: run, dial: defaultDial, log: slog.New(slog.DiscardHandler),
updateWait: defaultUpdateWait, updateRetry: defaultUpdateRetry,
}, nil
}
// SetLogger implements core.LogSetter.
func (r *Role) SetLogger(log *slog.Logger) {
if log != nil {
r.log = log
}
}
// Kind implements core.Role.
@@ -136,6 +160,11 @@ func (r *Role) plan(rev core.Revision) ([]target, error) {
if err := bundle.CheckPath(f.Path); err != nil {
return nil, err
}
if isUpdateFile(f.Path) {
// Revision data for dynamic updates, used from the revision
// directory (see loadDynamic).
continue
}
t := target{file: f, src: filepath.Join(rev.Dir, filepath.FromSlash(f.Path))}
switch {
case !strings.Contains(f.Path, "/") && strings.HasSuffix(f.Path, ".conf"):
@@ -262,6 +291,15 @@ func (r *Role) Validate(ctx context.Context, rev core.Revision) error {
if err := r.checkZoneDir(conf, targets); err != nil {
return err
}
dyn, err := r.loadDynamic(rev, targets)
if err != nil {
return err
}
if dyn != nil {
if err := checkUpdateKeyKnown(rev, dyn.key); err != nil {
return err
}
}
if err := r.checkConf(ctx, rev); err != nil {
return err
}
@@ -314,19 +352,32 @@ func (r *Role) checkConf(ctx context.Context, rev core.Revision) error {
return nil
}
// Activate installs rev and reloads BIND. See core.Role for the rules on
// CreateOnly files and removals. On failure the previous files are restored
// (and BIND is reloaded again if it had been reloaded).
// Activate installs rev and reloads BIND, then applies the managed records
// of dynamic zones (ADR 0026). See core.Role for the rules on CreateOnly
// files and removals. On failure the previous files are restored (and BIND
// is reloaded again if it had been reloaded); records already updated are
// restored by the core's rollback, which activates the previous revision.
func (r *Role) Activate(ctx context.Context, rev core.Revision, prev *core.Revision) (err error) {
targets, err := r.plan(rev)
if err != nil {
return err
}
dyn, err := r.loadDynamic(rev, targets)
if err != nil {
return err
}
var old []target
var oldDyn *dynSet
if prev != nil {
// A previous revision that no longer fits the configuration only
// loses the removal of its stale files.
old, _ = r.plan(*prev)
// loses the removal of its stale files and of its managed records.
var perr error
if old, perr = r.plan(*prev); perr == nil {
if oldDyn, perr = r.loadDynamic(*prev, old); perr != nil {
r.log.Warn("previous revision has unusable dynamic records; none are deleted",
"revision", prev.Number, "error", perr)
}
}
}
// BIND writes transferred secondary zones there; it stays when the
@@ -390,7 +441,10 @@ func (r *Role) Activate(ctx context.Context, rev core.Revision, prev *core.Revis
return fmt.Errorf("live configuration check failed, previous files restored: %w", cerr)
}
reloaded = true
return r.reload(ctx)
if err := r.reload(ctx); err != nil {
return err
}
return r.applyDynamic(ctx, rev, dyn, oldDyn, dynamicZones(targets))
}
// changes computes the files to write (zone files first, so that the
+32 -4
View File
@@ -26,18 +26,45 @@ import (
type fakeRunner struct {
mu sync.Mutex
calls []string
fn func(name string, args []string) ([]byte, error)
// inputs holds the stdin of every RunInput call, in order.
inputs []string
fn func(name string, args []string) ([]byte, error)
// fnInput answers RunInput calls if set (otherwise fn does).
fnInput func(input string, name string, args []string) ([]byte, error)
}
func (f *fakeRunner) Run(_ context.Context, name string, args ...string) ([]byte, error) {
return f.run(nil, name, args)
}
func (f *fakeRunner) RunInput(_ context.Context, input []byte, name string, args ...string) ([]byte, error) {
return f.run(input, name, args)
}
func (f *fakeRunner) Inputs() []string {
f.mu.Lock()
defer f.mu.Unlock()
return slices.Clone(f.inputs)
}
func (f *fakeRunner) run(input []byte, name string, args []string) ([]byte, error) {
f.mu.Lock()
f.calls = append(f.calls, strings.Join(append([]string{name}, args...), " "))
fn := f.fn
if input != nil {
f.inputs = append(f.inputs, string(input))
}
fn, fnInput := f.fn, f.fnInput
f.mu.Unlock()
if fn == nil {
var out []byte
var err error
switch {
case input != nil && fnInput != nil:
out, err = fnInput(string(input), name, args)
case fn != nil:
out, err = fn(name, args)
default:
return nil, nil
}
out, err := fn(name, args)
if err != nil {
// Like command.Exec: the error quotes the output.
return out, &command.Error{Name: name, Args: args, Err: err, Output: out}
@@ -55,6 +82,7 @@ func (f *fakeRunner) Reset() {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = nil
f.inputs = nil
}
type env struct {
+651
View File
@@ -0,0 +1,651 @@
// SPDX-License-Identifier: Apache-2.0
package dns
// Records of dynamic zones (ADR 0026). BIND owns the content of a dynamic
// zone once its initial file exists, so the records DDI manages there are
// applied as RFC 2136 updates: a revision for a target that serves dynamic
// zones as primary carries dynamic-records.json (the managed records per
// zone) and ddi-suite-update.key (the TSIG key nsupdate signs with). Both
// stay in the revision directory; nothing is installed into conf_dir.
//
// After the reload, Activate sends per zone: deletes of exactly the records
// the previous revision managed and the new one does not, then adds of all
// records of the new revision (adding an existing record changes nothing).
// Records DDI never managed (domain controllers, other clients, manual
// updates) are never touched.
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/netip"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
"time"
"git.beugsiud.de/beug.k/DDI/agent/core"
"git.beugsiud.de/beug.k/DDI/agent/role/internal/fsutil"
"git.beugsiud.de/beug.k/DDI/internal/bundle"
)
// Revision files of ADR 0026 (top level, never installed).
const (
updateKeyFile = "ddi-suite-update.key"
dynRecordsFile = "dynamic-records.json"
// dynRecordsVersion is the only format version understood.
dynRecordsVersion = 1
)
// Limits of the update files and of one update message.
const (
maxKeyFileSize = 16 << 10
maxRecordsSize = 64 << 20
// maxDataLen bounds the presentation form of one record's data.
maxDataLen = 8 << 10
// Updates are sent in batches of at most this many records or bytes of
// script text, so that one message stays well below the 64 KiB limit
// of DNS over TCP.
maxBatchRecords = 500
maxBatchBytes = 32 << 10
// nsupdateTimeout is passed to nsupdate -t (seconds per update).
nsupdateTimeout = "30"
)
// Retry while a zone is not loaded yet right after rndc reconfig (BIND
// answers NOTAUTH or SERVFAIL until then).
const (
defaultUpdateWait = 10 * time.Second
defaultUpdateRetry = time.Second
)
// dynRecord is one managed record in presentation form.
type dynRecord struct {
Name string `json:"name"`
TTL int64 `json:"ttl"`
Type string `json:"type"`
Data string `json:"data"`
}
// dynZone holds the managed records of one dynamic zone.
type dynZone struct {
Zone string `json:"zone"`
Records []dynRecord `json:"records"`
}
// dynFile is the content of dynamic-records.json.
type dynFile struct {
Version int `json:"version"`
Key string `json:"key"`
Zones []dynZone `json:"zones"`
}
// dynSet is the validated update data of one revision.
type dynSet struct {
key string // TSIG key name
keyFile string // path of the revision's key file
// zones maps the lowercase zone name to its records (validated, without
// duplicates, in file order).
zones map[string][]dynRecord
}
// identity returns a comparison key of r: names are case-insensitive, and
// so are the names in the data of PTR, CNAME, NS, MX and SRV records;
// addresses compare by value.
func (r dynRecord) identity() string {
data := r.Data
switch r.Type {
case "A", "AAAA":
if a, err := netip.ParseAddr(data); err == nil {
data = a.String()
}
case "PTR", "CNAME", "NS", "MX", "SRV":
data = strings.ToLower(data)
}
return strings.ToLower(r.Name) + " " + r.Type + " " + data
}
// dynamicZones returns the dynamic primary zones of a revision: zones whose
// file is create-only (the generator's initial file of a dynamic zone).
func dynamicZones(targets []target) map[string]bool {
zones := map[string]bool{}
for _, t := range targets {
if t.zone != "" && t.file.CreateOnly {
zones[strings.ToLower(t.zone)] = true
}
}
return zones
}
// isUpdateFile reports whether p is one of the revision files of ADR 0026.
func isUpdateFile(p string) bool { return p == updateKeyFile || p == dynRecordsFile }
// loadDynamic reads and validates the update files of rev. targets is the
// revision's plan. It returns nil if the revision has neither file.
func (r *Role) loadDynamic(rev core.Revision, targets []target) (*dynSet, error) {
var keyEntry, recEntry *bundle.File
for i, f := range rev.Files {
switch f.Path {
case updateKeyFile:
keyEntry = &rev.Files[i]
case dynRecordsFile:
recEntry = &rev.Files[i]
}
}
switch {
case keyEntry == nil && recEntry == nil:
return nil, nil
case keyEntry == nil || recEntry == nil:
return nil, fmt.Errorf("DNS revision must contain both %s and %s or neither", updateKeyFile, dynRecordsFile)
case keyEntry.CreateOnly || recEntry.CreateOnly:
return nil, fmt.Errorf("%s and %s must not be create-only", updateKeyFile, dynRecordsFile)
}
keyPath := filepath.Join(rev.Dir, updateKeyFile)
if strings.ContainsAny(keyPath, "\x00\n") {
return nil, fmt.Errorf("revision directory %q cannot be passed to nsupdate", rev.Dir)
}
keyData, err := readLimited(keyPath, maxKeyFileSize)
if err != nil {
return nil, fmt.Errorf("%s: %w", updateKeyFile, err)
}
keyName, err := parseUpdateKey(keyData)
if err != nil {
return nil, fmt.Errorf("%s: %w", updateKeyFile, err)
}
recData, err := readLimited(filepath.Join(rev.Dir, dynRecordsFile), maxRecordsSize)
if err != nil {
return nil, fmt.Errorf("%s: %w", dynRecordsFile, err)
}
set, err := parseDynamicRecords(recData, dynamicZones(targets))
if err != nil {
return nil, fmt.Errorf("%s: %w", dynRecordsFile, err)
}
if set.key != keyName {
return nil, fmt.Errorf("%s names the key %q, but %s holds the key %q", dynRecordsFile, set.key, updateKeyFile, keyName)
}
set.keyFile = keyPath
return set, nil
}
// checkUpdateKeyKnown verifies that the revision's keys file defines the
// update key, so that named can verify the updates.
func checkUpdateKeyKnown(rev core.Revision, key string) error {
if !slices.ContainsFunc(rev.Files, func(f bundle.File) bool { return f.Path == keysFile }) {
return fmt.Errorf("%s requires the key %q in %s, which the revision lacks", dynRecordsFile, key, keysFile)
}
keys, err := fsutil.ReadFile(filepath.Join(rev.Dir, keysFile))
if err != nil {
return err
}
stmt := regexp.MustCompile(`(?m)^\s*key\s+"` + regexp.QuoteMeta(key) + `"\s*\{`)
if !stmt.Match(keys) {
return fmt.Errorf("%s does not define the update key %q", keysFile, key)
}
return nil
}
func readLimited(p string, limit int64) ([]byte, error) {
data, err := fsutil.ReadFile(p)
if err != nil {
return nil, err
}
if int64(len(data)) > limit {
return nil, fmt.Errorf("larger than %d bytes", limit)
}
return data, nil
}
// keyStmt matches the key statement of the update key file (named.conf
// syntax as written by the generator and tsig-keygen) after comments are
// removed. Group 1 is the name, the body is checked by parseKeyBody.
var keyStmt = regexp.MustCompile(`^\s*key\s+"([^"]*)"\s*\{([^{}"]*(?:"[^"]*"[^{}"]*)*)\}\s*;\s*$`)
// keyNameRE restricts TSIG key names to what the server allows plus dots.
var keyNameRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$`)
var tsigAlgorithms = []string{"hmac-md5", "hmac-sha1", "hmac-sha224", "hmac-sha256", "hmac-sha384", "hmac-sha512"}
// parseUpdateKey checks that data holds exactly one key statement with an
// HMAC algorithm and a base64 secret and returns the key name. Errors never
// quote the content (it is secret).
func parseUpdateKey(data []byte) (string, error) {
if bytes.ContainsAny(data, "\x00") {
return "", errors.New("invalid content")
}
var b strings.Builder
for _, line := range strings.Split(string(data), "\n") {
t := strings.TrimSpace(line)
if t == "" || strings.HasPrefix(t, "//") || strings.HasPrefix(t, "#") {
continue
}
b.WriteString(t)
b.WriteByte('\n')
}
m := keyStmt.FindStringSubmatch(b.String())
if m == nil {
return "", errors.New("must contain exactly one key statement (key \"<name>\" { algorithm ...; secret \"...\"; };) and only comments besides")
}
name := m[1]
if !keyNameRE.MatchString(name) {
return "", fmt.Errorf("invalid key name %q", name)
}
var alg, secret string
for _, s := range strings.Split(m[2], ";") {
f := strings.Fields(s)
switch {
case len(f) == 0:
continue
case len(f) == 2 && f[0] == "algorithm" && alg == "":
alg = f[1]
case len(f) == 2 && f[0] == "secret" && secret == "":
secret = f[1]
default:
return "", errors.New("the key statement must hold exactly one algorithm and one secret")
}
}
if !slices.Contains(tsigAlgorithms, strings.ToLower(alg)) {
return "", errors.New("the key statement needs an HMAC algorithm")
}
raw, quoted := strings.CutPrefix(secret, `"`)
raw, closed := strings.CutSuffix(raw, `"`)
if !quoted || !closed || raw == "" {
return "", errors.New("the secret must be a quoted base64 string")
}
if _, err := base64.StdEncoding.DecodeString(raw); err != nil {
return "", errors.New("the secret is not valid base64")
}
return name, nil
}
// parseDynamicRecords decodes dynamic-records.json strictly and validates
// every field before it can reach an nsupdate script. dynamic holds the
// lowercase names of the revision's dynamic primary zones.
func parseDynamicRecords(data []byte, dynamic map[string]bool) (*dynSet, error) {
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
var f dynFile
if err := dec.Decode(&f); err != nil {
return nil, fmt.Errorf("invalid JSON: %w", err)
}
if _, err := dec.Token(); !errors.Is(err, io.EOF) {
return nil, errors.New("invalid JSON: data after the object")
}
if f.Version != dynRecordsVersion {
return nil, fmt.Errorf("unsupported version %d (want %d)", f.Version, dynRecordsVersion)
}
if !keyNameRE.MatchString(f.Key) {
return nil, fmt.Errorf("invalid key name %q", f.Key)
}
set := &dynSet{key: f.Key, zones: map[string][]dynRecord{}}
for i, z := range f.Zones {
zone := strings.ToLower(strings.TrimSuffix(z.Zone, "."))
if !validZoneName(zone) || !validLabels(zone, false) {
return nil, fmt.Errorf("zones[%d]: invalid zone name %q", i, z.Zone)
}
if _, dup := set.zones[zone]; dup {
return nil, fmt.Errorf("zones[%d]: zone %s is listed twice", i, zone)
}
if !dynamic[zone] {
return nil, fmt.Errorf("zones[%d]: %s is not a dynamic primary zone of this revision", i, zone)
}
seen := map[string]bool{}
records := make([]dynRecord, 0, len(z.Records))
for j, rec := range z.Records {
if err := validateRecord(rec, zone); err != nil {
return nil, fmt.Errorf("zone %s, records[%d]: %w", zone, j, err)
}
if id := rec.identity(); !seen[id] {
seen[id] = true
records = append(records, rec)
}
}
set.zones[zone] = records
}
return set, nil
}
// validateRecord checks one record of zone (lowercase, without trailing
// dot): an absolute owner name inside the zone, a TTL within RFC 2181, a
// supported type and data that is valid presentation form of that type on
// one line.
func validateRecord(r dynRecord, zone string) error {
if !validName(r.Name, true) {
return fmt.Errorf("invalid name %q (want an absolute name with trailing dot)", r.Name)
}
name := strings.ToLower(r.Name)
if name != zone+"." && !strings.HasSuffix(name, "."+zone+".") {
return fmt.Errorf("name %q is outside the zone %s", r.Name, zone)
}
if r.TTL < 0 || r.TTL > 1<<31-1 {
return fmt.Errorf("TTL %d is out of range", r.TTL)
}
if len(r.Data) == 0 || len(r.Data) > maxDataLen {
return fmt.Errorf("%s data is empty or too long", r.Type)
}
for i := 0; i < len(r.Data); i++ {
if c := r.Data[i]; c < 0x20 || c > 0x7e {
return fmt.Errorf("%s data contains a control or non-ASCII character", r.Type)
}
}
var err error
switch r.Type {
case "A":
err = checkAddr(r.Data, true)
case "AAAA":
err = checkAddr(r.Data, false)
case "PTR", "CNAME", "NS":
if !validName(r.Data, false) {
err = errors.New("want an absolute name with trailing dot")
}
case "MX":
err = checkFields(r.Data, 1, true)
case "SRV":
err = checkFields(r.Data, 3, true)
case "TXT":
err = checkTXT(r.Data)
default:
return fmt.Errorf("unsupported type %q (want A, AAAA, PTR, CNAME, MX, TXT, SRV or NS)", r.Type)
}
if err != nil {
return fmt.Errorf("invalid %s data %q: %w", r.Type, r.Data, err)
}
return nil
}
func checkAddr(s string, v4 bool) error {
a, err := netip.ParseAddr(s)
switch {
case err != nil:
return errors.New("not an IP address")
case a.Zone() != "":
return errors.New("address with zone")
case v4 != a.Is4():
return errors.New("wrong address family")
}
return nil
}
// checkFields checks "<n1> ... <nk> <target>" with single spaces: k
// numbers 0-65535 and an absolute target name ("." if rootOK).
func checkFields(s string, numbers int, rootOK bool) error {
f := strings.Split(s, " ")
if len(f) != numbers+1 {
return fmt.Errorf("want %d numbers and a target, separated by single spaces", numbers)
}
for _, n := range f[:numbers] {
if n == "" || strings.Trim(n, "0123456789") != "" {
return fmt.Errorf("%q is not a number", n)
}
if _, err := strconv.ParseUint(n, 10, 16); err != nil {
return fmt.Errorf("%q is out of range", n)
}
}
if t := f[numbers]; (!rootOK || t != ".") && !validName(t, false) {
return errors.New("the target must be an absolute name with trailing dot")
}
return nil
}
// checkTXT accepts one or more quoted character-strings separated by single
// spaces, each at most 255 bytes long. Inside the quotes only printable
// ASCII is allowed, '"' and '\' only escaped, other escapes only as \DDD.
func checkTXT(s string) error {
i := 0
for {
if i >= len(s) || s[i] != '"' {
return errors.New("want quoted strings separated by single spaces")
}
i++
n := 0
for {
if i >= len(s) {
return errors.New("unterminated string")
}
c := s[i]
if c == '"' {
i++
break
}
if c == '\\' {
switch {
case i+1 < len(s) && (s[i+1] == '"' || s[i+1] == '\\'):
i += 2
case i+3 < len(s) && isDigit(s[i+1]) && isDigit(s[i+2]) && isDigit(s[i+3]):
if v, _ := strconv.Atoi(s[i+1 : i+4]); v > 255 {
return errors.New("escape out of range")
}
i += 4
default:
return errors.New(`only \", \\ and \DDD escapes are allowed`)
}
} else {
i++
}
n++
}
if n > 255 {
return errors.New("string longer than 255 bytes")
}
if i == len(s) {
return nil
}
if s[i] != ' ' {
return errors.New("want quoted strings separated by single spaces")
}
i++
}
}
func isDigit(c byte) bool { return c >= '0' && c <= '9' }
// isNameChar reports whether c may appear in a label: letters, digits,
// '-' and '_' (service labels such as _ldap._tcp).
func isNameChar(c byte) bool {
return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || isDigit(c) || c == '-' || c == '_'
}
// validName accepts an absolute domain name with trailing dot made of
// labels of letters, digits, '-' and '_' ("*" as the first label only for
// owner names).
func validName(s string, owner bool) bool {
n, ok := strings.CutSuffix(s, ".")
if !ok || n == "" || len(s) > 254 {
return false
}
return validLabels(n, owner)
}
// validLabels checks the labels of a name without trailing dot.
func validLabels(n string, wildcard bool) bool {
labels := strings.Split(n, ".")
for i, l := range labels {
if l == "" || len(l) > 63 {
return false
}
if l == "*" && wildcard && i == 0 {
continue
}
for j := 0; j < len(l); j++ {
if !isNameChar(l[j]) {
return false
}
}
}
return true
}
// zoneUpdate is the work for one zone.
type zoneUpdate struct {
zone string
deletes []dynRecord
adds []dynRecord
}
// planUpdates computes the updates from the previous set (may be nil) to
// cur. Zones that only the previous set lists lose its records if they are
// still dynamic primary zones of the new revision; otherwise BIND no longer
// serves them and they are skipped.
func planUpdates(cur, prev *dynSet, dynamic map[string]bool) []zoneUpdate {
zones := map[string]bool{}
if cur != nil {
for z := range cur.zones {
zones[z] = true
}
}
if prev != nil {
for z := range prev.zones {
if dynamic[z] {
zones[z] = true
}
}
}
names := make([]string, 0, len(zones))
for z := range zones {
names = append(names, z)
}
slices.Sort(names)
var out []zoneUpdate
for _, z := range names {
u := zoneUpdate{zone: z}
keep := map[string]bool{}
if cur != nil {
u.adds = cur.zones[z]
for _, rec := range u.adds {
keep[rec.identity()] = true
}
}
if prev != nil {
for _, rec := range prev.zones[z] {
if !keep[rec.identity()] {
u.deletes = append(u.deletes, rec)
}
}
}
if len(u.adds) > 0 || len(u.deletes) > 0 {
out = append(out, u)
}
}
return out
}
// updateScript renders the nsupdate input for u: deletes first, then adds,
// in batches (each with its own server, zone and send lines). Every field
// has been validated; a line break would still be refused here.
func updateScript(server netip.AddrPort, u zoneUpdate) (string, error) {
header := fmt.Sprintf("server %s %d\nzone %s.\n", server.Addr().String(), server.Port(), u.zone)
var b, batch strings.Builder
count := 0
flush := func() {
if count == 0 {
return
}
b.WriteString(header)
b.WriteString(batch.String())
b.WriteString("send\n")
batch.Reset()
count = 0
}
add := func(line string) error {
if strings.ContainsAny(line, "\n\r\x00") {
return errors.New("record contains a line break")
}
if count >= maxBatchRecords || (count > 0 && batch.Len()+len(line) > maxBatchBytes) {
flush()
}
batch.WriteString(line)
batch.WriteByte('\n')
count++
return nil
}
for _, r := range u.deletes {
if err := add(fmt.Sprintf("update delete %s %s %s", r.Name, r.Type, r.Data)); err != nil {
return "", err
}
}
for _, r := range u.adds {
if err := add(fmt.Sprintf("update add %s %d %s %s", r.Name, r.TTL, r.Type, r.Data)); err != nil {
return "", err
}
}
flush()
return b.String(), nil
}
// retryableUpdateError reports whether nsupdate failed because the zone is
// not (yet) loaded or the server did not answer.
func retryableUpdateError(out []byte) bool {
s := strings.ToLower(string(out))
for _, m := range []string{"notauth", "servfail", "communication with", "timed out", "connection refused"} {
if strings.Contains(s, m) {
return true
}
}
return false
}
// applyDynamic sends the updates from prev to cur, one nsupdate run per
// zone. A failure is returned (Activate then restores the previous files;
// the core's rollback re-applies the previous revision's records).
func (r *Role) applyDynamic(ctx context.Context, rev core.Revision, cur, prev *dynSet, dynamic map[string]bool) error {
if cur == nil {
if prev != nil {
for z := range prev.zones {
if dynamic[z] {
r.log.Warn("revision has no update key: managed records of a dynamic zone stay as they are",
"revision", rev.Number, "zone", z)
}
}
}
return nil
}
updates := planUpdates(cur, prev, dynamic)
if len(updates) == 0 {
return nil
}
server, err := netip.ParseAddrPort(r.cfg.DNSAddress)
if err != nil {
return fmt.Errorf("dns.dns_address: %w", err)
}
deadline := time.Now().Add(r.updateWait)
for _, u := range updates {
script, err := updateScript(server, u)
if err != nil {
return fmt.Errorf("zone %s: %w", u.zone, err)
}
if err := r.nsupdate(ctx, cur.keyFile, script, deadline); err != nil {
return fmt.Errorf("dynamic update of zone %s failed: %w", u.zone, err)
}
r.log.Info("managed records of a dynamic zone updated", "revision", rev.Number, "zone", u.zone,
"records", len(u.adds), "deleted", len(u.deletes))
}
return nil
}
// nsupdate runs the script, retrying until deadline while the zone is not
// loaded yet. Adds and deletes of exact records are idempotent, so the
// whole script can be repeated.
func (r *Role) nsupdate(ctx context.Context, keyFile, script string, deadline time.Time) error {
for {
out, err := r.run.RunInput(ctx, []byte(script), r.cfg.Nsupdate, "-t", nsupdateTimeout, "-k", keyFile)
if err == nil {
return nil
}
if !retryableUpdateError(out) || time.Now().Add(r.updateRetry).After(deadline) {
return err
}
t := time.NewTimer(r.updateRetry)
select {
case <-ctx.Done():
t.Stop()
return errors.Join(err, ctx.Err())
case <-t.C:
}
}
}
+670
View File
@@ -0,0 +1,670 @@
// SPDX-License-Identifier: Apache-2.0
package dns
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/netip"
"path/filepath"
"slices"
"strings"
"testing"
"time"
"git.beugsiud.de/beug.k/DDI/agent/core"
)
const (
testUpdateKeyName = "ddi-agent-update"
testUpdateSecret = "c2VjcmV0LW9mLXRoZS11cGRhdGUta2V5LTEyMzQ1Ng=="
)
// testUpdateKey is a key file as the generator writes it.
var testUpdateKey = "// Generated by DDI Suite. Do not edit: the DNS agent uses this key\n" +
"// for dynamic updates of the records DDI manages.\n\n" +
"key \"" + testUpdateKeyName + "\" {\n algorithm hmac-sha256;\n secret \"" + testUpdateSecret + "\";\n};\n"
func TestParseUpdateKey(t *testing.T) {
valid := map[string]string{
"generator": testUpdateKey,
"tsig-keygen": "key \"k1\" {\n\talgorithm hmac-sha512;\n\tsecret \"" + testUpdateSecret + "\";\n};\n",
"secret first": "# comment\nkey \"k.example\" { secret \"" + testUpdateSecret + "\"; algorithm HMAC-SHA256; };",
"one line, no nl": "key \"k\" { algorithm hmac-sha1; secret \"" + testUpdateSecret + "\"; };",
}
wantNames := map[string]string{"generator": testUpdateKeyName, "tsig-keygen": "k1", "secret first": "k.example", "one line, no nl": "k"}
for name, src := range valid {
t.Run(name, func(t *testing.T) {
got, err := parseUpdateKey([]byte(src))
if err != nil || got != wantNames[name] {
t.Fatalf("parseUpdateKey = %q, %v", got, err)
}
})
}
sec := `secret "` + testUpdateSecret + `";`
invalid := map[string]string{
"empty": "",
"comments only": "// nothing\n",
"two keys": `key "a" { algorithm hmac-sha256; ` + sec + ` }; key "b" { algorithm hmac-sha256; ` + sec + ` };`,
"no secret": `key "a" { algorithm hmac-sha256; };`,
"two secrets": `key "a" { algorithm hmac-sha256; ` + sec + sec + ` };`,
"no algorithm": `key "a" { ` + sec + ` };`,
"unknown alg": `key "a" { algorithm rsa; ` + sec + ` };`,
"bad base64": `key "a" { algorithm hmac-sha256; secret "not base64!"; };`,
"unquoted": `key "a" { algorithm hmac-sha256; secret ` + testUpdateSecret + `; };`,
"half quoted": `key "a" { algorithm hmac-sha256; secret ` + testUpdateSecret + `"; };`,
"unquoted name": `key a { algorithm hmac-sha256; ` + sec + ` };`,
"bad name": `key "a b" { algorithm hmac-sha256; ` + sec + ` };`,
"extra stmt": `key "a" { algorithm hmac-sha256; ` + sec + ` }; include "/etc/passwd";`,
"other clause": `key "a" { algorithm hmac-sha256; ` + sec + ` foo bar; };`,
"options": `options { directory "/tmp"; };`,
"inline comment": `key "a" { algorithm hmac-sha256; ` + sec + ` // x` + "\n};",
"nul": "key \"a\" { algorithm hmac-sha256; " + sec + " };\x00",
}
for name, src := range invalid {
t.Run(name, func(t *testing.T) {
_, err := parseUpdateKey([]byte(src))
if err == nil {
t.Fatal("expected an error")
}
if strings.Contains(err.Error(), testUpdateSecret) {
t.Errorf("error quotes the secret: %v", err)
}
})
}
}
// dynJSON renders a dynamic-records.json for the tests.
func dynJSON(t *testing.T, key string, zones map[string][]dynRecord) string {
t.Helper()
f := dynFile{Version: 1, Key: key, Zones: []dynZone{}}
names := make([]string, 0, len(zones))
for z := range zones {
names = append(names, z)
}
slices.Sort(names)
for _, z := range names {
recs := zones[z]
if recs == nil {
recs = []dynRecord{}
}
f.Zones = append(f.Zones, dynZone{Zone: z, Records: recs})
}
b, err := json.Marshal(f)
if err != nil {
t.Fatal(err)
}
return string(b)
}
var allTypes = []dynRecord{
{Name: "srv1.ad.example.org.", TTL: 3600, Type: "A", Data: "192.0.2.5"},
{Name: "srv1.ad.example.org.", TTL: 3600, Type: "AAAA", Data: "2001:db8::5"},
{Name: "5.ad.example.org.", TTL: 300, Type: "PTR", Data: "srv1.ad.example.org."},
{Name: "www.ad.example.org.", TTL: 300, Type: "CNAME", Data: "srv1.ad.example.org."},
{Name: "ad.example.org.", TTL: 300, Type: "MX", Data: "10 mail.example.org."},
{Name: "_dmarc.ad.example.org.", TTL: 300, Type: "TXT", Data: `"v=DMARC1; p=none" "a \"quoted\" \\ \010 x"`},
{Name: "_ldap._tcp.dc._msdcs.ad.example.org.", TTL: 600, Type: "SRV", Data: "0 100 389 dc1.ad.example.org."},
{Name: "sub.ad.example.org.", TTL: 0, Type: "NS", Data: "ns1.example.org."},
{Name: "*.wild.ad.example.org.", TTL: 60, Type: "A", Data: "192.0.2.6"},
{Name: "SRV1.AD.example.org.", TTL: 60, Type: "A", Data: "192.0.2.5"}, // duplicate (case)
{Name: "null.ad.example.org.", TTL: 60, Type: "MX", Data: "0 ."},
}
func TestParseDynamicRecords(t *testing.T) {
dynamic := map[string]bool{"ad.example.org": true, "other.test": true}
src := dynJSON(t, "ddi-agent-update", map[string][]dynRecord{"ad.example.org": allTypes, "other.test": nil})
set, err := parseDynamicRecords([]byte(src), dynamic)
if err != nil {
t.Fatal(err)
}
if set.key != "ddi-agent-update" || len(set.zones) != 2 || len(set.zones["other.test"]) != 0 {
t.Fatalf("set = %+v", set)
}
if got := len(set.zones["ad.example.org"]); got != len(allTypes)-1 {
t.Errorf("%d records, want %d (one duplicate dropped)", got, len(allTypes)-1)
}
// The zone may be written with a trailing dot and in upper case.
upper := strings.Replace(src, `"zone":"ad.example.org"`, `"zone":"AD.Example.org."`, 1)
if _, err := parseDynamicRecords([]byte(upper), dynamic); err != nil {
t.Errorf("zone with trailing dot: %v", err)
}
}
func TestParseDynamicRecordsRejects(t *testing.T) {
dynamic := map[string]bool{"ad.example.org": true}
rec := func(name string, ttl int64, typ, data string) string {
b, _ := json.Marshal(dynRecord{Name: name, TTL: ttl, Type: typ, Data: data})
return `{"version":1,"key":"ddi-agent-update","zones":[{"zone":"ad.example.org","records":[` + string(b) + `]}]}`
}
const n = "srv1.ad.example.org."
cases := map[string]string{
"not JSON": `version: 1`,
"version 2": `{"version":2,"key":"k","zones":[]}`,
"no version": `{"key":"k","zones":[]}`,
"unknown field": `{"version":1,"key":"k","zones":[],"extra":true}`,
"unknown rec field": `{"version":1,"key":"k","zones":[{"zone":"ad.example.org","records":[{"name":"` + n + `","ttl":1,"type":"A","data":"192.0.2.1","class":"IN"}]}]}`,
"trailing data": `{"version":1,"key":"k","zones":[]} {}`,
"bad key name": `{"version":1,"key":"k\nsend","zones":[]}`,
"empty key name": `{"version":1,"key":"","zones":[]}`,
"zone not dynamic": `{"version":1,"key":"k","zones":[{"zone":"example.org","records":[]}]}`,
"zone twice": `{"version":1,"key":"k","zones":[{"zone":"ad.example.org","records":[]},{"zone":"ad.example.org.","records":[]}]}`,
"zone injection": `{"version":1,"key":"k","zones":[{"zone":"ad.example.org\nupdate delete ad.example.org. NS","records":[]}]}`,
"zone bad label": `{"version":1,"key":"k","zones":[{"zone":"ad..example.org","records":[]}]}`,
"ttl as string": `{"version":1,"key":"k","zones":[{"zone":"ad.example.org","records":[{"name":"` + n + `","ttl":"1","type":"A","data":"192.0.2.1"}]}]}`,
"relative name": rec("srv1", 60, "A", "192.0.2.1"),
"name outside zone": rec("srv1.example.org.", 60, "A", "192.0.2.1"),
"name suffix trick": rec("srv1.bad-ad.example.org.", 60, "A", "192.0.2.1"),
"name with newline": rec("srv1.ad.example.org.\nupdate delete ad.example.org. NS", 60, "A", "192.0.2.1"),
"name with space": rec("srv 1.ad.example.org.", 60, "A", "192.0.2.1"),
"name with semicolon": rec("srv1;.ad.example.org.", 60, "A", "192.0.2.1"),
"name with escape": rec(`srv\0461.ad.example.org.`, 60, "A", "192.0.2.1"),
"wildcard inside": rec("a.*.ad.example.org.", 60, "A", "192.0.2.1"),
"long label": rec(strings.Repeat("a", 64)+".ad.example.org.", 60, "A", "192.0.2.1"),
"empty label": rec("a..ad.example.org.", 60, "A", "192.0.2.1"),
"negative TTL": rec(n, -1, "A", "192.0.2.1"),
"TTL too large": rec(n, 1<<31, "A", "192.0.2.1"),
"type ANY": rec(n, 60, "ANY", "192.0.2.1"),
"type SOA": rec(n, 60, "SOA", "a. b. 1 2 3 4 5"),
"lowercase type": rec(n, 60, "a", "192.0.2.1"),
"type with data": rec(n, 60, "A 192.0.2.9", "192.0.2.1"),
"empty data": rec(n, 60, "A", ""),
"A newline injection": rec(n, 60, "A", "192.0.2.1\nsend\nupdate delete ad.example.org. NS"),
"A CR injection": rec(n, 60, "A", "192.0.2.1\rsend"),
"A semicolon": rec(n, 60, "A", "192.0.2.1;"),
"A with tab": rec(n, 60, "A", "192.0.2.1\t"),
"A is IPv6": rec(n, 60, "A", "2001:db8::1"),
"A not an address": rec(n, 60, "A", "srv1."),
"AAAA is IPv4": rec(n, 60, "AAAA", "192.0.2.1"),
"AAAA with zone": rec(n, 60, "AAAA", "fe80::1%eth0"),
"PTR relative": rec(n, 60, "PTR", "srv1"),
"CNAME two names": rec(n, 60, "CNAME", "a.example.org. b.example.org."),
"CNAME root": rec(n, 60, "CNAME", "."),
"NS non-ASCII": rec(n, 60, "NS", "nä.example.org."),
"MX no preference": rec(n, 60, "MX", "mail.example.org."),
"MX out of range": rec(n, 60, "MX", "70000 mail.example.org."),
"MX signed": rec(n, 60, "MX", "+10 mail.example.org."),
"MX double space": rec(n, 60, "MX", "10 mail.example.org."),
"MX extra field": rec(n, 60, "MX", "10 mail.example.org. x"),
"SRV too few": rec(n, 60, "SRV", "0 100 dc1.ad.example.org."),
"SRV relative": rec(n, 60, "SRV", "0 100 389 dc1"),
"TXT unquoted": rec(n, 60, "TXT", "v=spf1 -all"),
"TXT unterminated": rec(n, 60, "TXT", `"abc`),
"TXT trailing": rec(n, 60, "TXT", `"abc" x`),
"TXT trailing space": rec(n, 60, "TXT", `"abc" `),
"TXT newline": rec(n, 60, "TXT", "\"abc\nsend\""),
"TXT raw quote escape": rec(n, 60, "TXT", `"abc\"`),
"TXT letter escape": rec(n, 60, "TXT", `"a\nb"`),
"TXT short escape": rec(n, 60, "TXT", `"a\01"`),
"TXT escape > 255": rec(n, 60, "TXT", `"a\300"`),
"TXT too long": rec(n, 60, "TXT", `"`+strings.Repeat("a", 256)+`"`),
"TXT after string": rec(n, 60, "TXT", `"a";update delete ad.example.org. NS`),
"data too long": rec(n, 60, "TXT", strings.Repeat(`"`+strings.Repeat("a", 200)+`" `, 50)+`"x"`),
}
for name, src := range cases {
t.Run(name, func(t *testing.T) {
if _, err := parseDynamicRecords([]byte(src), dynamic); err == nil {
t.Fatal("expected an error")
}
})
}
}
func TestUpdateScript(t *testing.T) {
u := zoneUpdate{
zone: "ad.example.org",
deletes: []dynRecord{{Name: "old.ad.example.org.", TTL: 60, Type: "A", Data: "192.0.2.9"}},
adds: []dynRecord{
{Name: "srv1.ad.example.org.", TTL: 3600, Type: "A", Data: "192.0.2.5"},
{Name: "_dmarc.ad.example.org.", TTL: 300, Type: "TXT", Data: `"v=DMARC1; p=none"`},
},
}
got, err := updateScript(netip.MustParseAddrPort("127.0.0.1:53"), u)
if err != nil {
t.Fatal(err)
}
want := "server 127.0.0.1 53\nzone ad.example.org.\n" +
"update delete old.ad.example.org. A 192.0.2.9\n" +
"update add srv1.ad.example.org. 3600 A 192.0.2.5\n" +
"update add _dmarc.ad.example.org. 300 TXT \"v=DMARC1; p=none\"\n" +
"send\n"
if got != want {
t.Errorf("script =\n%s\nwant\n%s", got, want)
}
got, err = updateScript(netip.MustParseAddrPort("[::1]:5353"), zoneUpdate{zone: "z.test", adds: u.adds[:1]})
if err != nil || !strings.HasPrefix(got, "server ::1 5353\nzone z.test.\n") {
t.Errorf("IPv6 server: %q, %v", got, err)
}
// Defense in depth: a line break never reaches the script.
bad := zoneUpdate{zone: "z.test", adds: []dynRecord{{Name: "a.z.test.", TTL: 1, Type: "A", Data: "192.0.2.1\nsend"}}}
if _, err := updateScript(netip.MustParseAddrPort("127.0.0.1:53"), bad); err == nil {
t.Error("line break accepted")
}
}
func TestUpdateScriptBatches(t *testing.T) {
var u zoneUpdate
u.zone = "big.test"
for i := range 1200 {
u.adds = append(u.adds, dynRecord{Name: fmt.Sprintf("h%d.big.test.", i), TTL: 60, Type: "A", Data: "192.0.2.1"})
}
u.deletes = []dynRecord{{Name: "gone.big.test.", TTL: 60, Type: "A", Data: "192.0.2.2"}}
got, err := updateScript(netip.MustParseAddrPort("127.0.0.1:53"), u)
if err != nil {
t.Fatal(err)
}
batches := strings.Split(strings.TrimSuffix(got, "send\n"), "send\n")
if len(batches) != 3 {
t.Fatalf("%d batches, want 3", len(batches))
}
total := 0
for i, b := range batches {
if !strings.HasPrefix(b, "server 127.0.0.1 53\nzone big.test.\n") {
t.Errorf("batch %d lacks server and zone: %.60q", i, b)
}
n := strings.Count(b, "\nupdate ")
if n > maxBatchRecords {
t.Errorf("batch %d has %d records", i, n)
}
total += n
}
if total != 1201 || !strings.Contains(batches[0], "update delete gone.big.test. A 192.0.2.2\n") {
t.Errorf("%d records in total; first batch starts %.120q", total, batches[0])
}
// Long TXT records are cut by size, not count.
u = zoneUpdate{zone: "big.test"}
txt := `"` + strings.Repeat("x", 250) + `"`
for i := range 300 {
u.adds = append(u.adds, dynRecord{Name: fmt.Sprintf("t%d.big.test.", i), TTL: 60, Type: "TXT", Data: txt})
}
got, _ = updateScript(netip.MustParseAddrPort("127.0.0.1:53"), u)
for i, b := range strings.Split(got, "send\n") {
if len(b) > maxBatchBytes+512 {
t.Errorf("batch %d has %d bytes", i, len(b))
}
}
}
func TestPlanUpdates(t *testing.T) {
a := func(name, ip string) dynRecord {
return dynRecord{Name: name + ".z.test.", TTL: 60, Type: "A", Data: ip}
}
prev := &dynSet{zones: map[string][]dynRecord{
"z.test": {a("one", "192.0.2.1"), a("two", "192.0.2.2"), a("Three", "192.0.2.3")},
"gone.test": {{Name: "x.gone.test.", TTL: 60, Type: "A", Data: "192.0.2.4"}},
"left.test": {{Name: "x.left.test.", TTL: 60, Type: "A", Data: "192.0.2.5"}},
}}
cur := &dynSet{zones: map[string][]dynRecord{
"z.test": {a("one", "192.0.2.1"), a("three", "192.0.2.3"), a("four", "192.0.2.4")},
"new.test": {{Name: "y.new.test.", TTL: 60, Type: "A", Data: "192.0.2.6"}},
}}
// gone.test is still a dynamic primary zone, left.test is not.
dynamic := map[string]bool{"z.test": true, "new.test": true, "gone.test": true}
got := planUpdates(cur, prev, dynamic)
if len(got) != 3 || got[0].zone != "gone.test" || got[1].zone != "new.test" || got[2].zone != "z.test" {
t.Fatalf("updates = %+v", got)
}
if len(got[0].adds) != 0 || len(got[0].deletes) != 1 {
t.Errorf("gone.test: %+v", got[0])
}
if len(got[1].adds) != 1 || len(got[1].deletes) != 0 {
t.Errorf("new.test: %+v", got[1])
}
// "Three" equals "three" (names are case-insensitive): only "two" goes.
if len(got[2].adds) != 3 || len(got[2].deletes) != 1 || got[2].deletes[0].Name != "two.z.test." {
t.Errorf("z.test: %+v", got[2])
}
if got := planUpdates(cur, nil, dynamic); len(got) != 2 {
t.Errorf("first revision: %+v", got)
}
empty := &dynSet{zones: map[string][]dynRecord{}}
if got := planUpdates(empty, empty, dynamic); len(got) != 0 {
t.Errorf("nothing to do: %+v", got)
}
}
func TestRecordIdentity(t *testing.T) {
same := [][2]dynRecord{
{{Name: "A.z.", Type: "AAAA", Data: "2001:DB8:0::1"}, {Name: "a.z.", Type: "AAAA", Data: "2001:db8::1"}},
{{Name: "a.z.", Type: "CNAME", Data: "B.z."}, {Name: "a.z.", Type: "CNAME", Data: "b.z."}},
}
for _, p := range same {
if p[0].identity() != p[1].identity() {
t.Errorf("%+v and %+v differ", p[0], p[1])
}
}
txt1 := dynRecord{Name: "a.z.", Type: "TXT", Data: `"A"`}
txt2 := dynRecord{Name: "a.z.", Type: "TXT", Data: `"a"`}
if txt1.identity() == txt2.identity() {
t.Error("TXT data must compare case-sensitively")
}
}
func TestRetryableUpdateError(t *testing.T) {
for out, want := range map[string]bool{
"update failed: NOTAUTH": true,
"update failed: SERVFAIL": true,
"; Communication with 127.0.0.1#53 failed: timed out": true,
"couldn't get address for 'x': not found": false,
"update failed: REFUSED": false,
"update failed: NOTZONE": false,
} {
if got := retryableUpdateError([]byte(out)); got != want {
t.Errorf("%q: %v, want %v", out, got, want)
}
}
}
// --- Role tests (Unix paths) ---
const updateKeysConf = "key \"xfer\" {\n algorithm hmac-sha256;\n secret \"eGZlcg==\";\n};\n" +
"key \"" + testUpdateKeyName + "\" {\n algorithm hmac-sha256;\n secret \"" + testUpdateSecret + "\";\n};\n"
// dynRevision writes a revision with the dynamic zones in zones and the
// update files for records (nil records: no update files).
func (e *env) dynRevision(n uint64, zones []zone, records map[string][]dynRecord) revisionInfo {
e.t.Helper()
files := map[string]string{
confFile: confFor(e.zoneDir, zones),
optionsFile: "recursion no;\n",
keysFile: updateKeysConf,
}
createOnly := map[string]bool{}
for _, z := range zones {
p := zonesPrefix + zonePrefix + z.name
files[p] = zoneFile(z.name, n, z.extra)
createOnly[p] = z.dynamic
}
if records != nil {
files[updateKeyFile] = testUpdateKey
files[dynRecordsFile] = dynJSON(e.t, testUpdateKeyName, records)
}
r := writeRevision(e.t, n, files, createOnly)
return revisionInfo{Revision: r, keyFile: filepath.Join(r.Dir, updateKeyFile)}
}
type revisionInfo struct {
Revision core.Revision
keyFile string
}
var adZones = []zone{{name: "office.test"}, {name: "ad.office.test", dynamic: true}}
func adA(name, ip string) dynRecord {
return dynRecord{Name: name + ".ad.office.test.", TTL: 3600, Type: "A", Data: ip}
}
func (e *env) nsupdateCalls() []string {
var out []string
for _, c := range e.run.Calls() {
if strings.HasPrefix(c, "nsupdate ") {
out = append(out, c)
}
}
return out
}
func TestValidateDynamicRecords(t *testing.T) {
ctx := context.Background()
e := newEnv(t)
recs := map[string][]dynRecord{"ad.office.test": {adA("srv1", "192.0.2.5")}}
if err := e.role.Validate(ctx, e.dynRevision(1, adZones, recs).Revision); err != nil {
t.Fatalf("valid revision rejected: %v", err)
}
if got := e.nsupdateCalls(); len(got) != 0 {
t.Errorf("Validate ran nsupdate: %v", got)
}
for name, c := range map[string]struct {
mutate func(files map[string]string)
want string
}{
"only the key file": {func(f map[string]string) { delete(f, dynRecordsFile) }, "both"},
"only the records file": {func(f map[string]string) { delete(f, updateKeyFile) }, "both"},
"key name mismatch": {func(f map[string]string) {
f[updateKeyFile] = strings.Replace(f[updateKeyFile], testUpdateKeyName, "other-key", 1)
}, `holds the key "other-key"`},
"key unknown to named": {func(f map[string]string) {
f[keysFile] = "key \"xfer\" {\n algorithm hmac-sha256;\n secret \"eGZlcg==\";\n};\n"
}, "does not define the update key"},
"no keys file": {func(f map[string]string) { delete(f, keysFile) }, "which the revision lacks"},
"managed zone": {func(f map[string]string) {
f[dynRecordsFile] = dynJSON(t, testUpdateKeyName, map[string][]dynRecord{
"office.test": {{Name: "a.office.test.", TTL: 1, Type: "A", Data: "192.0.2.1"}}})
}, "not a dynamic primary zone"},
"injection": {func(f map[string]string) {
f[dynRecordsFile] = dynJSON(t, testUpdateKeyName, map[string][]dynRecord{
"ad.office.test": {adA("srv1", "192.0.2.5\nupdate delete ad.office.test. ANY")}})
}, "control or non-ASCII character"},
"bad key file": {func(f map[string]string) {
f[updateKeyFile] = "key \"" + testUpdateKeyName + "\" { algorithm hmac-sha256; };"
}, updateKeyFile},
} {
t.Run(name, func(t *testing.T) {
files := map[string]string{
confFile: confFor(e.zoneDir, adZones),
optionsFile: "recursion no;\n",
keysFile: updateKeysConf,
updateKeyFile: testUpdateKey,
dynRecordsFile: dynJSON(t, testUpdateKeyName, recs),
}
createOnly := map[string]bool{}
for _, z := range adZones {
p := zonesPrefix + zonePrefix + z.name
files[p] = zoneFile(z.name, 2, "")
createOnly[p] = z.dynamic
}
c.mutate(files)
rev := writeRevision(t, 2, files, createOnly)
e.run.Reset()
err := e.role.Validate(ctx, rev)
if err == nil || !strings.Contains(err.Error(), c.want) {
t.Fatalf("err = %v, want it to contain %q", err, c.want)
}
if strings.Contains(err.Error(), testUpdateSecret) {
t.Errorf("error quotes the secret: %v", err)
}
if e.run.Calls() != nil {
t.Errorf("programs ran for an invalid revision: %v", e.run.Calls())
}
})
}
}
func TestActivateAppliesDynamicRecords(t *testing.T) {
ctx := context.Background()
e := newEnv(t)
// Revision 1: add only, after the reload; the update files stay in the
// revision directory.
recs1 := map[string][]dynRecord{"ad.office.test": {adA("srv1", "192.0.2.5"), adA("srv2", "192.0.2.6")}}
rev1 := e.dynRevision(1, adZones, recs1)
if err := e.role.Activate(ctx, rev1.Revision, nil); err != nil {
t.Fatal(err)
}
calls := e.run.Calls()
wantCall := "nsupdate -t 30 -k " + rev1.keyFile
if len(calls) != 4 || calls[2] != "rndc -k /etc/bind/rndc.key reload" || calls[3] != wantCall {
t.Fatalf("calls = %v", calls)
}
want1 := "server 127.0.0.1 53\nzone ad.office.test.\n" +
"update add srv1.ad.office.test. 3600 A 192.0.2.5\nupdate add srv2.ad.office.test. 3600 A 192.0.2.6\nsend\n"
if in := e.run.Inputs(); len(in) != 1 || in[0] != want1 {
t.Fatalf("inputs = %q", in)
}
for _, f := range []string{updateKeyFile, dynRecordsFile} {
if exists(filepath.Join(e.confDir, f)) {
t.Errorf("%s was installed into conf_dir", f)
}
}
// Revision 2 removes srv2 and adds srv3: exactly srv2 is deleted.
recs2 := map[string][]dynRecord{"ad.office.test": {adA("srv1", "192.0.2.5"), adA("srv3", "192.0.2.7")}}
rev2 := e.dynRevision(2, adZones, recs2)
e.run.Reset()
if err := e.role.Activate(ctx, rev2.Revision, &rev1.Revision); err != nil {
t.Fatal(err)
}
want2 := "server 127.0.0.1 53\nzone ad.office.test.\nupdate delete srv2.ad.office.test. A 192.0.2.6\n" +
"update add srv1.ad.office.test. 3600 A 192.0.2.5\nupdate add srv3.ad.office.test. 3600 A 192.0.2.7\nsend\n"
if in := e.run.Inputs(); len(in) != 1 || in[0] != want2 {
t.Fatalf("inputs = %q", in)
}
if got := e.nsupdateCalls(); len(got) != 1 || got[0] != "nsupdate -t 30 -k "+rev2.keyFile {
t.Errorf("nsupdate must use the new revision's key: %v", got)
}
// Revision 3 lists the zone without records: all managed ones go.
rev3 := e.dynRevision(3, adZones, map[string][]dynRecord{"ad.office.test": nil})
e.run.Reset()
if err := e.role.Activate(ctx, rev3.Revision, &rev2.Revision); err != nil {
t.Fatal(err)
}
want3 := "server 127.0.0.1 53\nzone ad.office.test.\nupdate delete srv1.ad.office.test. A 192.0.2.5\n" +
"update delete srv3.ad.office.test. A 192.0.2.7\nsend\n"
if in := e.run.Inputs(); len(in) != 1 || in[0] != want3 {
t.Fatalf("inputs = %q", in)
}
// Revision 4 drops the dynamic zone: BIND no longer serves it, nothing
// is sent.
rev4 := e.dynRevision(4, adZones[:1], nil)
e.run.Reset()
if err := e.role.Activate(ctx, rev4.Revision, &rev2.Revision); err != nil {
t.Fatal(err)
}
if got := e.nsupdateCalls(); len(got) != 0 {
t.Errorf("nsupdate for a dropped zone: %v", got)
}
// Unchanged revision: everything is added again (idempotent), nothing
// deleted.
e.run.Reset()
if err := e.role.Activate(ctx, rev2.Revision, &rev2.Revision); err != nil {
t.Fatal(err)
}
if in := e.run.Inputs(); len(in) != 1 || strings.Contains(in[0], "delete") {
t.Errorf("inputs = %q", in)
}
}
func TestActivateDynamicWithoutPreviousFiles(t *testing.T) {
ctx := context.Background()
e := newEnv(t)
// The previous revision predates ADR 0026; its zone file is BIND's.
rev1 := e.dynRevision(1, adZones, nil)
if err := e.role.Activate(ctx, rev1.Revision, nil); err != nil {
t.Fatal(err)
}
if len(e.nsupdateCalls()) != 0 {
t.Fatalf("nsupdate without update files: %v", e.run.Calls())
}
rev2 := e.dynRevision(2, adZones, map[string][]dynRecord{"ad.office.test": {adA("srv1", "192.0.2.5")}})
e.run.Reset()
if err := e.role.Activate(ctx, rev2.Revision, &rev1.Revision); err != nil {
t.Fatal(err)
}
if in := e.run.Inputs(); len(in) != 1 || strings.Contains(in[0], "delete") || !strings.Contains(in[0], "update add srv1") {
t.Errorf("inputs = %q", in)
}
}
func TestActivateDynamicFailureRestores(t *testing.T) {
ctx := context.Background()
e := newEnv(t)
rev1 := e.dynRevision(1, adZones, map[string][]dynRecord{"ad.office.test": {adA("srv1", "192.0.2.5")}})
if err := e.role.Activate(ctx, rev1.Revision, nil); err != nil {
t.Fatal(err)
}
before := snapshot(t, e)
zones2 := []zone{{name: "office.test", extra: "www IN A 192.0.2.10\n"}, {name: "ad.office.test", dynamic: true}}
rev2 := e.dynRevision(2, zones2, map[string][]dynRecord{"ad.office.test": {adA("srv2", "192.0.2.6")}})
e.run.Reset()
e.run.fnInput = func(string, string, []string) ([]byte, error) {
return []byte("update failed: REFUSED"), errors.New("exit status 2")
}
err := e.role.Activate(ctx, rev2.Revision, &rev1.Revision)
if err == nil || !strings.Contains(err.Error(), "REFUSED") || !strings.Contains(err.Error(), "ad.office.test") {
t.Fatalf("err = %v", err)
}
if strings.Contains(err.Error(), testUpdateSecret) {
t.Errorf("error quotes the secret: %v", err)
}
if got := snapshot(t, e); !mapsEqual(got, before) {
t.Errorf("files not restored")
}
calls := e.run.Calls()
// REFUSED is final: no retry; then BIND is reloaded with the old files.
if len(e.nsupdateCalls()) != 1 || len(calls) != 6 || calls[4] != "rndc -k /etc/bind/rndc.key reconfig" {
t.Errorf("calls = %v", calls)
}
// The core's rollback activates revision 1 again with revision 2 as
// previous: srv2 is deleted, srv1 added.
e.run.fnInput = nil
e.run.Reset()
if err := e.role.Activate(ctx, rev1.Revision, &rev2.Revision); err != nil {
t.Fatal(err)
}
want := "server 127.0.0.1 53\nzone ad.office.test.\nupdate delete srv2.ad.office.test. A 192.0.2.6\n" +
"update add srv1.ad.office.test. 3600 A 192.0.2.5\nsend\n"
if in := e.run.Inputs(); len(in) != 1 || in[0] != want {
t.Errorf("rollback inputs = %q", in)
}
}
func TestActivateDynamicRetriesWhileZoneLoads(t *testing.T) {
ctx := context.Background()
e := newEnv(t)
e.role.updateRetry = time.Millisecond
attempts := 0
e.run.fnInput = func(string, string, []string) ([]byte, error) {
attempts++
if attempts < 3 {
return []byte("update failed: NOTAUTH"), errors.New("exit status 2")
}
return nil, nil
}
rev := e.dynRevision(1, adZones, map[string][]dynRecord{"ad.office.test": {adA("srv1", "192.0.2.5")}})
if err := e.role.Activate(ctx, rev.Revision, nil); err != nil {
t.Fatal(err)
}
if attempts != 3 {
t.Errorf("%d attempts, want 3", attempts)
}
// A zone that never loads fails after updateWait.
e.role.updateWait = 30 * time.Millisecond
e.run.fnInput = func(string, string, []string) ([]byte, error) {
return []byte("update failed: SERVFAIL"), errors.New("exit status 2")
}
e.run.Reset()
start := time.Now()
err := e.role.Activate(ctx, rev.Revision, &rev.Revision)
if err == nil || !strings.Contains(err.Error(), "SERVFAIL") {
t.Fatalf("err = %v", err)
}
if n := len(e.nsupdateCalls()); n < 2 || time.Since(start) > 5*time.Second {
t.Errorf("%d attempts in %s", n, time.Since(start))
}
}
func TestNewDecodesNsupdate(t *testing.T) {
if DefaultConfig().Nsupdate != "nsupdate" {
t.Error("default nsupdate program")
}
cfg := DefaultConfig()
cfg.Nsupdate = ""
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "dns.nsupdate") {
t.Errorf("empty nsupdate accepted: %v", err)
}
}
+4
View File
@@ -32,6 +32,7 @@ import (
"flag"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"time"
@@ -78,6 +79,9 @@ func run(args []string, stdout, stderr io.Writer) int {
_, _ = fmt.Fprintln(stderr, err)
return 2
}
if ls, ok := role.(core.LogSetter); ok {
ls.SetLogger(slog.New(slog.NewTextHandler(stderr, nil)))
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()