diff --git a/plugin/file/file.go b/plugin/file/file.go index a86c90991..a7c3eded6 100644 --- a/plugin/file/file.go +++ b/plugin/file/file.go @@ -24,6 +24,7 @@ type ( Next plugin.Handler Zones Xfer *transfer.Transfer + TransferInFunc Fall fall.F } @@ -71,7 +72,9 @@ func (f File) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (i log.Infof("Notify from %s for %s: checking transfer", state.IP(), zone) ok, err := z.shouldTransfer() if ok { - z.TransferIn(f.Xfer) + if err := f.transferIn(z, f.Xfer); err != nil { + log.Warningf("Notify from %s for %s: transfer failed: %s", state.IP(), zone, err) + } } else { log.Infof("Notify from %s for %s: no SOA serial increase seen", state.IP(), zone) } @@ -131,6 +134,13 @@ 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) transferIn(z *Zone, t *transfer.Transfer) error { + if f.TransferInFunc != nil { + return f.TransferInFunc(z, t) + } + return z.TransferIn(t) +} + type serialErr struct { err string zone string diff --git a/plugin/file/secondary.go b/plugin/file/secondary.go index c16be1867..5dcc550fd 100644 --- a/plugin/file/secondary.go +++ b/plugin/file/secondary.go @@ -9,8 +9,17 @@ import ( "github.com/miekg/dns" ) +// TransferInFunc transfers zone data into z. +type TransferInFunc func(z *Zone, t *transfer.Transfer) error + // TransferIn retrieves the zone from the masters, parses it and sets it live. func (z *Zone) TransferIn(t *transfer.Transfer) error { + return z.TransferInWithRecords(t, nil) +} + +// TransferInWithRecords retrieves the zone from the masters, calls validate +// with the transferred records, and sets the zone live if validation succeeds. +func (z *Zone) TransferInWithRecords(t *transfer.Transfer, validate func([]dns.RR) error) error { if len(z.TransferFrom) == 0 { return nil } @@ -22,6 +31,7 @@ func (z *Zone) TransferIn(t *transfer.Transfer) error { Err error tr string ) + var transferred []dns.RR Transfer: for _, tr = range z.TransferFrom { @@ -32,6 +42,7 @@ Transfer: Err = err continue Transfer } + var records []dns.RR for env := range c { if env.Error != nil { log.Errorf("Failed to transfer `%s' from %q: %v", z.origin, tr, env.Error) @@ -44,14 +55,23 @@ Transfer: Err = err continue Transfer } + if validate != nil { + records = append(records, rr) + } } } + transferred = records Err = nil break } if Err != nil { return Err } + if validate != nil { + if err := validate(transferred); err != nil { + return err + } + } z.setData(z1.Apex, z1.Tree) log.Infof("Transferred: %s from %s", z.origin, tr) @@ -114,6 +134,11 @@ func less(a, b uint32) bool { // server) it will retry every retry interval. If the zone failed to transfer before the expire, the zone // will be marked expired. func (z *Zone) Update(updateShutdown chan bool, t *transfer.Transfer) error { + return z.UpdateWithTransfer(updateShutdown, t, (*Zone).TransferIn) +} + +// UpdateWithTransfer updates the secondary zone using transferIn for zone transfers. +func (z *Zone) UpdateWithTransfer(updateShutdown chan bool, t *transfer.Transfer, transferIn TransferInFunc) error { // If we don't have a SOA, we don't have a zone, wait for it to appear. for z.getSOA() == nil { if waitOrShutdown(updateShutdown, time.Second) { @@ -162,7 +187,7 @@ Restart: } if ok { - if err := z.TransferIn(t); err != nil { + if err := transferIn(z, t); err != nil { // transfer failed, leave retryActive true break } @@ -188,7 +213,7 @@ Restart: } if ok { - if err := z.TransferIn(t); err != nil { + if err := transferIn(z, t); err != nil { // transfer failed retryActive = true break diff --git a/plugin/pkg/catalog/catalog.go b/plugin/pkg/catalog/catalog.go new file mode 100644 index 000000000..e6f162150 --- /dev/null +++ b/plugin/pkg/catalog/catalog.go @@ -0,0 +1,157 @@ +// Package catalog parses DNS catalog zones as defined by RFC 9432. +package catalog + +import ( + "fmt" + "sort" + "strings" + + "github.com/miekg/dns" +) + +// Version is the RFC 9432 catalog zone schema version supported by this package. +const Version = "2" + +// Catalog is the parsed catalog zone state. +type Catalog struct { + Origin string + Members []Member +} + +// Member is a member zone entry from a catalog zone. +type Member struct { + ID string + Zone string + Groups []string + ChangeOfOwnership string +} + +// Parse builds a Catalog from resource records belonging to origin. +func Parse(origin string, rrs []dns.RR) (*Catalog, error) { + origin = normalizeName(origin) + originLabels := dns.SplitDomainName(origin) + zonesLabels := append([]string{"zones"}, originLabels...) + versionOwner := "version." + origin + + var ( + hasSOA bool + hasNS bool + versionText []string + ) + + memberPTR := make(map[string][]string) + groups := make(map[string][]string) + coo := make(map[string][]string) + + for _, rr := range rrs { + h := rr.Header() + if h.Class != dns.ClassINET { + return nil, fmt.Errorf("catalog zone %s contains non-IN record %s", origin, rr.String()) + } + + owner := normalizeName(h.Name) + switch x := rr.(type) { + case *dns.SOA: + if owner == origin { + hasSOA = true + } + case *dns.NS: + if owner == origin { + hasNS = true + } + case *dns.TXT: + if owner == versionOwner { + versionText = append(versionText, txtValue(x)) + } else { + if prop, id, ok := propertyOwner(owner, zonesLabels); ok && prop == "group" { + groups[id] = append(groups[id], txtValue(x)) + } + } + case *dns.PTR: + switch { + case isMemberOwner(owner, zonesLabels): + id := dns.SplitDomainName(owner)[0] + memberPTR[id] = append(memberPTR[id], normalizeName(x.Ptr)) + default: + if prop, id, ok := propertyOwner(owner, zonesLabels); ok && prop == "coo" { + coo[id] = append(coo[id], normalizeName(x.Ptr)) + } + } + } + } + + if !hasSOA { + return nil, fmt.Errorf("catalog zone %s has no SOA record", origin) + } + if !hasNS { + return nil, fmt.Errorf("catalog zone %s has no NS record", origin) + } + if len(versionText) != 1 { + return nil, fmt.Errorf("catalog zone %s must have exactly one version TXT record", origin) + } + if versionText[0] != Version { + return nil, fmt.Errorf("catalog zone %s has unsupported version %q", origin, versionText[0]) + } + + seenZones := make(map[string]string) + members := make([]Member, 0, len(memberPTR)) + for id, zones := range memberPTR { + if len(zones) != 1 { + return nil, fmt.Errorf("catalog member %s.%s must have exactly one PTR record", id, "zones."+origin) + } + zone := zones[0] + if prevID, ok := seenZones[zone]; ok { + return nil, fmt.Errorf("catalog member zone %s is listed by both %s and %s", zone, prevID, id) + } + seenZones[zone] = id + + member := Member{ID: id, Zone: zone, Groups: append([]string(nil), groups[id]...)} + sort.Strings(member.Groups) + if values := coo[id]; len(values) > 1 { + return nil, fmt.Errorf("catalog member %s has more than one coo PTR record", id) + } else if len(values) == 1 { + member.ChangeOfOwnership = values[0] + } + members = append(members, member) + } + + sort.Slice(members, func(i, j int) bool { + return members[i].ID < members[j].ID + }) + + return &Catalog{Origin: origin, Members: members}, nil +} + +func normalizeName(name string) string { + return strings.ToLower(dns.Fqdn(name)) +} + +func txtValue(rr *dns.TXT) string { + return strings.Join(rr.Txt, "") +} + +func isMemberOwner(owner string, zonesLabels []string) bool { + labels := dns.SplitDomainName(owner) + return len(labels) == len(zonesLabels)+1 && hasLabelSuffix(labels, zonesLabels) +} + +func propertyOwner(owner string, zonesLabels []string) (property, id string, ok bool) { + labels := dns.SplitDomainName(owner) + if len(labels) != len(zonesLabels)+2 || !hasLabelSuffix(labels, zonesLabels) { + return "", "", false + } + return labels[0], labels[1], true +} + +func hasLabelSuffix(labels, suffix []string) bool { + if len(labels) < len(suffix) { + return false + } + offset := len(labels) - len(suffix) + for i := range suffix { + if labels[offset+i] != suffix[i] { + return false + } + } + return true +} diff --git a/plugin/pkg/catalog/catalog_test.go b/plugin/pkg/catalog/catalog_test.go new file mode 100644 index 000000000..29c19a6b9 --- /dev/null +++ b/plugin/pkg/catalog/catalog_test.go @@ -0,0 +1,191 @@ +package catalog + +import ( + "strings" + "testing" + + "github.com/miekg/dns" +) + +func TestParse(t *testing.T) { + rrs := parseZone(t, ` +$ORIGIN catalog.example. +@ 0 IN SOA invalid. hostmaster.invalid. 1 3600 600 604800 0 +@ 0 IN NS invalid. +version 0 IN TXT "2" +b.zones 0 IN PTR Example.NET. +a.zones 0 IN PTR example.com. +group.a.zones 0 IN TXT "operator-default" +group.a.zones 0 IN TXT "unsigned" +coo.b.zones 0 IN PTR other-catalog.example. +ignored 0 IN TXT "value" +`) + + cat, err := Parse("catalog.example.", rrs) + if err != nil { + t.Fatalf("Parse returned error: %v", err) + } + if cat.Origin != "catalog.example." { + t.Fatalf("expected origin catalog.example., got %q", cat.Origin) + } + if len(cat.Members) != 2 { + t.Fatalf("expected 2 members, got %d", len(cat.Members)) + } + + assertMember(t, cat.Members[0], Member{ + ID: "a", + Zone: "example.com.", + Groups: []string{"operator-default", "unsigned"}, + }) + assertMember(t, cat.Members[1], Member{ + ID: "b", + Zone: "example.net.", + ChangeOfOwnership: "other-catalog.example.", + }) +} + +func TestParseBrokenCatalog(t *testing.T) { + tests := []struct { + name string + zone string + err string + }{ + { + name: "missing soa", + zone: ` +$ORIGIN catalog.example. +@ 0 IN NS invalid. +version 0 IN TXT "2" +a.zones 0 IN PTR example.com. +`, + err: "no SOA", + }, + { + name: "missing ns", + zone: ` +$ORIGIN catalog.example. +@ 0 IN SOA invalid. hostmaster.invalid. 1 3600 600 604800 0 +version 0 IN TXT "2" +a.zones 0 IN PTR example.com. +`, + err: "no NS", + }, + { + name: "missing version", + zone: ` +$ORIGIN catalog.example. +@ 0 IN SOA invalid. hostmaster.invalid. 1 3600 600 604800 0 +@ 0 IN NS invalid. +a.zones 0 IN PTR example.com. +`, + err: "exactly one version", + }, + { + name: "multiple version", + zone: ` +$ORIGIN catalog.example. +@ 0 IN SOA invalid. hostmaster.invalid. 1 3600 600 604800 0 +@ 0 IN NS invalid. +version 0 IN TXT "2" +version 0 IN TXT "2" +a.zones 0 IN PTR example.com. +`, + err: "exactly one version", + }, + { + name: "unsupported version", + zone: ` +$ORIGIN catalog.example. +@ 0 IN SOA invalid. hostmaster.invalid. 1 3600 600 604800 0 +@ 0 IN NS invalid. +version 0 IN TXT "1" +a.zones 0 IN PTR example.com. +`, + err: "unsupported version", + }, + { + name: "multiple member ptr", + zone: ` +$ORIGIN catalog.example. +@ 0 IN SOA invalid. hostmaster.invalid. 1 3600 600 604800 0 +@ 0 IN NS invalid. +version 0 IN TXT "2" +a.zones 0 IN PTR example.com. +a.zones 0 IN PTR example.net. +`, + err: "exactly one PTR", + }, + { + name: "duplicate member zone", + zone: ` +$ORIGIN catalog.example. +@ 0 IN SOA invalid. hostmaster.invalid. 1 3600 600 604800 0 +@ 0 IN NS invalid. +version 0 IN TXT "2" +a.zones 0 IN PTR example.com. +b.zones 0 IN PTR example.com. +`, + err: "listed by both", + }, + { + name: "multiple coo ptr", + zone: ` +$ORIGIN catalog.example. +@ 0 IN SOA invalid. hostmaster.invalid. 1 3600 600 604800 0 +@ 0 IN NS invalid. +version 0 IN TXT "2" +a.zones 0 IN PTR example.com. +coo.a.zones 0 IN PTR new-a.example. +coo.a.zones 0 IN PTR new-b.example. +`, + err: "more than one coo", + }, + { + name: "non in class", + zone: ` +$ORIGIN catalog.example. +@ 0 IN SOA invalid. hostmaster.invalid. 1 3600 600 604800 0 +@ 0 IN NS invalid. +version 0 IN TXT "2" +a.zones 0 CH PTR example.com. +`, + err: "non-IN", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := Parse("catalog.example.", parseZone(t, tc.zone)) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), tc.err) { + t.Fatalf("expected error containing %q, got %q", tc.err, err) + } + }) + } +} + +func assertMember(t *testing.T, got, want Member) { + t.Helper() + if got.ID != want.ID || got.Zone != want.Zone || got.ChangeOfOwnership != want.ChangeOfOwnership { + t.Fatalf("expected member %+v, got %+v", want, got) + } + if strings.Join(got.Groups, ",") != strings.Join(want.Groups, ",") { + t.Fatalf("expected groups %v, got %v", want.Groups, got.Groups) + } +} + +func parseZone(t *testing.T, zone string) []dns.RR { + t.Helper() + + zp := dns.NewZoneParser(strings.NewReader(zone), "catalog.example.", "test") + var rrs []dns.RR + for rr, ok := zp.Next(); ok; rr, ok = zp.Next() { + rrs = append(rrs, rr) + } + if err := zp.Err(); err != nil { + t.Fatalf("failed to parse test zone: %v", err) + } + return rrs +} diff --git a/plugin/secondary/README.md b/plugin/secondary/README.md index 01bada625..4a904a2c9 100644 --- a/plugin/secondary/README.md +++ b/plugin/secondary/README.md @@ -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 diff --git a/plugin/secondary/catalog.go b/plugin/secondary/catalog.go new file mode 100644 index 000000000..66d4946de --- /dev/null +++ b/plugin/secondary/catalog.go @@ -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 +} diff --git a/plugin/secondary/catalog_test.go b/plugin/secondary/catalog_test.go new file mode 100644 index 000000000..0c4c79c15 --- /dev/null +++ b/plugin/secondary/catalog_test.go @@ -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 +} diff --git a/plugin/secondary/secondary.go b/plugin/secondary/secondary.go index 809edadbd..5500d6e10 100644 --- a/plugin/secondary/secondary.go +++ b/plugin/secondary/secondary.go @@ -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" } diff --git a/plugin/secondary/setup.go b/plugin/secondary/setup.go index b0c513362..bbcccca27 100644 --- a/plugin/secondary/setup.go +++ b/plugin/secondary/setup.go @@ -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 } diff --git a/plugin/secondary/setup_test.go b/plugin/secondary/setup_test.go index 58b7a15cb..135b83fcb 100644 --- a/plugin/secondary/setup_test.go +++ b/plugin/secondary/setup_test.go @@ -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) + } + } } }