plugin/secondary: parse catalog zones after transfer (#8209)

This commit is contained in:
houyuwushang
2026-07-05 08:06:05 +08:00
committed by GitHub
parent 9226f8a3aa
commit 97e3a71afb
10 changed files with 662 additions and 15 deletions

View File

@@ -27,6 +27,7 @@ A working syntax would be:
~~~
secondary [zones...] {
transfer from ADDRESS [ADDRESS...]
catalog
fallthrough [ZONES...]
}
~~~
@@ -35,6 +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.
* `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
will be subject to fallthrough. This can be useful in split DNS setups where the secondary zone

View File

@@ -0,0 +1,39 @@
package secondary
import (
"github.com/coredns/coredns/plugin/file"
"github.com/coredns/coredns/plugin/pkg/catalog"
"github.com/coredns/coredns/plugin/transfer"
"github.com/miekg/dns"
)
func (s *Secondary) transferIn(origin string, z *file.Zone, t *transfer.Transfer) error {
if _, ok := s.catalogZones[origin]; !ok {
return z.TransferIn(t)
}
var parsed *catalog.Catalog
if err := z.TransferInWithRecords(t, func(rrs []dns.RR) error {
cat, err := catalog.Parse(origin, rrs)
if err != nil {
return err
}
parsed = cat
return nil
}); err != nil {
return err
}
if parsed == nil {
return nil
}
s.catalogMu.Lock()
if s.catalogs == nil {
s.catalogs = make(map[string]*catalog.Catalog)
}
s.catalogs[origin] = parsed
s.catalogMu.Unlock()
log.Infof("Parsed catalog zone %s with %d member zones", origin, len(parsed.Members))
return nil
}

View File

@@ -0,0 +1,153 @@
package secondary
import (
"strings"
"testing"
"github.com/coredns/coredns/plugin/file"
"github.com/coredns/coredns/plugin/pkg/catalog"
"github.com/coredns/coredns/plugin/pkg/dnstest"
"github.com/miekg/dns"
)
func TestTransferInCatalog(t *testing.T) {
const origin = "catalog.example."
rrs := catalogZoneRecords(t, true)
server := dnstest.NewServer(transferHandler(rrs))
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: {}},
}
if err := s.transferIn(origin, z, nil); err != nil {
t.Fatalf("transferIn returned error: %v", err)
}
s.catalogMu.RLock()
cat := s.catalogs[origin]
s.catalogMu.RUnlock()
if cat == nil {
t.Fatal("expected parsed catalog")
}
if len(cat.Members) != 1 {
t.Fatalf("expected 1 catalog member, got %d", len(cat.Members))
}
if cat.Members[0].Zone != "example.org." {
t.Fatalf("expected member zone example.org., got %q", cat.Members[0].Zone)
}
if strings.Join(cat.Members[0].Groups, ",") != "default" {
t.Fatalf("expected default member group, got %v", cat.Members[0].Groups)
}
if z.SOA == nil {
t.Fatal("expected zone data to be live after valid catalog transfer")
}
}
func TestTransferInCatalogRejectsInvalidCatalog(t *testing.T) {
const origin = "catalog.example."
rrs := catalogZoneRecords(t, false)
server := dnstest.NewServer(transferHandler(rrs))
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: {}},
}
err := s.transferIn(origin, z, nil)
if err == nil {
t.Fatal("expected invalid catalog transfer to fail")
}
if !strings.Contains(err.Error(), "version") {
t.Fatalf("expected version error, got %v", err)
}
if s.catalogs[origin] != nil {
t.Fatal("expected invalid catalog not to be stored")
}
if z.SOA != nil {
t.Fatal("expected invalid catalog transfer not to replace live zone data")
}
}
func TestTransferInSkipsCatalogParseForRegularZone(t *testing.T) {
const origin = "catalog.example."
rrs := catalogZoneRecords(t, false)
server := dnstest.NewServer(transferHandler(rrs))
defer server.Close()
z := file.NewZone(origin, "stdin")
z.TransferFrom = []string{server.Addr}
s := &Secondary{
catalogs: make(map[string]*catalog.Catalog),
}
if err := s.transferIn(origin, z, nil); err != nil {
t.Fatalf("transferIn returned error: %v", err)
}
if s.catalogs[origin] != nil {
t.Fatal("expected regular secondary transfer not to store catalog")
}
if z.SOA == nil {
t.Fatal("expected regular secondary transfer to replace live zone data")
}
}
func transferHandler(rrs []dns.RR) dns.HandlerFunc {
return func(w dns.ResponseWriter, req *dns.Msg) {
m := new(dns.Msg)
m.SetReply(req)
if len(req.Question) > 0 {
switch req.Question[0].Qtype {
case dns.TypeSOA:
for _, rr := range rrs {
if rr.Header().Rrtype == dns.TypeSOA {
m.Answer = []dns.RR{rr}
break
}
}
case dns.TypeAXFR:
m.Answer = rrs
}
}
_ = w.WriteMsg(m)
}
}
func catalogZoneRecords(t *testing.T, includeVersion bool) []dns.RR {
t.Helper()
soa := mustRR(t, "catalog.example. 0 IN SOA invalid. hostmaster.invalid. 1 3600 600 604800 0")
rrs := []dns.RR{
soa,
mustRR(t, "catalog.example. 0 IN NS invalid."),
}
if includeVersion {
rrs = append(rrs, mustRR(t, `version.catalog.example. 0 IN TXT "2"`))
}
rrs = append(rrs,
mustRR(t, "a.zones.catalog.example. 0 IN PTR example.org."),
mustRR(t, `group.a.zones.catalog.example. 0 IN TXT "default"`),
soa,
)
return rrs
}
func mustRR(t *testing.T, s string) dns.RR {
t.Helper()
rr, err := dns.NewRR(s)
if err != nil {
t.Fatalf("failed to parse RR %q: %v", s, err)
}
return rr
}

View File

@@ -1,13 +1,22 @@
// Package secondary implements a secondary plugin.
package secondary
import "github.com/coredns/coredns/plugin/file"
import (
"sync"
"github.com/coredns/coredns/plugin/file"
"github.com/coredns/coredns/plugin/pkg/catalog"
)
// Secondary implements a secondary plugin that allows CoreDNS to retrieve (via AXFR)
// zone information from a primary server.
type Secondary struct {
file.File
catalogMu sync.RWMutex
catalogs map[string]*catalog.Catalog
catalogZones map[string]struct{}
}
// Name implements the Handler interface.
func (s Secondary) Name() string { return "secondary" }
func (s *Secondary) Name() string { return "secondary" }

View File

@@ -8,6 +8,7 @@ import (
"github.com/coredns/coredns/core/dnsserver"
"github.com/coredns/coredns/plugin"
"github.com/coredns/coredns/plugin/file"
"github.com/coredns/coredns/plugin/pkg/catalog"
"github.com/coredns/coredns/plugin/pkg/fall"
clog "github.com/coredns/coredns/plugin/pkg/log"
"github.com/coredns/coredns/plugin/pkg/parse"
@@ -20,12 +21,23 @@ var log = clog.NewWithPlugin("secondary")
func init() { plugin.Register("secondary", setup) }
func setup(c *caddy.Controller) error {
zones, fall, err := secondaryParse(c)
zones, fall, catalogZones, err := secondaryParse(c)
if err != nil {
return plugin.Error("secondary", err)
}
s := &Secondary{file.File{Zones: zones, Fall: fall}}
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)
}
var x *transfer.Transfer
c.OnStartup(func() error {
t := dnsserver.GetConfig(c).Handler("transfer")
@@ -51,7 +63,7 @@ func setup(c *caddy.Controller) error {
dur := time.Millisecond * 250
max := time.Second * 10
for {
err := z.TransferIn(x)
err := s.transferIn(n, z, x)
if err == nil {
break
}
@@ -69,7 +81,9 @@ func setup(c *caddy.Controller) error {
return
default:
}
z.Update(updateShutdown, x)
z.UpdateWithTransfer(updateShutdown, x, func(z *file.Zone, t *transfer.Transfer) error {
return s.transferIn(n, z, t)
})
}()
})
return nil
@@ -100,10 +114,11 @@ func waitForTransferRetry(updateShutdown <-chan bool, dur time.Duration) bool {
}
}
func secondaryParse(c *caddy.Controller) (file.Zones, fall.F, error) {
func secondaryParse(c *caddy.Controller) (file.Zones, fall.F, map[string]struct{}, error) {
z := make(map[string]*file.Zone)
names := []string{}
fall := fall.F{}
catalogZones := map[string]struct{}{}
for c.Next() {
if c.Val() == "secondary" {
// secondary [origin]
@@ -122,13 +137,20 @@ func secondaryParse(c *caddy.Controller) (file.Zones, fall.F, error) {
var err error
f, err = parse.TransferIn(c)
if err != nil {
return file.Zones{}, fall, err
return file.Zones{}, fall, nil, err
}
hasTransfer = true
case "catalog":
if len(c.RemainingArgs()) != 0 {
return file.Zones{}, fall, nil, c.ArgErr()
}
for _, origin := range origins {
catalogZones[origin] = struct{}{}
}
case "fallthrough":
fall.SetZonesFromArgs(c.RemainingArgs())
default:
return file.Zones{}, fall, c.Errf("unknown property '%s'", c.Val())
return file.Zones{}, fall, nil, c.Errf("unknown property '%s'", c.Val())
}
for _, origin := range origins {
@@ -139,9 +161,9 @@ func secondaryParse(c *caddy.Controller) (file.Zones, fall.F, error) {
}
}
if !hasTransfer {
return file.Zones{}, fall, c.Err("secondary zones require a transfer from property")
return file.Zones{}, fall, nil, c.Err("secondary zones require a transfer from property")
}
}
}
return file.Zones{Z: z, Names: names}, fall, nil
return file.Zones{Z: z, Names: names}, fall, catalogZones, nil
}

View File

@@ -14,6 +14,7 @@ func TestSecondaryParse(t *testing.T) {
transferFrom string
zones []string
fall fall.F
catalogZones []string
}{
{
`secondary {
@@ -23,6 +24,7 @@ func TestSecondaryParse(t *testing.T) {
"127.0.0.1:53",
nil,
fall.F{},
nil,
},
{
`secondary example.org {
@@ -32,6 +34,29 @@ func TestSecondaryParse(t *testing.T) {
"127.0.0.1:53",
[]string{"example.org."},
fall.F{},
nil,
},
{
`secondary catalog.example {
transfer from 127.0.0.1
catalog
}`,
false,
"127.0.0.1:53",
[]string{"catalog.example."},
fall.F{},
[]string{"catalog.example."},
},
{
`secondary catalog.example {
transfer from 127.0.0.1
catalog extra
}`,
true,
"",
nil,
fall.F{},
nil,
},
{
`secondary`,
@@ -39,6 +64,7 @@ func TestSecondaryParse(t *testing.T) {
"",
nil,
fall.F{},
nil,
},
{
`secondary example.org {
@@ -48,6 +74,7 @@ func TestSecondaryParse(t *testing.T) {
"",
nil,
fall.F{},
nil,
},
// fallthrough: bare (all zones)
{
@@ -59,6 +86,7 @@ func TestSecondaryParse(t *testing.T) {
"127.0.0.1:53",
nil,
fall.Root,
nil,
},
// fallthrough: specific zone
{
@@ -70,12 +98,13 @@ func TestSecondaryParse(t *testing.T) {
"127.0.0.1:53",
[]string{"example.org."},
fall.F{Zones: []string{"example.org."}},
nil,
},
}
for i, test := range tests {
c := caddy.NewTestController("dns", test.inputFileRules)
s, f, err := secondaryParse(c)
s, f, catalogZones, err := secondaryParse(c)
if err == nil && test.shouldErr {
t.Fatalf("Test %d expected errors, but got no error", i)
@@ -99,5 +128,13 @@ func TestSecondaryParse(t *testing.T) {
if !f.Equal(test.fall) {
t.Fatalf("Test %d fallthrough not equal: expected %v, got %v", i, test.fall, f)
}
if len(catalogZones) != len(test.catalogZones) {
t.Fatalf("Test %d catalog zone count mismatch: expected %d, got %d", i, len(test.catalogZones), len(catalogZones))
}
for _, name := range test.catalogZones {
if _, ok := catalogZones[name]; !ok {
t.Fatalf("Test %d expected catalog zone %q", i, name)
}
}
}
}