Files
coredns/test/dynupdate_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

358 lines
10 KiB
Go

package test
import (
"context"
"fmt"
"net"
"os/exec"
"strings"
"testing"
"time"
plugintest "github.com/coredns/coredns/plugin/test"
"github.com/miekg/dns"
)
const (
dynUpdateKey = "update-key.example.org."
dynUpdateSecret = "i9M+00yrECfVZG2qCjr4mPpaGim/Bq+IWMiNrLjUO4Y="
)
const dynUpdateZone = `$ORIGIN example.org.
@ 60 IN SOA ns.example.org. hostmaster.example.org. 10 60 60 60 60
@ 60 IN NS ns.example.org.
ns 60 IN A 192.0.2.53
www 60 IN A 192.0.2.1
`
func TestDynUpdateUDPAndTCP(t *testing.T) {
zoneFile, remove, err := plugintest.TempFile(".", dynUpdateZone)
if err != nil {
t.Fatalf("creating zone file: %v", err)
}
defer remove()
corefile := fmt.Sprintf(`example.org:0 {
tsig {
secret %s %s
require_opcode UPDATE
}
dynupdate {
file %s
allow %s * TXT
}
}`, dynUpdateKey, dynUpdateSecret, zoneFile, dynUpdateKey)
server, udp, tcp, err := CoreDNSServerAndPorts(corefile)
if err != nil {
t.Fatalf("starting CoreDNS: %v", err)
}
defer server.Stop()
for _, tc := range []struct {
name string
net string
addr string
}{
{name: "udp", net: "udp", addr: udp},
{name: "tcp", net: "tcp", addr: tcp},
} {
t.Run(tc.name, func(t *testing.T) {
owner := tc.name + ".example.org."
rr, err := dns.NewRR(owner + ` 60 IN TXT "dynamic"`)
if err != nil {
t.Fatalf("creating update RR: %v", err)
}
msg := new(dns.Msg).SetUpdate("example.org.")
msg.Insert([]dns.RR{rr})
msg.SetTsig(dynUpdateKey, dns.HmacSHA256, 300, time.Now().Unix())
client := &dns.Client{Net: tc.net, TsigSecret: map[string]string{dynUpdateKey: dynUpdateSecret}}
resp, _, err := client.Exchange(msg, tc.addr)
if err != nil {
t.Fatalf("sending %s UPDATE: %v", tc.name, err)
}
if resp.Rcode != dns.RcodeSuccess {
t.Fatalf("UPDATE rcode = %s, want NOERROR", dns.RcodeToString[resp.Rcode])
}
if resp.Opcode != dns.OpcodeUpdate {
t.Fatalf("response opcode = %d, want UPDATE", resp.Opcode)
}
query := new(dns.Msg)
query.SetQuestion(owner, dns.TypeTXT)
answer, _, err := (&dns.Client{Net: "udp"}).Exchange(query, udp)
if err != nil {
t.Fatalf("querying updated record: %v", err)
}
if answer.Rcode != dns.RcodeSuccess || len(answer.Answer) != 1 {
t.Fatalf("updated query response = rcode %s, %d answers", dns.RcodeToString[answer.Rcode], len(answer.Answer))
}
if got := answer.Answer[0].String(); got != owner+"\t60\tIN\tTXT\t\"dynamic\"" {
t.Fatalf("updated record = %q", got)
}
})
}
}
func TestDynUpdateRejectsUnsignedRequest(t *testing.T) {
zoneFile, remove, err := plugintest.TempFile(".", dynUpdateZone)
if err != nil {
t.Fatalf("creating zone file: %v", err)
}
defer remove()
corefile := fmt.Sprintf(`example.org:0 {
tsig {
secret %s %s
require_opcode UPDATE
}
dynupdate {
file %s
allow %s * TXT
}
}`, dynUpdateKey, dynUpdateSecret, zoneFile, dynUpdateKey)
server, udp, _, err := CoreDNSServerAndPorts(corefile)
if err != nil {
t.Fatalf("starting CoreDNS: %v", err)
}
defer server.Stop()
msg := new(dns.Msg).SetUpdate("example.org.")
rr, err := dns.NewRR("unsigned.example.org. 60 IN TXT \"denied\"")
if err != nil {
t.Fatalf("creating update RR: %v", err)
}
msg.Insert([]dns.RR{rr})
resp, err := dns.Exchange(msg, udp)
if err != nil {
t.Fatalf("sending unsigned UPDATE: %v", err)
}
if resp.Rcode != dns.RcodeRefused {
t.Fatalf("unsigned UPDATE rcode = %s, want REFUSED", dns.RcodeToString[resp.Rcode])
}
}
func TestDynUpdateRejectsOtherZones(t *testing.T) {
seed, removeSeed, err := plugintest.TempFile(".", dynUpdateZone)
if err != nil {
t.Fatal(err)
}
defer removeSeed()
other, removeOther, err := plugintest.TempFile(".", strings.ReplaceAll(dynUpdateZone, "example.org.", "other.example."))
if err != nil {
t.Fatal(err)
}
defer removeOther()
corefile := fmt.Sprintf(`.:0 {
bind 127.0.0.1
tsig {
secret %s %s
require_opcode UPDATE
}
dynupdate example.org. {
file %s
allow %s * TXT
}
file %s other.example.
}`, dynUpdateKey, dynUpdateSecret, seed, dynUpdateKey, other)
s, udp, tcp, err := CoreDNSServerAndPorts(corefile)
if err != nil {
t.Fatal(err)
}
defer stopDynUpdateServer(t, s)
for _, network := range []string{"udp", "tcp"} {
t.Run(network, func(t *testing.T) {
addr := udp
if network == "tcp" {
addr = tcp
}
client := &dns.Client{Net: network, TsigSecret: map[string]string{dynUpdateKey: dynUpdateSecret}}
query := new(dns.Msg).SetQuestion("other.example.", dns.TypeSOA)
r := exchangeDynUpdate(t, client, addr, query, dns.RcodeSuccess)
if len(r.Answer) != 1 {
t.Fatalf("ordinary query did not reach file: %v", r)
}
rr, err := dns.NewRR(`new.other.example. 60 IN TXT "must not succeed"`)
if err != nil {
t.Fatal(err)
}
update := new(dns.Msg).SetUpdate("other.example.")
update.Insert([]dns.RR{rr})
update.SetTsig(dynUpdateKey, dns.HmacSHA256, 300, time.Now().Unix())
r, _, err = client.Exchange(update, addr)
// miekg/dns returns ErrAuth for every NOTAUTH before verifying TSIG.
// The nsupdate subtest below also checks the signed response with BIND.
if (err != nil && err != dns.ErrAuth) || r == nil || r.Rcode != dns.RcodeNotAuth {
t.Fatalf("wrong-zone UPDATE: response=%v err=%v, want NOTAUTH", r, err)
}
if tsig := r.IsTsig(); tsig == nil || tsig.Error != dns.RcodeSuccess || tsig.MAC == "" {
t.Fatalf("wrong-zone rejection lost TSIG: %v", r)
}
query.SetQuestion(rr.Header().Name, dns.TypeTXT)
exchangeDynUpdate(t, client, addr, query, dns.RcodeNameError)
t.Run("nsupdate", func(t *testing.T) {
nsupdate, err := exec.LookPath("nsupdate")
if err != nil {
t.Skip("BIND nsupdate is not installed")
}
host, port, err := net.SplitHostPort(addr)
if err != nil {
t.Fatal(err)
}
args := []string{"-y", "hmac-sha256:" + dynUpdateKey + ":" + dynUpdateSecret}
if network == "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\nzone other.example.\nupdate add %s\nsend\n", host, port, rr))
out, err := cmd.CombinedOutput()
if err == nil || !strings.Contains(string(out), "update failed: NOTAUTH") || strings.Contains(strings.ToLower(string(out)), "tsig") {
t.Fatalf("nsupdate: err=%v output=%s, want NOTAUTH without a TSIG error", err, out)
}
})
})
}
}
func TestDynUpdateIsNotServedFromCache(t *testing.T) {
zoneFile, remove, err := plugintest.TempFile(".", dynUpdateZone)
if err != nil {
t.Fatalf("creating zone file: %v", err)
}
defer remove()
corefile := fmt.Sprintf(`example.org:0 {
tsig {
secret %s %s
require_opcode UPDATE
}
cache
dynupdate {
file %s
allow %s * TXT
}
}`, dynUpdateKey, dynUpdateSecret, zoneFile, dynUpdateKey)
server, udp, _, err := CoreDNSServerAndPorts(corefile)
if err != nil {
t.Fatalf("starting CoreDNS: %v", err)
}
defer server.Stop()
owner := "example.org."
query := new(dns.Msg)
query.SetQuestion(owner, dns.TypeSOA)
if resp, _, err := (&dns.Client{Net: "udp"}).Exchange(query, udp); err != nil {
t.Fatalf("priming SOA cache: %v", err)
} else if resp.Rcode != dns.RcodeSuccess || len(resp.Answer) != 1 {
t.Fatalf("priming query response = %s with %d answers, want SOA", dns.RcodeToString[resp.Rcode], len(resp.Answer))
}
rr, err := dns.NewRR("cached.example.org. 60 IN TXT \"dynamic\"")
if err != nil {
t.Fatalf("creating update RR: %v", err)
}
update := new(dns.Msg).SetUpdate("example.org.")
update.Insert([]dns.RR{rr})
update.SetTsig(dynUpdateKey, dns.HmacSHA256, 300, time.Now().Unix())
client := &dns.Client{Net: "udp", TsigSecret: map[string]string{dynUpdateKey: dynUpdateSecret}}
resp, _, err := client.Exchange(update, udp)
if err != nil {
t.Fatalf("sending UPDATE after cached query: %v", err)
}
if resp.Rcode != dns.RcodeSuccess {
t.Fatalf("UPDATE rcode after cached query = %s, want NOERROR", dns.RcodeToString[resp.Rcode])
}
// Use a distinct cache key to verify the committed record without being
// masked by the SOA entry deliberately primed above.
query.SetQuestion("cached.example.org.", dns.TypeTXT)
query.CheckingDisabled = true
resp, _, err = (&dns.Client{Net: "udp"}).Exchange(query, udp)
if err != nil {
t.Fatalf("querying updated record: %v", err)
}
if resp.Rcode != dns.RcodeSuccess || len(resp.Answer) != 1 || resp.Answer[0].String() != "cached.example.org.\t60\tIN\tTXT\t\"dynamic\"" {
t.Fatalf("updated response = %#v, want one TXT answer", resp)
}
}
func TestDynUpdateAXFRIncludesUpdatedRecord(t *testing.T) {
zoneFile, remove, err := plugintest.TempFile(".", dynUpdateZone)
if err != nil {
t.Fatalf("creating zone file: %v", err)
}
defer remove()
corefile := fmt.Sprintf(`example.org:0 {
tsig {
secret %s %s
require_opcode UPDATE
}
transfer {
to *
}
dynupdate {
file %s
allow %s * TXT
}
}`, dynUpdateKey, dynUpdateSecret, zoneFile, dynUpdateKey)
server, _, tcp, err := CoreDNSServerAndPorts(corefile)
if err != nil {
t.Fatalf("starting CoreDNS: %v", err)
}
defer server.Stop()
rr, err := dns.NewRR("axfr.example.org. 60 IN TXT \"dynamic\"")
if err != nil {
t.Fatalf("creating update RR: %v", err)
}
update := new(dns.Msg).SetUpdate("example.org.")
update.Insert([]dns.RR{rr})
update.SetTsig(dynUpdateKey, dns.HmacSHA256, 300, time.Now().Unix())
client := &dns.Client{
Net: "tcp",
TsigSecret: map[string]string{dynUpdateKey: dynUpdateSecret},
}
resp, _, err := client.Exchange(update, tcp)
if err != nil {
t.Fatalf("sending signed UPDATE: %v", err)
}
if resp.Rcode != dns.RcodeSuccess {
t.Fatalf("UPDATE rcode = %s, want NOERROR", dns.RcodeToString[resp.Rcode])
}
transfer := &dns.Transfer{
DialTimeout: 5 * time.Second,
ReadTimeout: 5 * time.Second,
}
query := new(dns.Msg)
query.SetAxfr("example.org.")
envelopes, err := transfer.In(query, tcp)
if err != nil {
t.Fatalf("starting AXFR: %v", err)
}
var records []dns.RR
for envelope := range envelopes {
if envelope.Error != nil {
t.Fatalf("AXFR envelope: %v", envelope.Error)
}
records = append(records, envelope.RR...)
}
if len(records) < 2 || records[0].Header().Rrtype != dns.TypeSOA || records[len(records)-1].Header().Rrtype != dns.TypeSOA {
t.Fatalf("unexpected AXFR framing: %v", records)
}
found := false
for _, transferred := range records {
if transferred.String() == rr.String() {
found = true
break
}
}
if !found {
t.Fatalf("AXFR did not include %s", rr)
}
}