Render always writes the secret ddi-suite-keys.conf (only a comment without keys). Primary zones of a cluster get "notify explicit", "also-notify" and "allow-transfer" with the transfer key; secondary zones transfer from their primaries with the key into <zone_dir>/secondary/ and have no artifact. Dynamic zones accept the update keys in their ACL. Keys are validated (name, algorithm whitelist, canonical base64) and every referenced key must exist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
564 lines
17 KiB
Go
564 lines
17 KiB
Go
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
|
|
|
|
package bindgen
|
|
|
|
import (
|
|
"cmp"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"net/netip"
|
|
"path"
|
|
"slices"
|
|
"strings"
|
|
|
|
"git.beugsiud.de/beug.k/DDI/server/internal/configgen"
|
|
)
|
|
|
|
// maxTTL is the largest TTL BIND accepts (RFC 2181 §8).
|
|
const maxTTL = 1<<31 - 1
|
|
|
|
// maxRDataLen is the largest RDATA in wire format.
|
|
const maxRDataLen = 65535
|
|
|
|
// model is the validated, normalized and sorted input of the writers.
|
|
type model struct {
|
|
serial uint32
|
|
zoneDir string
|
|
options options
|
|
zones []zoneData
|
|
keys []configgen.TSIGKey // sorted by name
|
|
}
|
|
|
|
// remote is a validated server of the cluster.
|
|
type remote struct {
|
|
addr netip.Addr
|
|
key string // "" for none
|
|
}
|
|
|
|
// options are the validated server options.
|
|
type options struct {
|
|
recursion bool
|
|
allowRecursion []netip.Prefix // sorted, without duplicates
|
|
forwarders []netip.Addr // input order (the administrator's choice)
|
|
}
|
|
|
|
// zoneData is one validated zone.
|
|
type zoneData struct {
|
|
name string
|
|
// secondary zones use only name and primaries.
|
|
secondary bool
|
|
primaries []remote // sorted by address
|
|
dynamic bool
|
|
defaultTTL uint32
|
|
soa configgen.SOA
|
|
nameServers []string // sorted FQDNs
|
|
glue []rr // sorted, without duplicates
|
|
records []rr // sorted, without duplicates and without RRs already in glue or NS
|
|
allowUpdate []netip.Prefix
|
|
updateKeys []string // sorted, without duplicates
|
|
alsoNotify []remote // sorted by address
|
|
transferKey string
|
|
}
|
|
|
|
// rr is one validated resource record with its rendered RDATA.
|
|
type rr struct {
|
|
owner string // relative name or "@"
|
|
ttl uint32 // 0: zone default
|
|
typ configgen.RRType
|
|
rdata string
|
|
// Sort keys besides rdata: addresses and numeric fields compare as
|
|
// numbers, not as text.
|
|
addr netip.Addr
|
|
nums [3]uint16
|
|
}
|
|
|
|
// invalid builds the error returned for rejected input.
|
|
func invalid(path, msg string) error {
|
|
return &configgen.ValidationError{Path: path, Message: msg}
|
|
}
|
|
|
|
// validate checks cfg completely and returns the model the writers render.
|
|
func validate(cfg configgen.DNSConfig) (*model, error) {
|
|
if cfg.Serial == 0 {
|
|
return nil, invalid("serial", "must not be 0")
|
|
}
|
|
if err := checkZoneDir(cfg.ZoneDir); err != nil {
|
|
return nil, err
|
|
}
|
|
opts, err := validateOptions(cfg.Options)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
keys, err := validateKeys(cfg.Keys)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
m := &model{serial: cfg.Serial, zoneDir: cfg.ZoneDir, options: opts, keys: keys}
|
|
known := make(map[string]bool, len(keys))
|
|
for _, k := range keys {
|
|
known[k.Name] = true
|
|
}
|
|
seen := make(map[string]bool, len(cfg.Zones))
|
|
for i, z := range cfg.Zones {
|
|
zd, err := validateZone(i, z, known)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if seen[zd.name] {
|
|
return nil, invalid(fmt.Sprintf("zones[%d].name", i), "duplicate zone")
|
|
}
|
|
seen[zd.name] = true
|
|
m.zones = append(m.zones, zd)
|
|
}
|
|
slices.SortFunc(m.zones, func(a, b zoneData) int { return strings.Compare(a.name, b.name) })
|
|
return m, nil
|
|
}
|
|
|
|
// checkZoneDir accepts clean absolute paths of [A-Za-z0-9/._-].
|
|
func checkZoneDir(dir string) error {
|
|
const p = "zone_dir"
|
|
for i := 0; i < len(dir); i++ {
|
|
c := dir[i]
|
|
if (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') && (c < '0' || c > '9') &&
|
|
c != '/' && c != '.' && c != '_' && c != '-' {
|
|
return invalid(p, "may only contain A-Z, a-z, 0-9, '/', '.', '_' and '-'")
|
|
}
|
|
}
|
|
switch {
|
|
case !strings.HasPrefix(dir, "/"):
|
|
return invalid(p, "must be an absolute path")
|
|
case dir == "/":
|
|
return invalid(p, "must not be the root directory")
|
|
case path.Clean(dir) != dir:
|
|
return invalid(p, "must be a clean path (no trailing '/', no '.' or '..' elements)")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateOptions checks the server options.
|
|
func validateOptions(o configgen.DNSOptions) (options, error) {
|
|
allow, err := checkPrefixes("options.allow_recursion", o.AllowRecursion)
|
|
if err != nil {
|
|
return options{}, err
|
|
}
|
|
if o.Recursion && len(allow) == 0 {
|
|
return options{}, invalid("options.allow_recursion", "at least one client prefix is required when recursion is enabled")
|
|
}
|
|
for i, a := range o.Forwarders {
|
|
p := fmt.Sprintf("options.forwarders[%d]", i)
|
|
if err := checkHostAddr(p, a); err != nil {
|
|
return options{}, err
|
|
}
|
|
if slices.Contains(o.Forwarders[:i], a) {
|
|
return options{}, invalid(p, "duplicate forwarder")
|
|
}
|
|
}
|
|
return options{
|
|
recursion: o.Recursion,
|
|
allowRecursion: allow,
|
|
forwarders: slices.Clone(o.Forwarders),
|
|
}, nil
|
|
}
|
|
|
|
// checkPrefixes validates an address match list and returns it sorted and
|
|
// without duplicates.
|
|
func checkPrefixes(p string, in []netip.Prefix) ([]netip.Prefix, error) {
|
|
for i, pfx := range in {
|
|
ip := fmt.Sprintf("%s[%d]", p, i)
|
|
switch {
|
|
case !pfx.IsValid():
|
|
return nil, invalid(ip, "is not a valid prefix")
|
|
case pfx.Addr().Zone() != "":
|
|
return nil, invalid(ip, "must not have an IPv6 zone")
|
|
case pfx.Addr().Is4In6():
|
|
return nil, invalid(ip, "must not be IPv4-mapped; use the IPv4 form")
|
|
case pfx.Masked() != pfx:
|
|
return nil, invalid(ip, "has host bits set; use "+pfx.Masked().String())
|
|
case pfx.Bits() == 0:
|
|
return nil, invalid(ip, "must not match every address")
|
|
}
|
|
}
|
|
out := slices.Clone(in)
|
|
slices.SortFunc(out, func(a, b netip.Prefix) int {
|
|
return cmp.Or(a.Addr().Compare(b.Addr()), cmp.Compare(a.Bits(), b.Bits()))
|
|
})
|
|
return slices.Compact(out), nil
|
|
}
|
|
|
|
// checkHostAddr accepts unicast addresses usable as server addresses.
|
|
func checkHostAddr(p string, a netip.Addr) error {
|
|
switch {
|
|
case !a.IsValid():
|
|
return invalid(p, "is not a valid IP address")
|
|
case a.Zone() != "":
|
|
return invalid(p, "must not have an IPv6 zone")
|
|
case a.Is4In6():
|
|
return invalid(p, "must not be IPv4-mapped; use the IPv4 form")
|
|
case a.IsUnspecified() || a.IsMulticast():
|
|
return invalid(p, "must be a unicast address")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// checkTTL validates a TTL or SOA timer.
|
|
func checkTTL(p string, v uint32, zeroOK bool) error {
|
|
switch {
|
|
case v == 0 && !zeroOK:
|
|
return invalid(p, "must not be 0")
|
|
case v > maxTTL:
|
|
return invalid(p, fmt.Sprintf("must not exceed %d", maxTTL))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// checkFQDN validates an absolute host name given without trailing dot.
|
|
func checkFQDN(p, name string) error {
|
|
if msg := checkName(name, false); msg != "" {
|
|
return invalid(p, msg)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateZone checks one zone; i is its index in the input, keys are the
|
|
// names of the keys in DNSConfig.Keys.
|
|
func validateZone(i int, z configgen.Zone, keys map[string]bool) (zoneData, error) {
|
|
if msg := checkName(z.Name, false); msg != "" {
|
|
return zoneData{}, invalid(fmt.Sprintf("zones[%d].name", i), msg)
|
|
}
|
|
p := fmt.Sprintf("zones[%q]", z.Name)
|
|
reverse := strings.HasSuffix(z.Name, ".in-addr.arpa") || strings.HasSuffix(z.Name, ".ip6.arpa")
|
|
switch z.Kind {
|
|
case configgen.ZoneForward:
|
|
if z.Name == "arpa" || strings.HasSuffix(z.Name, ".arpa") {
|
|
return zoneData{}, invalid(p+".kind", "a zone below arpa must be a reverse zone")
|
|
}
|
|
case configgen.ZoneReverse:
|
|
if !reverse {
|
|
return zoneData{}, invalid(p+".name", "a reverse zone must end in .in-addr.arpa or .ip6.arpa")
|
|
}
|
|
default:
|
|
return zoneData{}, invalid(p+".kind", "must be forward or reverse")
|
|
}
|
|
switch z.Type {
|
|
case "", configgen.ZonePrimary:
|
|
case configgen.ZoneSecondary:
|
|
if len(z.Primaries) == 0 {
|
|
return zoneData{}, invalid(p+".primaries", "a secondary zone needs at least one primary")
|
|
}
|
|
primaries, err := checkRemotes(p+".primaries", z.Primaries, keys)
|
|
if err != nil {
|
|
return zoneData{}, err
|
|
}
|
|
return zoneData{name: z.Name, secondary: true, primaries: primaries}, nil
|
|
default:
|
|
return zoneData{}, invalid(p+".type", "must be primary or secondary")
|
|
}
|
|
if len(z.Primaries) > 0 {
|
|
return zoneData{}, invalid(p+".primaries", "only a secondary zone has primaries")
|
|
}
|
|
zd := zoneData{name: z.Name, defaultTTL: z.DefaultTTL, soa: z.SOA}
|
|
alsoNotify, err := checkRemotes(p+".also_notify", z.AlsoNotify, keys)
|
|
if err != nil {
|
|
return zoneData{}, err
|
|
}
|
|
zd.alsoNotify = alsoNotify
|
|
if z.TransferKey != "" {
|
|
if !keys[z.TransferKey] {
|
|
return zoneData{}, invalid(p+".transfer_key", "names no key of the configuration")
|
|
}
|
|
zd.transferKey = z.TransferKey
|
|
}
|
|
switch z.Mode {
|
|
case configgen.ZoneManaged:
|
|
case configgen.ZoneDynamic:
|
|
zd.dynamic = true
|
|
default:
|
|
return zoneData{}, invalid(p+".mode", "must be managed or dynamic")
|
|
}
|
|
if err := checkTTL(p+".default_ttl", z.DefaultTTL, false); err != nil {
|
|
return zoneData{}, err
|
|
}
|
|
if err := validateSOA(p+".soa", z.SOA); err != nil {
|
|
return zoneData{}, err
|
|
}
|
|
if err := validateNameServers(p, &zd, z.NameServers); err != nil {
|
|
return zoneData{}, err
|
|
}
|
|
if err := validateRecords(p, &zd, z.Records); err != nil {
|
|
return zoneData{}, err
|
|
}
|
|
if zd.dynamic {
|
|
if len(z.AllowUpdate) == 0 && len(z.UpdateKeys) == 0 {
|
|
return zoneData{}, invalid(p+".allow_update", "a dynamic zone must allow updates from at least one prefix or key")
|
|
}
|
|
allow, err := checkPrefixes(p+".allow_update", z.AllowUpdate)
|
|
if err != nil {
|
|
return zoneData{}, err
|
|
}
|
|
zd.allowUpdate = allow
|
|
for j, k := range z.UpdateKeys {
|
|
if !keys[k] {
|
|
return zoneData{}, invalid(fmt.Sprintf("%s.update_keys[%d]", p, j), "names no key of the configuration")
|
|
}
|
|
}
|
|
zd.updateKeys = slices.Compact(slices.Sorted(slices.Values(z.UpdateKeys)))
|
|
}
|
|
return zd, nil
|
|
}
|
|
|
|
// checkRemotes validates the servers of a primaries or also-notify list and
|
|
// returns them sorted by address.
|
|
func checkRemotes(p string, in []configgen.Remote, keys map[string]bool) ([]remote, error) {
|
|
out := make([]remote, 0, len(in))
|
|
for i, r := range in {
|
|
rp := fmt.Sprintf("%s[%d]", p, i)
|
|
if err := checkHostAddr(rp+".addr", r.Addr); err != nil {
|
|
return nil, err
|
|
}
|
|
if slices.ContainsFunc(in[:i], func(o configgen.Remote) bool { return o.Addr == r.Addr }) {
|
|
return nil, invalid(rp+".addr", "duplicate server")
|
|
}
|
|
if r.Key != "" && !keys[r.Key] {
|
|
return nil, invalid(rp+".key", "names no key of the configuration")
|
|
}
|
|
out = append(out, remote{addr: r.Addr, key: r.Key})
|
|
}
|
|
slices.SortFunc(out, func(a, b remote) int { return a.addr.Compare(b.addr) })
|
|
return out, nil
|
|
}
|
|
|
|
// keyAlgorithms are the TSIG algorithms the generator writes (ADR 0023).
|
|
var keyAlgorithms = []string{"hmac-sha256", "hmac-sha384", "hmac-sha512"}
|
|
|
|
// validateKeys checks the TSIG keys and returns them sorted by name.
|
|
func validateKeys(in []configgen.TSIGKey) ([]configgen.TSIGKey, error) {
|
|
for i, k := range in {
|
|
p := fmt.Sprintf("keys[%d]", i)
|
|
if msg := checkName(k.Name, false); msg != "" {
|
|
return nil, invalid(p+".name", msg)
|
|
}
|
|
if slices.ContainsFunc(in[:i], func(o configgen.TSIGKey) bool { return o.Name == k.Name }) {
|
|
return nil, invalid(p+".name", "duplicate key")
|
|
}
|
|
if !slices.Contains(keyAlgorithms, k.Algorithm) {
|
|
return nil, invalid(p+".algorithm", "must be one of "+strings.Join(keyAlgorithms, ", "))
|
|
}
|
|
// The canonical standard encoding only: its alphabet cannot leave
|
|
// the quoted string, and BIND decodes it.
|
|
raw, err := base64.StdEncoding.Strict().DecodeString(k.Secret)
|
|
if err != nil || len(raw) == 0 || base64.StdEncoding.EncodeToString(raw) != k.Secret {
|
|
return nil, invalid(p+".secret", "must be non-empty standard base64")
|
|
}
|
|
}
|
|
out := slices.Clone(in)
|
|
slices.SortFunc(out, func(a, b configgen.TSIGKey) int { return strings.Compare(a.Name, b.Name) })
|
|
return out, nil
|
|
}
|
|
|
|
// validateSOA checks the SOA fields.
|
|
func validateSOA(p string, s configgen.SOA) error {
|
|
if err := checkFQDN(p+".primary_ns", s.PrimaryNS); err != nil {
|
|
return err
|
|
}
|
|
if err := checkFQDN(p+".hostmaster", s.Hostmaster); err != nil {
|
|
return err
|
|
}
|
|
for _, t := range []struct {
|
|
field string
|
|
v uint32
|
|
}{{"refresh", s.Refresh}, {"retry", s.Retry}, {"expire", s.Expire}, {"minimum", s.Minimum}} {
|
|
if err := checkTTL(p+"."+t.field, t.v, false); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateNameServers checks the apex NS set and builds the glue records.
|
|
func validateNameServers(zp string, zd *zoneData, nss []configgen.NameServer) error {
|
|
if len(nss) == 0 {
|
|
return invalid(zp+".name_servers", "at least one name server is required")
|
|
}
|
|
for i, ns := range nss {
|
|
p := fmt.Sprintf("%s.name_servers[%d]", zp, i)
|
|
if err := checkFQDN(p+".name", ns.Name); err != nil {
|
|
return err
|
|
}
|
|
if slices.Contains(zd.nameServers, ns.Name) {
|
|
return invalid(p+".name", "duplicate name server")
|
|
}
|
|
zd.nameServers = append(zd.nameServers, ns.Name)
|
|
for j, a := range ns.Addrs {
|
|
if err := checkHostAddr(fmt.Sprintf("%s.addrs[%d]", p, j), a); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if !inZone(ns.Name, zd.name) {
|
|
continue // glue is only written for name servers inside the zone
|
|
}
|
|
if len(ns.Addrs) == 0 {
|
|
return invalid(p+".addrs", "a name server inside the zone needs glue addresses")
|
|
}
|
|
owner := relativeName(ns.Name, zd.name)
|
|
for _, a := range ns.Addrs {
|
|
typ := configgen.TypeA
|
|
if a.Is6() {
|
|
typ = configgen.TypeAAAA
|
|
}
|
|
zd.glue = append(zd.glue, rr{owner: owner, typ: typ, rdata: a.String(), addr: a})
|
|
}
|
|
}
|
|
slices.Sort(zd.nameServers)
|
|
zd.glue = sortRRs(zd.glue)
|
|
return nil
|
|
}
|
|
|
|
// validateRecords checks the records and stores them sorted in zd.
|
|
func validateRecords(zp string, zd *zoneData, recs []configgen.Record) error {
|
|
rrs := make([]rr, 0, len(recs))
|
|
for i, r := range recs {
|
|
x, err := validateRecord(fmt.Sprintf("%s.records[%d]", zp, i), zd.name, r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rrs = append(rrs, x)
|
|
}
|
|
|
|
// A CNAME owner must not own anything else (RFC 1034 §3.6.2), glue
|
|
// included.
|
|
owners := make(map[string]int, len(rrs)+len(zd.glue))
|
|
for _, x := range rrs {
|
|
owners[x.owner]++
|
|
}
|
|
for _, g := range zd.glue {
|
|
owners[g.owner]++
|
|
}
|
|
for i, x := range rrs {
|
|
if x.typ == configgen.TypeCNAME && owners[x.owner] > 1 {
|
|
return invalid(fmt.Sprintf("%s.records[%d].name", zp, i), "a CNAME owner must not have other records")
|
|
}
|
|
}
|
|
|
|
// Drop records that repeat glue or apex NS records.
|
|
written := make(map[[3]string]bool, len(zd.glue)+len(zd.nameServers))
|
|
for _, g := range zd.glue {
|
|
written[g.key()] = true
|
|
}
|
|
for _, ns := range zd.nameServers {
|
|
written[[3]string{"@", string(configgen.TypeNS), ns + "."}] = true
|
|
}
|
|
rrs = slices.DeleteFunc(rrs, func(x rr) bool { return written[x.key()] })
|
|
zd.records = sortRRs(rrs)
|
|
return nil
|
|
}
|
|
|
|
// validateRecord checks one record and renders its RDATA.
|
|
func validateRecord(p, zone string, r configgen.Record) (rr, error) {
|
|
if err := checkOwner(p+".name", r.Name, zone); err != nil {
|
|
return rr{}, err
|
|
}
|
|
if err := checkTTL(p+".ttl", r.TTL, true); err != nil {
|
|
return rr{}, err
|
|
}
|
|
x := rr{owner: r.Name, ttl: r.TTL, typ: r.Type}
|
|
target := func(underscore bool) error {
|
|
if msg := checkName(r.Target, underscore); msg != "" {
|
|
return invalid(p+".target", msg)
|
|
}
|
|
return nil
|
|
}
|
|
switch r.Type {
|
|
case configgen.TypeA:
|
|
if !r.Addr.Is4() {
|
|
return rr{}, invalid(p+".addr", "must be an IPv4 address")
|
|
}
|
|
x.addr, x.rdata = r.Addr, r.Addr.String()
|
|
case configgen.TypeAAAA:
|
|
if !r.Addr.Is6() || r.Addr.Is4In6() || r.Addr.Zone() != "" {
|
|
return rr{}, invalid(p+".addr", "must be an IPv6 address without zone")
|
|
}
|
|
x.addr, x.rdata = r.Addr, r.Addr.String()
|
|
case configgen.TypeCNAME:
|
|
if r.Name == "@" {
|
|
return rr{}, invalid(p+".name", "a CNAME is not allowed at the zone apex")
|
|
}
|
|
// CNAME targets may be service names (DKIM, ACME delegation).
|
|
if err := target(true); err != nil {
|
|
return rr{}, err
|
|
}
|
|
x.rdata = r.Target + "."
|
|
case configgen.TypePTR, configgen.TypeNS:
|
|
if err := target(false); err != nil {
|
|
return rr{}, err
|
|
}
|
|
x.rdata = r.Target + "."
|
|
case configgen.TypeMX:
|
|
if err := target(false); err != nil {
|
|
return rr{}, err
|
|
}
|
|
x.nums[0] = r.Preference
|
|
x.rdata = fmt.Sprintf("%d %s.", r.Preference, r.Target)
|
|
case configgen.TypeSRV:
|
|
if err := target(false); err != nil {
|
|
return rr{}, err
|
|
}
|
|
x.nums = [3]uint16{r.Priority, r.Weight, r.Port}
|
|
x.rdata = fmt.Sprintf("%d %d %d %s.", r.Priority, r.Weight, r.Port, r.Target)
|
|
case configgen.TypeTXT:
|
|
if len(r.Text) == 0 {
|
|
return rr{}, invalid(p+".text", "at least one string is required")
|
|
}
|
|
rdata, wire := txtRData(r.Text)
|
|
if wire > maxRDataLen {
|
|
return rr{}, invalid(p+".text", fmt.Sprintf("exceeds %d bytes in wire format", maxRDataLen))
|
|
}
|
|
x.rdata = rdata
|
|
default:
|
|
return rr{}, invalid(p+".type", "unsupported record type")
|
|
}
|
|
return x, nil
|
|
}
|
|
|
|
// checkOwner validates a record owner relative to zone.
|
|
func checkOwner(p, name, zone string) error {
|
|
switch {
|
|
case name == "@":
|
|
return nil
|
|
case strings.HasSuffix(name, "."):
|
|
return invalid(p, "must be relative to the zone (no trailing dot)")
|
|
case inZone(name, zone):
|
|
return invalid(p, "must be relative to the zone (without the zone name)")
|
|
}
|
|
if msg := checkName(name, true); msg != "" {
|
|
return invalid(p, msg)
|
|
}
|
|
if len(name)+1+len(zone) > maxNameLen {
|
|
return invalid(p, fmt.Sprintf("is longer than %d characters together with the zone name", maxNameLen))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// key identifies an RR regardless of its TTL.
|
|
func (x rr) key() [3]string { return [3]string{x.owner, string(x.typ), x.rdata} }
|
|
|
|
// compareRR orders RRs by owner (canonical), type, RDATA and TTL.
|
|
func compareRR(a, b rr) int {
|
|
return cmp.Or(
|
|
compareOwners(a.owner, b.owner),
|
|
strings.Compare(string(a.typ), string(b.typ)),
|
|
a.addr.Compare(b.addr),
|
|
cmp.Compare(a.nums[0], b.nums[0]),
|
|
cmp.Compare(a.nums[1], b.nums[1]),
|
|
cmp.Compare(a.nums[2], b.nums[2]),
|
|
strings.Compare(a.rdata, b.rdata),
|
|
cmp.Compare(a.ttl, b.ttl),
|
|
)
|
|
}
|
|
|
|
// sortRRs sorts rrs and removes repeated RRs; of RRs differing only in TTL
|
|
// the first in sort order stays.
|
|
func sortRRs(rrs []rr) []rr {
|
|
slices.SortFunc(rrs, compareRR)
|
|
return slices.CompactFunc(rrs, func(a, b rr) bool { return a.key() == b.key() })
|
|
}
|