Files
dns-updater/internal/updater/updater_test.go
T
unkinben 02e3e0315d
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Initial implementation: dns-updater daemon
RFC2136 dynamic-DNS updater. Watches a records file (inotify) and new
interface addresses and pushes TSIG-signed updates to BIND per zone, sending
only the delta. Native miekg/dns (structured per-zone RCODEs), local status
API + facter fact, systemd unit, nfpm RPM, Woodpecker CI.

Replaces the puppet dns-update shell script; keeps the same records-file and
TSIG-key contract.
2026-07-17 23:24:49 +10:00

179 lines
5.3 KiB
Go

package updater
import (
"net"
"sync"
"testing"
"time"
"github.com/miekg/dns"
"git.unkin.net/unkin/dns-updater/internal/records"
"git.unkin.net/unkin/dns-updater/internal/tsig"
)
// fakeServer is an in-process TSIG-verifying DNS server that records the update
// messages it receives and replies with a per-zone rcode.
type fakeServer struct {
addr string
srv *dns.Server
key *tsig.Key
mu sync.Mutex
received []*dns.Msg
rcodes map[string]int // zone (fqdn) -> rcode
}
func newFakeServer(t *testing.T, key *tsig.Key) *fakeServer {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
fs := &fakeServer{addr: l.Addr().String(), key: key, rcodes: map[string]int{}}
fs.srv = &dns.Server{
Listener: l,
Net: "tcp",
TsigSecret: key.SecretMap(),
// The default accept func rejects the UPDATE opcode with NOTIMP; a real
// BIND update server accepts it, so opt in here.
MsgAcceptFunc: func(dns.Header) dns.MsgAcceptAction { return dns.MsgAccept },
Handler: dns.HandlerFunc(func(w dns.ResponseWriter, r *dns.Msg) {
fs.mu.Lock()
fs.received = append(fs.received, r)
rc := dns.RcodeSuccess
if len(r.Question) > 0 {
if v, ok := fs.rcodes[r.Question[0].Name]; ok {
rc = v
}
}
fs.mu.Unlock()
m := new(dns.Msg)
m.SetReply(r)
m.Rcode = rc
if r.IsTsig() != nil {
m.SetTsig(key.Name, key.Algorithm, 300, time.Now().Unix())
}
_ = w.WriteMsg(m)
}),
}
go func() { _ = fs.srv.ActivateAndServe() }()
// give the server a moment to start accepting
time.Sleep(50 * time.Millisecond)
t.Cleanup(func() { _ = fs.srv.Shutdown() })
return fs
}
func testKey() *tsig.Key {
return &tsig.Key{Name: "test-key.", Algorithm: dns.HmacSHA256, Secret: "dGVzdHNlY3JldA=="}
}
func mustSet(t *testing.T, recs ...records.Record) *records.Set {
t.Helper()
s := records.NewSet()
for _, r := range recs {
if err := s.Add(r); err != nil {
t.Fatalf("add %v: %v", r, err)
}
}
return s
}
func TestReconcileAddsAndSigns(t *testing.T) {
key := testKey()
fs := newFakeServer(t, key)
app := New(fs.addr, key, 3*time.Second)
desired := mustSet(t,
records.Record{Zone: "main.unkin.net", Name: "host1", Type: "A", TTL: 300, Value: "198.18.24.18"},
records.Record{Zone: "main.unkin.net", Name: "au-syd1-pve.main.unkin.net.", Type: "CNAME", TTL: 300, Value: "host1.main.unkin.net."},
records.Record{Zone: "24.18.198.in-addr.arpa", Name: "18", Type: "PTR", TTL: 300, Value: "host1.main.unkin.net."},
)
res := app.Reconcile(desired, records.NewSet())
if !res.OK() {
t.Fatalf("reconcile not OK: %s", res.String())
}
// two zones, both applied
if len(res.Zones) != 2 {
t.Fatalf("zones=%d want 2 (%s)", len(res.Zones), res.String())
}
fs.mu.Lock()
got := len(fs.received)
fs.mu.Unlock()
if got != 2 {
t.Fatalf("server received %d messages want 2", got)
}
}
func TestReconcileDeletesRemoved(t *testing.T) {
key := testKey()
fs := newFakeServer(t, key)
app := New(fs.addr, key, 3*time.Second)
applied := mustSet(t,
records.Record{Zone: "main.unkin.net", Name: "host1", Type: "A", TTL: 300, Value: "198.18.24.18"},
records.Record{Zone: "main.unkin.net", Name: "gone", Type: "A", TTL: 300, Value: "198.18.24.99"},
)
desired := mustSet(t,
records.Record{Zone: "main.unkin.net", Name: "host1", Type: "A", TTL: 300, Value: "198.18.24.18"},
)
res := app.Reconcile(desired, applied)
if !res.OK() {
t.Fatalf("not OK: %s", res.String())
}
if len(res.Zones) != 1 || res.Zones[0].Deletes != 1 {
t.Fatalf("want 1 delete, got %s", res.String())
}
}
func TestReconcileZoneRcodeFailure(t *testing.T) {
key := testKey()
fs := newFakeServer(t, key)
fs.rcodes["ceph.unkin.net."] = dns.RcodeNotZone // simulate NOTZONE
app := New(fs.addr, key, 3*time.Second)
desired := mustSet(t,
records.Record{Zone: "main.unkin.net", Name: "host1", Type: "A", TTL: 300, Value: "198.18.24.18"},
records.Record{Zone: "ceph.unkin.net", Name: "dashboard.ceph.unkin.net.", Type: "CNAME", TTL: 300, Value: "host1.main.unkin.net."},
)
res := app.Reconcile(desired, records.NewSet())
if res.OK() {
t.Fatal("expected failure due to NOTZONE")
}
// The good zone still applied; only ceph failed — the "one bad zone must not
// abort the others" property that the shell version lacked.
var mainOK, cephFailed bool
for _, z := range res.Zones {
if z.Zone == "main.unkin.net." && z.OK() {
mainOK = true
}
if z.Zone == "ceph.unkin.net." && !z.OK() {
cephFailed = true
}
}
if !mainOK || !cephFailed {
t.Fatalf("mainOK=%v cephFailed=%v (%s)", mainOK, cephFailed, res.String())
}
// Applied state keeps the good zone, drops the failed one for retry.
appliedNow := res.Applied(desired, records.NewSet())
if appliedNow.Len() != 1 {
t.Errorf("applied=%d want 1 (only main)", appliedNow.Len())
}
}
func TestReconcileNoChanges(t *testing.T) {
key := testKey()
fs := newFakeServer(t, key)
app := New(fs.addr, key, 3*time.Second)
same := mustSet(t, records.Record{Zone: "main.unkin.net", Name: "host1", Type: "A", TTL: 300, Value: "198.18.24.18"})
res := app.Reconcile(same, same)
if res.String() != "no changes" {
t.Fatalf("want no changes, got %s", res.String())
}
fs.mu.Lock()
got := len(fs.received)
fs.mu.Unlock()
if got != 0 {
t.Errorf("server got %d messages, want 0 (nothing to do)", got)
}
}