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>
291 lines
8.1 KiB
Go
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)
|
|
}
|
|
}
|