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:
co-authored by
Claude Opus 5.5
parent
b5e57c3cb7
commit
d61b738a8c
@@ -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
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user