Files
DDI/agent/role/dns/health_test.go
T
sir_melonchenandClaude Opus 5.5 70643f8146 feat(agent/dns): keys file, secondary zones and their health
The DNS role installs ddi-suite-keys.conf (a keys file without keys for
revisions from before M5, so that named.conf can always include it),
includes it before ddi-suite.conf in the validation named.conf, accepts
secondary zone files in <zone_dir>/secondary/ and creates that
directory with the mode and owner of zone_dir without ever touching its
content. The health check queries secondary zones too; a zone still
waiting for its first transfer is reported but does not fail the
deployment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:30:40 +02:00

291 lines
8.1 KiB
Go

// 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 TestHealthSecondaryZones(t *testing.T) {
e, f := healthEnv(t)
rev := e.revisionWithKeys(7, clusterZones, testKeys)
f.set("office.test", 7)
f.set("remote.test", 3) // any serial: the primary decides
// 2.0.192.in-addr.arpa has not been transferred yet: reported, but the
// state stays OK, so the revision is not rolled back.
h := e.role.Health(context.Background(), &rev)
if h.State != core.HealthOK || !strings.Contains(h.Message, "1 of 2 secondary zones loaded") {
t.Fatalf("health = %+v", h)
}
checks := map[string]core.HealthCheck{}
for _, c := range h.Checks {
checks[c.Name] = c
}
if c := checks["secondary zone remote.test"]; c.State != core.HealthOK || c.Message != "serial 3" {
t.Errorf("loaded secondary: %+v", c)
}
if c := checks["secondary zone 2.0.192.in-addr.arpa"]; c.State != core.HealthDegraded || !strings.Contains(c.Message, "waiting for a zone transfer") {
t.Errorf("missing secondary: %+v", c)
}
if c := checks["zone office.test"]; c.State != core.HealthOK {
t.Errorf("primary zone: %+v", c)
}
if len(h.Checks) != 4 {
t.Errorf("checks = %+v", h.Checks)
}
// A member that serves only secondary zones is healthy while waiting.
rev = e.revisionWithKeys(8, clusterZones[1:], testKeys)
if h := e.role.Health(context.Background(), &rev); h.State != core.HealthOK || !strings.Contains(h.Message, "1 of 2") {
t.Errorf("secondary only: %+v", h)
}
// The primary zone still decides.
f.set("office.test", 6)
rev = e.revisionWithKeys(7, clusterZones, testKeys)
if h := e.role.Health(context.Background(), &rev); h.State != core.HealthFailed || strings.Contains(h.Message, "secondary") {
t.Errorf("stale primary zone: %+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)
}
}