mirror of
https://github.com/coredns/coredns.git
synced 2026-10-09 03:55:21 -04:00
* 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>
512 lines
16 KiB
Go
512 lines
16 KiB
Go
package dynupdate
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/coredns/caddy"
|
|
"github.com/coredns/coredns/core/dnsserver"
|
|
"github.com/coredns/coredns/plugin/pkg/dnstest"
|
|
coretest "github.com/coredns/coredns/plugin/test"
|
|
|
|
"github.com/miekg/dns"
|
|
bolt "go.etcd.io/bbolt"
|
|
)
|
|
|
|
func persistentTestZone(t *testing.T, path string) *DynUpdate {
|
|
t.Helper()
|
|
d := newTestDynUpdate(t)
|
|
d.database = path
|
|
d.seed = path + ".zone"
|
|
var seed strings.Builder
|
|
for _, rr := range d.records {
|
|
fmt.Fprintln(&seed, rr)
|
|
}
|
|
if err := os.WriteFile(d.seed, []byte(seed.String()), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if err := d.close(); err != nil {
|
|
t.Errorf("closing dynamic zone: %v", err)
|
|
}
|
|
})
|
|
return d
|
|
}
|
|
|
|
func querySnapshot(t *testing.T, d *DynUpdate, name string, rrType uint16) *dns.Msg {
|
|
t.Helper()
|
|
w := dnstest.NewRecorder(&coretest.ResponseWriter{})
|
|
r := new(dns.Msg)
|
|
r.SetQuestion(name, rrType)
|
|
code, err := d.ServeDNS(context.Background(), w, r)
|
|
if err != nil || code != dns.RcodeSuccess || w.Msg == nil {
|
|
t.Fatalf("query %s: code=%d err=%v response=%v", name, code, err, w.Msg)
|
|
}
|
|
return w.Msg
|
|
}
|
|
|
|
func TestStoreSurvivesRestart(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "updates.db")
|
|
d := persistentTestZone(t, path)
|
|
add := mustRR(t, `new.example.org. 120 IN TXT "persistent"`)
|
|
if code, err := d.applyUpdate(testKey, nil, []dns.RR{add}); code != dns.RcodeSuccess || err != nil {
|
|
t.Fatalf("update: %d %v", code, err)
|
|
}
|
|
if err := d.close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
restarted := persistentTestZone(t, path)
|
|
// The database is authoritative once initialized, even if the seed goes away.
|
|
if err := os.Remove(restarted.seed); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
answer := querySnapshot(t, restarted, add.Header().Name, dns.TypeTXT)
|
|
if len(answer.Answer) != 1 || answer.Answer[0].String() != add.String() {
|
|
t.Fatalf("record lost on restart: %v", answer)
|
|
}
|
|
soa := querySnapshot(t, restarted, testZone, dns.TypeSOA)
|
|
if len(soa.Answer) != 1 || soa.Answer[0].(*dns.SOA).Serial != 11 {
|
|
t.Fatalf("serial lost on restart: %v", soa)
|
|
}
|
|
ch, err := restarted.Transfer(testZone, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
found := false
|
|
for batch := range ch {
|
|
for _, rr := range batch {
|
|
found = found || rr.String() == add.String()
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("AXFR lost persistent record")
|
|
}
|
|
}
|
|
|
|
func TestStoreSurvivesProcessKill(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "updates.db")
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(ctx, exe, "-test.run=^TestStoreCrashHelper$")
|
|
cmd.Env = append(os.Environ(), "COREDNS_DYNUPDATE_CRASH_TEST="+path)
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cmd.Stderr = os.Stderr
|
|
if err := cmd.Start(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
scanner := bufio.NewScanner(stdout)
|
|
ready := scanner.Scan() && scanner.Text() == "committed"
|
|
killErr := cmd.Process.Kill()
|
|
waitErr := cmd.Wait()
|
|
if !ready || killErr != nil || waitErr == nil {
|
|
t.Fatalf("helper did not commit then crash: ready=%v kill=%v wait=%v", ready, killErr, waitErr)
|
|
}
|
|
d := persistentTestZone(t, path)
|
|
answer := querySnapshot(t, d, "crash.example.org.", dns.TypeTXT)
|
|
if len(answer.Answer) != 1 || answer.Answer[0].String() != "crash.example.org.\t60\tIN\tTXT\t\"committed\"" {
|
|
t.Fatalf("acknowledged record lost after process kill: %v", answer)
|
|
}
|
|
}
|
|
|
|
func TestStoreCrashHelper(t *testing.T) {
|
|
path := os.Getenv("COREDNS_DYNUPDATE_CRASH_TEST")
|
|
if path == "" {
|
|
return
|
|
}
|
|
d := persistentTestZone(t, path)
|
|
rr := mustRR(t, `crash.example.org. 60 IN TXT "committed"`)
|
|
if code, err := d.applyUpdate(testKey, nil, []dns.RR{rr}); code != dns.RcodeSuccess || err != nil {
|
|
t.Fatalf("update: %d %v", code, err)
|
|
}
|
|
fmt.Println("committed")
|
|
// Do not close the database or run cleanup; the parent kills this process.
|
|
time.Sleep(time.Minute)
|
|
}
|
|
|
|
func TestStoreReloadSharesTransactions(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "updates.db")
|
|
old := persistentTestZone(t, path)
|
|
next := persistentTestZone(t, path)
|
|
// Open both before writing, as happens with overlapping Corefile instances.
|
|
querySnapshot(t, old, testZone, dns.TypeSOA)
|
|
querySnapshot(t, next, testZone, dns.TypeSOA)
|
|
const count = 16
|
|
var wg sync.WaitGroup
|
|
for i := range count {
|
|
rr := mustRR(t, fmt.Sprintf("host-%d.example.org. 60 IN A 192.0.2.%d", i, i+1))
|
|
wg.Go(func() {
|
|
d := old
|
|
if i%2 == 0 {
|
|
d = next
|
|
}
|
|
if code, err := d.applyUpdate(testKey, nil, []dns.RR{rr}); code != dns.RcodeSuccess || err != nil {
|
|
t.Errorf("update %d: %d %v", i, code, err)
|
|
}
|
|
})
|
|
}
|
|
wg.Wait()
|
|
for _, d := range []*DynUpdate{old, next} {
|
|
for i := range count {
|
|
answer := querySnapshot(t, d, fmt.Sprintf("host-%d.example.org.", i), dns.TypeA)
|
|
if len(answer.Answer) != 1 {
|
|
t.Fatalf("overlapping instance lost update %d: %v", i, answer)
|
|
}
|
|
}
|
|
}
|
|
if err := old.close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
soa := querySnapshot(t, next, testZone, dns.TypeSOA)
|
|
if len(soa.Answer) != 1 || soa.Answer[0].(*dns.SOA).Serial != 10+count {
|
|
t.Fatalf("surviving instance has wrong serial: %v", soa)
|
|
}
|
|
}
|
|
|
|
func TestStoreConcurrentPrerequisites(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "updates.db")
|
|
instances := []*DynUpdate{persistentTestZone(t, path), persistentTestZone(t, path)}
|
|
prereq := emptyRR("claimed.example.org.", dns.TypeANY, dns.ClassNONE)
|
|
var wg sync.WaitGroup
|
|
codes := make(chan int, 2)
|
|
for i, d := range instances {
|
|
rr := mustRR(t, fmt.Sprintf("claimed.example.org. 60 IN A 192.0.2.%d", i+1))
|
|
wg.Go(func() {
|
|
code, err := d.applyUpdate(testKey, []dns.RR{prereq}, []dns.RR{rr})
|
|
if err != nil {
|
|
t.Errorf("claim: %v", err)
|
|
}
|
|
codes <- code
|
|
})
|
|
}
|
|
wg.Wait()
|
|
first, second := <-codes, <-codes
|
|
valid := first == dns.RcodeSuccess && second == dns.RcodeYXDomain || second == dns.RcodeSuccess && first == dns.RcodeYXDomain
|
|
if !valid {
|
|
t.Fatalf("non-atomic prerequisites: %d, %d", first, second)
|
|
}
|
|
}
|
|
|
|
func TestStoreCommitFailureDoesNotPublish(t *testing.T) {
|
|
d := persistentTestZone(t, filepath.Join(t.TempDir(), "updates.db"))
|
|
querySnapshot(t, d, testZone, dns.TypeSOA)
|
|
// Removing the bucket makes the real write transaction fail, without a
|
|
// production-only test hook or platform-specific filesystem permissions.
|
|
if err := d.store.db.Update(func(tx *bolt.Tx) error { return tx.DeleteBucket(storeBucket) }); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
add := mustRR(t, `failed.example.org. 60 IN TXT "must not publish"`)
|
|
code, err := d.applyUpdate(testKey, nil, []dns.RR{add})
|
|
if code != dns.RcodeServerFailure || err == nil {
|
|
t.Fatalf("failed commit acknowledged: %d %v", code, err)
|
|
}
|
|
answer := querySnapshot(t, d, add.Header().Name, dns.TypeTXT)
|
|
if answer.Rcode != dns.RcodeNameError {
|
|
t.Fatalf("failed commit became visible: %v", answer)
|
|
}
|
|
if got := serial(d); got != 10 {
|
|
t.Fatalf("failed commit changed serial to %d", got)
|
|
}
|
|
}
|
|
|
|
func TestUpdateLimitsAreAtomic(t *testing.T) {
|
|
for _, mode := range []string{"memory", "database"} {
|
|
for _, limit := range []string{"records", "bytes", "update records"} {
|
|
t.Run(mode+"/"+limit, func(t *testing.T) {
|
|
d := newTestDynUpdate(t)
|
|
if mode == "database" {
|
|
d = persistentTestZone(t, filepath.Join(t.TempDir(), "updates.db"))
|
|
querySnapshot(t, d, testZone, dns.TypeSOA)
|
|
}
|
|
switch limit {
|
|
case "records":
|
|
d.limits.records = len(d.records)
|
|
case "bytes":
|
|
for _, rr := range d.records {
|
|
d.limits.bytes += dns.Len(rr)
|
|
}
|
|
case "update records":
|
|
d.limits.updateRecords = 1
|
|
}
|
|
updates := []dns.RR{mustRR(t, "new.example.org. 60 IN A 192.0.2.10"), mustRR(t, "other.example.org. 60 IN A 192.0.2.11")}
|
|
if code, _ := d.applyUpdate(testKey, nil, updates); code != dns.RcodeRefused {
|
|
t.Fatalf("oversized update: %d", code)
|
|
}
|
|
if serial(d) != 10 || len(d.records) != 4 {
|
|
t.Fatal("rejected update changed zone")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseDatabaseReleasesLock(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "updates.db")
|
|
seed := persistentTestZone(t, path)
|
|
c := caddy.NewTestController("dns", fmt.Sprintf(`dynupdate example.org. {
|
|
file %s
|
|
database updates.db
|
|
max_records 100
|
|
max_bytes 1048576
|
|
max_update_records 16
|
|
allow %s * A TXT
|
|
}`, filepath.Base(seed.seed), testKey))
|
|
dnsserver.GetConfig(c).Root = dir
|
|
d, err := parse(c)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if d.store != nil || d.database != path || d.limits != (limits{100, 1048576, 16}) {
|
|
t.Fatalf("unexpected parsed database configuration: %+v", d)
|
|
}
|
|
// A failed later directive must not leave a database lock behind.
|
|
db, err := bolt.Open(path, 0600, &bolt.Options{Timeout: 100 * time.Millisecond})
|
|
if err != nil {
|
|
t.Fatalf("parse retained database lock: %v", err)
|
|
}
|
|
db.Close()
|
|
}
|
|
|
|
func TestParseDatabaseIsReadOnly(t *testing.T) {
|
|
for _, state := range []string{"missing", "existing", "active", "empty", "invalid seed"} {
|
|
t.Run(state, func(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "updates.db")
|
|
seed := persistentTestZone(t, path)
|
|
switch state {
|
|
case "existing", "active":
|
|
add := mustRR(t, `saved.example.org. 60 IN TXT "durable"`)
|
|
if code, err := seed.applyUpdate(testKey, nil, []dns.RR{add}); code != dns.RcodeSuccess || err != nil {
|
|
t.Fatalf("update: %d %v", code, err)
|
|
}
|
|
if state == "existing" {
|
|
if err := seed.close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := os.Remove(seed.seed); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
case "empty":
|
|
if err := os.WriteFile(path, nil, 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
case "invalid seed":
|
|
if err := os.WriteFile(seed.seed, []byte("invalid zone data"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
before, beforeErr := os.ReadFile(path)
|
|
if beforeErr != nil && !os.IsNotExist(beforeErr) {
|
|
t.Fatal(beforeErr)
|
|
}
|
|
c := caddy.NewTestController("dns", fmt.Sprintf(`dynupdate example.org. {
|
|
file "%s"
|
|
database "%s"
|
|
allow %s * *
|
|
}`, filepath.ToSlash(seed.seed), filepath.ToSlash(path), testKey))
|
|
d, err := parse(c)
|
|
wantErr := state == "empty" || state == "invalid seed"
|
|
if (err != nil) != wantErr {
|
|
t.Errorf("parse error = %v, want error = %v", err, wantErr)
|
|
}
|
|
after, afterErr := os.ReadFile(path)
|
|
if os.IsNotExist(beforeErr) {
|
|
if !os.IsNotExist(afterErr) {
|
|
t.Errorf("parse created database: %v", afterErr)
|
|
}
|
|
} else if afterErr != nil || !bytes.Equal(before, after) {
|
|
t.Errorf("parse modified existing database: %v", afterErr)
|
|
}
|
|
if d != nil {
|
|
t.Cleanup(func() {
|
|
if err := d.close(); err != nil {
|
|
t.Error(err)
|
|
}
|
|
})
|
|
if d.store != nil {
|
|
t.Fatal("parse retained a runtime database reference")
|
|
}
|
|
if state == "existing" || state == "active" {
|
|
if !hasRecord(d, "saved.example.org.", dns.TypeTXT, "") || serial(d) != 11 {
|
|
t.Fatal("parse ignored acknowledged updates")
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDatabaseLargerThanDNSMessage(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "updates.db")
|
|
d := persistentTestZone(t, path)
|
|
var extra strings.Builder
|
|
for i := range 400 {
|
|
fmt.Fprintf(&extra, "large-%d.example.org. 60 IN TXT %q\n", i, strings.Repeat("a", 200))
|
|
}
|
|
seed, err := os.ReadFile(d.seed)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(d.seed, append(seed, extra.String()...), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
querySnapshot(t, d, testZone, dns.TypeSOA)
|
|
if err := d.close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
restarted := persistentTestZone(t, path)
|
|
answer := querySnapshot(t, restarted, "large-399.example.org.", dns.TypeTXT)
|
|
if len(answer.Answer) != 1 || answer.Answer[0].(*dns.TXT).Txt[0] != strings.Repeat("a", 200) {
|
|
t.Fatalf("zone larger than a DNS message did not round-trip: %v", answer)
|
|
}
|
|
}
|
|
|
|
func TestParseInvalidLimits(t *testing.T) {
|
|
for _, property := range []string{"max_records", "max_bytes", "max_update_records"} {
|
|
for _, value := range []string{"0", "-1", "invalid", "999999999999999999999999", "1 2", ""} {
|
|
t.Run(property+"/"+value, func(t *testing.T) {
|
|
c := caddy.NewTestController("dns", fmt.Sprintf("dynupdate example.org. {\nfile seed\nallow %s * A\n%s %s\n}", testKey, property, value))
|
|
if _, err := parse(c); err == nil || strings.Contains(err.Error(), "opening zone file") {
|
|
t.Fatalf("invalid limit was not rejected before loading: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDatabaseCannotReplaceSeed(t *testing.T) {
|
|
d := persistentTestZone(t, filepath.Join(t.TempDir(), "updates.db"))
|
|
before, err := os.ReadFile(d.seed)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
d.database = d.seed
|
|
if _, err := d.snapshot(); err == nil {
|
|
t.Fatal("database accepted the seed path")
|
|
}
|
|
after, err := os.ReadFile(d.seed)
|
|
if err != nil || string(after) != string(before) {
|
|
t.Fatalf("seed modified: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestStoreSnapshotsDuringUpdates(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "updates.db")
|
|
d := persistentTestZone(t, path)
|
|
reader := persistentTestZone(t, path)
|
|
querySnapshot(t, reader, testZone, dns.TypeSOA)
|
|
var wg sync.WaitGroup
|
|
for range 4 {
|
|
wg.Go(func() {
|
|
for range 40 {
|
|
ch, err := reader.Transfer(testZone, 0)
|
|
if err != nil {
|
|
t.Error(err)
|
|
return
|
|
}
|
|
var records []dns.RR
|
|
for batch := range ch {
|
|
records = append(records, batch...)
|
|
}
|
|
if len(records) < 2 {
|
|
t.Error("incomplete transfer")
|
|
return
|
|
}
|
|
first, ok1 := records[0].(*dns.SOA)
|
|
last, ok2 := records[len(records)-1].(*dns.SOA)
|
|
if !ok1 || !ok2 || first.Serial != last.Serial || int(first.Serial)-10 != len(records)-5 {
|
|
t.Errorf("mixed zone generations: %v", records)
|
|
return
|
|
}
|
|
}
|
|
})
|
|
}
|
|
for i := range 20 {
|
|
rr := mustRR(t, fmt.Sprintf("node-%d.example.org. 60 IN A 192.0.2.%d", i, i+1))
|
|
if code, err := d.applyUpdate(testKey, nil, []dns.RR{rr}); code != dns.RcodeSuccess || err != nil {
|
|
t.Errorf("update %d: %d %v", i, code, err)
|
|
}
|
|
}
|
|
wg.Wait()
|
|
}
|
|
|
|
func TestStoreRejectsInvalidDatabase(t *testing.T) {
|
|
for _, tc := range []string{"wrong zone", "unknown format", "truncated records", "invalid zone", "too many records", "too many bytes"} {
|
|
t.Run(tc, func(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "updates.db")
|
|
d := persistentTestZone(t, path)
|
|
querySnapshot(t, d, testZone, dns.TypeSOA)
|
|
if err := d.close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
db, err := bolt.Open(path, 0600, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err = db.Update(func(tx *bolt.Tx) error {
|
|
b := tx.Bucket(storeBucket)
|
|
switch tc {
|
|
case "wrong zone":
|
|
return b.Put(originKey, []byte("other.example."))
|
|
case "unknown format":
|
|
if err := tx.DeleteBucket(storeBucket); err != nil {
|
|
return err
|
|
}
|
|
_, err := tx.CreateBucket([]byte("dynupdate-future"))
|
|
return err
|
|
case "truncated records":
|
|
return b.Put(recordsKey, []byte{0xff, 0x01})
|
|
case "invalid zone":
|
|
return putRecords(b, []dns.RR{mustRR(t, "outside.example. 60 IN A 192.0.2.1")})
|
|
}
|
|
return nil
|
|
})
|
|
db.Close()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
next := persistentTestZone(t, path)
|
|
if tc == "too many records" {
|
|
next.limits.records = 3
|
|
}
|
|
if tc == "too many bytes" {
|
|
next.limits.bytes = 1
|
|
}
|
|
if s, err := next.acquireStore(true); err == nil {
|
|
releaseStore(s)
|
|
t.Fatal("read-only validation accepted an invalid database")
|
|
}
|
|
if _, err := next.snapshot(); err == nil {
|
|
t.Fatal("invalid database was silently replaced with seed")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func FuzzDecodeRecords(f *testing.F) {
|
|
f.Add([]byte{})
|
|
f.Add([]byte{0xc0, 0x00, 0x00, 0x01})
|
|
f.Fuzz(func(t *testing.T, data []byte) {
|
|
records, err := decodeRecords(data, limits{records: 64, bytes: 4096})
|
|
if err == nil && (len(records) == 0 || len(records) > 64) {
|
|
t.Fatalf("unbounded decoded records: %d", len(records))
|
|
}
|
|
})
|
|
}
|