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

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

* plugin/dynupdate: fix README test fixtures

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

* test: format README fixture map

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

* plugin/dynupdate: persist updates and bound writable zones

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

* plugin/dynupdate: preserve middleware and fix interoperability fixtures

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

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

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

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

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

* plugin/dynupdate: fix update routing and startup validation

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

---------

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

400 lines
11 KiB
Go

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
}