Files
coredns/plugin/dynupdate/zone.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

324 lines
7.2 KiB
Go

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
}
}