plugin/secondary: serve catalog member zones (#8230)

Signed-off-by: houyuwushang <liuluoqianqiu@outlook.com>
This commit is contained in:
houyuwushang
2026-07-10 08:42:30 +08:00
committed by GitHub
parent e4990abfa3
commit 1e01c0ad7c
9 changed files with 368 additions and 67 deletions

View File

@@ -24,11 +24,15 @@ type (
Next plugin.Handler
Zones
Xfer *transfer.Transfer
ZoneLookupFunc
TransferInFunc
Fall fall.F
}
// ZoneLookupFunc looks up the authoritative zone for qname.
ZoneLookupFunc func(qname string) (zone string, z *Zone, ok bool)
// Zones maps zone names to a *Zone.
Zones struct {
Z map[string]*Zone // A map mapping zone (origin) to the Zone's data
@@ -41,9 +45,8 @@ func (f File) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (i
state := request.Request{W: w, Req: r}
qname := state.Name()
// TODO(miek): match the qname better in the map
zone := plugin.Zones(f.Zones.Names).Matches(qname)
if zone == "" {
zone, z, ok := f.lookupZone(qname)
if !ok {
// If no next plugin is configured, it's more correct to return REFUSED as file acts as an authoritative server
if f.Next == nil {
return dns.RcodeRefused, nil
@@ -51,8 +54,7 @@ func (f File) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (i
return plugin.NextOrFailure(f.Name(), f.Next, ctx, w, r)
}
z, ok := f.Z[zone]
if !ok || z == nil {
if z == nil {
return dns.RcodeServerFailure, nil
}
@@ -134,6 +136,22 @@ func (f File) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (i
// Name implements the Handler interface.
func (f File) Name() string { return "file" }
func (f File) lookupZone(qname string) (string, *Zone, bool) {
if f.ZoneLookupFunc != nil {
return f.ZoneLookupFunc(qname)
}
// TODO(miek): match the qname better in the map
zone := plugin.Zones(f.Zones.Names).Matches(qname)
if zone == "" {
return "", nil, false
}
z, ok := f.Z[zone]
if !ok {
return zone, nil, true
}
return zone, z, true
}
func (f File) transferIn(z *Zone, t *transfer.Transfer) error {
if f.TransferInFunc != nil {
return f.TransferInFunc(z, t)

View File

@@ -9,8 +9,8 @@ import (
// Transfer implements the transfer.Transfer interface.
func (f File) Transfer(zone string, serial uint32) (<-chan []dns.RR, error) {
z, ok := f.Z[zone]
if !ok || z == nil {
matched, z, ok := f.lookupZone(zone)
if !ok || matched != zone || z == nil {
return nil, transfer.ErrNotAuthoritative
}
return z.Transfer(serial)

View File

@@ -8,6 +8,7 @@ import (
"github.com/coredns/coredns/plugin/pkg/dnstest"
"github.com/coredns/coredns/plugin/test"
"github.com/coredns/coredns/plugin/transfer"
"github.com/miekg/dns"
)
@@ -70,3 +71,15 @@ func TestAXFRWithOutTransferPlugin(t *testing.T) {
t.Errorf("Expecting REFUSED, got %d", code)
}
}
func TestTransferRequiresExactZone(t *testing.T) {
zone, err := Parse(strings.NewReader(dbMiekNL), testzone, "stdin", 0)
if err != nil {
t.Fatalf("Expected no error when reading zone, got %q", err)
}
fm := File{Zones: Zones{Z: map[string]*Zone{testzone: zone}, Names: []string{testzone}}}
if _, err := fm.Transfer("www."+testzone, 0); err != transfer.ErrNotAuthoritative {
t.Fatalf("expected ErrNotAuthoritative for subdomain transfer, got %v", err)
}
}

View File

@@ -36,8 +36,9 @@ secondary [zones...] {
times; if one does not work, another will be tried. Transferring this zone outwards again can be
done by enabling the *transfer* plugin.
* `catalog` treats the transferred zone as an RFC 9432 catalog zone and parses the catalog member
list after each successful transfer.
* `catalog` treats the transferred zone as an RFC 9432 catalog zone. After each successful catalog
transfer, CoreDNS adds and removes the catalog member zones and transfers those member zones from
the same primary servers.
* `fallthrough` If a query for a record in the zone results in NXDOMAIN, the query will be passed
to the next plugin in the chain. If **[ZONES...]** are listed, then only queries for those zones

View File

@@ -3,6 +3,7 @@ package secondary
import (
"github.com/coredns/coredns/plugin/file"
"github.com/coredns/coredns/plugin/pkg/catalog"
"github.com/coredns/coredns/plugin/pkg/upstream"
"github.com/coredns/coredns/plugin/transfer"
"github.com/miekg/dns"
@@ -34,6 +35,92 @@ func (s *Secondary) transferIn(origin string, z *file.Zone, t *transfer.Transfer
}
s.catalogs[origin] = parsed
s.catalogMu.Unlock()
s.applyCatalog(origin, parsed, z, t)
log.Infof("Parsed catalog zone %s with %d member zones", origin, len(parsed.Members))
return nil
}
type dynamicZoneStart struct {
origin string
zone *file.Zone
shutdown chan bool
}
func (s *Secondary) applyCatalog(origin string, cat *catalog.Catalog, catalogZone *file.Zone, t *transfer.Transfer) {
memberZones := make(map[string]struct{}, len(cat.Members))
var starts []dynamicZoneStart
s.zoneMu.Lock()
s.ensureZoneStateLocked()
for _, member := range cat.Members {
memberZones[member.Zone] = struct{}{}
if existing, ok := s.Z[member.Zone]; ok {
if dyn, ok := s.dynamicZones[member.Zone]; ok && dyn.catalog == origin && existing != nil {
continue
}
log.Warningf("Skipping catalog member zone %s from %s: zone already exists", member.Zone, origin)
continue
}
z := file.NewZone(member.Zone, "stdin")
if catalogZone != nil {
z.TransferFrom = append([]string(nil), catalogZone.TransferFrom...)
}
z.Upstream = upstream.New()
shutdown := make(chan bool)
s.Z[member.Zone] = z
s.Names = append(s.Names, member.Zone)
s.zoneNames[z] = member.Zone
s.dynamicZones[member.Zone] = &dynamicZone{catalog: origin, shutdown: shutdown}
starts = append(starts, dynamicZoneStart{origin: member.Zone, zone: z, shutdown: shutdown})
log.Infof("Added catalog member zone %s from catalog %s", member.Zone, origin)
}
for member := range s.catalogMemberZones[origin] {
if _, ok := memberZones[member]; ok {
continue
}
dyn, ok := s.dynamicZones[member]
if !ok || dyn.catalog != origin {
continue
}
dyn.stopOnce.Do(func() { close(dyn.shutdown) })
delete(s.dynamicZones, member)
if z := s.Z[member]; z != nil {
delete(s.zoneNames, z)
}
delete(s.Z, member)
s.Names = removeZoneName(s.Names, member)
log.Infof("Removed catalog member zone %s from catalog %s", member, origin)
}
s.catalogMemberZones[origin] = memberZones
s.zoneMu.Unlock()
for _, start := range starts {
go s.transferAndUpdate(start.origin, start.zone, t, start.shutdown)
}
}
func (s *Secondary) ensureZoneStateLocked() {
if s.Z == nil {
s.Z = make(map[string]*file.Zone)
}
if s.zoneNames == nil {
s.zoneNames = make(map[*file.Zone]string, len(s.Z))
for name, zone := range s.Z {
if zone != nil {
s.zoneNames[zone] = name
}
}
}
if s.dynamicZones == nil {
s.dynamicZones = make(map[string]*dynamicZone)
}
if s.catalogMemberZones == nil {
s.catalogMemberZones = make(map[string]map[string]struct{})
}
}

View File

@@ -1,29 +1,34 @@
package secondary
import (
"context"
"strings"
"sync"
"testing"
"time"
"github.com/coredns/coredns/plugin/file"
"github.com/coredns/coredns/plugin/pkg/catalog"
"github.com/coredns/coredns/plugin/pkg/dnstest"
"github.com/coredns/coredns/plugin/pkg/fall"
plugintest "github.com/coredns/coredns/plugin/test"
"github.com/miekg/dns"
)
func TestTransferInCatalog(t *testing.T) {
const origin = "catalog.example."
rrs := catalogZoneRecords(t, true)
zones := newTestTransferZones(map[string][]dns.RR{
origin: catalogZoneRecords(t, true),
"example.org.": memberZoneRecords(t),
})
server := dnstest.NewServer(transferHandler(rrs))
server := dnstest.NewServer(zones.handler())
defer server.Close()
z := file.NewZone(origin, "stdin")
z.TransferFrom = []string{server.Addr}
s := &Secondary{
catalogs: make(map[string]*catalog.Catalog),
catalogZones: map[string]struct{}{origin: {}},
}
s := newTestSecondary(origin, z, true)
t.Cleanup(s.stopDynamicZones)
if err := s.transferIn(origin, z, nil); err != nil {
t.Fatalf("transferIn returned error: %v", err)
@@ -47,21 +52,59 @@ func TestTransferInCatalog(t *testing.T) {
if z.SOA == nil {
t.Fatal("expected zone data to be live after valid catalog transfer")
}
msg := waitForAnswer(t, s, "www.example.org.", dns.TypeA)
if len(msg.Answer) != 1 {
t.Fatalf("expected 1 member zone answer, got %d", len(msg.Answer))
}
a, ok := msg.Answer[0].(*dns.A)
if !ok {
t.Fatalf("expected A answer, got %T", msg.Answer[0])
}
if a.A.String() != "192.0.2.1" {
t.Fatalf("expected 192.0.2.1, got %s", a.A.String())
}
}
func TestTransferInCatalogRemovesMemberZone(t *testing.T) {
const origin = "catalog.example."
zones := newTestTransferZones(map[string][]dns.RR{
origin: catalogZoneRecords(t, true),
"example.org.": memberZoneRecords(t),
})
server := dnstest.NewServer(zones.handler())
defer server.Close()
z := file.NewZone(origin, "stdin")
z.TransferFrom = []string{server.Addr}
s := newTestSecondary(origin, z, true)
t.Cleanup(s.stopDynamicZones)
if err := s.transferIn(origin, z, nil); err != nil {
t.Fatalf("transferIn returned error: %v", err)
}
waitForAnswer(t, s, "www.example.org.", dns.TypeA)
zones.set(origin, catalogZoneRecordsWithoutMembers(t))
if err := s.transferIn(origin, z, nil); err != nil {
t.Fatalf("transferIn returned error: %v", err)
}
if _, _, ok := s.lookupZone("www.example.org."); ok {
t.Fatal("expected member zone to be removed after catalog update")
}
}
func TestTransferInCatalogRejectsInvalidCatalog(t *testing.T) {
const origin = "catalog.example."
rrs := catalogZoneRecords(t, false)
server := dnstest.NewServer(transferHandler(rrs))
server := dnstest.NewServer(newTestTransferZones(map[string][]dns.RR{origin: rrs}).handler())
defer server.Close()
z := file.NewZone(origin, "stdin")
z.TransferFrom = []string{server.Addr}
s := &Secondary{
catalogs: make(map[string]*catalog.Catalog),
catalogZones: map[string]struct{}{origin: {}},
}
s := newTestSecondary(origin, z, true)
err := s.transferIn(origin, z, nil)
if err == nil {
@@ -82,14 +125,12 @@ func TestTransferInSkipsCatalogParseForRegularZone(t *testing.T) {
const origin = "catalog.example."
rrs := catalogZoneRecords(t, false)
server := dnstest.NewServer(transferHandler(rrs))
server := dnstest.NewServer(newTestTransferZones(map[string][]dns.RR{origin: rrs}).handler())
defer server.Close()
z := file.NewZone(origin, "stdin")
z.TransferFrom = []string{server.Addr}
s := &Secondary{
catalogs: make(map[string]*catalog.Catalog),
}
s := newTestSecondary(origin, z, false)
if err := s.transferIn(origin, z, nil); err != nil {
t.Fatalf("transferIn returned error: %v", err)
@@ -102,11 +143,31 @@ func TestTransferInSkipsCatalogParseForRegularZone(t *testing.T) {
}
}
func transferHandler(rrs []dns.RR) dns.HandlerFunc {
type testTransferZones struct {
mu sync.RWMutex
records map[string][]dns.RR
}
func newTestTransferZones(records map[string][]dns.RR) *testTransferZones {
return &testTransferZones{records: records}
}
func (z *testTransferZones) set(zone string, rrs []dns.RR) {
z.mu.Lock()
z.records[zone] = rrs
z.mu.Unlock()
}
func (z *testTransferZones) handler() dns.HandlerFunc {
return func(w dns.ResponseWriter, req *dns.Msg) {
m := new(dns.Msg)
m.SetReply(req)
if len(req.Question) > 0 {
qname := strings.ToLower(dns.Fqdn(req.Question[0].Name))
z.mu.RLock()
rrs := append([]dns.RR(nil), z.records[qname]...)
z.mu.RUnlock()
switch req.Question[0].Qtype {
case dns.TypeSOA:
for _, rr := range rrs {
@@ -123,6 +184,32 @@ func transferHandler(rrs []dns.RR) dns.HandlerFunc {
}
}
func newTestSecondary(origin string, z *file.Zone, catalog bool) *Secondary {
catalogZones := map[string]struct{}{}
if catalog {
catalogZones[origin] = struct{}{}
}
return newSecondary(file.Zones{Z: map[string]*file.Zone{origin: z}, Names: []string{origin}}, fall.F{}, catalogZones)
}
func waitForAnswer(t *testing.T, s *Secondary, name string, qtype uint16) *dns.Msg {
t.Helper()
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
req := new(dns.Msg)
req.SetQuestion(name, qtype)
rec := dnstest.NewRecorder(&plugintest.ResponseWriter{})
code, err := s.ServeDNS(context.Background(), rec, req)
if err == nil && code == dns.RcodeSuccess && rec.Msg != nil && rec.Msg.Rcode == dns.RcodeSuccess && len(rec.Msg.Answer) > 0 {
return rec.Msg
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("timed out waiting for answer to %s", name)
return nil
}
func catalogZoneRecords(t *testing.T, includeVersion bool) []dns.RR {
t.Helper()
@@ -142,6 +229,30 @@ func catalogZoneRecords(t *testing.T, includeVersion bool) []dns.RR {
return rrs
}
func catalogZoneRecordsWithoutMembers(t *testing.T) []dns.RR {
t.Helper()
soa := mustRR(t, "catalog.example. 0 IN SOA invalid. hostmaster.invalid. 2 3600 600 604800 0")
return []dns.RR{
soa,
mustRR(t, "catalog.example. 0 IN NS invalid."),
mustRR(t, `version.catalog.example. 0 IN TXT "2"`),
soa,
}
}
func memberZoneRecords(t *testing.T) []dns.RR {
t.Helper()
soa := mustRR(t, "example.org. 0 IN SOA ns.example.org. hostmaster.example.org. 1 3600 600 604800 0")
return []dns.RR{
soa,
mustRR(t, "example.org. 0 IN NS ns.example.org."),
mustRR(t, "www.example.org. 0 IN A 192.0.2.1"),
soa,
}
}
func mustRR(t *testing.T, s string) dns.RR {
t.Helper()

View File

@@ -13,9 +13,20 @@ import (
type Secondary struct {
file.File
zoneMu sync.RWMutex
zoneNames map[*file.Zone]string
dynamicZones map[string]*dynamicZone
catalogMu sync.RWMutex
catalogs map[string]*catalog.Catalog
catalogZones map[string]struct{}
catalogMemberZones map[string]map[string]struct{}
}
type dynamicZone struct {
catalog string
shutdown chan bool
stopOnce sync.Once
}
// Name implements the Handler interface.

View File

@@ -26,18 +26,7 @@ func setup(c *caddy.Controller) error {
return plugin.Error("secondary", err)
}
s := &Secondary{
File: file.File{Zones: zones, Fall: fall},
catalogs: make(map[string]*catalog.Catalog),
catalogZones: catalogZones,
}
zoneNames := make(map[*file.Zone]string, len(zones.Z))
for name, zone := range zones.Z {
zoneNames[zone] = name
}
s.TransferInFunc = func(z *file.Zone, t *transfer.Transfer) error {
return s.transferIn(zoneNames[z], z, t)
}
s := newSecondary(zones, fall, catalogZones)
var x *transfer.Transfer
c.OnStartup(func() error {
t := dnsserver.GetConfig(c).Handler("transfer")
@@ -59,15 +48,57 @@ func setup(c *caddy.Controller) error {
c.OnStartup(func() error {
z.StartupOnce.Do(func() {
go func() {
go s.transferAndUpdate(n, z, x, updateShutdown)
})
return nil
})
c.OnShutdown(func() error {
updateShutdownOnce.Do(func() { close(updateShutdown) })
return nil
})
}
}
c.OnShutdown(func() error {
s.stopDynamicZones()
return nil
})
dnsserver.GetConfig(c).AddPlugin(func(next plugin.Handler) plugin.Handler {
s.Next = next
return s
})
return nil
}
func newSecondary(zones file.Zones, fall fall.F, catalogZones map[string]struct{}) *Secondary {
s := &Secondary{
File: file.File{Zones: zones, Fall: fall},
zoneNames: make(map[*file.Zone]string, len(zones.Z)),
dynamicZones: make(map[string]*dynamicZone),
catalogs: make(map[string]*catalog.Catalog),
catalogZones: catalogZones,
catalogMemberZones: make(map[string]map[string]struct{}),
}
for name, zone := range zones.Z {
s.zoneNames[zone] = name
}
s.ZoneLookupFunc = s.lookupZone
s.TransferInFunc = func(z *file.Zone, t *transfer.Transfer) error {
return s.transferIn(s.zoneName(z), z, t)
}
return s
}
func (s *Secondary) transferAndUpdate(origin string, z *file.Zone, x *transfer.Transfer, updateShutdown chan bool) {
dur := time.Millisecond * 250
max := time.Second * 10
for {
err := s.transferIn(n, z, x)
err := s.transferIn(origin, z, x)
if err == nil {
break
}
log.Warningf("All '%s' masters failed to transfer, retrying in %s: %s", n, dur.String(), err)
log.Warningf("All '%s' masters failed to transfer, retrying in %s: %s", origin, dur.String(), err)
if waitForTransferRetry(updateShutdown, dur) {
return
}
@@ -82,25 +113,8 @@ func setup(c *caddy.Controller) error {
default:
}
z.UpdateWithTransfer(updateShutdown, x, func(z *file.Zone, t *transfer.Transfer) error {
return s.transferIn(n, z, t)
return s.transferIn(origin, z, t)
})
}()
})
return nil
})
c.OnShutdown(func() error {
updateShutdownOnce.Do(func() { close(updateShutdown) })
return nil
})
}
}
dnsserver.GetConfig(c).AddPlugin(func(next plugin.Handler) plugin.Handler {
s.Next = next
return s
})
return nil
}
func waitForTransferRetry(updateShutdown <-chan bool, dur time.Duration) bool {

46
plugin/secondary/zones.go Normal file
View File

@@ -0,0 +1,46 @@
package secondary
import (
"github.com/coredns/coredns/plugin"
"github.com/coredns/coredns/plugin/file"
)
func (s *Secondary) lookupZone(qname string) (string, *file.Zone, bool) {
s.zoneMu.RLock()
defer s.zoneMu.RUnlock()
zone := plugin.Zones(s.Names).Matches(qname)
if zone == "" {
return "", nil, false
}
z, ok := s.Z[zone]
if !ok {
return zone, nil, true
}
return zone, z, true
}
func (s *Secondary) zoneName(z *file.Zone) string {
s.zoneMu.RLock()
defer s.zoneMu.RUnlock()
return s.zoneNames[z]
}
func (s *Secondary) stopDynamicZones() {
s.zoneMu.Lock()
defer s.zoneMu.Unlock()
for _, dyn := range s.dynamicZones {
dyn.stopOnce.Do(func() { close(dyn.shutdown) })
}
}
func removeZoneName(names []string, name string) []string {
for i, n := range names {
if n == name {
return append(names[:i], names[i+1:]...)
}
}
return names
}