feat(agent/dns): BIND role with validation, activation and SOA health check

Validate maps ddi-suite*.conf to conf_dir and zones/db.<zone> to
zone_dir, rejects other files and a zone directory that differs from
dns.zone_dir, and runs named-checkconf on a temporary named.conf plus
strict named-checkzone per zone. Activate backs up all touched files,
writes zone files then includes atomically (unchanged files are kept),
never touches existing create-only files, removes files dropped since
the previous revision and the stale journal of a zone that became
managed, runs named-checkconf -z on the live named.conf and then
rndc reconfig and rndc reload; failures restore the backup (and reload
again). Health runs rndc status and compares the SOA serial of every
managed zone (UDP with TCP fallback, authoritative answers only).

golang.org/x/net becomes a direct dependency (dnsmessage).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
beug.k@beugsiud.de
2026-10-13 19:14:34 +02:00
co-authored by Claude Opus 5.5
parent b5e57c3cb7
commit d61b738a8c
7 changed files with 1619 additions and 1 deletions
+97
View File
@@ -0,0 +1,97 @@
// SPDX-License-Identifier: Apache-2.0
package dns
import (
"errors"
"fmt"
"net/netip"
"path"
"strings"
"time"
)
// Config is the "dns:" section of the agent configuration file.
type Config struct {
// ConfDir receives ddi-suite.conf and ddi-suite-options.conf; the
// distribution's named.conf includes them.
ConfDir string `yaml:"conf_dir"`
// ZoneDir receives the zone files. It must equal the zone directory of
// the server's generator settings because ddi-suite.conf names the zone
// files with absolute paths.
ZoneDir string `yaml:"zone_dir"`
// NamedConf is the named.conf the running server uses.
NamedConf string `yaml:"named_conf"`
// Program names (looked up in PATH) or absolute paths.
Named string `yaml:"named"`
NamedCheckconf string `yaml:"named_checkconf"`
NamedCheckzone string `yaml:"named_checkzone"`
Rndc string `yaml:"rndc"`
// 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 string `yaml:"dns_address"`
// CommandTimeout bounds every program run.
CommandTimeout time.Duration `yaml:"command_timeout"`
}
// DefaultConfig returns the defaults of ADR 0021.
func DefaultConfig() Config {
return Config{
ConfDir: "/etc/bind/ddi-suite",
ZoneDir: "/var/lib/bind/ddi-suite",
NamedConf: "/etc/bind/named.conf",
Named: "named",
NamedCheckconf: "named-checkconf",
NamedCheckzone: "named-checkzone",
Rndc: "rndc",
DNSAddress: "127.0.0.1:53",
CommandTimeout: 60 * time.Second,
}
}
// Validate checks the configuration.
func (c *Config) Validate() error {
var errs []error
for _, d := range []struct{ name, value string }{
{"conf_dir", c.ConfDir}, {"zone_dir", c.ZoneDir}, {"named_conf", c.NamedConf},
} {
if err := checkAbsPath(d.value); err != nil {
errs = append(errs, fmt.Errorf("dns.%s: %w", d.name, err))
}
}
for _, p := range []struct{ name, value string }{
{"named", c.Named}, {"named_checkconf", c.NamedCheckconf},
{"named_checkzone", c.NamedCheckzone}, {"rndc", c.Rndc},
} {
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))
}
}
for i, a := range c.RndcArgs {
if strings.ContainsRune(a, 0) {
errs = append(errs, fmt.Errorf("dns.rndc_args[%d]: invalid argument", i))
}
}
if ap, err := netip.ParseAddrPort(c.DNSAddress); err != nil || ap.Port() == 0 {
errs = append(errs, fmt.Errorf("dns.dns_address: %q is not ip:port", c.DNSAddress))
}
if c.CommandTimeout <= 0 || c.CommandTimeout > time.Hour {
errs = append(errs, fmt.Errorf("dns.command_timeout: %s is out of range (0, 1h]", c.CommandTimeout))
}
return errors.Join(errs...)
}
// checkAbsPath accepts clean absolute slash paths that can be written into
// a named.conf string (no quotes, backslashes or control characters).
func checkAbsPath(p string) error {
if p == "" || !path.IsAbs(p) || path.Clean(p) != p || p == "/" {
return fmt.Errorf("%q must be a clean absolute path", p)
}
for _, r := range p {
if r < 0x20 || r == 0x7f || r == '"' || r == '\\' || r == ';' {
return fmt.Errorf("%q contains an invalid character", p)
}
}
return nil
}
+374
View File
@@ -0,0 +1,374 @@
// SPDX-License-Identifier: Apache-2.0
// Package dns is the agent role for BIND 9 (ADR 0004, ADR 0021).
//
// A DNS revision consists of ddi-suite.conf (top-level statements: ACLs and
// zones), ddi-suite-options.conf (statements for the options block) and one
// zone file per zone under zones/. The distribution's named.conf includes
// the two .conf files from Config.ConfDir; zone files go to Config.ZoneDir.
// Initial files of dynamic zones are CreateOnly: BIND owns them once they
// exist.
package dns
import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"regexp"
"strings"
"git.beugsiud.de/beug.k/DDI/agent/core"
"git.beugsiud.de/beug.k/DDI/agent/role/internal/command"
"git.beugsiud.de/beug.k/DDI/agent/role/internal/fsutil"
"git.beugsiud.de/beug.k/DDI/internal/bundle"
)
// Bundle layout produced by the server's BIND generator.
const (
confFile = "ddi-suite.conf"
optionsFile = "ddi-suite-options.conf"
zonesPrefix = "zones/"
zonePrefix = "db."
// journalSuffix is appended to a zone file name by BIND for the journal
// of a dynamic zone (BIND 9 ARM, "journal" zone option: default is the
// zone file name with ".jnl" appended).
journalSuffix = ".jnl"
)
// Role manages BIND.
type Role struct {
cfg Config
run command.Runner
// dial is used for SOA queries (replaced in tests).
dial dialFunc
}
var _ core.Role = (*Role)(nil)
// New creates the role from the "dns:" section of the agent configuration;
// it matches core.RoleFactory. decode may be nil (no section).
func New(decode func(v any) error) (core.Role, error) {
cfg := DefaultConfig()
if decode != nil {
if err := decode(&cfg); err != nil {
return nil, fmt.Errorf("dns configuration: %w", err)
}
}
return newRole(cfg, command.Exec{Timeout: cfg.CommandTimeout})
}
func newRole(cfg Config, run command.Runner) (*Role, error) {
if err := cfg.Validate(); err != nil {
return nil, err
}
return &Role{cfg: cfg, run: run, dial: defaultDial}, nil
}
// Kind implements core.Role.
func (r *Role) Kind() core.Kind { return core.KindDNS }
// ServiceVersion runs "named -v", which prints e.g.
// "BIND 9.20.3 (Stable Release) <id:...>", and returns "BIND 9.20.3".
func (r *Role) ServiceVersion(ctx context.Context) (string, error) {
out, err := r.run.Run(ctx, r.cfg.Named, "-v")
if err != nil {
return "", err
}
return parseNamedVersion(string(out))
}
func parseNamedVersion(out string) (string, error) {
line, _, _ := strings.Cut(strings.TrimSpace(out), "\n")
f := strings.Fields(line)
if len(f) < 2 || f[0] != "BIND" {
return "", fmt.Errorf("unexpected output of named -v: %q", command.Excerpt([]byte(line)))
}
return f[0] + " " + f[1], nil
}
// target is one revision file and where it goes on the host.
type target struct {
file bundle.File
src string // inside the revision directory
dst string // live path
zone string // zone name for zone files, "" for .conf files
}
// plan maps the files of rev to their live paths. Only top-level *.conf
// files and zones/db.<zone> are allowed.
func (r *Role) plan(rev core.Revision) ([]target, error) {
out := make([]target, 0, len(rev.Files))
var haveConf, haveOptions bool
for _, f := range rev.Files {
if err := bundle.CheckPath(f.Path); err != nil {
return nil, err
}
t := target{file: f, src: filepath.Join(rev.Dir, filepath.FromSlash(f.Path))}
switch {
case !strings.Contains(f.Path, "/") && strings.HasSuffix(f.Path, ".conf"):
t.dst = filepath.Join(r.cfg.ConfDir, f.Path)
haveConf = haveConf || f.Path == confFile
haveOptions = haveOptions || f.Path == optionsFile
case strings.HasPrefix(f.Path, zonesPrefix):
name := strings.TrimPrefix(f.Path, zonesPrefix)
zone, ok := strings.CutPrefix(name, zonePrefix)
if !ok || strings.Contains(name, "/") || !validZoneName(zone) {
return nil, fmt.Errorf("unexpected zone file %q in DNS revision (want zones/db.<zone>)", f.Path)
}
t.dst = filepath.Join(r.cfg.ZoneDir, name)
t.zone = zone
default:
return nil, fmt.Errorf("unexpected file %q in DNS revision", f.Path)
}
if f.CreateOnly && t.zone == "" {
return nil, fmt.Errorf("file %q: only zone files may be create-only", f.Path)
}
out = append(out, t)
}
if !haveConf || !haveOptions {
return nil, fmt.Errorf("DNS revision must contain %s and %s", confFile, optionsFile)
}
return out, nil
}
// validZoneName accepts dot-separated, non-empty labels without a trailing
// dot (bundle.CheckPath has already restricted the characters).
func validZoneName(z string) bool {
if z == "" || len(z) > 253 {
return false
}
for _, l := range strings.Split(z, ".") {
if l == "" || len(l) > 63 {
return false
}
}
return true
}
// fileStmt finds the zone file statements in ddi-suite.conf.
var fileStmt = regexp.MustCompile(`(?m)^\s*file\s+"([^"]*)"\s*;`)
// checkZoneDir verifies that ddi-suite.conf names its zone files inside
// the configured zone_dir and only files of this revision.
func (r *Role) checkZoneDir(conf []byte, targets []target) error {
zoneFiles := map[string]bool{}
for _, t := range targets {
if t.zone != "" {
zoneFiles[path.Base(t.file.Path)] = true
}
}
for _, m := range fileStmt.FindAllSubmatch(conf, -1) {
p := string(m[1])
if dir := path.Dir(p); dir != r.cfg.ZoneDir {
return fmt.Errorf("%s places zone files in %q, but this agent's dns.zone_dir is %q: "+
"set the same zone directory in the server's generator settings and the agent configuration",
confFile, dir, r.cfg.ZoneDir)
}
if !zoneFiles[path.Base(p)] {
return fmt.Errorf("%s references %q, which is not part of the revision", confFile, p)
}
}
return nil
}
// Validate checks rev with named-checkconf (on a temporary named.conf that
// includes the revision's files) and named-checkzone. Nothing live changes.
func (r *Role) Validate(ctx context.Context, rev core.Revision) error {
targets, err := r.plan(rev)
if err != nil {
return err
}
for _, d := range []string{r.cfg.ConfDir, r.cfg.ZoneDir} {
if err := fsutil.RequireDir(d); err != nil {
return fmt.Errorf("target directory: %w", err)
}
}
conf, err := fsutil.ReadFile(filepath.Join(rev.Dir, confFile))
if err != nil {
return err
}
if err := r.checkZoneDir(conf, targets); err != nil {
return err
}
if err := r.checkConf(ctx, rev.Dir); err != nil {
return err
}
var errs []error
for _, t := range targets {
if t.zone == "" {
continue
}
// Strict like the server's integration tests: fail on bad names and
// on MX/SRV records that point to addresses or CNAMEs.
if _, err := r.run.Run(ctx, r.cfg.NamedCheckzone, "-k", "fail", "-m", "fail", "-M", "fail",
"-S", "fail", "-i", "local", t.zone, t.src); err != nil {
errs = append(errs, fmt.Errorf("zone %s: %w", t.zone, err))
if len(errs) >= 10 {
errs = append(errs, errors.New("further zones not checked"))
break
}
}
}
return errors.Join(errs...)
}
// checkConf writes a temporary named.conf that includes the revision's two
// files the way the distribution's named.conf does and checks it.
func (r *Role) checkConf(ctx context.Context, revDir string) error {
if strings.ContainsAny(revDir, "\"\\\n;") {
return fmt.Errorf("revision directory %q cannot be used in named.conf", revDir)
}
tmp, err := os.MkdirTemp("", "ddi-dns-validate-")
if err != nil {
return err
}
defer func() { _ = os.RemoveAll(tmp) }()
slash := filepath.ToSlash
named := fmt.Sprintf("options {\n\tdirectory %q;\n\tinclude %q;\n};\ninclude %q;\n",
slash(tmp), slash(filepath.Join(revDir, optionsFile)), slash(filepath.Join(revDir, confFile)))
nc := filepath.Join(tmp, "named.conf")
if err := os.WriteFile(nc, []byte(named), 0o600); err != nil {
return err
}
if _, err := r.run.Run(ctx, r.cfg.NamedCheckconf, nc); err != nil {
return fmt.Errorf("configuration check failed: %w", err)
}
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).
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
}
var old []target
if prev != nil {
// A previous revision that no longer fits the configuration only
// loses the removal of its stale files.
old, _ = r.plan(*prev)
}
var backup fsutil.Backup
writes, removes, err := r.changes(targets, old)
if err != nil {
return err
}
for _, w := range writes {
if err := backup.Save(w.dst); err != nil {
return err
}
}
for _, p := range removes {
if err := backup.Save(p); err != nil {
return err
}
}
reloaded := false
defer func() {
if err == nil {
return
}
if rerr := backup.Restore(); rerr != nil {
err = errors.Join(err, fmt.Errorf("restoring the previous files failed: %w", rerr))
return
}
if reloaded {
if rerr := r.reload(context.WithoutCancel(ctx)); rerr != nil {
err = errors.Join(err, fmt.Errorf("reload after restoring the previous files failed: %w", rerr))
}
}
}()
for _, w := range writes {
data, rerr := fsutil.ReadFile(w.src)
if rerr != nil {
return rerr
}
if fsutil.Same(w.dst, data, fs.FileMode(w.file.Mode)) {
// Unchanged files keep their modification time, so that
// "rndc reload" does not reload zones that did not change.
continue
}
if werr := fsutil.WriteFile(w.dst, data, fs.FileMode(w.file.Mode), fsutil.OwnerFor(w.dst)); werr != nil {
return werr
}
}
for _, p := range removes {
if rerr := fsutil.Remove(p); rerr != nil {
return fmt.Errorf("remove %s: %w", p, rerr)
}
}
if _, cerr := r.run.Run(ctx, r.cfg.NamedCheckconf, "-z", r.cfg.NamedConf); cerr != nil {
return fmt.Errorf("live configuration check failed, previous files restored: %w", cerr)
}
reloaded = true
return r.reload(ctx)
}
// changes computes the files to write (zone files first, so that the
// configuration never names a missing zone file) and the files to remove.
func (r *Role) changes(targets, old []target) (writes []target, removes []string, err error) {
inRev := map[string]bool{}
for _, t := range targets {
inRev[t.dst] = true
}
oldCreateOnly := map[string]bool{}
for _, t := range old {
oldCreateOnly[t.dst] = t.file.CreateOnly
}
var confs []target
for _, t := range targets {
if t.file.CreateOnly {
exists, err := fsutil.Exists(t.dst)
if err != nil {
return nil, nil, err
}
if exists {
continue // BIND owns it
}
} else if oldCreateOnly[t.dst] {
// The zone was dynamic and is now managed: its journal no
// longer matches the new file and would be replayed on load.
removes = append(removes, t.dst+journalSuffix)
}
if t.zone == "" {
confs = append(confs, t)
} else {
writes = append(writes, t)
}
}
writes = append(writes, confs...)
for _, t := range old {
if !inRev[t.dst] && !t.file.CreateOnly {
removes = append(removes, t.dst)
}
}
return writes, removes, nil
}
// reload makes BIND read the new configuration and zone files.
//
// "rndc reconfig" loads the configuration and new zones; "rndc reload"
// (without a zone) reloads the configuration and all zones (BIND 9 ARM,
// rndc manual page, 9.18 and 9.20). Dynamic zones do not fail this
// command: named skips them when they are loaded already (lib/dns/zt.c
// maps DNS_R_DYNAMIC to success), whereas "rndc reload <zone>" would fail
// with "dynamic zone". Zone loading after "rndc reload" is asynchronous;
// the health check confirms the serials.
func (r *Role) reload(ctx context.Context) error {
var errs []error
for _, cmd := range []string{"reconfig", "reload"} {
if _, err := r.run.Run(ctx, r.cfg.Rndc, append(append([]string(nil), r.cfg.RndcArgs...), cmd)...); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
+582
View File
@@ -0,0 +1,582 @@
// SPDX-License-Identifier: Apache-2.0
package dns
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"sync"
"testing"
"time"
"go.yaml.in/yaml/v3"
"git.beugsiud.de/beug.k/DDI/agent/core"
"git.beugsiud.de/beug.k/DDI/agent/role/internal/command"
"git.beugsiud.de/beug.k/DDI/internal/bundle"
)
// fakeRunner records program runs and answers them with fn.
type fakeRunner struct {
mu sync.Mutex
calls []string
fn func(name string, args []string) ([]byte, error)
}
func (f *fakeRunner) Run(_ context.Context, name string, args ...string) ([]byte, error) {
f.mu.Lock()
f.calls = append(f.calls, strings.Join(append([]string{name}, args...), " "))
fn := f.fn
f.mu.Unlock()
if fn == nil {
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}
}
return out, nil
}
func (f *fakeRunner) Calls() []string {
f.mu.Lock()
defer f.mu.Unlock()
return slices.Clone(f.calls)
}
func (f *fakeRunner) Reset() {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = nil
}
type env struct {
t *testing.T
role *Role
run *fakeRunner
confDir string
zoneDir string
}
func newEnv(t *testing.T) *env {
t.Helper()
base := t.TempDir()
cfg := DefaultConfig()
cfg.ConfDir = filepath.ToSlash(filepath.Join(base, "etc"))
cfg.ZoneDir = filepath.ToSlash(filepath.Join(base, "zones"))
cfg.NamedConf = filepath.ToSlash(filepath.Join(base, "named.conf"))
cfg.RndcArgs = []string{"-k", "/etc/bind/rndc.key"}
if runtime.GOOS == "windows" {
// Validation wants slash paths; Windows only compiles these tests.
t.Skip("DNS role tests need Unix paths")
}
for _, d := range []string{cfg.ConfDir, cfg.ZoneDir} {
if err := os.MkdirAll(d, 0o750); err != nil {
t.Fatal(err)
}
}
run := &fakeRunner{}
r, err := newRole(cfg, run)
if err != nil {
t.Fatal(err)
}
return &env{t: t, role: r, run: run, confDir: cfg.ConfDir, zoneDir: cfg.ZoneDir}
}
// zone describes one zone of a fixture revision.
type zone struct {
name string
dynamic bool
extra string
}
func confFor(zoneDir string, zones []zone) string {
var b strings.Builder
b.WriteString("// Generated by DDI Suite.\n")
for _, z := range zones {
update := "none"
if z.dynamic {
update = "any"
}
fmt.Fprintf(&b, "\nzone %q {\n type primary;\n file \"%s/db.%s\";\n notify no;\n allow-update { %s; };\n};\n",
z.name, zoneDir, z.name, update)
}
return b.String()
}
func zoneFile(name string, serial uint64, extra string) string {
return fmt.Sprintf("$ORIGIN %s.\n$TTL 3600\n@ IN SOA ns1.%s. hostmaster.%s. %d 3600 600 1209600 300\n@ IN NS ns1.%s.\nns1 IN A 192.0.2.53\n%s",
name, name, name, serial, name, extra)
}
// revision writes a generator-like revision into a temporary directory.
func (e *env) revision(n uint64, zones []zone) core.Revision {
e.t.Helper()
files := map[string]string{
confFile: confFor(e.zoneDir, zones),
optionsFile: "recursion no;\nallow-transfer { none; };\n",
}
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
}
return writeRevision(e.t, n, files, createOnly)
}
func writeRevision(t *testing.T, n uint64, files map[string]string, createOnly map[string]bool) core.Revision {
t.Helper()
rev := core.Revision{Number: n, Dir: t.TempDir()}
paths := make([]string, 0, len(files))
for p := range files {
paths = append(paths, p)
}
slices.Sort(paths)
for _, p := range paths {
full := filepath.Join(rev.Dir, filepath.FromSlash(p))
if err := os.MkdirAll(filepath.Dir(full), 0o750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, []byte(files[p]), 0o600); err != nil {
t.Fatal(err)
}
rev.Files = append(rev.Files, bundle.File{Path: p, Mode: 0o640, Size: int64(len(files[p])), CreateOnly: createOnly[p]})
}
return rev
}
func readFile(t *testing.T, p string) string {
t.Helper()
b, err := os.ReadFile(p)
if err != nil {
t.Fatal(err)
}
return string(b)
}
func exists(p string) bool {
_, err := os.Lstat(p)
return err == nil
}
func TestNewDecodesConfig(t *testing.T) {
src := "conf_dir: /etc/named/ddi\nrndc_args: [-k, /etc/named/rndc.key, -p, \"9953\"]\ncommand_timeout: 5s\ndns_address: \"[::1]:5353\"\n"
decode := func(v any) error {
dec := yaml.NewDecoder(strings.NewReader(src))
dec.KnownFields(true)
return dec.Decode(v)
}
r, err := New(decode)
if err != nil {
t.Fatal(err)
}
cfg := r.(*Role).cfg
if cfg.ConfDir != "/etc/named/ddi" || cfg.ZoneDir != "/var/lib/bind/ddi-suite" || cfg.CommandTimeout != 5*time.Second ||
!slices.Equal(cfg.RndcArgs, []string{"-k", "/etc/named/rndc.key", "-p", "9953"}) || cfg.DNSAddress != "[::1]:5353" {
t.Errorf("unexpected config %+v", cfg)
}
if r.Kind() != core.KindDNS {
t.Error("wrong kind")
}
if r, err := New(nil); err != nil || r.(*Role).cfg.NamedConf != "/etc/bind/named.conf" {
t.Errorf("defaults: %v", err)
}
}
func TestNewRejectsInvalidConfig(t *testing.T) {
for name, src := range map[string]string{
"relative dir": "zone_dir: var/lib/bind",
"quote in path": "conf_dir: '/etc/bind/x\"y'",
"host name": "dns_address: localhost:53",
"no port": "dns_address: 127.0.0.1",
"zero timeout": "command_timeout: 0s",
"empty program": "rndc: ''",
"unknown field": "zonedir: /x",
} {
t.Run(name, func(t *testing.T) {
_, err := New(func(v any) error {
dec := yaml.NewDecoder(strings.NewReader(src))
dec.KnownFields(true)
return dec.Decode(v)
})
if err == nil {
t.Fatal("expected an error")
}
})
}
}
func TestServiceVersion(t *testing.T) {
e := newEnv(t)
e.run.fn = func(name string, args []string) ([]byte, error) {
return []byte("BIND 9.20.3 (Stable Release) <id:abc>\n"), nil
}
v, err := e.role.ServiceVersion(context.Background())
if err != nil || v != "BIND 9.20.3" {
t.Fatalf("version = %q, %v", v, err)
}
if got := e.run.Calls(); !slices.Equal(got, []string{"named -v"}) {
t.Errorf("calls = %v", got)
}
for in, want := range map[string]string{
"BIND 9.18.28-0ubuntu0.22.04.1-Ubuntu (Extended Support Version) <id:>": "BIND 9.18.28-0ubuntu0.22.04.1-Ubuntu",
"named: unknown option": "",
} {
got, err := parseNamedVersion(in)
if got != want || (want == "") != (err != nil) {
t.Errorf("parseNamedVersion(%q) = %q, %v", in, got, err)
}
}
}
var sampleZones = []zone{
{name: "office.test"},
{name: "2.0.192.in-addr.arpa", extra: "10 IN PTR www.office.test.\n"},
{name: "dyn.test", dynamic: true},
}
func TestValidateRunsCheckers(t *testing.T) {
e := newEnv(t)
rev := e.revision(1, sampleZones)
var namedConf string
e.run.fn = func(name string, args []string) ([]byte, error) {
if name == "named-checkconf" {
namedConf = readFile(t, args[len(args)-1])
}
return nil, nil
}
if err := e.role.Validate(context.Background(), rev); err != nil {
t.Fatal(err)
}
calls := e.run.Calls()
if len(calls) != 4 || !strings.HasPrefix(calls[0], "named-checkconf ") || strings.Contains(calls[0], "-z") {
t.Fatalf("calls = %v", calls)
}
for _, want := range []string{
"include \"" + filepath.ToSlash(filepath.Join(rev.Dir, optionsFile)) + "\";",
"include \"" + filepath.ToSlash(filepath.Join(rev.Dir, confFile)) + "\";",
"options {",
} {
if !strings.Contains(namedConf, want) {
t.Errorf("temporary named.conf lacks %q:\n%s", want, namedConf)
}
}
// The options include must be inside the options block.
if strings.Index(namedConf, optionsFile) > strings.Index(namedConf, "};") {
t.Errorf("options include outside the options block:\n%s", namedConf)
}
for _, z := range sampleZones {
want := "named-checkzone -k fail -m fail -M fail -S fail -i local " + z.name + " " +
filepath.Join(rev.Dir, "zones", "db."+z.name)
if !slices.Contains(calls, want) {
t.Errorf("missing call %q in %v", want, calls)
}
}
if exists(filepath.Join(e.confDir, confFile)) {
t.Error("Validate changed live files")
}
}
func TestValidateRejects(t *testing.T) {
e := newEnv(t)
good := e.revision(1, sampleZones)
cases := map[string]struct {
rev func() core.Revision
fn func(name string, args []string) ([]byte, error)
want string
}{
"unexpected file": {
rev: func() core.Revision {
return writeRevision(t, 1, map[string]string{confFile: "", optionsFile: "", "other/x": ""}, nil)
},
want: "unexpected file",
},
"nested zone file": {
rev: func() core.Revision {
return writeRevision(t, 1, map[string]string{confFile: "", optionsFile: "", "zones/a/db.x": ""}, nil)
},
want: "unexpected zone file",
},
"missing options": {
rev: func() core.Revision { return writeRevision(t, 1, map[string]string{confFile: ""}, nil) },
want: optionsFile,
},
"create-only conf": {
rev: func() core.Revision {
return writeRevision(t, 1, map[string]string{confFile: "", optionsFile: ""}, map[string]bool{confFile: true})
},
want: "create-only",
},
"zone dir mismatch": {
rev: func() core.Revision {
return writeRevision(t, 1, map[string]string{
confFile: confFor("/etc/bind/ddi-suite/zones", []zone{{name: "a.test"}}),
optionsFile: "",
"zones/db.a.test": zoneFile("a.test", 1, ""),
}, nil)
},
want: "dns.zone_dir",
},
"unknown zone file": {
rev: func() core.Revision {
return writeRevision(t, 1, map[string]string{
confFile: confFor(e.zoneDir, []zone{{name: "a.test"}}), optionsFile: "",
}, nil)
},
want: "not part of the revision",
},
"checkconf fails": {
rev: func() core.Revision { return good },
fn: func(name string, args []string) ([]byte, error) {
if name == "named-checkconf" {
return []byte("ddi-suite.conf:3: unknown option 'bogus'"), errors.New("exit status 1")
}
return nil, nil
},
want: "configuration check failed",
},
"checkzone fails": {
rev: func() core.Revision { return good },
fn: func(name string, args []string) ([]byte, error) {
if name == "named-checkzone" && slices.Contains(args, "office.test") {
return []byte("db.office.test:7: syntax error"), errors.New("exit status 1")
}
return nil, nil
},
want: "zone office.test",
},
}
for name, c := range cases {
t.Run(name, func(t *testing.T) {
e.run.fn = c.fn
err := e.role.Validate(context.Background(), c.rev())
if err == nil || !strings.Contains(err.Error(), c.want) {
t.Fatalf("err = %v, want it to contain %q", err, c.want)
}
})
}
}
func TestValidateRequiresTargetDirs(t *testing.T) {
e := newEnv(t)
rev := e.revision(1, sampleZones)
if err := os.Remove(e.zoneDir); err != nil {
t.Fatal(err)
}
if err := e.role.Validate(context.Background(), rev); err == nil || !strings.Contains(err.Error(), "target directory") {
t.Fatalf("err = %v", err)
}
}
func TestActivateWritesRemovesAndReloads(t *testing.T) {
ctx := context.Background()
e := newEnv(t)
rev1 := e.revision(1, sampleZones)
if err := e.role.Activate(ctx, rev1, nil); err != nil {
t.Fatal(err)
}
want := []string{
"named-checkconf -z " + e.role.cfg.NamedConf,
"rndc -k /etc/bind/rndc.key reconfig",
"rndc -k /etc/bind/rndc.key reload",
}
if got := e.run.Calls(); !slices.Equal(got, want) {
t.Fatalf("calls = %v, want %v", got, want)
}
for _, z := range sampleZones {
p := filepath.Join(e.zoneDir, "db."+z.name)
if got := readFile(t, p); got != zoneFile(z.name, 1, z.extra) {
t.Errorf("%s = %q", p, got)
}
if fi, _ := os.Stat(p); fi.Mode().Perm() != 0o640 {
t.Errorf("%s: mode %o", p, fi.Mode().Perm())
}
}
if got := readFile(t, filepath.Join(e.confDir, confFile)); got != confFor(e.zoneDir, sampleZones) {
t.Errorf("ddi-suite.conf = %q", got)
}
// BIND changes the dynamic zone; revision 2 drops the reverse zone.
dynFile := filepath.Join(e.zoneDir, "db.dyn.test")
if err := os.WriteFile(dynFile, []byte("owned by BIND"), 0o644); err != nil {
t.Fatal(err)
}
zones2 := []zone{{name: "office.test", extra: "www IN A 192.0.2.10\n"}, {name: "dyn.test", dynamic: true}}
rev2 := e.revision(2, zones2)
e.run.Reset()
if err := e.role.Activate(ctx, rev2, &rev1); err != nil {
t.Fatal(err)
}
if got := readFile(t, dynFile); got != "owned by BIND" {
t.Errorf("create-only file was overwritten: %q", got)
}
if exists(filepath.Join(e.zoneDir, "db.2.0.192.in-addr.arpa")) {
t.Error("zone file of the removed zone still exists")
}
if got := readFile(t, filepath.Join(e.zoneDir, "db.office.test")); got != zoneFile("office.test", 2, zones2[0].extra) {
t.Errorf("office.test not updated: %q", got)
}
if len(e.run.Calls()) != 3 {
t.Errorf("calls = %v", e.run.Calls())
}
// Revision 3 drops the dynamic zone: its file stays.
rev3 := e.revision(3, zones2[:1])
if err := e.role.Activate(ctx, rev3, &rev2); err != nil {
t.Fatal(err)
}
if !exists(dynFile) {
t.Error("create-only file was removed")
}
}
func TestActivateKeepsUnchangedFiles(t *testing.T) {
ctx := context.Background()
e := newEnv(t)
rev1 := e.revision(1, sampleZones)
if err := e.role.Activate(ctx, rev1, nil); err != nil {
t.Fatal(err)
}
p := filepath.Join(e.confDir, optionsFile)
old := time.Now().Add(-time.Hour)
if err := os.Chtimes(p, old, old); err != nil {
t.Fatal(err)
}
rev2 := e.revision(2, sampleZones)
if err := e.role.Activate(ctx, rev2, &rev1); err != nil {
t.Fatal(err)
}
if fi, _ := os.Stat(p); !fi.ModTime().Equal(old) {
t.Error("unchanged file was rewritten")
}
}
func TestActivateRestoresWhenLiveCheckFails(t *testing.T) {
ctx := context.Background()
e := newEnv(t)
rev1 := e.revision(1, sampleZones)
if err := e.role.Activate(ctx, rev1, nil); err != nil {
t.Fatal(err)
}
before := snapshot(t, e)
rev2 := e.revision(2, []zone{{name: "office.test"}, {name: "new.test"}, {name: "new-dyn.test", dynamic: true}})
e.run.Reset()
e.run.fn = func(name string, args []string) ([]byte, error) {
if name == "named-checkconf" {
return []byte("options: 'notify' redefined"), errors.New("exit status 1")
}
return nil, nil
}
err := e.role.Activate(ctx, rev2, &rev1)
if err == nil || !strings.Contains(err.Error(), "redefined") {
t.Fatalf("err = %v", err)
}
if got := snapshot(t, e); !mapsEqual(got, before) {
t.Errorf("files not restored:\n got %v\nwant %v", keys(got), keys(before))
}
for _, c := range e.run.Calls() {
if strings.HasPrefix(c, "rndc") {
t.Errorf("rndc called although the check failed: %v", e.run.Calls())
}
}
}
func TestActivateRestoresAndReloadsWhenReloadFails(t *testing.T) {
ctx := context.Background()
e := newEnv(t)
rev1 := e.revision(1, sampleZones)
if err := e.role.Activate(ctx, rev1, nil); err != nil {
t.Fatal(err)
}
before := snapshot(t, e)
rev2 := e.revision(2, sampleZones[:1])
e.run.Reset()
failed := false
e.run.fn = func(name string, args []string) ([]byte, error) {
if name == "rndc" && args[len(args)-1] == "reload" && !failed {
failed = true
return []byte("rndc: 'reload' failed: failure"), errors.New("exit status 1")
}
return nil, nil
}
err := e.role.Activate(ctx, rev2, &rev1)
if err == nil || !strings.Contains(err.Error(), "'reload' failed") {
t.Fatalf("err = %v", err)
}
if got := snapshot(t, e); !mapsEqual(got, before) {
t.Errorf("files not restored")
}
calls := e.run.Calls()
if n := len(calls); n != 5 || calls[3] != "rndc -k /etc/bind/rndc.key reconfig" || calls[4] != "rndc -k /etc/bind/rndc.key reload" {
t.Errorf("expected a second reconfig/reload after restoring, calls = %v", calls)
}
}
func TestActivateRemovesJournalWhenZoneBecomesManaged(t *testing.T) {
ctx := context.Background()
e := newEnv(t)
rev1 := e.revision(1, []zone{{name: "z.test", dynamic: true}})
if err := e.role.Activate(ctx, rev1, nil); err != nil {
t.Fatal(err)
}
jnl := filepath.Join(e.zoneDir, "db.z.test.jnl")
if err := os.WriteFile(jnl, []byte("journal"), 0o644); err != nil {
t.Fatal(err)
}
rev2 := e.revision(2, []zone{{name: "z.test"}})
if err := e.role.Activate(ctx, rev2, &rev1); err != nil {
t.Fatal(err)
}
if exists(jnl) {
t.Error("journal of the formerly dynamic zone still exists")
}
if got := readFile(t, filepath.Join(e.zoneDir, "db.z.test")); got != zoneFile("z.test", 2, "") {
t.Errorf("managed zone file not written: %q", got)
}
}
func snapshot(t *testing.T, e *env) map[string]string {
t.Helper()
out := map[string]string{}
for _, d := range []string{e.confDir, e.zoneDir} {
entries, err := os.ReadDir(d)
if err != nil {
t.Fatal(err)
}
for _, en := range entries {
out[filepath.Join(d, en.Name())] = readFile(t, filepath.Join(d, en.Name()))
}
}
return out
}
func mapsEqual(a, b map[string]string) bool {
if len(a) != len(b) {
return false
}
for k, v := range a {
if w, ok := b[k]; !ok || w != v {
return false
}
}
return true
}
func keys(m map[string]string) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, filepath.Base(k))
}
slices.Sort(out)
return out
}
+144
View File
@@ -0,0 +1,144 @@
// SPDX-License-Identifier: Apache-2.0
package dns
import (
"context"
"fmt"
"strings"
"sync"
"git.beugsiud.de/beug.k/DDI/agent/core"
"git.beugsiud.de/beug.k/DDI/agent/role/internal/command"
)
// maxParallelQueries bounds concurrent SOA queries of one health check.
const maxParallelQueries = 8
// Health runs "rndc status" and, for every managed (not create-only) zone
// of expected, an SOA query to dns_address whose serial must equal the
// revision number (the generator writes the revision as SOA serial).
// Dynamic zones are not compared: BIND increments their serial on updates.
//
// State: Failed if rndc status fails or no zone serves the expected
// serial; Degraded if only some zones do or rndc status reports that the
// last reload failed or is still in progress; OK otherwise.
func (r *Role) Health(ctx context.Context, expected *core.Revision) core.Health {
var h core.Health
status := core.HealthCheck{Name: "rndc status", State: core.HealthOK}
out, err := r.run.Run(ctx, r.cfg.Rndc, append(append([]string(nil), r.cfg.RndcArgs...), "status")...)
if err != nil {
status.State = core.HealthFailed
status.Message = err.Error()
} else {
var reloadOK bool
status.Message, reloadOK = summarizeStatus(string(out))
if !reloadOK {
status.State = core.HealthDegraded
}
}
h.Checks = append(h.Checks, status)
var zones []string
if expected != nil {
for _, f := range expected.Files {
if z, ok := strings.CutPrefix(f.Path, zonesPrefix+zonePrefix); ok && !f.CreateOnly && validZoneName(z) {
zones = append(zones, z)
}
}
}
zoneChecks := r.checkZones(ctx, zones, expected)
h.Checks = append(h.Checks, zoneChecks...)
good := 0
for _, c := range zoneChecks {
if c.State == core.HealthOK {
good++
}
}
switch {
case status.State == core.HealthFailed:
h.State = core.HealthFailed
h.Message = "named does not answer rndc status"
case status.State != core.HealthOK && (len(zones) == 0 || good > 0):
h.State = core.HealthDegraded
h.Message = status.Message
case len(zones) > 0 && good == 0:
h.State = core.HealthFailed
h.Message = fmt.Sprintf("no zone serves serial %d", uint32(expected.Number)) //nolint:gosec // serials are revision numbers < 2^32 (ADR 0021)
case good < len(zones):
h.State = core.HealthDegraded
h.Message = fmt.Sprintf("%d of %d zones serve serial %d", good, len(zones), uint32(expected.Number)) //nolint:gosec // see above
default:
h.State = core.HealthOK
h.Message = "named is running"
if len(zones) > 0 {
h.Message += fmt.Sprintf("; %d zones serve serial %d", len(zones), uint32(expected.Number)) //nolint:gosec // see above
}
}
return h
}
func (r *Role) checkZones(ctx context.Context, zones []string, expected *core.Revision) []core.HealthCheck {
checks := make([]core.HealthCheck, len(zones))
if len(zones) == 0 {
return checks
}
want := uint32(expected.Number) //nolint:gosec // serials are revision numbers < 2^32 (ADR 0021)
sem := make(chan struct{}, maxParallelQueries)
var wg sync.WaitGroup
for i, z := range zones {
wg.Add(1)
sem <- struct{}{}
go func() {
defer func() { <-sem; wg.Done() }()
c := core.HealthCheck{Name: "zone " + z}
serial, err := querySOA(ctx, r.dial, r.cfg.DNSAddress, z)
switch {
case err != nil:
c.State = core.HealthFailed
c.Message = "SOA query: " + err.Error()
case serial != want:
c.State = core.HealthFailed
c.Message = fmt.Sprintf("serial %d, expected %d", serial, want)
default:
c.State = core.HealthOK
c.Message = fmt.Sprintf("serial %d", serial)
}
checks[i] = c
}()
}
wg.Wait()
return checks
}
// summarizeStatus picks the version, the reload state and the final status
// line from the output of rndc status, e.g. "BIND 9.20.3 (Stable Release);
// server is up and running". reloadOK is false if named reports
// "reload/reconfig failed" or "reload/reconfig in progress" (printed by
// named_server_status in BIND 9.18 and 9.20 while the last reload is not
// done).
func summarizeStatus(out string) (summary string, reloadOK bool) {
var parts []string
reloadOK = true
for _, l := range strings.Split(out, "\n") {
l = strings.TrimSpace(l)
if v, ok := strings.CutPrefix(l, "version: "); ok {
if i := strings.Index(v, " <id:"); i >= 0 {
v = v[:i]
}
parts = append(parts, v)
}
if strings.HasPrefix(l, "reload/reconfig ") {
parts = append(parts, l)
reloadOK = false
}
if strings.HasPrefix(l, "server is ") {
parts = append(parts, l)
}
}
if len(parts) == 0 {
return command.Excerpt([]byte(out)), reloadOK
}
return strings.Join(parts, "; "), reloadOK
}
+247
View File
@@ -0,0 +1,247 @@
// SPDX-License-Identifier: Apache-2.0
package dns
import (
"context"
"encoding/binary"
"errors"
"io"
"net"
"strings"
"sync"
"testing"
"golang.org/x/net/dns/dnsmessage"
"git.beugsiud.de/beug.k/DDI/agent/core"
)
// fakeDNS answers SOA queries on 127.0.0.1 over UDP and TCP (same port).
type fakeDNS struct {
addr string
mu sync.Mutex
// serials maps a zone (without trailing dot) to its SOA serial.
serials map[string]uint32
// truncate lists zones answered with TC over UDP.
truncate map[string]bool
// nonAuth lists zones answered without the AA bit.
nonAuth map[string]bool
tcpUsed int
}
func startFakeDNS(t *testing.T) *fakeDNS {
t.Helper()
f := &fakeDNS{serials: map[string]uint32{}, truncate: map[string]bool{}, nonAuth: map[string]bool{}}
var (
ln net.Listener
pc net.PacketConn
err error
)
for range 20 {
if ln, err = net.Listen("tcp", "127.0.0.1:0"); err != nil {
t.Skipf("no TCP on 127.0.0.1: %v", err)
}
if pc, err = net.ListenPacket("udp", ln.Addr().String()); err == nil {
break
}
_ = ln.Close()
}
if err != nil {
t.Skipf("no UDP port pair on 127.0.0.1: %v", err)
}
f.addr = ln.Addr().String()
t.Cleanup(func() { _ = ln.Close(); _ = pc.Close() })
go func() {
buf := make([]byte, 65535)
for {
n, from, err := pc.ReadFrom(buf)
if err != nil {
return
}
if resp := f.answer(buf[:n], true); resp != nil {
_, _ = pc.WriteTo(resp, from)
}
}
}()
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
go func() {
defer func() { _ = c.Close() }()
var l [2]byte
if _, err := io.ReadFull(c, l[:]); err != nil {
return
}
q := make([]byte, binary.BigEndian.Uint16(l[:]))
if _, err := io.ReadFull(c, q); err != nil {
return
}
f.mu.Lock()
f.tcpUsed++
f.mu.Unlock()
resp := f.answer(q, false)
out := make([]byte, 2+len(resp))
binary.BigEndian.PutUint16(out, uint16(len(resp)))
copy(out[2:], resp)
_, _ = c.Write(out)
}()
}
}()
return f
}
func (f *fakeDNS) set(zone string, serial uint32) {
f.mu.Lock()
defer f.mu.Unlock()
f.serials[zone] = serial
}
func (f *fakeDNS) answer(query []byte, udp bool) []byte {
var p dnsmessage.Parser
h, err := p.Start(query)
if err != nil {
return nil
}
q, err := p.Question()
if err != nil {
return nil
}
zone := strings.TrimSuffix(q.Name.String(), ".")
f.mu.Lock()
serial, known := f.serials[zone]
trunc := udp && f.truncate[zone]
auth := !f.nonAuth[zone]
f.mu.Unlock()
rh := dnsmessage.Header{ID: h.ID, Response: true, Authoritative: auth && known}
switch {
case !known:
rh.RCode = dnsmessage.RCodeRefused
case trunc:
rh.Truncated = true
}
b := dnsmessage.NewBuilder(nil, rh)
_ = b.StartQuestions()
_ = b.Question(q)
if known && !trunc {
_ = b.StartAnswers()
// An unrelated record first, to exercise skipping.
_ = b.AResource(dnsmessage.ResourceHeader{Name: q.Name, Class: dnsmessage.ClassINET, TTL: 60},
dnsmessage.AResource{A: [4]byte{192, 0, 2, 1}})
_ = b.SOAResource(dnsmessage.ResourceHeader{Name: q.Name, Class: dnsmessage.ClassINET, TTL: 60},
dnsmessage.SOAResource{
NS: dnsmessage.MustNewName("ns1." + zone + "."), MBox: dnsmessage.MustNewName("hostmaster." + zone + "."),
Serial: serial, Refresh: 3600, Retry: 600, Expire: 1209600, MinTTL: 300,
})
}
msg, err := b.Finish()
if err != nil {
return nil
}
return msg
}
func healthEnv(t *testing.T) (*env, *fakeDNS) {
t.Helper()
e := newEnv(t)
f := startFakeDNS(t)
e.role.cfg.DNSAddress = f.addr
e.run.fn = func(name string, args []string) ([]byte, error) {
return []byte("version: BIND 9.20.3 (Stable Release) <id:x>\nnumber of zones: 103\nserver is up and running\n"), nil
}
return e, f
}
func TestHealthOK(t *testing.T) {
e, f := healthEnv(t)
rev := e.revision(7, sampleZones)
f.set("office.test", 7)
f.set("2.0.192.in-addr.arpa", 7)
f.set("dyn.test", 1) // dynamic zones are not compared
h := e.role.Health(context.Background(), &rev)
if h.State != core.HealthOK {
t.Fatalf("health = %+v", h)
}
if len(h.Checks) != 3 || h.Checks[0].Message != "BIND 9.20.3 (Stable Release); server is up and running" {
t.Errorf("checks = %+v", h.Checks)
}
if got := e.run.Calls(); len(got) != 1 || got[0] != "rndc -k /etc/bind/rndc.key status" {
t.Errorf("calls = %v", got)
}
}
func TestHealthStates(t *testing.T) {
e, f := healthEnv(t)
rev := e.revision(7, sampleZones)
f.set("office.test", 7)
f.set("2.0.192.in-addr.arpa", 6)
if h := e.role.Health(context.Background(), &rev); h.State != core.HealthDegraded || !strings.Contains(h.Message, "1 of 2") {
t.Errorf("one stale zone: %+v", h)
}
f.set("office.test", 6)
if h := e.role.Health(context.Background(), &rev); h.State != core.HealthFailed {
t.Errorf("all zones stale: %+v", h)
}
f.set("office.test", 7)
f.set("2.0.192.in-addr.arpa", 7)
f.mu.Lock()
f.nonAuth["office.test"] = true
f.mu.Unlock()
h := e.role.Health(context.Background(), &rev)
if h.State != core.HealthDegraded {
t.Errorf("non-authoritative answer: %+v", h)
}
f.mu.Lock()
f.nonAuth["office.test"] = false
f.mu.Unlock()
e.run.fn = func(string, []string) ([]byte, error) {
return []byte("version: BIND 9.20.3 <id:x>\nreload/reconfig in progress\nserver is up and running\n"), nil
}
if h := e.role.Health(context.Background(), &rev); h.State != core.HealthDegraded || !strings.Contains(h.Message, "in progress") {
t.Errorf("reload in progress: %+v", h)
}
e.run.fn = func(string, []string) ([]byte, error) {
return []byte("rndc: connect failed: 127.0.0.1#953: connection refused"), errors.New("exit status 1")
}
if h := e.role.Health(context.Background(), &rev); h.State != core.HealthFailed || !strings.Contains(h.Checks[0].Message, "connection refused") {
t.Errorf("rndc failure: %+v", h)
}
}
func TestHealthWithoutRevision(t *testing.T) {
e, _ := healthEnv(t)
if h := e.role.Health(context.Background(), nil); h.State != core.HealthOK || len(h.Checks) != 1 {
t.Errorf("health = %+v", h)
}
}
func TestQuerySOAFallsBackToTCP(t *testing.T) {
f := startFakeDNS(t)
f.set("big.test", 42)
f.mu.Lock()
f.truncate["big.test"] = true
f.mu.Unlock()
serial, err := querySOA(context.Background(), defaultDial, f.addr, "big.test")
if err != nil || serial != 42 {
t.Fatalf("serial = %d, %v", serial, err)
}
f.mu.Lock()
tcpUsed := f.tcpUsed
f.mu.Unlock()
if tcpUsed != 1 {
t.Errorf("TCP used %d times", tcpUsed)
}
if _, err := querySOA(context.Background(), defaultDial, f.addr, "unknown.test"); err == nil || !strings.Contains(err.Error(), "Refused") {
t.Errorf("unknown zone: %v", err)
}
}
+174
View File
@@ -0,0 +1,174 @@
// SPDX-License-Identifier: Apache-2.0
package dns
import (
"context"
"crypto/rand"
"encoding/binary"
"errors"
"fmt"
"io"
"net"
"strings"
"time"
"golang.org/x/net/dns/dnsmessage"
)
// queryTimeout bounds one SOA query (UDP and, if truncated, TCP).
const queryTimeout = 3 * time.Second
type dialFunc func(ctx context.Context, network, address string) (net.Conn, error)
func defaultDial(ctx context.Context, network, address string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, network, address)
}
// querySOA asks server for the SOA record of zone and returns its serial.
// The answer must be authoritative, so that a zone BIND failed to load
// (and that might be answered from elsewhere) does not count. A truncated
// UDP answer is repeated over TCP.
func querySOA(ctx context.Context, dial dialFunc, server, zone string) (uint32, error) {
ctx, cancel := context.WithTimeout(ctx, queryTimeout)
defer cancel()
name, err := dnsmessage.NewName(strings.TrimSuffix(zone, ".") + ".")
if err != nil {
return 0, fmt.Errorf("invalid zone name %q: %w", zone, err)
}
var idb [2]byte
if _, err := rand.Read(idb[:]); err != nil {
return 0, err
}
id := binary.BigEndian.Uint16(idb[:])
b := dnsmessage.NewBuilder(nil, dnsmessage.Header{ID: id})
b.EnableCompression()
if err := b.StartQuestions(); err != nil {
return 0, err
}
q := dnsmessage.Question{Name: name, Type: dnsmessage.TypeSOA, Class: dnsmessage.ClassINET}
if err := b.Question(q); err != nil {
return 0, err
}
msg, err := b.Finish()
if err != nil {
return 0, err
}
resp, err := exchangeUDP(ctx, dial, server, msg, id)
if err != nil {
return 0, err
}
serial, truncated, err := parseSOA(resp, id, q)
if truncated {
if resp, err = exchangeTCP(ctx, dial, server, msg); err != nil {
return 0, err
}
serial, _, err = parseSOA(resp, id, q)
}
return serial, err
}
func exchangeUDP(ctx context.Context, dial dialFunc, server string, msg []byte, id uint16) ([]byte, error) {
conn, err := dial(ctx, "udp", server)
if err != nil {
return nil, err
}
defer func() { _ = conn.Close() }()
if dl, ok := ctx.Deadline(); ok {
_ = conn.SetDeadline(dl)
}
if _, err := conn.Write(msg); err != nil {
return nil, err
}
buf := make([]byte, 65535)
for {
n, err := conn.Read(buf)
if err != nil {
return nil, err
}
// Ignore stray datagrams with another ID.
if n >= 2 && binary.BigEndian.Uint16(buf[:2]) == id {
return buf[:n], nil
}
}
}
func exchangeTCP(ctx context.Context, dial dialFunc, server string, msg []byte) ([]byte, error) {
conn, err := dial(ctx, "tcp", server)
if err != nil {
return nil, err
}
defer func() { _ = conn.Close() }()
if dl, ok := ctx.Deadline(); ok {
_ = conn.SetDeadline(dl)
}
framed := make([]byte, 2+len(msg))
binary.BigEndian.PutUint16(framed, uint16(len(msg))) //nolint:gosec // a query is far below 64 KiB
copy(framed[2:], msg)
if _, err := conn.Write(framed); err != nil {
return nil, err
}
var l [2]byte
if _, err := io.ReadFull(conn, l[:]); err != nil {
return nil, err
}
resp := make([]byte, binary.BigEndian.Uint16(l[:]))
if _, err := io.ReadFull(conn, resp); err != nil {
return nil, err
}
return resp, nil
}
// parseSOA extracts the SOA serial for q from a response. truncated is
// true if the server set the TC bit (the caller retries over TCP).
func parseSOA(resp []byte, id uint16, q dnsmessage.Question) (serial uint32, truncated bool, err error) {
var p dnsmessage.Parser
h, err := p.Start(resp)
if err != nil {
return 0, false, fmt.Errorf("invalid DNS response: %w", err)
}
switch {
case h.ID != id || !h.Response:
return 0, false, errors.New("unexpected DNS response")
case h.Truncated:
return 0, true, nil
case h.RCode != dnsmessage.RCodeSuccess:
return 0, false, fmt.Errorf("server answered %s", rcodeString(h.RCode))
case !h.Authoritative:
return 0, false, errors.New("answer is not authoritative (zone not loaded?)")
}
qs, err := p.AllQuestions()
if err != nil {
return 0, false, fmt.Errorf("invalid DNS response: %w", err)
}
if len(qs) != 1 || qs[0].Type != q.Type || !strings.EqualFold(qs[0].Name.String(), q.Name.String()) {
return 0, false, errors.New("DNS response is for another question")
}
for {
ah, err := p.AnswerHeader()
if errors.Is(err, dnsmessage.ErrSectionDone) {
return 0, false, errors.New("no SOA record in the answer")
}
if err != nil {
return 0, false, fmt.Errorf("invalid DNS response: %w", err)
}
if ah.Type != dnsmessage.TypeSOA || !strings.EqualFold(ah.Name.String(), q.Name.String()) {
if err := p.SkipAnswer(); err != nil {
return 0, false, fmt.Errorf("invalid DNS response: %w", err)
}
continue
}
soa, err := p.SOAResource()
if err != nil {
return 0, false, fmt.Errorf("invalid SOA record: %w", err)
}
return soa.Serial, false, nil
}
}
func rcodeString(rc dnsmessage.RCode) string {
s := rc.String()
return strings.TrimPrefix(s, "RCode")
}
+1 -1
View File
@@ -15,6 +15,7 @@ require (
github.com/prometheus/client_golang v1.25.0
go.yaml.in/yaml/v3 v3.0.5
golang.org/x/crypto v0.58.0
golang.org/x/net v0.61.0
golang.org/x/oauth2 v0.37.0
google.golang.org/grpc v1.84.0
google.golang.org/protobuf v1.36.12
@@ -40,7 +41,6 @@ require (
github.com/sethvargo/go-retry v0.5.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/mod v0.42.0 // indirect
golang.org/x/net v0.61.0 // indirect
golang.org/x/sync v0.24.0 // indirect
golang.org/x/sys v0.49.0 // indirect
golang.org/x/telemetry v0.0.0-20261010193011-f71dc92da45e // indirect