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:
co-authored by
Claude Opus 5.5
parent
3e957d7b33
commit
b461fedab3
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user