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>
This commit is contained in:
houyuwushang
2026-09-22 15:53:26 +08:00
committed by GitHub
parent 8d66643935
commit 5aa4dc2941
24 changed files with 4326 additions and 1 deletions

318
test/dynupdate_kea_test.go Normal file
View File

@@ -0,0 +1,318 @@
package test
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"testing"
"time"
"github.com/miekg/dns"
)
// Exercise Kea's actual RFC 4703 state machine. Only the lease-change
// notifications are synthetic; Kea constructs and signs every DNS UPDATE.
func TestDynUpdateKeaLifecycle(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Kea DHCP-DDNS is not available on Windows")
}
kea, err := exec.LookPath("kea-dhcp-ddns")
if err != nil {
t.Skip("Kea DHCP-DDNS is not installed")
}
version, err := exec.Command(kea, "-v").CombinedOutput()
if err != nil {
t.Fatalf("Kea version: %v: %s", err, version)
}
t.Logf("Kea DHCP-DDNS %s", strings.TrimSpace(string(version)))
for _, tc := range []struct {
name, address, conflictAddress, reverse string
rrType uint16
}{
{"v4", "192.0.2.10", "192.0.2.11", "2.0.192.in-addr.arpa.", dns.TypeA},
{"v6", "2001:db8::10", "2001:db8::11", "8.b.d.0.1.0.0.2.ip6.arpa.", dns.TypeAAAA},
} {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
var corefile strings.Builder
for i, zone := range []string{"example.org.", tc.reverse} {
seed := filepath.Join(dir, fmt.Sprintf("zone-%d", i))
data := fmt.Sprintf("%s 60 IN SOA ns.example.org. hostmaster.example.org. 10 60 60 60 60\n%s 60 IN NS ns.example.org.\n", zone, zone)
if err := os.WriteFile(seed, []byte(data), 0600); err != nil {
t.Fatal(err)
}
grants := "A AAAA DHCID ANY"
if i == 1 {
grants = "PTR DHCID ANY"
}
fmt.Fprintf(&corefile, `%s:0 {
bind 127.0.0.1
cache
tsig {
secret %s %s
require_opcode UPDATE
}
dynupdate {
file %q
database %q
allow %s * %s
}
}
`, zone, dynUpdateKey, dynUpdateSecret, seed, seed+".db", dynUpdateKey, grants)
}
s, addr, _, err := CoreDNSServerAndPorts(corefile.String())
if err != nil {
t.Fatal(err)
}
defer stopDynUpdateServer(t, s)
send := startKeaDynUpdate(t, kea, addr, tc.reverse)
client := &dns.Client{Net: "udp", Timeout: time.Second}
owner := tc.name + ".example.org."
ptr, err := dns.ReverseAddr(tc.address)
if err != nil {
t.Fatal(err)
}
conflictPTR, err := dns.ReverseAddr(tc.conflictAddress)
if err != nil {
t.Fatal(err)
}
digest := sha256.Sum256([]byte("client-" + tc.name))
dhcid := append([]byte{0, 1, 1}, digest[:]...)
check := func(name string, rrType uint16, ttl uint32, value string) {
t.Helper()
query := new(dns.Msg).SetQuestion(name, rrType)
r := exchangeDynUpdate(t, client, addr, query, dns.RcodeSuccess)
want, err := dns.NewRR(fmt.Sprintf("%s %d IN %s %s", name, ttl, dns.TypeToString[rrType], value))
if err != nil {
t.Fatal(err)
}
if ttl == 0 && len(r.Answer) == 1 {
want.Header().Ttl = r.Answer[0].Header().Ttl
}
if !r.Authoritative || len(r.Answer) != 1 || r.Answer[0].String() != want.String() {
t.Fatalf("expected %s, got %v", want, r)
}
}
checkLease := func(ttl uint32) {
t.Helper()
check(owner, tc.rrType, ttl, tc.address)
// Renewals replace addresses, but need not replace the ownership RR.
check(owner, dns.TypeDHCID, 0, base64.StdEncoding.EncodeToString(dhcid))
check(ptr, dns.TypePTR, ttl, owner)
}
serials := func() []uint32 {
t.Helper()
var result []uint32
for _, zone := range []string{"example.org.", tc.reverse} {
r := exchangeDynUpdate(t, client, addr, new(dns.Msg).SetQuestion(zone, dns.TypeSOA), dns.RcodeSuccess)
if len(r.Answer) != 1 {
t.Fatalf("missing SOA: %v", r)
}
result = append(result, r.Answer[0].(*dns.SOA).Serial)
}
return result
}
// Populate negative cache entries before claiming the name.
for _, name := range []string{owner, ptr} {
exchangeDynUpdate(t, client, addr, new(dns.Msg).SetQuestion(name, dns.TypeANY), dns.RcodeNameError)
}
send(0, owner, tc.address, dhcid, 60, "DHCP_DDNS_ADD_SUCCEEDED")
checkLease(60)
send(0, owner, tc.address, dhcid, 120, "DHCP_DDNS_ADD_SUCCEEDED")
checkLease(120)
before := serials()
other := append([]byte(nil), dhcid...)
other[len(other)-1] ^= 1
send(0, owner, tc.conflictAddress, other, 60, "DHCP_DDNS_ADD_FAILED")
checkLease(120)
exchangeDynUpdate(t, client, addr, new(dns.Msg).SetQuestion(conflictPTR, dns.TypePTR), dns.RcodeNameError)
after := serials()
if before[0] != after[0] || before[1] != after[1] {
t.Fatalf("conflicting client changed zone serials: %v -> %v", before, after)
}
send(1, owner, tc.address, dhcid, 120, "DHCP_DDNS_REMOVE_SUCCEEDED")
for _, name := range []string{owner, ptr} {
exchangeDynUpdate(t, client, addr, new(dns.Msg).SetQuestion(name, dns.TypeANY), dns.RcodeNameError)
}
// A different client may claim the name only after the old owner releases it.
send(0, owner, tc.conflictAddress, other, 60, "DHCP_DDNS_ADD_SUCCEEDED")
check(owner, tc.rrType, 60, tc.conflictAddress)
check(owner, dns.TypeDHCID, 60, base64.StdEncoding.EncodeToString(other))
check(conflictPTR, dns.TypePTR, 60, owner)
send(1, owner, tc.conflictAddress, other, 60, "DHCP_DDNS_REMOVE_SUCCEEDED")
})
}
}
func startKeaDynUpdate(t *testing.T, executable, dnsAddr, reverseZone string) func(int, string, string, []byte, uint32, string) {
t.Helper()
dir := t.TempDir()
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
ncrAddr := pc.LocalAddr().String()
ncrPort := pc.LocalAddr().(*net.UDPAddr).Port
if err := pc.Close(); err != nil {
t.Fatal(err)
}
host, port, err := net.SplitHostPort(dnsAddr)
if err != nil {
t.Fatal(err)
}
dnsPort, err := strconv.Atoi(port)
if err != nil {
t.Fatal(err)
}
domain := func(zone string) map[string]any {
return map[string]any{"ddns-domains": []any{map[string]any{
"name": zone, "key-name": dynUpdateKey,
"dns-servers": []any{map[string]any{"ip-address": host, "port": dnsPort}},
}}}
}
config := map[string]any{"DhcpDdns": map[string]any{
"ip-address": "127.0.0.1", "port": ncrPort,
"dns-server-timeout": 2000, "ncr-protocol": "UDP", "ncr-format": "JSON",
"tsig-keys": []any{map[string]any{"name": dynUpdateKey, "algorithm": "HMAC-SHA256", "secret": dynUpdateSecret}},
"forward-ddns": domain("example.org."), "reverse-ddns": domain(reverseZone),
"loggers": []any{map[string]any{
"name": "kea-dhcp-ddns", "severity": "DEBUG", "debuglevel": 99,
"output_options": []any{map[string]any{"output": "stdout", "flush": true}},
}},
}}
data, err := json.Marshal(config)
if err != nil {
t.Fatal(err)
}
configDir := dir
if root := os.Getenv("COREDNS_KEA_CONFIG_DIR"); root != "" {
configDir, err = os.MkdirTemp(root, "coredns-ddns-") //nolint:usetesting // Must use a path permitted by the distribution's AppArmor profile.
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := os.RemoveAll(configDir); err != nil {
t.Error(err)
}
})
}
// Distribution AppArmor profiles may require this config basename and
// fixed runtime directories. CI provisions them without changing the profile.
path := filepath.Join(configDir, "kea-dhcp-ddns.conf")
if err := os.WriteFile(path, data, 0600); err != nil {
t.Fatal(err)
}
logfile, err := os.Create(filepath.Join(dir, "kea.log"))
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
cmd := exec.CommandContext(ctx, executable, "-c", path)
cmd.Env = os.Environ()
for _, name := range []string{"KEA_PIDFILE_DIR", "KEA_LOCKFILE_DIR"} {
if os.Getenv(name) == "" {
cmd.Env = append(cmd.Env, name+"="+dir)
}
}
cmd.Stdout, cmd.Stderr = logfile, logfile
if err := cmd.Start(); err != nil {
cancel()
logfile.Close()
t.Fatal(err)
}
done := make(chan struct{})
var waitErr error
go func() {
waitErr = cmd.Wait()
close(done)
}()
t.Cleanup(func() {
// Graceful shutdown removes the PID file before the next lifecycle case.
_ = cmd.Process.Signal(os.Interrupt)
select {
case <-done:
case <-time.After(5 * time.Second):
cancel()
<-done
}
cancel()
logfile.Close()
if t.Failed() {
out, _ := os.ReadFile(logfile.Name())
t.Logf("Kea log:\n%s", out)
}
})
waitFor := func(offset int64, event string) {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
out, err := os.ReadFile(logfile.Name())
if err != nil {
t.Fatal(err)
}
if int64(len(out)) >= offset {
recent := string(out[offset:])
if strings.Contains(recent, event) {
return
}
for _, terminal := range []string{"DHCP_DDNS_ADD_SUCCEEDED", "DHCP_DDNS_ADD_FAILED", "DHCP_DDNS_REMOVE_SUCCEEDED", "DHCP_DDNS_REMOVE_FAILED"} {
if strings.Contains(recent, terminal) {
t.Fatalf("Kea reported %s while waiting for %s", terminal, event)
}
}
}
select {
case <-done:
t.Fatalf("Kea exited before %s: %v", event, waitErr)
default:
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("Kea did not report %s", event)
}
waitFor(0, "DHCP_DDNS_QUEUE_MGR_STARTED")
return func(change int, fqdn, address string, dhcid []byte, ttl uint32, event string) {
t.Helper()
info, err := logfile.Stat()
if err != nil {
t.Fatal(err)
}
ncr, err := json.Marshal(map[string]any{
"change-type": change, "forward-change": true, "reverse-change": true,
"fqdn": fqdn, "ip-address": address, "dhcid": hex.EncodeToString(dhcid),
"lease-expires-on": time.Now().UTC().Add(time.Duration(ttl) * time.Second).Format("20060102150405"),
"lease-length": ttl, "use-conflict-resolution": true,
})
if err != nil {
t.Fatal(err)
}
// Kea's UDP NCR framing is a network-order uint16 length followed by JSON.
wire := make([]byte, 2+len(ncr))
binary.BigEndian.PutUint16(wire, uint16(len(ncr)))
copy(wire[2:], ncr)
conn, err := net.DialTimeout("udp", ncrAddr, time.Second)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
if _, err := conn.Write(wire); err != nil {
t.Fatal(err)
}
waitFor(info.Size(), event)
}
}

View File

@@ -0,0 +1,379 @@
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")
}
}
}

357
test/dynupdate_test.go Normal file
View File

@@ -0,0 +1,357 @@
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)
}
}

View File

@@ -20,6 +20,7 @@ import (
var contents = map[string]string{
"Kexample.org.+013+45330.key": examplePub,
"Kexample.org.+013+45330.private": examplePriv,
"example.org.zone": exampleOrg,
"example.org.signed": exampleOrg, // not signed, but does not matter for this test.
}