Files
coredns/test/dynupdate_persistence_test.go
houyuwushang 5aa4dc2941 plugin/dynupdate: add durable authenticated RFC 2136 updates (#8520)
* plugin/dynupdate: add authenticated RFC 2136 updates

Signed-off-by: houyuwushang <liuluoqianqiu@outlook.com>

* plugin/dynupdate: fix README test fixtures

Signed-off-by: houyuwushang <liuluoqianqiu@outlook.com>

* test: format README fixture map

Signed-off-by: houyuwushang <liuluoqianqiu@outlook.com>

* plugin/dynupdate: persist updates and bound writable zones

Signed-off-by: houyuwushang <liuluoqianqiu@outlook.com>

* plugin/dynupdate: preserve middleware and fix interoperability fixtures

Signed-off-by: houyuwushang <liuluoqianqiu@outlook.com>

* test(dynupdate): validate Kea lifecycle and bounded zone costs

Signed-off-by: houyuwushang <liuluoqianqiu@outlook.com>

* plugin/dynupdate: reject duplicate directives and harden client fixtures

Signed-off-by: houyuwushang <liuluoqianqiu@outlook.com>

* plugin/dynupdate: fix update routing and startup validation

Signed-off-by: houyuwushang <liuluoqianqiu@outlook.com>

---------

Signed-off-by: houyuwushang <liuluoqianqiu@outlook.com>
2026-09-22 00:53:26 -07:00

380 lines
12 KiB
Go

package test
import (
"context"
"crypto/tls"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/coredns/caddy"
"github.com/miekg/dns"
)
func persistentDynUpdateConfig(t *testing.T, network string) (corefile, seed string) {
t.Helper()
dir := t.TempDir()
seed = filepath.Join(dir, "example.org.zone")
if err := os.WriteFile(seed, []byte(dynUpdateZone), 0600); err != nil {
t.Fatal(err)
}
server, tlsLine := "example.org:0", ""
if network == "tcp-tls" {
server = "tls://" + server
tlsLine = "tls ../plugin/tls/test_cert.pem ../plugin/tls/test_key.pem"
}
return fmt.Sprintf(`%s {
bind 127.0.0.1
%s
header {
response set ra
}
tsig {
secret %s %s
require_opcode UPDATE
}
cache
dynupdate {
file "%s"
database "%s"
allow %s * *
}
}`, server, tlsLine, dynUpdateKey, dynUpdateSecret, filepath.ToSlash(seed), filepath.ToSlash(filepath.Join(dir, "updates.db")), dynUpdateKey), seed
}
func stopDynUpdateServer(t *testing.T, s *caddy.Instance) {
t.Helper()
if err := s.Stop(); err != nil {
t.Errorf("stopping server: %v", err)
}
for _, err := range s.ShutdownCallbacks() {
t.Errorf("shutdown callback: %v", err)
}
}
func exchangeDynUpdate(t *testing.T, client *dns.Client, addr string, m *dns.Msg, code int) *dns.Msg {
t.Helper()
if m.Opcode == dns.OpcodeUpdate {
m.SetTsig(dynUpdateKey, dns.HmacSHA256, 300, time.Now().Unix())
}
r, _, err := client.Exchange(m, addr)
if err != nil || r == nil || r.Rcode != code {
t.Fatalf("exchange: response=%v err=%v want=%s", r, err, dns.RcodeToString[code])
}
return r
}
func TestDynUpdatePersistentWire(t *testing.T) {
for _, network := range []string{"udp", "tcp", "tcp-tls"} {
t.Run(network, func(t *testing.T) {
corefile, seed := persistentDynUpdateConfig(t, network)
s, udp, tcp, err := CoreDNSServerAndPorts(corefile)
if err != nil {
t.Fatal(err)
}
defer func() {
if s != nil {
stopDynUpdateServer(t, s)
}
}()
addr := tcp
if network == "udp" {
addr = udp
}
client := &dns.Client{
Net: network, Timeout: 5 * time.Second,
TsigSecret: map[string]string{dynUpdateKey: dynUpdateSecret},
TLSConfig: &tls.Config{InsecureSkipVerify: true}, // test certificate
}
query := new(dns.Msg)
query.SetQuestion("new.example.org.", dns.TypeTXT)
exchangeDynUpdate(t, client, addr, query, dns.RcodeNameError)
query.SetQuestion("www.example.org.", dns.TypeA)
exchangeDynUpdate(t, client, addr, query, dns.RcodeSuccess)
types := []string{
`www.example.org. 120 IN A 192.0.2.100`,
`www.example.org. 120 IN AAAA 2001:db8::100`,
`new.example.org. 120 IN TXT "durable"`,
`ptr.example.org. 120 IN PTR www.example.org.`,
`_service._tcp.example.org. 120 IN SRV 0 0 443 www.example.org.`,
`www.example.org. 120 IN DHCID AAEAAQ==`,
`example.org. 120 IN CAA 0 issue "ca.example"`,
}
records := make([]dns.RR, 0, len(types))
for _, text := range types {
rr, err := dns.NewRR(text)
if err != nil {
t.Fatal(err)
}
records = append(records, rr)
}
update := new(dns.Msg).SetUpdate("example.org.")
update.RemoveRRset([]dns.RR{records[0]})
update.Insert(records)
exchangeDynUpdate(t, client, addr, update, dns.RcodeSuccess)
for round := range 2 {
for _, rr := range records {
query.SetQuestion(rr.Header().Name, rr.Header().Rrtype)
r := exchangeDynUpdate(t, client, addr, query, dns.RcodeSuccess)
if len(r.Answer) != 1 || r.Answer[0].String() != rr.String() {
t.Fatalf("round %d: stale or lost record: %v", round, r)
}
if !r.RecursionAvailable {
t.Fatal("dynamic answer bypassed the configured header plugin")
}
}
query.SetQuestion("example.org.", dns.TypeSOA)
soa := exchangeDynUpdate(t, client, addr, query, dns.RcodeSuccess)
if len(soa.Answer) != 1 || soa.Answer[0].(*dns.SOA).Serial != 11 {
t.Fatalf("wrong SOA serial: %v", soa)
}
if round == 0 {
stopDynUpdateServer(t, s)
s = nil
if err := os.Remove(seed); err != nil {
t.Fatal(err)
}
s, udp, tcp, err = CoreDNSServerAndPorts(corefile)
if err != nil {
t.Fatal(err)
}
addr = tcp
if network == "udp" {
addr = udp
}
}
}
// Delete after recovery and confirm the positive response cannot stick.
update = new(dns.Msg).SetUpdate("example.org.")
update.RemoveRRset([]dns.RR{records[2]})
exchangeDynUpdate(t, client, addr, update, dns.RcodeSuccess)
query.SetQuestion("new.example.org.", dns.TypeTXT)
exchangeDynUpdate(t, client, addr, query, dns.RcodeNameError)
})
}
}
func TestDynUpdateFailedStartupDoesNotCreateDatabase(t *testing.T) {
for _, failure := range []string{"directive", "listener"} {
t.Run(failure, func(t *testing.T) {
corefile, seed := persistentDynUpdateConfig(t, "udp")
bad := strings.Replace(corefile, "\n\t\tcache", "\n\t\tfile\n\t\tcache", 1)
if failure == "listener" {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
bad = strings.Replace(corefile, "example.org:0", fmt.Sprintf("example.org:%d", ln.Addr().(*net.TCPAddr).Port), 1)
}
if s, err := CoreDNSServer(bad); err == nil {
stopDynUpdateServer(t, s)
t.Fatal("invalid startup unexpectedly succeeded")
}
database := filepath.Join(filepath.Dir(seed), "updates.db")
if _, err := os.Stat(database); !os.IsNotExist(err) {
t.Errorf("failed startup created a database: %v", err)
}
updatedSeed := strings.Replace(dynUpdateZone, "10 60 60 60 60", "20 60 60 60 60", 1)
if err := os.WriteFile(seed, []byte(updatedSeed), 0600); err != nil {
t.Fatal(err)
}
s, udp, _, err := CoreDNSServerAndPorts(corefile)
if err != nil {
t.Fatalf("retrying startup: %v", err)
}
defer stopDynUpdateServer(t, s)
query := new(dns.Msg).SetQuestion("example.org.", dns.TypeSOA)
r := exchangeDynUpdate(t, &dns.Client{Net: "udp"}, udp, query, dns.RcodeSuccess)
if len(r.Answer) != 1 || r.Answer[0].(*dns.SOA).Serial != 20 {
t.Fatalf("retry reused data from failed startup: %v", r)
}
})
}
}
func TestDynUpdateRejectsDuplicateDirective(t *testing.T) {
corefile, seed := persistentDynUpdateConfig(t, "udp")
duplicate := fmt.Sprintf(`
dynupdate example.org. {
file "%s"
allow %s restricted.example.org. TXT
}
`, filepath.ToSlash(seed), dynUpdateKey)
corefile = strings.Replace(corefile, "\n\t\tcache", duplicate+"\n\t\tcache", 1)
if s, err := CoreDNSServer(corefile); err == nil {
stopDynUpdateServer(t, s)
t.Fatal("duplicate dynupdate directive was silently accepted")
} else if !strings.Contains(err.Error(), "can only be used once") {
t.Fatalf("unexpected startup failure: %v", err)
}
}
func TestDynUpdateCorefileReload(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Caddy listener file-descriptor inheritance is unavailable on Windows")
}
corefile, _ := persistentDynUpdateConfig(t, "udp")
s, udp, _, err := CoreDNSServerAndPorts(corefile)
if err != nil {
t.Fatal(err)
}
defer func() { stopDynUpdateServer(t, s) }()
client := &dns.Client{Net: "udp", TsigSecret: map[string]string{dynUpdateKey: dynUpdateSecret}}
rr, err := dns.NewRR(`reload.example.org. 60 IN TXT "survives"`)
if err != nil {
t.Fatal(err)
}
update := new(dns.Msg).SetUpdate("example.org.")
update.Insert([]dns.RR{rr})
exchangeDynUpdate(t, client, udp, update, dns.RcodeSuccess)
// file is initialized after dynupdate. An invalid directive must leave the old server
// and database usable, without retaining a ref for the abandoned config.
bad := strings.Replace(corefile, "\n\t\tcache", "\n\t\tfile\n\t\tcache", 1)
if next, err := s.Restart(NewInput(bad)); err == nil {
s = next
t.Fatal("invalid reload unexpectedly succeeded")
}
query := new(dns.Msg)
query.SetQuestion(rr.Header().Name, dns.TypeTXT)
exchangeDynUpdate(t, client, udp, query, dns.RcodeSuccess)
next, err := s.Restart(NewInput(corefile))
if err != nil {
t.Fatal(err)
}
s = next
udp, _ = CoreDNSServerPorts(s, 0)
r := exchangeDynUpdate(t, client, udp, query, dns.RcodeSuccess)
if len(r.Answer) != 1 || r.Answer[0].String() != rr.String() {
t.Fatalf("reload lost acknowledged update: %v", r)
}
update = new(dns.Msg).SetUpdate("example.org.")
update.RemoveRRset([]dns.RR{rr})
exchangeDynUpdate(t, client, udp, update, dns.RcodeSuccess)
exchangeDynUpdate(t, client, udp, query, dns.RcodeNameError)
}
func TestDynUpdateNsupdate(t *testing.T) {
nsupdate, err := exec.LookPath("nsupdate")
if err != nil {
t.Skip("BIND nsupdate is not installed")
}
corefile, _ := persistentDynUpdateConfig(t, "udp")
s, udp, tcpAddr, err := CoreDNSServerAndPorts(corefile)
if err != nil {
t.Fatal(err)
}
defer stopDynUpdateServer(t, s)
for _, tcp := range []bool{false, true} {
addr := udp
if tcp {
addr = tcpAddr
}
host, port, err := net.SplitHostPort(addr)
if err != nil {
t.Fatal(err)
}
args := []string{"-y", "hmac-sha256:" + dynUpdateKey + ":" + dynUpdateSecret}
if tcp {
args = append(args, "-v")
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, nsupdate, args...)
cmd.Stdin = strings.NewReader(fmt.Sprintf(`server %s %s
zone example.org.
prereq nxdomain nsupdate.example.org.
update add nsupdate.example.org. 60 TXT "interop"
send
prereq yxrrset nsupdate.example.org. TXT "interop"
update delete nsupdate.example.org. TXT
send
`, host, port))
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("nsupdate tcp=%v: %v\n%s", tcp, err, out)
}
}
}
func TestDynUpdateCoalescesNotify(t *testing.T) {
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
blocked := make(chan struct{})
release := make(chan struct{})
var unblock sync.Once
defer unblock.Do(func() { close(release) })
notifications := make(chan struct{}, 2)
var calls atomic.Int32
secondary := &dns.Server{PacketConn: pc, Handler: dns.HandlerFunc(func(w dns.ResponseWriter, r *dns.Msg) {
if r.Opcode != dns.OpcodeNotify || len(r.Question) != 1 || r.Question[0].Name != "example.org." {
t.Errorf("unexpected notification: %v", r)
}
if calls.Add(1) == 1 {
close(blocked)
<-release
}
if err := w.WriteMsg(new(dns.Msg).SetReply(r)); err != nil {
t.Errorf("replying to NOTIFY: %v", err)
}
select {
case notifications <- struct{}{}:
default:
}
})}
stopped := make(chan error, 1)
go func() { stopped <- secondary.ActivateAndServe() }()
defer func() {
unblock.Do(func() { close(release) })
secondary.Shutdown()
if err := <-stopped; err != nil {
t.Errorf("notification server: %v", err)
}
}()
corefile, _ := persistentDynUpdateConfig(t, "udp")
corefile = strings.Replace(corefile, "\n\t\tcache", fmt.Sprintf("\n\t\ttransfer {\n\t\t\tto %s\n\t\t}\n\t\tcache", pc.LocalAddr()), 1)
s, udp, _, err := CoreDNSServerAndPorts(corefile)
if err != nil {
t.Fatal(err)
}
defer stopDynUpdateServer(t, s)
client := &dns.Client{Net: "udp", TsigSecret: map[string]string{dynUpdateKey: dynUpdateSecret}}
for i := range 4 {
rr, err := dns.NewRR(fmt.Sprintf("notify-%d.example.org. 60 IN A 192.0.2.%d", i, i+1))
if err != nil {
t.Fatal(err)
}
update := new(dns.Msg).SetUpdate("example.org.")
update.Insert([]dns.RR{rr})
exchangeDynUpdate(t, client, udp, update, dns.RcodeSuccess)
if i == 0 {
select {
case <-blocked:
case <-time.After(5 * time.Second):
t.Fatal("committed update did not trigger NOTIFY")
}
}
}
if got := calls.Load(); got != 1 {
t.Fatalf("concurrent NOTIFY operations: %d", got)
}
unblock.Do(func() { close(release) })
for range 2 {
select {
case <-notifications:
case <-time.After(5 * time.Second):
t.Fatal("pending notification was lost")
}
}
}