From 5aa4dc29417a60a65ca9c6828964e1431ef85981 Mon Sep 17 00:00:00 2001 From: houyuwushang <180804215+houyuwushang@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:53:26 +0800 Subject: [PATCH] plugin/dynupdate: add durable authenticated RFC 2136 updates (#8520) * plugin/dynupdate: add authenticated RFC 2136 updates Signed-off-by: houyuwushang * plugin/dynupdate: fix README test fixtures Signed-off-by: houyuwushang * test: format README fixture map Signed-off-by: houyuwushang * plugin/dynupdate: persist updates and bound writable zones Signed-off-by: houyuwushang * plugin/dynupdate: preserve middleware and fix interoperability fixtures Signed-off-by: houyuwushang * test(dynupdate): validate Kea lifecycle and bounded zone costs Signed-off-by: houyuwushang * plugin/dynupdate: reject duplicate directives and harden client fixtures Signed-off-by: houyuwushang * plugin/dynupdate: fix update routing and startup validation Signed-off-by: houyuwushang --------- Signed-off-by: houyuwushang --- .github/workflows/go.test.yml | 17 + core/dnsserver/zdirectives.go | 1 + core/plugin/zplugin.go | 1 + go.mod | 1 + go.sum | 2 + plugin.cfg | 1 + plugin/cache/cache.go | 7 + plugin/cache/handler.go | 9 +- plugin/cache/non_query_test.go | 77 +++ plugin/cache/setup.go | 5 + plugin/dynupdate/README.md | 253 ++++++++++ plugin/dynupdate/benchmark_test.go | 141 ++++++ plugin/dynupdate/dynupdate.go | 143 ++++++ plugin/dynupdate/dynupdate_test.go | 723 +++++++++++++++++++++++++++++ plugin/dynupdate/limits.go | 49 ++ plugin/dynupdate/setup.go | 360 ++++++++++++++ plugin/dynupdate/store.go | 249 ++++++++++ plugin/dynupdate/store_test.go | 511 ++++++++++++++++++++ plugin/dynupdate/update.go | 399 ++++++++++++++++ plugin/dynupdate/zone.go | 323 +++++++++++++ test/dynupdate_kea_test.go | 318 +++++++++++++ test/dynupdate_persistence_test.go | 379 +++++++++++++++ test/dynupdate_test.go | 357 ++++++++++++++ test/readme_test.go | 1 + 24 files changed, 4326 insertions(+), 1 deletion(-) create mode 100644 plugin/cache/non_query_test.go create mode 100644 plugin/dynupdate/README.md create mode 100644 plugin/dynupdate/benchmark_test.go create mode 100644 plugin/dynupdate/dynupdate.go create mode 100644 plugin/dynupdate/dynupdate_test.go create mode 100644 plugin/dynupdate/limits.go create mode 100644 plugin/dynupdate/setup.go create mode 100644 plugin/dynupdate/store.go create mode 100644 plugin/dynupdate/store_test.go create mode 100644 plugin/dynupdate/update.go create mode 100644 plugin/dynupdate/zone.go create mode 100644 test/dynupdate_kea_test.go create mode 100644 test/dynupdate_persistence_test.go create mode 100644 test/dynupdate_test.go diff --git a/.github/workflows/go.test.yml b/.github/workflows/go.test.yml index 67f53fdc5..b3ae04943 100644 --- a/.github/workflows/go.test.yml +++ b/.github/workflows/go.test.yml @@ -112,7 +112,24 @@ jobs: - name: Build run: go build -v ./... + - name: Install clients for DDNS interoperability tests + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends bind9-dnsutils kea-dhcp-ddns-server + nsupdate -V + kea-dhcp-ddns -v + # Keep the distribution AppArmor profile and use its allowed paths. + sudo systemctl stop kea-dhcp-ddns-server + sudo install -d -m 0700 -o "$(id -u)" -g "$(id -g)" /etc/kea/coredns-tests + sudo install -d -m 0755 -o "$(id -u)" -g "$(id -g)" /run/kea + sudo install -m 0600 -o "$(id -u)" -g "$(id -g)" /dev/null /run/kea/logger_lockfile + test ! -e /run/kea/kea-dhcp-ddns.kea-dhcp-ddns.pid + - name: Test + env: + COREDNS_KEA_CONFIG_DIR: /etc/kea/coredns-tests + KEA_PIDFILE_DIR: /run/kea + KEA_LOCKFILE_DIR: /run/kea run: | ( cd test; go test -race ./... ) diff --git a/core/dnsserver/zdirectives.go b/core/dnsserver/zdirectives.go index aed84bfbc..7940a4bec 100644 --- a/core/dnsserver/zdirectives.go +++ b/core/dnsserver/zdirectives.go @@ -52,6 +52,7 @@ var Directives = []string{ "minimal", "template", "transfer", + "dynupdate", "hosts", "route53", "azure", diff --git a/core/plugin/zplugin.go b/core/plugin/zplugin.go index e15eca03b..d1af57f43 100644 --- a/core/plugin/zplugin.go +++ b/core/plugin/zplugin.go @@ -20,6 +20,7 @@ import ( _ "github.com/coredns/coredns/plugin/dns64" _ "github.com/coredns/coredns/plugin/dnssec" _ "github.com/coredns/coredns/plugin/dnstap" + _ "github.com/coredns/coredns/plugin/dynupdate" _ "github.com/coredns/coredns/plugin/erratic" _ "github.com/coredns/coredns/plugin/errors" _ "github.com/coredns/coredns/plugin/etcd" diff --git a/go.mod b/go.mod index e9cd14b26..31acdf27a 100644 --- a/go.mod +++ b/go.mod @@ -34,6 +34,7 @@ require ( github.com/prometheus/common v0.71.0 github.com/quic-go/quic-go v0.62.0 github.com/stretchr/testify v1.12.1 + go.etcd.io/bbolt v1.5.0 go.etcd.io/etcd/api/v3 v3.7.1 go.etcd.io/etcd/client/v3 v3.7.1 go.uber.org/automaxprocs v1.6.0 diff --git a/go.sum b/go.sum index c9b3fc9c4..8cffdb9b9 100644 --- a/go.sum +++ b/go.sum @@ -405,6 +405,8 @@ github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE= github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= +go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU= +go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk= go.etcd.io/etcd/api/v3 v3.7.1 h1:KJG0/DcWGfe3Y1otDf/fsBf0TSSgpxZ5RO/L8SFt73E= go.etcd.io/etcd/api/v3 v3.7.1/go.mod h1:8bXIpCMeV7E3/XL0Ix123ATn3dB+0V7d9zklHbB0m78= go.etcd.io/etcd/client/pkg/v3 v3.7.1 h1:rKYsj3pRkR0eK3yjT3XOgrhqfmIfj9pzNgxjh7mfFv4= diff --git a/plugin.cfg b/plugin.cfg index 6e7072737..da8cbac5b 100644 --- a/plugin.cfg +++ b/plugin.cfg @@ -62,6 +62,7 @@ tls:tls minimal:minimal template:template transfer:transfer +dynupdate:dynupdate hosts:hosts route53:route53 azure:azure diff --git a/plugin/cache/cache.go b/plugin/cache/cache.go index 149fc5803..fecee3d2c 100644 --- a/plugin/cache/cache.go +++ b/plugin/cache/cache.go @@ -54,6 +54,7 @@ type Cache struct { // Positive/negative zone exceptions pexcept []string nexcept []string + bypass []string // mutable authoritative zones discovered at startup // Keep ttl option keepttl bool @@ -62,6 +63,12 @@ type Cache struct { now func() time.Time } +// ZoneBypasser identifies canonical authoritative zones that must always be +// queried directly. Cache discovers implementations in its server block at startup. +type ZoneBypasser interface { + CacheBypassZones() []string +} + // New returns an initialized Cache with default settings. It's up to the // caller to set the Next handler. func New() *Cache { diff --git a/plugin/cache/handler.go b/plugin/cache/handler.go index 117e87d5c..f861fa83f 100644 --- a/plugin/cache/handler.go +++ b/plugin/cache/handler.go @@ -15,13 +15,20 @@ import ( // ServeDNS implements the plugin.Handler interface. func (c *Cache) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + // The cache only handles QUERY messages. In particular, an UPDATE can + // have the same zone name and QTYPE as a cached query, but must always + // reach the authoritative handler instead of being answered from cache. + if r.Opcode != dns.OpcodeQuery { + return plugin.NextOrFailure(c.Name(), c.Next, ctx, w, r) + } + rc := r.Copy() // We potentially modify r, to prevent other plugins from seeing this (r is a pointer), copy r into rc. state := request.Request{W: w, Req: rc} do := state.Do() cd := r.CheckingDisabled ad := r.AuthenticatedData - if !plugin.Zones(c.Zones).Contains(state.Name()) { + if !plugin.Zones(c.Zones).Contains(state.Name()) || plugin.Zones(c.bypass).Contains(state.Name()) { return plugin.NextOrFailure(c.Name(), c.Next, ctx, w, rc) } diff --git a/plugin/cache/non_query_test.go b/plugin/cache/non_query_test.go new file mode 100644 index 000000000..808665655 --- /dev/null +++ b/plugin/cache/non_query_test.go @@ -0,0 +1,77 @@ +package cache + +import ( + "context" + "testing" + + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/pkg/dnstest" + "github.com/coredns/coredns/plugin/test" + + "github.com/miekg/dns" +) + +func TestCachePassesNonQueryToNext(t *testing.T) { + c := New() + called := false + c.Next = plugin.HandlerFunc(func(_ context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + called = true + if r.Opcode != dns.OpcodeUpdate { + t.Errorf("next handler received opcode %d, want UPDATE", r.Opcode) + } + m := new(dns.Msg).SetReply(r) + if err := w.WriteMsg(m); err != nil { + return dns.RcodeServerFailure, err + } + return dns.RcodeSuccess, nil + }) + + r := new(dns.Msg).SetUpdate("example.org.") + w := dnstest.NewRecorder(&test.ResponseWriter{}) + code, err := c.ServeDNS(context.Background(), w, r) + if err != nil || code != dns.RcodeSuccess { + t.Fatalf("ServeDNS returned code=%d err=%v", code, err) + } + if !called { + t.Fatal("non-QUERY message did not reach the next handler") + } + if w.Msg == nil || w.Msg.Opcode != dns.OpcodeUpdate { + t.Fatalf("response = %#v, want an UPDATE response", w.Msg) + } +} + +func TestCacheBypassesMutableZones(t *testing.T) { + c := New() + calls := 0 + c.Next = plugin.HandlerFunc(func(_ context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + calls++ + m := new(dns.Msg).SetReply(r) + rr, err := dns.NewRR(r.Question[0].Name + " 60 IN A 192.0.2.1") + if err != nil { + return dns.RcodeServerFailure, err + } + m.Answer = []dns.RR{rr} + return dns.RcodeSuccess, w.WriteMsg(m) + }) + query := func(name string) { + t.Helper() + r := new(dns.Msg) + r.SetQuestion(name, dns.TypeA) + w := dnstest.NewRecorder(&test.ResponseWriter{}) + if code, err := c.ServeDNS(context.Background(), w, r); code != dns.RcodeSuccess || err != nil { + t.Fatalf("query: %d %v", code, err) + } + } + query("mutable.example.org.") + c.bypass = []string{"example.org."} + query("mutable.example.org.") + query("mutable.example.org.") + if calls != 3 { + t.Fatalf("mutable queries used cache: %d upstream calls", calls) + } + query("static.example.net.") + query("static.example.net.") + if calls != 4 { + t.Fatalf("unrelated zone was not cached: %d upstream calls", calls) + } +} diff --git a/plugin/cache/setup.go b/plugin/cache/setup.go index 5763ed37c..fdcaf146d 100644 --- a/plugin/cache/setup.go +++ b/plugin/cache/setup.go @@ -26,6 +26,11 @@ func setup(c *caddy.Controller) error { c.OnStartup(func() error { ca.viewMetricLabel = dnsserver.GetConfig(c).ViewName + for _, h := range dnsserver.GetConfig(c).Handlers() { + if b, ok := h.(ZoneBypasser); ok { + ca.bypass = append(ca.bypass, b.CacheBypassZones()...) + } + } return nil }) diff --git a/plugin/dynupdate/README.md b/plugin/dynupdate/README.md new file mode 100644 index 000000000..cd718c499 --- /dev/null +++ b/plugin/dynupdate/README.md @@ -0,0 +1,253 @@ +# dynupdate + +## Name + +*dynupdate* - accepts authenticated RFC 2136 DNS UPDATE messages for an +explicit, opt-in authoritative zone. + +## Description + +The *dynupdate* plugin serves one writable authoritative zone through the +normal CoreDNS authoritative file implementation and may be used only once +per server block. Use separate server blocks for separate writable zones. +An RFC 1035-style zone file provides the initial data and is never modified. +Configure `database` for a +persistent primary: a successful UPDATE is committed to the local database +before its new snapshot becomes visible or the success response is sent. +Without `database`, updates are in memory only and are lost on restart or +Corefile reload; this mode is intended for temporary data and testing. + +UPDATE requests must carry a TSIG that has been validated by the *tsig* +plugin. The *dynupdate* plugin does not receive or store TSIG secrets. Every +mutation must also match an explicit `allow` rule containing the key name, +owner name, and RR type. Use `*` as the owner name or RR type only when that +broader permission is intentional. Configure `require_opcode UPDATE` in the +*tsig* plugin so unsigned UPDATE requests are rejected at the protocol +boundary. UPDATE requests for a different zone receive NOTAUTH; they do not +fall through to query-only backends. Ordinary queries outside the dynamic +zone still pass to the next plugin. + +The implementation supports RFC 2136 prerequisites, add and delete +operations, CNAME and apex SOA/NS invariants, automatic SOA serial updates, +and the current snapshot for AXFR. SOA serial zero is rejected because RFC +2136 recommends avoiding it for interoperability, and automatic increments +skip zero after wraparound. DNSSEC records and related zone-integrity metadata +(`SIG`, `KEY`, `NXT`, `DS`, `RRSIG`, `NSEC`, `DNSKEY`, `NSEC3`, `NSEC3PARAM`, +`TALINK`, `CDS`, `CDNSKEY`, `TA`, `DLV`, and `ZONEMD`) are rejected because +the plugin cannot regenerate them after an update. IXFR and automatic DNSSEC +re-signing are not supported. The plugin is experimental; it does not provide +multi-primary replication or atomic transactions across zones. Do not expose +the UPDATE service without network controls in addition to TSIG authentication. + +The *cache* plugin automatically bypasses dynamic zones, so their authoritative +queries always read the current snapshot, including after negative or positive +answers. Other middleware, such as *header*, still processes those requests and +responses, and unrelated zones remain cacheable. External recursive +caches can still retain old answers until their TTL expires. AXFR requests +pass through *transfer* and its access controls. Successful changes trigger +best-effort NOTIFY; bursts are coalesced to one in-flight notification per zone +instance. + +### Persistence + +`database` uses an embedded [bbolt](https://github.com/etcd-io/bbolt) database; +no etcd server or container is required. Its parent directory must exist and +be writable by CoreDNS. Use a local filesystem with working file locks and +sync semantics, not a shared network filesystem. The database is private to +one zone and one CoreDNS process. Overlapping instances in that process share +transactions and snapshots during a Corefile reload, so prerequisites cannot +race and an old instance cannot overwrite a newer generation. + +Configuration validation does not create or modify the database. A missing +database is initialized from `file` on the first query, transfer or +authenticated UPDATE after startup. This prevents a failed startup from +preserving an obsolete seed. Until that first access, the seed must remain +available; creation errors return SERVFAIL rather than acknowledging an update. +Subsequently, the database, including the SOA serial, is authoritative; +editing or removing the seed does not replace dynamic data. Corrupt, incompatible, wrong-zone, or +over-limit databases cause an error, not a fallback to the seed. A failed +commit returns SERVFAIL without publishing the candidate snapshot or serial. +After an abrupt process exit, the database reopens at a committed transaction. + +Stop CoreDNS before copying the database for an offline backup or restoring +it. Never edit, replace, or delete a live database. To deliberately reset the +zone, stop CoreDNS, back up and remove the database, then restart with the +desired seed. If initial creation fails, remove the uninitialized database +before retrying. Do not lower limits below the existing zone's size when +reloading. Database files can retain reusable free pages after records are +deleted; `max_bytes` limits live uncompressed DNS data, not on-disk file size. + +## Syntax + +~~~ +dynupdate [ZONE] { + file DBFILE + database PATH + allow KEY NAME TYPE [TYPE...] + max_records COUNT + max_bytes BYTES + max_update_records COUNT +} +~~~ + +* **ZONE** is the single authoritative zone. If omitted, the server block + must define exactly one zone. +* **DBFILE** is the RFC 1035-style seed zone file. A relative path is resolved + below the path configured by the *root* plugin. Required, but read only when + initializing a new database or starting in memory-only mode. +* `database` is optional. **PATH** is a local database file, also resolved + relative to *root*. The database is created with mode 0600 on systems that + support Unix file permissions. +* **KEY** is the normalized TSIG key name configured in the *tsig* plugin. +* **NAME** is an exact owner name, `@` for the zone apex, or `*` for all names + in the zone. +* **TYPE** is one or more RR types, `ANY` to authorize deleting all RRsets at + one owner name, or `*` for all supported update operations. A wildcard type + must be the only type in the rule. +* `max_records` defaults to 10000 records in the zone. +* `max_bytes` defaults to 8388608 bytes of uncompressed DNS record data. +* `max_update_records` defaults to 1024 records total in an UPDATE's + Prerequisite and Update sections. + +Limits must be positive integers. Requests exceeding the configured limits +are refused atomically with REFUSED; seed or stored data above the zone limits +is rejected during startup. Updates are serialized and rebuild the bounded +zone snapshot, so this backend is intended for small dynamic zones, not +high-volume bulk loading. + +At least one `allow` rule is required. The plugin owns the configured zone; +do not configure a second authoritative backend for the same zone unless its +independent behavior is explicitly intended. + +## Examples + +For temporary ACME challenge records, load a seed zone and permit one key to +update TXT records at the challenge owner. This example uses memory-only mode. +Generate a private key for your deployment; the example secret is public. + +~~~ corefile +example.org { + tsig { + secret update-key.example.org. i9M+00yrECfVZG2qCjr4mPpaGim/Bq+IWMiNrLjUO4Y= + require_opcode UPDATE + } + dynupdate { + file example.org.zone + allow update-key.example.org. _acme-challenge.example.org. TXT + } +} +~~~ + +For a persistent zone, add `database`. A client that needs several record +types at selected names can use separate narrow rules: + +~~~ +example.org { + tsig { + secret update-key.example.org. i9M+00yrECfVZG2qCjr4mPpaGim/Bq+IWMiNrLjUO4Y= + require_opcode UPDATE + } + dynupdate { + file example.org.zone + database example.org.db + allow update-key.example.org. host.example.org. A AAAA + allow update-key.example.org. _acme-challenge.example.org. TXT + } +} +~~~ + +A minimal seed file is: + +~~~ zone +$ORIGIN example.org. +@ 60 IN SOA ns.example.org. hostmaster.example.org. 1 3600 600 86400 60 +@ 60 IN NS ns.example.org. +ns 60 IN A 192.0.2.53 +~~~ + +With a BIND-format TSIG key file, `nsupdate -k update.key` can submit: + +~~~ text +server 127.0.0.1 53 +zone example.org. +prereq nxrrset host.example.org. A +update add host.example.org. 60 A 192.0.2.10 +send +~~~ + +Use `nsupdate -v -k update.key` for TCP. Query `host.example.org. A` directly +on this server to see the change. With `database` configured it remains after +restart. For DHCP forward and reverse updates, configure each zone in its own +server block with its own seed, database and least-privilege `allow` rules. +The DHCP server remains responsible for lease expiry, record cleanup, and +coordinating its forward and reverse requests. + +### DHCP Client Permissions + +The required permissions depend on the UPDATE messages sent by the DHCP +implementation, not just the address records it creates. For example, Kea +2.0.2 D2 writes DHCID records in both the forward and reverse zones and uses +an `ANY` deletion when releasing a name. Allowing only A/AAAA or PTR lets +some steps succeed but refuses later steps. Forward and reverse updates are +separate transactions: a rejected reverse update does not undo a successful +forward update. + +For a DHCP-managed `host.example.org.` at `192.0.2.10`, the forward-zone +rule can be: + +~~~ text +allow update-key.example.org. host.example.org. A AAAA DHCID ANY +~~~ + +The corresponding rule in `2.0.192.in-addr.arpa.` can be: + +~~~ text +allow update-key.example.org. 10.2.0.192.in-addr.arpa. PTR DHCID ANY +~~~ + +`ANY` explicitly permits deleting all RRsets at the authorized name; it +does not mean only the other types listed in the rule. Do not place unrelated +static records at those names. Use `*` for the name only when the DHCP +updater is trusted to manage the entire zone. Keep DHCID conflict resolution +enabled on the DHCP side; a TSIG key identifies the updater, not the client +that owns a lease. + +### Interoperability And Sizing + +With BIND `nsupdate` and Kea `kea-dhcp-ddns` installed, run: + +~~~ sh +go test -race ./test -run '^TestDynUpdate' -count=3 +go test ./plugin/dynupdate -run '^$' -bench '^BenchmarkUpdate$' -benchmem -count=3 +~~~ + +The Kea test supplies synthetic lease-change notifications to a real D2 +process and verifies IPv4 and IPv6 forward/reverse creation, renewal, +ownership conflicts, removal, and name reuse. It is not a DHCP address +allocation, lease-expiration, or physical-network test. Missing client +binaries skip the corresponding local tests; Linux CI installs both. +Distribution confinement may require approved paths for the Kea test +process. `COREDNS_KEA_CONFIG_DIR`, `KEA_PIDFILE_DIR`, and `KEA_LOCKFILE_DIR` +can select prepared writable directories. Linux CI uses this facility to +keep Ubuntu's AppArmor policy enabled. Do not point a test at runtime +directories used by a live Kea service. + +The benchmark changes a record in 100-, 1000-, and 10000-record zones, +with and without synchronous persistence and four concurrent query workers. +`ns/op` measures one protocol-engine transaction, excluding transport and +TSIG verification, while the query metrics report concurrent query latency +and throughput. Allocations include the query workers when +enabled. Measure on the filesystem and hardware used for deployment: +updates rebuild the entire zone and queries can wait for the update and +disk commit. The record limits bound accepted data, not update latency or +peak process memory. This backend is intended for small, infrequently +updated zones, not a high-throughput DHCP service. + +## See Also + +See the *file*, *transfer*, and *tsig* plugins for authoritative data, +AXFR/NOTIFY, and TSIG authentication configuration. + +* [RFC 2136](https://www.rfc-editor.org/rfc/rfc2136) defines DNS UPDATE. +* [RFC 1982](https://www.rfc-editor.org/rfc/rfc1982) defines DNS serial + number arithmetic. diff --git a/plugin/dynupdate/benchmark_test.go b/plugin/dynupdate/benchmark_test.go new file mode 100644 index 000000000..ff615a702 --- /dev/null +++ b/plugin/dynupdate/benchmark_test.go @@ -0,0 +1,141 @@ +package dynupdate + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/coredns/coredns/plugin/pkg/dnstest" + coretest "github.com/coredns/coredns/plugin/test" + + "github.com/miekg/dns" +) + +// BenchmarkUpdate measures a bounded, changing zone rather than duplicate +// no-op updates. Durable mode uses bbolt's normal synchronous commit path. +func BenchmarkUpdate(b *testing.B) { + for _, count := range []int{100, 1000, defaultMaxRecords} { + for _, durable := range []bool{false, true} { + for _, readers := range []int{0, 4} { + b.Run(fmt.Sprintf("records=%d/durable=%t/readers=%d", count, durable, readers), func(b *testing.B) { + d := benchmarkZone(b, count, durable) + updates := make([][]dns.RR, 2) + for i := range updates { + rr, err := dns.NewRR(fmt.Sprintf("change.example.org. 60 IN A 192.0.2.%d", i+1)) + if err != nil { + b.Fatal(err) + } + m := new(dns.Msg).SetUpdate(testZone) + m.RemoveRRset([]dns.RR{rr}) + m.Insert([]dns.RR{rr}) + updates[i] = m.Ns + } + var queries, queryNanos, maxQueryNanos atomic.Int64 + var wg sync.WaitGroup + startQueries := make(chan struct{}) + stop := make(chan struct{}) + errors := make(chan error, readers) + for range readers { + wg.Go(func() { + query := new(dns.Msg).SetQuestion("change.example.org.", dns.TypeA) + <-startQueries + for { + select { + case <-stop: + return + default: + } + start := time.Now() + w := dnstest.NewRecorder(&coretest.ResponseWriter{}) + code, err := d.ServeDNS(context.Background(), w, query) + if err != nil || code != dns.RcodeSuccess || w.Msg == nil || len(w.Msg.Answer) != 1 { + errors <- fmt.Errorf("concurrent query: code=%d err=%v reply=%v", code, err, w.Msg) + return + } + latency := time.Since(start).Nanoseconds() + queries.Add(1) + queryNanos.Add(latency) + for old := maxQueryNanos.Load(); latency > old; old = maxQueryNanos.Load() { + if maxQueryNanos.CompareAndSwap(old, latency) { + break + } + } + } + }) + } + b.ReportAllocs() + b.ResetTimer() + start := time.Now() + close(startQueries) + for i := range b.N { + code, err := d.applyUpdate(testKey, nil, updates[i%2]) + if code != dns.RcodeSuccess || err != nil { + b.Errorf("update: code=%d err=%v", code, err) + break + } + } + b.StopTimer() + close(stop) + wg.Wait() + elapsed := time.Since(start) + close(errors) + for err := range errors { + b.Error(err) + } + if n := queries.Load(); n > 0 { + b.ReportMetric(float64(queryNanos.Load())/float64(n), "ns/query") + b.ReportMetric(float64(maxQueryNanos.Load()), "max-ns/query") + b.ReportMetric(float64(n)/elapsed.Seconds(), "queries/s") + } + }) + } + } + } +} + +func benchmarkZone(b *testing.B, count int, durable bool) *DynUpdate { + b.Helper() + texts := []string{ + "example.org. 60 IN SOA ns.example.org. hostmaster.example.org. 10 60 60 60 60", + "example.org. 60 IN NS ns.example.org.", + "change.example.org. 60 IN A 192.0.2.254", + } + for i := len(texts); i < count; i++ { + texts = append(texts, fmt.Sprintf("host-%d.example.org. 60 IN A 192.0.2.100", i)) + } + d := &DynUpdate{Zone: testZone, permissions: []permission{{key: testKey, name: allNames, allTypes: true}}} + for _, text := range texts { + rr, err := dns.NewRR(text) + if err != nil { + b.Fatal(err) + } + d.records = append(d.records, rr) + } + var err error + d.view, err = d.build(d.records) + if err != nil { + b.Fatal(err) + } + if durable { + dir := b.TempDir() + d.seed, d.database = filepath.Join(dir, "seed.zone"), filepath.Join(dir, "zone.db") + if err := os.WriteFile(d.seed, []byte(strings.Join(texts, "\n")+"\n"), 0600); err != nil { + b.Fatal(err) + } + if err := d.ensureStore(); err != nil { + b.Fatal(err) + } + } + b.Cleanup(func() { + if err := d.close(); err != nil { + b.Error(err) + } + }) + return d +} diff --git a/plugin/dynupdate/dynupdate.go b/plugin/dynupdate/dynupdate.go new file mode 100644 index 000000000..b7e5d6511 --- /dev/null +++ b/plugin/dynupdate/dynupdate.go @@ -0,0 +1,143 @@ +// Package dynupdate implements RFC 2136 dynamic updates for a file-backed +// authoritative zone. +// +// The seed file is never modified. An optional local database makes accepted +// updates durable. Queries and AXFR see the same atomically replaced snapshot. +package dynupdate + +import ( + "context" + "fmt" + "sync" + + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/file" + "github.com/coredns/coredns/plugin/pkg/upstream" + "github.com/coredns/coredns/plugin/transfer" + + "github.com/miekg/dns" +) + +const pluginName = "dynupdate" + +var ( + _ plugin.Handler = (*DynUpdate)(nil) + _ transfer.Transferer = (*DynUpdate)(nil) +) + +// DynUpdate serves one authoritative zone and accepts RFC 2136 UPDATE +// messages for it. +type DynUpdate struct { + Next plugin.Handler + + // Zone is the canonical, fully-qualified origin served by this instance. + Zone string + + // Xfer is populated when the transfer plugin is configured in the same + // server block. It is used for best-effort NOTIFY after a committed update. + Xfer *transfer.Transfer + + permissions []permission + limits limits + seed string + database string + + mu sync.RWMutex + records []dns.RR + view *file.File + store *zoneStore + closed bool + notifyPending bool + notifyRunning bool +} + +// ServeDNS implements the plugin.Handler interface. +func (d *DynUpdate) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + if r.Opcode == dns.OpcodeUpdate { + return d.serveUpdate(ctx, w, r) + } + if len(r.Question) == 1 && !inZone(d.Zone, r.Question[0].Name) { + return plugin.NextOrFailure(d.Name(), d.Next, ctx, w, r) + } + view, err := d.snapshot() + if err != nil { + return dns.RcodeServerFailure, err + } + + return view.ServeDNS(ctx, w, r) +} + +// Transfer implements transfer.Transferer. The current immutable view is +// used, so a transfer observes either the old or the new zone generation. +func (d *DynUpdate) Transfer(zone string, serial uint32) (<-chan []dns.RR, error) { + zone = canonicalName(zone) + if zone != d.Zone { + return nil, transfer.ErrNotAuthoritative + } + + view, err := d.snapshot() + if err != nil { + return nil, err + } + return view.Transfer(zone, serial) +} + +// Name implements the plugin.Handler interface. +func (d *DynUpdate) Name() string { return pluginName } + +// CacheBypassZones prevents caching of mutable data without bypassing other +// middleware between cache and this authoritative backend. +func (d *DynUpdate) CacheBypassZones() []string { return []string{d.Zone} } + +func (d *DynUpdate) snapshot() (*file.File, error) { + d.mu.Lock() + defer d.mu.Unlock() + if err := d.ensureStore(); err != nil { + return nil, err + } + view := d.view + if d.store != nil { + d.store.mu.RLock() + view = d.store.view + d.store.mu.RUnlock() + } + if view == nil { + return nil, fmt.Errorf("zone %q has no snapshot", d.Zone) + } + copyView := *view + copyView.Next = d.Next + return ©View, nil +} + +// build creates the read and transfer view for a record snapshot. file.Zone +// already contains CoreDNS's authoritative lookup, wildcard, delegation, and +// DNSSEC response behavior, so this plugin does not duplicate those rules. +func (d *DynUpdate) build(records []dns.RR) (*file.File, error) { + if err := d.limits.check(records); err != nil { + return nil, err + } + if err := validateRecords(records, d.Zone); err != nil { + return nil, err + } + + z := file.NewZone(d.Zone, "") + z.Upstream = upstream.New() + for _, rr := range records { + if err := z.Insert(dns.Copy(rr)); err != nil { + return nil, err + } + } + return &file.File{ + Next: d.Next, + Zones: file.Zones{ + Z: map[string]*file.Zone{d.Zone: z}, + Names: []string{d.Zone}, + }, + }, nil +} + +// install swaps a fully built view. The caller must hold d.mu for writing. +func (d *DynUpdate) install(records []dns.RR, view *file.File) { + d.records = records + d.view = view +} diff --git a/plugin/dynupdate/dynupdate_test.go b/plugin/dynupdate/dynupdate_test.go new file mode 100644 index 000000000..37f424df3 --- /dev/null +++ b/plugin/dynupdate/dynupdate_test.go @@ -0,0 +1,723 @@ +package dynupdate + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/coredns/caddy" + "github.com/coredns/coredns/core/dnsserver" + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/pkg/dnstest" + coretest "github.com/coredns/coredns/plugin/test" + "github.com/coredns/coredns/plugin/transfer" + + "github.com/miekg/dns" +) + +const ( + testZone = "example.org." + testKey = "update-key.example.org." +) + +func mustRR(t *testing.T, text string) dns.RR { + t.Helper() + rr, err := dns.NewRR(text) + if err != nil { + t.Fatalf("parsing RR %q: %v", text, err) + } + return rr +} + +func withClass(t *testing.T, rr dns.RR, class uint16) dns.RR { + t.Helper() + rr = dns.Copy(rr) + rr.Header().Class = class + // PackRR fills RDLENGTH, which is significant for wire-format UPDATE + // validation and makes the test record equivalent to a received RR. + buf := make([]byte, dns.Len(rr)+1024) + if _, err := dns.PackRR(rr, buf, 0, nil, false); err != nil { + t.Fatalf("packing RR %q: %v", rr, err) + } + return rr +} + +func emptyRR(name string, rrType, class uint16) dns.RR { + return &dns.RFC3597{Hdr: dns.RR_Header{ + Name: name, + Rrtype: rrType, + Class: class, + }} +} + +func emptyA(name string, class uint16) dns.RR { + return &dns.A{Hdr: dns.RR_Header{ + Name: name, + Rrtype: dns.TypeA, + Class: class, + }} +} + +func newTestDynUpdate(t *testing.T, extra ...string) *DynUpdate { + t.Helper() + texts := make([]string, 0, 4+len(extra)) + texts = append(texts, + "example.org. 60 IN SOA ns.example.org. hostmaster.example.org. 10 60 60 60 60", + "example.org. 60 IN NS ns.example.org.", + "ns.example.org. 60 IN A 192.0.2.53", + "www.example.org. 60 IN A 192.0.2.1", + ) + texts = append(texts, extra...) + records := make([]dns.RR, 0, len(texts)) + for _, text := range texts { + records = append(records, mustRR(t, text)) + } + d := &DynUpdate{ + Zone: testZone, + records: records, + permissions: []permission{{ + key: testKey, + name: allNames, + allTypes: true, + }}, + } + var err error + d.view, err = d.build(records) + if err != nil { + t.Fatalf("building test zone: %v", err) + } + return d +} + +func serial(d *DynUpdate) uint32 { + return soaAt(d.records, d.Zone).Serial +} + +func hasRecord(d *DynUpdate, name string, rrType uint16, want string) bool { + for _, rr := range d.rrset(name, rrType) { + if want == "" || rr.String() == want { + return true + } + } + return false +} + +func TestSerialArithmetic(t *testing.T) { + tests := []struct { + name string + a, b uint32 + greater bool + }{ + {name: "equal", a: 10, b: 10, greater: false}, + {name: "forward", a: 11, b: 10, greater: true}, + {name: "wrap", a: 1, b: ^uint32(0), greater: true}, + {name: "backward", a: 10, b: 11, greater: false}, + {name: "half-space", a: 1<<31 + 10, b: 10, greater: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := serialGreater(tt.a, tt.b); got != tt.greater { + t.Fatalf("serialGreater(%d, %d) = %v, want %v", tt.a, tt.b, got, tt.greater) + } + }) + } +} + +func TestApplyAddAndSerial(t *testing.T) { + d := newTestDynUpdate(t) + add := mustRR(t, "new.example.org. 60 IN TXT \"first\"") + if got, err := d.applyUpdate(testKey, nil, []dns.RR{add}); got != dns.RcodeSuccess || err != nil { + t.Fatalf("add returned rcode=%d err=%v", got, err) + } + if !hasRecord(d, "new.example.org.", dns.TypeTXT, "new.example.org.\t60\tIN\tTXT\t\"first\"") { + t.Fatalf("added TXT record is missing") + } + if got := serial(d); got != 11 { + t.Fatalf("serial after add = %d, want 11", got) + } + + // A duplicate with the same TTL is a no-op and must not consume a serial. + duplicate := mustRR(t, "new.example.org. 60 IN TXT \"first\"") + if got, err := d.applyUpdate(testKey, nil, []dns.RR{duplicate}); got != dns.RcodeSuccess || err != nil { + t.Fatalf("duplicate returned rcode=%d err=%v", got, err) + } + if got := serial(d); got != 11 { + t.Fatalf("serial after duplicate = %d, want 11", got) + } + + // The same RDATA with a different TTL replaces the existing RR. + ttlChange := mustRR(t, "new.example.org. 120 IN TXT \"first\"") + if got, err := d.applyUpdate(testKey, nil, []dns.RR{ttlChange}); got != dns.RcodeSuccess || err != nil { + t.Fatalf("TTL change returned rcode=%d err=%v", got, err) + } + if got := serial(d); got != 12 { + t.Fatalf("serial after TTL change = %d, want 12", got) + } + if rr := d.rrset("new.example.org.", dns.TypeTXT); len(rr) != 1 || rr[0].Header().Ttl != 120 { + t.Fatalf("TTL change was not applied: %v", rr) + } +} + +func TestPrerequisites(t *testing.T) { + d := newTestDynUpdate(t) + existingA := emptyA("www.example.org.", dns.ClassANY) + nonexistent := emptyA("missing.example.org.", dns.ClassANY) + existingName := emptyRR("www.example.org.", dns.TypeANY, dns.ClassANY) + missingName := emptyRR("missing.example.org.", dns.TypeANY, dns.ClassNONE) + value := withClass(t, mustRR(t, "www.example.org. 0 IN A 192.0.2.1"), dns.ClassINET) + value.Header().Ttl = 0 + wrongValue := withClass(t, mustRR(t, "www.example.org. 0 IN A 192.0.2.99"), dns.ClassINET) + wrongValue.Header().Ttl = 0 + + tests := []struct { + name string + prereq []dns.RR + wantRcode int + }{ + {name: "name in use", prereq: []dns.RR{existingName}, wantRcode: dns.RcodeSuccess}, + {name: "name not in use", prereq: []dns.RR{missingName}, wantRcode: dns.RcodeSuccess}, + {name: "rrset exists", prereq: []dns.RR{existingA}, wantRcode: dns.RcodeSuccess}, + {name: "rrset missing", prereq: []dns.RR{nonexistent}, wantRcode: dns.RcodeNXRrset}, + {name: "rrset does not exist", prereq: []dns.RR{emptyA("missing.example.org.", dns.ClassNONE)}, wantRcode: dns.RcodeSuccess}, + {name: "rrset exists unexpectedly", prereq: []dns.RR{emptyA("www.example.org.", dns.ClassNONE)}, wantRcode: dns.RcodeYXRrset}, + {name: "value dependent", prereq: []dns.RR{value}, wantRcode: dns.RcodeSuccess}, + {name: "value mismatch", prereq: []dns.RR{wrongValue}, wantRcode: dns.RcodeNXRrset}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + before := serial(d) + add := mustRR(t, "prereq-"+strings.ReplaceAll(tt.name, " ", "-")+".example.org. 60 IN TXT \"ok\"") + got, err := d.applyUpdate(testKey, tt.prereq, []dns.RR{add}) + if err != nil || got != tt.wantRcode { + t.Fatalf("got rcode=%d err=%v, want %d", got, err, tt.wantRcode) + } + if tt.wantRcode != dns.RcodeSuccess { + if serial(d) != before { + t.Fatalf("failed prerequisite changed serial from %d to %d", before, serial(d)) + } + return + } + if !hasRecord(d, add.Header().Name, dns.TypeTXT, "") { + t.Fatalf("successful prerequisite did not permit update") + } + }) + } +} + +func TestUpdateOperationsAndApexProtection(t *testing.T) { + d := newTestDynUpdate(t, "multi.example.org. 60 IN A 192.0.2.2", "multi.example.org. 60 IN TXT \"x\"") + before := serial(d) + + deleteRRset := emptyA("multi.example.org.", dns.ClassANY) + if got, err := d.applyUpdate(testKey, nil, []dns.RR{deleteRRset}); got != dns.RcodeSuccess || err != nil { + t.Fatalf("RRset delete returned rcode=%d err=%v", got, err) + } + if d.rrsetExists("multi.example.org.", dns.TypeA) || !d.rrsetExists("multi.example.org.", dns.TypeTXT) { + t.Fatalf("RRset delete did not remove only the requested RRset") + } + if serial(d) != before+1 { + t.Fatalf("serial after RRset delete = %d, want %d", serial(d), before+1) + } + deleteTXT := emptyRR("multi.example.org.", dns.TypeTXT, dns.ClassANY) + if got, err := d.applyUpdate(testKey, nil, []dns.RR{deleteTXT}); got != dns.RcodeSuccess || err != nil { + t.Fatalf("second RRset delete returned rcode=%d err=%v", got, err) + } + if d.rrsetExists("multi.example.org.", dns.TypeTXT) { + t.Fatalf("second RRset delete left records behind") + } + + // Delete all data at the apex, while retaining SOA and NS as required by + // RFC 2136. + deleteAll := emptyRR(testZone, dns.TypeANY, dns.ClassANY) + if got, err := d.applyUpdate(testKey, nil, []dns.RR{deleteAll}); got != dns.RcodeSuccess || err != nil { + t.Fatalf("apex delete returned rcode=%d err=%v", got, err) + } + if soaAt(d.records, testZone) == nil || !d.rrsetExists(testZone, dns.TypeNS) { + t.Fatalf("apex delete removed SOA or NS") + } + + // A last apex NS cannot be removed with an exact delete. + lastNS := withClass(t, mustRR(t, "example.org. 0 IN NS ns.example.org."), dns.ClassNONE) + lastNS.Header().Ttl = 0 + if got, err := d.applyUpdate(testKey, nil, []dns.RR{lastNS}); got != dns.RcodeSuccess || err != nil { + t.Fatalf("last NS delete returned rcode=%d err=%v", got, err) + } + if !d.rrsetExists(testZone, dns.TypeNS) { + t.Fatalf("last apex NS was removed") + } +} + +func TestExplicitAnyPermission(t *testing.T) { + d := newTestDynUpdate(t) + d.permissions = []permission{{ + key: testKey, + name: "www.example.org.", + types: map[uint16]struct{}{dns.TypeANY: {}}, + }} + + deleteAll := emptyRR("www.example.org.", dns.TypeANY, dns.ClassANY) + if got, err := d.applyUpdate(testKey, nil, []dns.RR{deleteAll}); got != dns.RcodeSuccess || err != nil { + t.Fatalf("explicit ANY delete returned rcode=%d err=%v", got, err) + } + if d.nameInUse("www.example.org.") { + t.Fatal("explicit ANY permission did not remove the owner data") + } + + add := mustRR(t, "www.example.org. 60 IN A 192.0.2.20") + if got, err := d.applyUpdate(testKey, nil, []dns.RR{add}); got != dns.RcodeRefused || err != nil { + t.Fatalf("ordinary RR with ANY-only permission returned rcode=%d err=%v, want REFUSED", got, err) + } +} + +func TestCNAMERules(t *testing.T) { + d := newTestDynUpdate(t) + before := serial(d) + // A CNAME cannot be added where ordinary data exists. + cname := mustRR(t, "www.example.org. 60 IN CNAME target.example.org.") + if got, err := d.applyUpdate(testKey, nil, []dns.RR{cname}); got != dns.RcodeSuccess || err != nil { + t.Fatalf("CNAME conflict returned rcode=%d err=%v", got, err) + } + if d.rrsetExists("www.example.org.", dns.TypeCNAME) || serial(d) != before { + t.Fatalf("CNAME conflict changed the zone") + } + + // Processed in order: deleting the A permits the later CNAME add. + deleteA := withClass(t, mustRR(t, "www.example.org. 0 IN A 192.0.2.1"), dns.ClassNONE) + deleteA.Header().Ttl = 0 + if got, err := d.applyUpdate(testKey, nil, []dns.RR{deleteA, cname}); got != dns.RcodeSuccess || err != nil { + t.Fatalf("ordered CNAME update returned rcode=%d err=%v", got, err) + } + if !d.rrsetExists("www.example.org.", dns.TypeCNAME) { + t.Fatalf("ordered CNAME update did not add CNAME") + } + before = serial(d) + duplicate := mustRR(t, "www.example.org. 60 IN CNAME target.example.org.") + if got, err := d.applyUpdate(testKey, nil, []dns.RR{duplicate}); got != dns.RcodeSuccess || err != nil { + t.Fatalf("duplicate CNAME returned rcode=%d err=%v", got, err) + } + if serial(d) != before { + t.Fatalf("duplicate CNAME consumed a serial") + } +} + +func TestValidationIsAtomic(t *testing.T) { + d := newTestDynUpdate(t) + before := serial(d) + valid := mustRR(t, "new.example.org. 60 IN A 192.0.2.10") + outOfZone := mustRR(t, "outside.test. 60 IN A 192.0.2.11") + if got, err := d.applyUpdate(testKey, nil, []dns.RR{valid, outOfZone}); got != dns.RcodeNotZone || err != nil { + t.Fatalf("out-of-zone transaction returned rcode=%d err=%v", got, err) + } + if d.rrsetExists("new.example.org.", dns.TypeA) || serial(d) != before { + t.Fatalf("failed prescan partially changed the zone") + } + + malformed := emptyA("new.example.org.", dns.ClassANY) + malformed.Header().Ttl = 1 + if got, err := d.applyUpdate(testKey, nil, []dns.RR{malformed}); got != dns.RcodeFormatError || err != nil { + t.Fatalf("malformed delete returned rcode=%d err=%v", got, err) + } + + d.permissions = []permission{{key: testKey, name: "allowed.example.org.", types: map[uint16]struct{}{dns.TypeA: {}}}} + unauthorized := mustRR(t, "blocked.example.org. 60 IN A 192.0.2.12") + if got, err := d.applyUpdate(testKey, nil, []dns.RR{unauthorized}); got != dns.RcodeRefused || err != nil { + t.Fatalf("unauthorized update returned rcode=%d err=%v", got, err) + } +} + +func TestPrerequisitesPrecedeUpdatePrescan(t *testing.T) { + d := newTestDynUpdate(t) + missingRRset := emptyA("missing.example.org.", dns.ClassANY) + outOfZone := mustRR(t, "outside.test. 60 IN A 192.0.2.13") + + got, err := d.applyUpdate(testKey, []dns.RR{missingRRset}, []dns.RR{outOfZone}) + if err != nil || got != dns.RcodeNXRrset { + t.Fatalf("transaction returned rcode=%d err=%v, want NXRRSET", got, err) + } + if d.rrsetExists("outside.test.", dns.TypeA) { + t.Fatalf("failed prerequisite allowed an out-of-zone update") + } +} + +func TestSerialWrap(t *testing.T) { + d := newTestDynUpdate(t) + d.records[0].(*dns.SOA).Serial = ^uint32(0) + newRecord := mustRR(t, "wrap.example.org. 60 IN TXT \"x\"") + if got, err := d.applyUpdate(testKey, nil, []dns.RR{newRecord}); got != dns.RcodeSuccess || err != nil { + t.Fatalf("wrap update returned rcode=%d err=%v", got, err) + } + if got := serial(d); got != 1 { + t.Fatalf("wrapped serial = %d, want 1", got) + } +} + +func TestServeUpdateRequiresValidatedTSIG(t *testing.T) { + d := newTestDynUpdate(t) + r := new(dns.Msg) + r.SetQuestion(testZone, dns.TypeSOA) + r.Opcode = dns.OpcodeUpdate + w := dnstest.NewRecorder(&coretest.ResponseWriter{}) + code, err := d.ServeDNS(context.Background(), w, r) + if err != nil || code != dns.RcodeSuccess { + t.Fatalf("ServeDNS returned code=%d err=%v", code, err) + } + if w.Msg == nil || w.Msg.Rcode != dns.RcodeRefused { + t.Fatalf("unsigned update response = %#v, want REFUSED", w.Msg) + } + if w.Msg.Opcode != dns.OpcodeUpdate { + t.Fatalf("response opcode = %d, want UPDATE", w.Msg.Opcode) + } +} + +func TestServeUpdateRejectsOtherZones(t *testing.T) { + d := newTestDynUpdate(t) + called := false + d.Next = plugin.HandlerFunc(func(_ context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + called = true + m := new(dns.Msg).SetReply(r) + if err := w.WriteMsg(m); err != nil { + return dns.RcodeServerFailure, err + } + return dns.RcodeSuccess, nil + }) + + r := new(dns.Msg) + r.SetQuestion("other.example.", dns.TypeSOA) + r.Opcode = dns.OpcodeUpdate + w := dnstest.NewRecorder(&coretest.ResponseWriter{}) + code, err := d.ServeDNS(context.Background(), w, r) + if err != nil || code != dns.RcodeSuccess { + t.Fatalf("UPDATE returned code=%d err=%v", code, err) + } + if called { + t.Error("UPDATE for another zone reached the next handler") + } + if w.Msg == nil || w.Msg.Rcode != dns.RcodeNotAuth { + t.Fatalf("UPDATE response = %#v, want NOTAUTH", w.Msg) + } +} + +func TestTransferUsesCurrentSnapshot(t *testing.T) { + d := newTestDynUpdate(t) + added := mustRR(t, "transfer.example.org. 60 IN TXT \"dynamic\"") + if got, err := d.applyUpdate(testKey, nil, []dns.RR{added}); got != dns.RcodeSuccess || err != nil { + t.Fatalf("dynamic update returned rcode=%d err=%v", got, err) + } + ch, err := d.Transfer(testZone, 0) + if err != nil { + t.Fatalf("Transfer returned error: %v", err) + } + var records []dns.RR + for batch := range ch { + records = append(records, batch...) + } + if len(records) < 2 || records[0].Header().Rrtype != dns.TypeSOA || records[len(records)-1].Header().Rrtype != dns.TypeSOA { + t.Fatalf("unexpected transfer framing: %v", records) + } + found := false + for _, rr := range records { + if sameRR(rr, added) { + found = true + break + } + } + if !found { + t.Fatalf("transfer did not include dynamic record") + } + + ch, err = d.Transfer("EXAMPLE.ORG", 0) + if err != nil || ch == nil { + t.Fatalf("case-insensitive transfer returned channel=%v err=%v", ch, err) + } + for range ch { + } + if _, err := d.Transfer("child.example.org.", 0); err != transfer.ErrNotAuthoritative { + t.Fatalf("subdomain transfer error = %v, want ErrNotAuthoritative", err) + } +} + +func TestExternalCNAMEUsesInitializedUpstream(t *testing.T) { + d := newTestDynUpdate(t, "alias.example.org. 60 IN CNAME external.test.") + r := new(dns.Msg) + r.SetQuestion("alias.example.org.", dns.TypeA) + w := dnstest.NewRecorder(&coretest.ResponseWriter{}) + + code, err := d.ServeDNS(context.Background(), w, r) + if err != nil { + t.Fatalf("ServeDNS returned error: %v", err) + } + if code != dns.RcodeSuccess || w.Msg == nil || w.Msg.Rcode != dns.RcodeServerFailure { + t.Fatalf("external CNAME response = code %d, message %#v; want written SERVFAIL", code, w.Msg) + } +} + +func TestConcurrentUpdates(t *testing.T) { + d := newTestDynUpdate(t) + const updates = 16 + updateRecords := make([]dns.RR, updates) + for i := range updates { + updateRecords[i] = mustRR(t, fmt.Sprintf("node-%d.example.org. 60 IN A 192.0.2.%d", i, i+20)) + } + var wg sync.WaitGroup + for i := range updates { + wg.Add(1) + go func(i int) { + defer wg.Done() + if got, err := d.applyUpdate(testKey, nil, []dns.RR{updateRecords[i]}); got != dns.RcodeSuccess || err != nil { + t.Errorf("update %d returned rcode=%d err=%v", i, got, err) + } + }(i) + } + wg.Wait() + if got := serial(d); got != 10+updates { + t.Fatalf("serial after concurrent updates = %d, want %d", got, 10+updates) + } + for i := range updates { + if !d.rrsetExists(fmt.Sprintf("node-%d.example.org.", i), dns.TypeA) { + t.Errorf("missing concurrent update %d", i) + } + } +} + +func TestParse(t *testing.T) { + dir := t.TempDir() + zonePath := filepath.Join(dir, "db.example.org") + zoneText := strings.Join([]string{ + "$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", + }, "\n") + if err := os.WriteFile(zonePath, []byte(zoneText), 0600); err != nil { + t.Fatalf("writing test zone: %v", err) + } + + c := caddy.NewTestController("dns", `dynupdate { + file db.example.org + allow update-key.example.org. * A TXT + }`) + c.ServerBlockKeys = []string{testZone} + dnsserver.GetConfig(c).Root = dir + d, err := parse(c) + if err != nil { + t.Fatalf("parse returned error: %v", err) + } + if d.Zone != testZone || len(d.records) != 3 { + t.Fatalf("parsed zone = %q with %d records", d.Zone, len(d.records)) + } + if !d.allows(testKey, "new.example.org.", dns.TypeTXT) || d.allows(testKey, "new.example.org.", dns.TypeAAAA) { + t.Fatalf("parsed allow rule has unexpected permissions") + } + + explicit := caddy.NewTestController("dns", `dynupdate example.org. { + file db.example.org + allow update-key.example.org. * A + }`) + explicit.ServerBlockKeys = []string{testZone, "other.example."} + dnsserver.GetConfig(explicit).Root = dir + explicitDynUpdate, err := parse(explicit) + if err != nil { + t.Fatalf("explicit zone in a multi-zone server block returned err=%v", err) + } + if explicitDynUpdate.Zone != testZone { + t.Fatalf("explicit zone in a multi-zone server block returned zone=%q", explicitDynUpdate.Zone) + } +} + +func TestParseRejectsUnsafeConfiguration(t *testing.T) { + dir := t.TempDir() + zoneText := strings.Join([]string{ + "$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", + }, "\n") + if err := os.WriteFile(filepath.Join(dir, "db.example.org"), []byte(zoneText), 0600); err != nil { + t.Fatalf("writing test zone: %v", err) + } + + tests := []struct { + name string + body string + }{ + { + name: "missing file", + body: `dynupdate { + allow update-key.example.org. * A + }`, + }, + { + name: "missing allow", + body: `dynupdate { + file db.example.org + }`, + }, + { + name: "unknown policy type", + body: `dynupdate { + file db.example.org + allow update-key.example.org. * TYPE65280 + }`, + }, + { + name: "wildcard type mixed with named type", + body: `dynupdate { + file db.example.org + allow update-key.example.org. * * TXT + }`, + }, + { + name: "invalid key wildcard", + body: `dynupdate { + file db.example.org + allow * * TXT + }`, + }, + { + name: "owner outside zone", + body: `dynupdate { + file db.example.org + allow update-key.example.org. outside.test. TXT + }`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := caddy.NewTestController("dns", tt.body) + c.ServerBlockKeys = []string{testZone} + dnsserver.GetConfig(c).Root = dir + if _, err := parse(c); err == nil { + t.Fatalf("parse accepted unsafe configuration") + } + }) + } + + validAny := caddy.NewTestController("dns", `dynupdate { + file db.example.org + allow update-key.example.org. host.example.org. ANY + }`) + validAny.ServerBlockKeys = []string{testZone} + dnsserver.GetConfig(validAny).Root = dir + d, err := parse(validAny) + if err != nil { + t.Fatalf("parse rejected an explicit ANY update permission: %v", err) + } + if !d.allows(testKey, "host.example.org.", dns.TypeANY) { + t.Fatal("explicit ANY update permission was not recorded") + } + + c := caddy.NewTestController("dns", `dynupdate { + file db.example.org + allow update-key.example.org. * A + }`) + c.ServerBlockKeys = []string{testZone, "other.example."} + dnsserver.GetConfig(c).Root = dir + if _, err := parse(c); err == nil { + t.Fatalf("parse accepted a server block with multiple implicit zones") + } +} + +func TestParseRejectsDuplicateDirective(t *testing.T) { + for _, durable := range []bool{false, true} { + t.Run(fmt.Sprintf("durable=%t", durable), func(t *testing.T) { + dir := t.TempDir() + seed := filepath.Join(dir, "seed.zone") + if err := os.WriteFile(seed, []byte("example.org. 60 IN SOA ns.example.org. hostmaster.example.org. 1 60 60 60 60\n"), 0600); err != nil { + t.Fatal(err) + } + database := "" + if durable { + database = "database updates.db" + } + body := fmt.Sprintf(`dynupdate example.org. { + file seed.zone + %s + allow update-key.example.org. * A + } + dynupdate example.org. { + file seed.zone + allow update-key.example.org. restricted.example.org. TXT + }`, database) + c := caddy.NewTestController("dns", body) + c.ServerBlockKeys = []string{testZone} + dnsserver.GetConfig(c).Root = dir + if _, err := parse(c); !errors.Is(err, plugin.ErrOnce) { + t.Errorf("duplicate directive: got %v, want %v", err, plugin.ErrOnce) + } + if _, err := os.Stat(filepath.Join(dir, "updates.db")); !errors.Is(err, os.ErrNotExist) { + t.Errorf("invalid configuration created a database: %v", err) + } + }) + } +} + +func TestReadZoneRejectsInvalidZoneData(t *testing.T) { + tests := []struct { + name string + zone string + }{ + { + name: "missing SOA", + zone: "$ORIGIN example.org.\n@ 60 IN NS ns.example.org.\n", + }, + { + name: "duplicate SOA", + zone: "$ORIGIN example.org.\n@ 60 IN SOA ns.example.org. hostmaster.example.org. 10 60 60 60 60\n@ 60 IN SOA ns.example.org. hostmaster.example.org. 11 60 60 60 60\n", + }, + { + name: "zero serial", + zone: "$ORIGIN example.org.\n@ 60 IN SOA ns.example.org. hostmaster.example.org. 0 60 60 60 60\n", + }, + { + name: "CNAME data conflict", + zone: "$ORIGIN example.org.\n@ 60 IN SOA ns.example.org. hostmaster.example.org. 10 60 60 60 60\nalias 60 IN CNAME target.example.org.\nalias 60 IN A 192.0.2.20\n", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "db.example.org") + if err := os.WriteFile(path, []byte(tt.zone), 0600); err != nil { + t.Fatalf("writing zone: %v", err) + } + if _, err := readZone(path, testZone); err == nil { + t.Fatalf("readZone accepted %s", tt.name) + } + }) + } +} + +func TestUnsupportedMetadataIsRejected(t *testing.T) { + unsupported := []uint16{ + dns.TypeSIG, dns.TypeKEY, dns.TypeNXT, + dns.TypeDS, dns.TypeRRSIG, dns.TypeNSEC, dns.TypeDNSKEY, + dns.TypeNSEC3, dns.TypeNSEC3PARAM, + dns.TypeTALINK, dns.TypeCDS, dns.TypeCDNSKEY, + dns.TypeZONEMD, dns.TypeTA, dns.TypeDLV, + } + for _, rrType := range unsupported { + t.Run(dns.TypeToString[rrType], func(t *testing.T) { + soa := mustRR(t, "example.org. 60 IN SOA ns.example.org. hostmaster.example.org. 10 60 60 60 60") + rr := dns.TypeToRR[rrType]() + rr.Header().Name = "record.example.org." + rr.Header().Rrtype = rrType + rr.Header().Class = dns.ClassINET + if err := validateRecords([]dns.RR{soa, rr}, testZone); err == nil { + t.Fatalf("validateRecords accepted %s", dns.TypeToString[rrType]) + } + + d := newTestDynUpdate(t) + before := serial(d) + if got, err := d.applyUpdate(testKey, nil, []dns.RR{rr}); got != dns.RcodeNotImplemented || err != nil { + t.Fatalf("update returned rcode=%d err=%v, want NOTIMP", got, err) + } + if serial(d) != before { + t.Fatalf("rejected metadata update changed serial") + } + }) + } +} diff --git a/plugin/dynupdate/limits.go b/plugin/dynupdate/limits.go new file mode 100644 index 000000000..259c21dee --- /dev/null +++ b/plugin/dynupdate/limits.go @@ -0,0 +1,49 @@ +package dynupdate + +import ( + "fmt" + + "github.com/miekg/dns" +) + +const ( + defaultMaxRecords = 10000 + defaultMaxBytes = 8 << 20 + defaultMaxUpdateRecords = 1024 +) + +type limits struct { + records, bytes, updateRecords int +} + +func (l limits) defaults() limits { + if l.records == 0 { + l.records = defaultMaxRecords + } + if l.bytes == 0 { + l.bytes = defaultMaxBytes + } + if l.updateRecords == 0 { + l.updateRecords = defaultMaxUpdateRecords + } + return l +} + +func (l limits) check(records []dns.RR) error { + l = l.defaults() + if len(records) > l.records { + return fmt.Errorf("zone exceeds max_records (%d)", l.records) + } + remaining := l.bytes + for _, rr := range records { + if rr == nil { + return fmt.Errorf("zone contains a nil record") + } + size := dns.Len(rr) + if size > remaining { + return fmt.Errorf("zone exceeds max_bytes (%d)", l.bytes) + } + remaining -= size + } + return nil +} diff --git a/plugin/dynupdate/setup.go b/plugin/dynupdate/setup.go new file mode 100644 index 000000000..64597fa3e --- /dev/null +++ b/plugin/dynupdate/setup.go @@ -0,0 +1,360 @@ +package dynupdate + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/coredns/caddy" + "github.com/coredns/coredns/core/dnsserver" + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/file" + "github.com/coredns/coredns/plugin/transfer" + + "github.com/miekg/dns" +) + +func init() { plugin.Register(pluginName, setup) } + +func setup(c *caddy.Controller) error { + d, err := parse(c) + if err != nil { + return plugin.Error(pluginName, err) + } + + cfg := dnsserver.GetConfig(c) + cfg.AllowOpcode(dns.OpcodeUpdate) + cfg.AddPlugin(func(next plugin.Handler) plugin.Handler { + d.mu.Lock() + d.Next = next + if d.view != nil { + d.view.Next = next + } + d.mu.Unlock() + return d + }) + + c.OnStartup(func() error { + if h := dnsserver.GetConfig(c).Handler("transfer"); h != nil { + if t, ok := h.(*transfer.Transfer); ok { + d.mu.Lock() + d.Xfer = t + d.mu.Unlock() + } + } + return nil + }) + c.OnShutdown(d.close) + + return nil +} + +func parse(c *caddy.Controller) (*DynUpdate, error) { + if !c.Next() { + return nil, c.ArgErr() + } + + origin, err := parseOrigin(c.RemainingArgs(), c.ServerBlockKeys) + if err != nil { + return nil, err + } + + var ( + seed string + seedDefined bool + permissions []permission + database string + bound limits + ) + seen := make(map[string]bool) + for c.NextBlock() { + property := c.Val() + if property != "allow" && seen[property] { + return nil, c.Errf("%s specified more than once", property) + } + seen[property] = true + switch c.Val() { + case "database": + args := c.RemainingArgs() + if len(args) != 1 || args[0] == "" { + return nil, c.ArgErr() + } + database = args[0] + + case "max_records", "max_bytes", "max_update_records": + args := c.RemainingArgs() + if len(args) != 1 { + return nil, c.ArgErr() + } + n, err := strconv.Atoi(args[0]) + if err != nil || n <= 0 { + return nil, c.Errf("%s requires a positive integer", property) + } + switch property { + case "max_records": + bound.records = n + case "max_bytes": + bound.bytes = n + case "max_update_records": + bound.updateRecords = n + } + case "file": + args := c.RemainingArgs() + if len(args) != 1 { + return nil, c.ArgErr() + } + if seedDefined { + return nil, c.Err("file specified more than once") + } + seed, seedDefined = args[0], true + + case "allow": + p, err := parsePermission(c.RemainingArgs(), origin) + if err != nil { + return nil, err + } + permissions = append(permissions, p) + + default: + return nil, c.Errf("unknown property %q", c.Val()) + } + } + + if c.Next() { + return nil, plugin.ErrOnce + } + if !seedDefined { + return nil, c.Err("file is required") + } + if len(permissions) == 0 { + return nil, c.Err("at least one allow rule is required") + } + + if !filepath.IsAbs(seed) && dnsserver.GetConfig(c).Root != "" { + seed = filepath.Join(dnsserver.GetConfig(c).Root, seed) + } + d := &DynUpdate{ + Zone: origin, + permissions: permissions, + limits: bound.defaults(), + seed: seed, + } + if database != "" { + d.database, err = databasePath(database, dnsserver.GetConfig(c).Root) + if err != nil { + return nil, err + } + s, err := d.acquireStore(true) + if err != nil { + return nil, err + } + s.mu.RLock() + d.records = s.records + copyView := *s.view + d.view = ©View + s.mu.RUnlock() + if err := releaseStore(s); err != nil { + return nil, err + } + return d, nil + } + d.records, err = readZoneLimited(seed, origin, d.limits) + if err != nil { + return nil, err + } + d.view, err = d.build(d.records) + if err != nil { + return nil, fmt.Errorf("building zone %q: %w", origin, err) + } + return d, nil +} + +func parseOrigin(args, serverBlockKeys []string) (string, error) { + var token string + switch { + case len(args) == 0: + serverZones, err := normalizeServerBlockZones(serverBlockKeys) + if err != nil { + return "", err + } + if len(serverZones) != 1 { + return "", fmt.Errorf("zone is required when the server block does not define exactly one zone") + } + token = serverZones[0] + case len(args) == 1: + token = args[0] + default: + return "", fmt.Errorf("exactly one zone is allowed") + } + + hosts := plugin.Host(token).NormalizeExact() + if len(hosts) != 1 || hosts[0] == "" { + return "", fmt.Errorf("invalid zone %q", token) + } + origin := canonicalName(hosts[0]) + if _, ok := dns.IsDomainName(origin); !ok { + return "", fmt.Errorf("invalid zone %q", token) + } + return origin, nil +} + +func normalizeServerBlockZones(keys []string) ([]string, error) { + seen := make(map[string]struct{}, len(keys)) + for _, key := range keys { + hosts := plugin.Host(key).NormalizeExact() + if len(hosts) != 1 || hosts[0] == "" { + return nil, fmt.Errorf("invalid server block zone %q", key) + } + zone := canonicalName(hosts[0]) + if _, ok := dns.IsDomainName(zone); !ok { + return nil, fmt.Errorf("invalid server block zone %q", key) + } + seen[zone] = struct{}{} + } + + zones := make([]string, 0, len(seen)) + for zone := range seen { + zones = append(zones, zone) + } + return zones, nil +} + +func parsePermission(args []string, origin string) (permission, error) { + if len(args) < 3 { + return permission{}, fmt.Errorf("allow requires KEY NAME and at least one RR type") + } + + if strings.TrimSpace(args[0]) == "" || args[0] == "*" || args[0] == "@" { + return permission{}, fmt.Errorf("invalid TSIG key %q", args[0]) + } + key := plugin.Name(args[0]).Normalize() + if _, ok := dns.IsDomainName(key); !ok { + return permission{}, fmt.Errorf("invalid TSIG key %q", args[0]) + } + + p := permission{key: key, name: allNames, types: make(map[uint16]struct{})} + if args[1] != allNames { + name := args[1] + if name == "@" { + name = origin + } else { + name = canonicalName(name) + } + if _, ok := dns.IsDomainName(name); !ok || !inZone(origin, name) { + return permission{}, fmt.Errorf("allow name %q is outside zone %q", args[1], origin) + } + p.name = name + } + + for _, raw := range args[2:] { + if raw == allTypes { + if len(args) != 3 { + return permission{}, fmt.Errorf("wildcard RR type must be the only type in an allow rule") + } + p.allTypes = true + continue + } + rrType, ok := parseRRType(raw) + if !ok || !validPolicyType(rrType) { + return permission{}, fmt.Errorf("invalid RR type %q in allow rule", raw) + } + p.types[rrType] = struct{}{} + } + + return p, nil +} + +func parseRRType(raw string) (uint16, bool) { + name := strings.ToUpper(raw) + if rrType, ok := dns.StringToType[name]; ok { + return rrType, true + } + if !strings.HasPrefix(name, "TYPE") { + return 0, false + } + n, err := strconv.ParseUint(strings.TrimPrefix(name, "TYPE"), 10, 16) + if err != nil || n == 0 || n == uint64(dns.TypeReserved) { + return 0, false + } + return uint16(n), true +} + +func validPolicyType(rrType uint16) bool { + if !knownRRType(rrType) || rrType == dns.TypeNone || unsupportedRRType(rrType) { + return false + } + switch rrType { + case dns.TypeANY: + // ANY is an UPDATE metatype for deleting all RRsets at one name, + // and is therefore a valid explicit authorization target. + return true + case dns.TypeAXFR, dns.TypeIXFR, dns.TypeMAILA, dns.TypeMAILB, + dns.TypeOPT, dns.TypeTKEY, dns.TypeTSIG: + return false + default: + return true + } +} + +func readZone(path, origin string) ([]dns.RR, error) { + return readZoneLimited(path, origin, limits{}) +} + +func readZoneLimited(path, origin string, bound limits) ([]dns.RR, error) { + f, err := os.Open(filepath.Clean(path)) + if err != nil { + return nil, fmt.Errorf("opening zone file %q: %w", path, err) + } + defer f.Close() + + zp := dns.NewZoneParser(f, dns.Fqdn(origin), path) + zp.SetIncludeAllowed(true) + z := file.NewZone(origin, path) + soaCount := 0 + bound = bound.defaults() + count, remaining := 0, bound.bytes + for rr, ok := zp.Next(); ok; rr, ok = zp.Next() { + count++ + size := dns.Len(rr) + if count > bound.records || size > remaining { + return nil, fmt.Errorf("zone file %q exceeds configured limits", path) + } + remaining -= size + if _, ok := rr.(*dns.SOA); ok { + soaCount++ + } + if err := z.Insert(rr); err != nil { + return nil, fmt.Errorf("parsing zone file %q: %w", path, err) + } + } + if err := zp.Err(); err != nil { + return nil, fmt.Errorf("parsing zone file %q: %w", path, err) + } + if soaCount == 0 { + return nil, fmt.Errorf("zone %q has no SOA", origin) + } + if soaCount != 1 { + return nil, fmt.Errorf("zone %q contains more than one SOA", origin) + } + + apex, err := z.ApexIfDefined() + if err != nil { + return nil, fmt.Errorf("zone %q has no SOA: %w", origin, err) + } + records := make([]dns.RR, 0, len(apex)) + for _, rr := range apex { + records = append(records, dns.Copy(rr)) + } + for _, elem := range z.All() { + for _, rr := range elem.All() { + records = append(records, dns.Copy(rr)) + } + } + + if err := validateRecords(records, origin); err != nil { + return nil, fmt.Errorf("invalid zone %q: %w", origin, err) + } + return records, nil +} diff --git a/plugin/dynupdate/store.go b/plugin/dynupdate/store.go new file mode 100644 index 000000000..9b1f54c19 --- /dev/null +++ b/plugin/dynupdate/store.go @@ -0,0 +1,249 @@ +package dynupdate + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "github.com/coredns/coredns/plugin/file" + + "github.com/miekg/dns" + bolt "go.etcd.io/bbolt" +) + +var ( + storeBucket = []byte("dynupdate-v1") + originKey = []byte("origin") + recordsKey = []byte("records") + + storesMu sync.Mutex + stores = make(map[string]*zoneStore) +) + +// A store is shared by overlapping instances during a Corefile reload. Its +// mutex covers prerequisite evaluation, durable commit, and snapshot publication. +// The file view has no Next handler; each plugin supplies its own chain on reads. +type zoneStore struct { + mu sync.RWMutex + db *bolt.DB + origin string + records []dns.RR + view *file.File + refs int // protected by storesMu +} + +// readOnly validates configuration without creating or modifying a database. +// Only runtime stores are registered for sharing across overlapping instances. +func (d *DynUpdate) acquireStore(readOnly bool) (*zoneStore, error) { + storesMu.Lock() + defer storesMu.Unlock() + + // SameFile also handles case aliases on Windows and symlinks/hard links. + info, statErr := os.Stat(d.database) + if statErr != nil && !errors.Is(statErr, os.ErrNotExist) { + return nil, statErr + } + if seedInfo, err := os.Stat(d.seed); err == nil && info != nil && os.SameFile(info, seedInfo) { + return nil, errors.New("database and seed must be different files") + } + for path, s := range stores { + same := path == d.database + if !same && info != nil { + other, err := os.Stat(path) + same = err == nil && os.SameFile(info, other) + } + if !same { + continue + } + if s.origin != d.Zone { + return nil, fmt.Errorf("database already serves zone %q", s.origin) + } + s.mu.RLock() + err := d.limits.check(s.records) + s.mu.RUnlock() + if err != nil { + return nil, err + } + s.refs++ + return s, nil + } + + s := &zoneStore{origin: d.Zone, refs: 1} + var err error + if errors.Is(statErr, os.ErrNotExist) { + s.records, err = readZoneLimited(d.seed, d.Zone, d.limits) + if err != nil { + return nil, err + } + s.view, err = d.build(s.records) + if err != nil { + return nil, err + } + if readOnly { + return s, nil + } + } + + db, err := bolt.Open(d.database, 0600, &bolt.Options{Timeout: time.Second, ReadOnly: readOnly}) + if err != nil { + return nil, fmt.Errorf("opening database %q: %w", d.database, err) + } + s.db = db + load := func(tx *bolt.Tx) error { + b := tx.Bucket(storeBucket) + if b != nil { + if string(b.Get(originKey)) != d.Zone { + return fmt.Errorf("database belongs to a different zone") + } + s.records, err = decodeRecords(b.Get(recordsKey), d.limits) + return err + } + // An existing database without our metadata is corrupt or incompatible, + // not an invitation to silently replace acknowledged updates with a seed. + if statErr == nil { + return errors.New("unrecognized database format") + } + b, err = tx.CreateBucket(storeBucket) + if err != nil { + return err + } + if err := b.Put(originKey, []byte(d.Zone)); err != nil { + return err + } + return putRecords(b, s.records) + } + if readOnly { + err = db.View(load) + } else { + err = db.Update(load) + } + if err == nil { + s.view, err = d.build(s.records) + } + if err != nil { + db.Close() + return nil, fmt.Errorf("loading database %q: %w", d.database, err) + } + s.view.Next = nil + if !readOnly { + stores[d.database] = s + } + return s, nil +} + +func releaseStore(s *zoneStore) error { + storesMu.Lock() + defer storesMu.Unlock() + s.refs-- + if s.refs != 0 { + return nil + } + for path, active := range stores { + if active == s { + delete(stores, path) + break + } + } + if s.db != nil { + return s.db.Close() + } + return nil +} + +// Called with d.mu held. Opening lazily avoids retaining a database lock when +// a later directive or listener makes startup fail (Caddy does not run shutdown +// callbacks for failed startups). Configuration validation is read-only; the +// first query, transfer or authenticated update initializes a missing database. +func (d *DynUpdate) ensureStore() error { + if d.closed { + return errors.New("dynamic zone is closed") + } + if d.database == "" || d.store != nil { + return nil + } + s, err := d.acquireStore(false) + if err != nil { + return err + } + d.store = s + return nil +} + +func (d *DynUpdate) close() error { + d.mu.Lock() + defer d.mu.Unlock() + d.closed = true + if d.store == nil { + return nil + } + s := d.store + d.store = nil + return releaseStore(s) +} + +// commit must be called with s.mu held. bbolt's default synchronous transaction +// commits before the new immutable snapshot can become visible to queries. +func (s *zoneStore) commit(records []dns.RR, view *file.File) error { + if err := s.db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket(storeBucket) + if b == nil { + return errors.New("dynamic zone bucket is missing") + } + return putRecords(b, records) + }); err != nil { + return err + } + s.records = records + copyView := *view + copyView.Next = nil + s.view = ©View + return nil +} + +// RRs are stored as consecutive, uncompressed wire records, not a DNS message: +// a zone is not limited by the 64 KiB message or 16-bit section-count limits. +func putRecords(b *bolt.Bucket, records []dns.RR) error { + var data []byte + for _, rr := range records { + wire := make([]byte, dns.Len(rr)) + n, err := dns.PackRR(rr, wire, 0, nil, false) + if err != nil { + return err + } + data = append(data, wire[:n]...) + } + return b.Put(recordsKey, data) +} + +func decodeRecords(data []byte, bound limits) ([]dns.RR, error) { + bound = bound.defaults() + if len(data) == 0 || len(data) > bound.bytes { + return nil, fmt.Errorf("invalid stored zone size (max_bytes %d)", bound.bytes) + } + var records []dns.RR + for offset := 0; offset < len(data); { + if len(records) == bound.records { + return nil, fmt.Errorf("stored zone exceeds max_records (%d)", bound.records) + } + rr, next, err := dns.UnpackRR(data, offset) + if err != nil { + return nil, err + } + if next <= offset { + return nil, errors.New("invalid stored record length") + } + records = append(records, rr) + offset = next + } + return records, bound.check(records) +} + +func databasePath(path, root string) (string, error) { + if !filepath.IsAbs(path) { + path = filepath.Join(root, path) + } + return filepath.Abs(path) +} diff --git a/plugin/dynupdate/store_test.go b/plugin/dynupdate/store_test.go new file mode 100644 index 000000000..3675ad9e6 --- /dev/null +++ b/plugin/dynupdate/store_test.go @@ -0,0 +1,511 @@ +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)) + } + }) +} diff --git a/plugin/dynupdate/update.go b/plugin/dynupdate/update.go new file mode 100644 index 000000000..87739e167 --- /dev/null +++ b/plugin/dynupdate/update.go @@ -0,0 +1,399 @@ +package dynupdate + +import ( + "context" + "fmt" + + clog "github.com/coredns/coredns/plugin/pkg/log" + "github.com/coredns/coredns/plugin/tsig" + + "github.com/miekg/dns" +) + +var log = clog.NewWithPlugin(pluginName) + +func (d *DynUpdate) serveUpdate(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + if len(r.Question) != 1 || r.Question[0].Qtype != dns.TypeSOA || r.Question[0].Qclass != dns.ClassINET { + return d.reply(w, r, dns.RcodeFormatError) + } + + zone := canonicalName(r.Question[0].Name) + if zone != d.Zone { + // Query-only backends can acknowledge an UPDATE without applying it. + // Writable zones must use separate server blocks, not fallthrough. + return d.reply(w, r, dns.RcodeNotAuth) + } + + var key string + var ok bool + if ctx != nil { + key, ok = tsig.ValidatedKeyName(ctx) + } + if !ok { + log.Debugf("refusing UPDATE for %s without a validated TSIG identity", zone) + return d.reply(w, r, dns.RcodeRefused) + } + + rcode, err := d.applyUpdate(key, r.Answer, r.Ns) + if err != nil { + log.Errorf("UPDATE for %s failed: %v", zone, err) + } + return d.reply(w, r, rcode) +} + +// applyUpdate performs one complete RFC 2136 transaction. It returns only +// after either the old snapshot is unchanged or a fully built new snapshot is +// installed. The caller must not hold d.mu. +func (d *DynUpdate) applyUpdate(key string, prerequisites, updates []dns.RR) (int, error) { + d.mu.Lock() + changed, rcode, err := d.updateLocked(key, prerequisites, updates) + if changed && d.Xfer != nil { + d.notifyPending = true + if !d.notifyRunning { + d.notifyRunning = true + go d.notify() + } + } + d.mu.Unlock() + return rcode, err +} + +// Coalesce bursts and keep at most one NOTIFY operation in flight per instance. +func (d *DynUpdate) notify() { + for { + d.mu.Lock() + if d.closed || !d.notifyPending { + d.notifyRunning = false + d.mu.Unlock() + return + } + d.notifyPending = false + xfer, zone := d.Xfer, d.Zone + d.mu.Unlock() + if err := xfer.Notify(zone); err != nil { + log.Warningf("NOTIFY for %s after UPDATE failed: %v", zone, err) + } + } +} + +func (d *DynUpdate) updateLocked(key string, prerequisites, updates []dns.RR) (bool, int, error) { + // Authenticate and identify the key before doing semantic work on the + // request. An unknown valid TSIG must not be able to probe zone state. + if !d.configuredKey(key) { + return false, dns.RcodeRefused, nil + } + bound := d.limits.defaults() + if len(prerequisites) > bound.updateRecords || len(updates) > bound.updateRecords-len(prerequisites) { + return false, dns.RcodeRefused, nil + } + if err := d.ensureStore(); err != nil { + return false, dns.RcodeServerFailure, err + } + if d.store != nil { + d.store.mu.Lock() + defer d.store.mu.Unlock() + d.records = d.store.records + } + // RFC 2136 evaluates prerequisites against the current snapshot before + // checking permissions and prescanning the Update section. Keeping this + // order matters when a request contains both a failed prerequisite and an + // invalid update record. + if rcode := d.checkPrerequisites(prerequisites); rcode != dns.RcodeSuccess { + return false, rcode, nil + } + if rcode := d.authorize(key, updates); rcode != dns.RcodeSuccess { + return false, rcode, nil + } + if rcode := d.validateUpdates(updates); rcode != dns.RcodeSuccess { + return false, rcode, nil + } + + candidate, changed, explicitSOA := d.apply(updates) + if !changed { + return false, dns.RcodeSuccess, nil + } + if err := d.limits.check(candidate); err != nil { + return false, dns.RcodeRefused, err + } + if !explicitSOA { + bumpSerial(candidate) + } + view, err := d.build(candidate) + if err != nil { + return false, dns.RcodeServerFailure, fmt.Errorf("building candidate zone: %w", err) + } + if d.store != nil { + if err := d.store.commit(candidate, view); err != nil { + return false, dns.RcodeServerFailure, fmt.Errorf("committing zone: %w", err) + } + } + d.install(candidate, view) + return true, dns.RcodeSuccess, nil +} + +func (d *DynUpdate) checkPrerequisites(prerequisites []dns.RR) int { + valueDependent := make(map[rrsetKey][]dns.RR) + for _, rr := range prerequisites { + if rr == nil { + return dns.RcodeFormatError + } + h := rr.Header() + if h.Ttl != 0 { + return dns.RcodeFormatError + } + if !inZone(d.Zone, h.Name) { + return dns.RcodeNotZone + } + if unsupportedRRType(h.Rrtype) { + return dns.RcodeNotImplemented + } + + switch h.Class { + case dns.ClassANY: + if h.Rdlength != 0 || !validPrerequisiteType(h.Rrtype, true) { + return dns.RcodeFormatError + } + if h.Rrtype == dns.TypeANY { + if !d.nameInUse(h.Name) { + return dns.RcodeNameError + } + } else if !d.rrsetExists(h.Name, h.Rrtype) { + return dns.RcodeNXRrset + } + + case dns.ClassNONE: + if h.Rdlength != 0 || !validPrerequisiteType(h.Rrtype, true) { + return dns.RcodeFormatError + } + if h.Rrtype == dns.TypeANY { + if d.nameInUse(h.Name) { + return dns.RcodeYXDomain + } + } else if d.rrsetExists(h.Name, h.Rrtype) { + return dns.RcodeYXRrset + } + + case dns.ClassINET: + if !validPrerequisiteType(h.Rrtype, false) { + return dns.RcodeFormatError + } + key := rrsetKey{name: canonicalName(h.Name), rrType: h.Rrtype} + valueDependent[key] = append(valueDependent[key], rr) + + default: + return dns.RcodeFormatError + } + } + + for key, want := range valueDependent { + if !sameRRset(d.rrset(key.name, key.rrType), want) { + return dns.RcodeNXRrset + } + } + return dns.RcodeSuccess +} + +func validPrerequisiteType(rrType uint16, allowAny bool) bool { + if !knownRRType(rrType) || rrType == dns.TypeNone { + return false + } + if rrType == dns.TypeANY { + return allowAny + } + return !isQueryMetaType(rrType) +} + +type rrsetKey struct { + name string + rrType uint16 +} + +func (d *DynUpdate) authorize(key string, updates []dns.RR) int { + for _, rr := range updates { + if rr == nil { + return dns.RcodeFormatError + } + if !d.allows(key, rr.Header().Name, rr.Header().Rrtype) { + return dns.RcodeRefused + } + } + return dns.RcodeSuccess +} + +func (d *DynUpdate) validateUpdates(updates []dns.RR) int { + for _, rr := range updates { + if rr == nil { + return dns.RcodeFormatError + } + h := rr.Header() + if !inZone(d.Zone, h.Name) { + return dns.RcodeNotZone + } + if !knownRRType(h.Rrtype) || h.Rrtype == dns.TypeNone { + return dns.RcodeFormatError + } + + switch h.Class { + case dns.ClassINET: + if isQueryMetaType(h.Rrtype) { + return dns.RcodeFormatError + } + if h.Rrtype == dns.TypeSOA { + if canonicalName(h.Name) != d.Zone { + return dns.RcodeFormatError + } + soa, ok := rr.(*dns.SOA) + // RFC 2136 sections 4.2 and 7.11 prohibit zero for + // interoperability with older DNS implementations. + if !ok || soa.Serial == 0 { + return dns.RcodeFormatError + } + } + case dns.ClassANY: + if h.Ttl != 0 || h.Rdlength != 0 || isQueryMetaType(h.Rrtype) && h.Rrtype != dns.TypeANY { + return dns.RcodeFormatError + } + case dns.ClassNONE: + if h.Ttl != 0 || h.Rrtype == dns.TypeANY || isQueryMetaType(h.Rrtype) { + return dns.RcodeFormatError + } + default: + return dns.RcodeFormatError + } + + if unsupportedRRType(h.Rrtype) { + return dns.RcodeNotImplemented + } + } + + return dns.RcodeSuccess +} + +// unsupportedRRType identifies records whose contents become invalid when a +// different RRset is updated without regenerating its associated metadata. +// This stage deliberately fails closed instead of serving stale DNSSEC or +// zone-digest data. The list includes obsolete DNSSEC types because they are +// still representable by miekg/dns and can otherwise enter a seed zone. +func unsupportedRRType(rrType uint16) bool { + switch rrType { + case dns.TypeSIG, dns.TypeKEY, dns.TypeNXT, + dns.TypeDS, dns.TypeRRSIG, dns.TypeNSEC, dns.TypeDNSKEY, + dns.TypeNSEC3, dns.TypeNSEC3PARAM, + dns.TypeTALINK, dns.TypeCDS, dns.TypeCDNSKEY, + dns.TypeZONEMD, dns.TypeTA, dns.TypeDLV: + return true + default: + return false + } +} + +// apply follows RFC 2136 section 3.4.2 against a private copy. The boolean +// explicitSOA reports whether an accepted SOA update supplied the new serial; +// otherwise the server increments the serial after any real change. +func (d *DynUpdate) apply(updates []dns.RR) ([]dns.RR, bool, bool) { + records := cloneRecords(d.records) + changed := false + explicitSOA := false + + for _, rr := range updates { + h := rr.Header() + name := canonicalName(h.Name) + apex := name == d.Zone + + switch h.Class { + case dns.ClassINET: + switch h.Rrtype { + case dns.TypeSOA: + current := soaAt(records, d.Zone) + incoming, ok := rr.(*dns.SOA) + if !ok || current == nil || !serialGreater(incoming.Serial, current.Serial) { + continue + } + records, _ = removeRecords(records, func(existing dns.RR) bool { + return canonicalName(existing.Header().Name) == name && existing.Header().Rrtype == dns.TypeSOA + }) + records = append(records, dns.Copy(rr)) + changed = true + explicitSOA = true + + case dns.TypeCNAME: + if hasOtherData(records, name) { + continue + } + existing := rrsetOf(records, name, dns.TypeCNAME) + if len(existing) == 1 && sameRR(existing[0], rr) && existing[0].Header().Ttl == h.Ttl { + continue + } + records, _ = removeRecords(records, func(existing dns.RR) bool { + return canonicalName(existing.Header().Name) == name && existing.Header().Rrtype == dns.TypeCNAME + }) + records = append(records, dns.Copy(rr)) + changed = true + + default: + if hasCNAME(records, name) && !cnameCompatibleType(h.Rrtype) { + continue + } + if index := findRR(records, rr); index >= 0 { + if records[index].Header().Ttl != h.Ttl { + records[index] = dns.Copy(rr) + changed = true + } + continue + } + records = append(records, dns.Copy(rr)) + changed = true + } + + case dns.ClassANY: + if h.Rrtype == dns.TypeANY { + var removed bool + records, removed = removeRecords(records, func(existing dns.RR) bool { + if canonicalName(existing.Header().Name) != name { + return false + } + if apex && (existing.Header().Rrtype == dns.TypeSOA || existing.Header().Rrtype == dns.TypeNS) { + return false + } + return true + }) + changed = changed || removed + continue + } + if apex && (h.Rrtype == dns.TypeSOA || h.Rrtype == dns.TypeNS) { + continue + } + var removed bool + records, removed = removeRecords(records, func(existing dns.RR) bool { + return canonicalName(existing.Header().Name) == name && existing.Header().Rrtype == h.Rrtype + }) + changed = changed || removed + + case dns.ClassNONE: + if apex && h.Rrtype == dns.TypeSOA { + continue + } + if apex && h.Rrtype == dns.TypeNS && countRRset(records, name, dns.TypeNS) <= 1 { + continue + } + if index := findRR(records, rr); index >= 0 { + records = append(records[:index], records[index+1:]...) + changed = true + } + } + } + + return records, changed, explicitSOA +} + +func (d *DynUpdate) reply(w dns.ResponseWriter, r *dns.Msg, rcode int) (int, error) { + m := new(dns.Msg) + m.SetReply(r) + m.Opcode = dns.OpcodeUpdate + m.Rcode = rcode + m.Authoritative = true + if err := w.WriteMsg(m); err != nil { + return dns.RcodeServerFailure, err + } + return dns.RcodeSuccess, nil +} diff --git a/plugin/dynupdate/zone.go b/plugin/dynupdate/zone.go new file mode 100644 index 000000000..57581572d --- /dev/null +++ b/plugin/dynupdate/zone.go @@ -0,0 +1,323 @@ +package dynupdate + +import ( + "errors" + "fmt" + "strings" + + "github.com/miekg/dns" +) + +const ( + allNames = "*" + allTypes = "*" +) + +var errMissingSOA = errors.New("zone has no SOA") + +func validateRecords(records []dns.RR, origin string) error { + origin = canonicalName(origin) + soaCount := 0 + nameTypes := make(map[string]map[uint16]struct{}) + for _, rr := range records { + if rr == nil { + return errors.New("zone contains a nil record") + } + h := rr.Header() + if h.Class != dns.ClassINET { + return errors.New("zone contains a non-IN record") + } + if !inZone(origin, h.Name) { + return errors.New("zone contains an out-of-zone record") + } + if unsupportedRRType(h.Rrtype) { + return fmt.Errorf("zone contains unsupported RR type %s", dns.TypeToString[h.Rrtype]) + } + name := canonicalName(h.Name) + types := nameTypes[name] + if types == nil { + types = make(map[uint16]struct{}) + nameTypes[name] = types + } + if h.Rrtype == dns.TypeCNAME && len(types) != 0 { + return fmt.Errorf("zone contains CNAME data conflict at %s", h.Name) + } + if h.Rrtype != dns.TypeCNAME { + if _, exists := types[dns.TypeCNAME]; exists { + return fmt.Errorf("zone contains CNAME data conflict at %s", h.Name) + } + } + types[h.Rrtype] = struct{}{} + if h.Rrtype == dns.TypeSOA { + soa, ok := rr.(*dns.SOA) + if !ok { + return errors.New("zone contains an invalid SOA record") + } + if canonicalName(h.Name) != origin { + return errors.New("zone contains a non-apex SOA record") + } + // RFC 2136 sections 4.2 and 7.11 prohibit zero for + // interoperability with older DNS implementations. + if soa.Serial == 0 { + return errors.New("zone contains an SOA with serial zero") + } + soaCount++ + } + } + if soaCount == 0 { + return errMissingSOA + } + if soaCount != 1 { + return errors.New("zone contains more than one SOA record") + } + return nil +} + +type permission struct { + key string + name string + types map[uint16]struct{} + allTypes bool +} + +func canonicalName(name string) string { + return strings.ToLower(dns.CanonicalName(name)) +} + +func inZone(origin, name string) bool { + name = canonicalName(name) + return name == origin || dns.IsSubDomain(origin, name) +} + +func (d *DynUpdate) configuredKey(key string) bool { + key = canonicalName(key) + for _, p := range d.permissions { + if p.key == key { + return true + } + } + return false +} + +func (d *DynUpdate) allows(key, name string, rrType uint16) bool { + key = canonicalName(key) + name = canonicalName(name) + for _, p := range d.permissions { + if p.key != key || (p.name != allNames && p.name != name) { + continue + } + if p.allTypes { + return true + } + if _, ok := p.types[rrType]; ok { + return true + } + } + return false +} + +func (d *DynUpdate) nameInUse(name string) bool { + name = canonicalName(name) + for _, rr := range d.records { + if canonicalName(rr.Header().Name) == name { + return true + } + } + return false +} + +func (d *DynUpdate) rrset(name string, rrType uint16) []dns.RR { + return rrsetOf(d.records, name, rrType) +} + +func rrsetOf(records []dns.RR, name string, rrType uint16) []dns.RR { + name = canonicalName(name) + var set []dns.RR + for _, rr := range records { + h := rr.Header() + if canonicalName(h.Name) == name && h.Rrtype == rrType { + set = append(set, rr) + } + } + return set +} + +func (d *DynUpdate) rrsetExists(name string, rrType uint16) bool { + return len(d.rrset(name, rrType)) != 0 +} + +func soaOf(records []dns.RR) *dns.SOA { + for _, rr := range records { + if soa, ok := rr.(*dns.SOA); ok { + return soa + } + } + return nil +} + +func soaAt(records []dns.RR, name string) *dns.SOA { + name = canonicalName(name) + for _, rr := range records { + soa, ok := rr.(*dns.SOA) + if ok && canonicalName(soa.Header().Name) == name { + return soa + } + } + return nil +} + +func cloneRecords(records []dns.RR) []dns.RR { + cloned := make([]dns.RR, len(records)) + for i, rr := range records { + cloned[i] = dns.Copy(rr) + } + return cloned +} + +func sameRR(a, b dns.RR) bool { + if a == nil || b == nil { + return false + } + + // dns.IsDuplicate implements miekg/dns's generated, type-aware RDATA + // comparison and deliberately ignores TTL. UPDATE delete records use + // CLASS NONE on the wire, while the corresponding zone RR has the zone + // class, so normalize both classes before comparing. + left, right := dns.Copy(a), dns.Copy(b) + if left == nil || right == nil { + return false + } + left.Header().Class = dns.ClassINET + right.Header().Class = dns.ClassINET + return dns.IsDuplicate(left, right) +} + +func sameRRset(have, want []dns.RR) bool { + // Treat each section as a set. Zone files and UPDATE messages should not + // contain duplicate RRs, but ignoring duplicates here follows the RFC's + // RRset semantics and avoids making comparison depend on wire ordering. + have = uniqueRecords(have) + want = uniqueRecords(want) + if len(have) != len(want) { + return false + } + used := make([]bool, len(have)) + for _, wanted := range want { + found := false + for i, actual := range have { + if !used[i] && sameRR(actual, wanted) { + used[i] = true + found = true + break + } + } + if !found { + return false + } + } + return true +} + +func uniqueRecords(records []dns.RR) []dns.RR { + unique := make([]dns.RR, 0, len(records)) + for _, rr := range records { + duplicate := false + for _, existing := range unique { + if sameRR(existing, rr) { + duplicate = true + break + } + } + if !duplicate { + unique = append(unique, rr) + } + } + return unique +} + +func findRR(records []dns.RR, want dns.RR) int { + for i, rr := range records { + if sameRR(rr, want) { + return i + } + } + return -1 +} + +func removeRecords(records []dns.RR, match func(dns.RR) bool) ([]dns.RR, bool) { + result := make([]dns.RR, 0, len(records)) + changed := false + for _, rr := range records { + if match(rr) { + changed = true + continue + } + result = append(result, rr) + } + return result, changed +} + +func countRRset(records []dns.RR, name string, rrType uint16) int { + name = canonicalName(name) + count := 0 + for _, rr := range records { + if canonicalName(rr.Header().Name) == name && rr.Header().Rrtype == rrType { + count++ + } + } + return count +} + +func hasCNAME(records []dns.RR, name string) bool { + return countRRset(records, name, dns.TypeCNAME) != 0 +} + +func hasOtherData(records []dns.RR, name string) bool { + name = canonicalName(name) + for _, rr := range records { + if canonicalName(rr.Header().Name) != name { + continue + } + if !cnameCompatibleType(rr.Header().Rrtype) { + return true + } + } + return false +} + +func cnameCompatibleType(rrType uint16) bool { + return rrType == dns.TypeCNAME +} + +// serialGreater implements RFC 1982 serial arithmetic. The half-space value +// is deliberately not considered greater because RFC 1982 leaves it +// undefined. +func serialGreater(a, b uint32) bool { + return a != b && a-b < 1<<31 +} + +func bumpSerial(records []dns.RR) { + if soa := soaOf(records); soa != nil { + soa.Serial++ + // RFC 2136 section 7.11 recommends that an automatically incremented + // serial never become zero after wrapping at 2^32. + if soa.Serial == 0 { + soa.Serial = 1 + } + } +} + +func knownRRType(rrType uint16) bool { + _, ok := dns.TypeToRR[rrType] + return ok +} + +func isQueryMetaType(rrType uint16) bool { + switch rrType { + case dns.TypeANY, dns.TypeAXFR, dns.TypeIXFR, dns.TypeMAILA, dns.TypeMAILB, + dns.TypeOPT, dns.TypeTKEY, dns.TypeTSIG: + return true + default: + return false + } +} diff --git a/test/dynupdate_kea_test.go b/test/dynupdate_kea_test.go new file mode 100644 index 000000000..215a6f4f6 --- /dev/null +++ b/test/dynupdate_kea_test.go @@ -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) + } +} diff --git a/test/dynupdate_persistence_test.go b/test/dynupdate_persistence_test.go new file mode 100644 index 000000000..124661bfa --- /dev/null +++ b/test/dynupdate_persistence_test.go @@ -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") + } + } +} diff --git a/test/dynupdate_test.go b/test/dynupdate_test.go new file mode 100644 index 000000000..ab45179cb --- /dev/null +++ b/test/dynupdate_test.go @@ -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) + } +} diff --git a/test/readme_test.go b/test/readme_test.go index c44e2c268..8a6371260 100644 --- a/test/readme_test.go +++ b/test/readme_test.go @@ -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. }