mirror of
https://github.com/coredns/coredns.git
synced 2026-07-10 17:50:12 -04:00
plugin/secondary: parse catalog zones after transfer (#8209)
This commit is contained in:
157
plugin/pkg/catalog/catalog.go
Normal file
157
plugin/pkg/catalog/catalog.go
Normal file
@@ -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
|
||||
}
|
||||
191
plugin/pkg/catalog/catalog_test.go
Normal file
191
plugin/pkg/catalog/catalog_test.go
Normal file
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user