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

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

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