diff --git a/pkg/dns/resource.go b/pkg/dns/resource.go index d684e79..3542b83 100644 --- a/pkg/dns/resource.go +++ b/pkg/dns/resource.go @@ -247,6 +247,241 @@ func (r *Resource) GetHasDS() bool { return r.HasDS() } +// ToNS projects NS-capable resource entries into authority NS records. +// +// The helper mirrors the JS reference behavior: +// - NS, GLUE, and SYNTH entries all contribute NS targets +// - duplicate targets are emitted once, in first-seen order +func (r *Resource) ToNS(_ string) []NSRecord { + if r == nil { + return nil + } + + authority := make([]NSRecord, 0, len(r.Records)) + seen := make(map[string]struct{}, len(r.Records)) + + for _, record := range r.Records { + ns, ok := resourceAuthorityNS(record) + if !ok { + continue + } + + key := strings.ToLower(fqdn(ns.NS)) + if _, ok := seen[key]; ok { + continue + } + + seen[key] = struct{}{} + authority = append(authority, ns) + } + + return authority +} + +// GetToNS is an alias for ToNS. +func (r *Resource) GetToNS(name string) []NSRecord { + return r.ToNS(name) +} + +// ToGlue projects glue-capable entries into address-bearing glue records. +// +// GLUE records are only returned when their host falls under the requested +// name. SYNTH records always project into their synthetic hostnames. +func (r *Resource) ToGlue(name string) []ResourceRecord { + if r == nil { + return nil + } + + additional := make([]ResourceRecord, 0, len(r.Records)) + + for _, record := range r.Records { + switch rr := record.(type) { + case GLUE4Record: + if !isSubdomain(name, rr.NS) { + continue + } + additional = append(additional, rr) + case *GLUE4Record: + if rr == nil || !isSubdomain(name, rr.NS) { + continue + } + additional = append(additional, *rr) + case GLUE6Record: + if !isSubdomain(name, rr.NS) { + continue + } + additional = append(additional, rr) + case *GLUE6Record: + if rr == nil || !isSubdomain(name, rr.NS) { + continue + } + additional = append(additional, *rr) + case SYNTH4Record: + additional = append(additional, GLUE4Record{NS: rr.NS(), Address: rr.Address}) + case *SYNTH4Record: + if rr == nil { + continue + } + additional = append(additional, GLUE4Record{NS: rr.NS(), Address: rr.Address}) + case SYNTH6Record: + additional = append(additional, GLUE6Record{NS: rr.NS(), Address: rr.Address}) + case *SYNTH6Record: + if rr == nil { + continue + } + additional = append(additional, GLUE6Record{NS: rr.NS(), Address: rr.Address}) + } + } + + return additional +} + +// GetToGlue is an alias for ToGlue. +func (r *Resource) GetToGlue(name string) []ResourceRecord { + return r.ToGlue(name) +} + +// ToDS filters DS records in first-seen order. +func (r *Resource) ToDS(_ string) []DSRecord { + if r == nil { + return nil + } + + answer := make([]DSRecord, 0, len(r.Records)) + for _, record := range r.Records { + switch rr := record.(type) { + case DSRecord: + answer = append(answer, rr) + case *DSRecord: + if rr != nil { + answer = append(answer, *rr) + } + } + } + + return answer +} + +// GetToDS is an alias for ToDS. +func (r *Resource) GetToDS(name string) []DSRecord { + return r.ToDS(name) +} + +// ToTXT filters TXT records in first-seen order. +func (r *Resource) ToTXT(_ string) []TXTRecord { + if r == nil { + return nil + } + + answer := make([]TXTRecord, 0, len(r.Records)) + for _, record := range r.Records { + switch rr := record.(type) { + case TXTRecord: + answer = append(answer, rr) + case *TXTRecord: + if rr != nil { + answer = append(answer, *rr) + } + } + } + + return answer +} + +// GetToTXT is an alias for ToTXT. +func (r *Resource) GetToTXT(name string) []TXTRecord { + return r.ToTXT(name) +} + +// ToZone projects the resource into zone-order records. +// +// Authority-like records are emitted first in original order, with duplicate NS +// targets collapsed. Glue records for in-zone hosts are appended last. +func (r *Resource) ToZone(name string) []ResourceRecord { + if r == nil { + return nil + } + + zone := make([]ResourceRecord, 0, len(r.Records)*2) + seenNS := make(map[string]struct{}, len(r.Records)) + + for _, record := range r.Records { + if record == nil { + continue + } + + if ns, ok := resourceAuthorityNS(record); ok { + key := strings.ToLower(fqdn(ns.NS)) + if _, ok := seenNS[key]; ok { + continue + } + + seenNS[key] = struct{}{} + zone = append(zone, ns) + continue + } + + switch rr := record.(type) { + case DSRecord: + zone = append(zone, rr) + case *DSRecord: + if rr != nil { + zone = append(zone, *rr) + } + case TXTRecord: + zone = append(zone, rr) + case *TXTRecord: + if rr != nil { + zone = append(zone, *rr) + } + } + } + + for _, record := range r.Records { + switch rr := record.(type) { + case GLUE4Record: + if isSubdomain(name, rr.NS) { + zone = append(zone, rr) + } + case *GLUE4Record: + if rr != nil && isSubdomain(name, rr.NS) { + zone = append(zone, *rr) + } + case GLUE6Record: + if isSubdomain(name, rr.NS) { + zone = append(zone, rr) + } + case *GLUE6Record: + if rr != nil && isSubdomain(name, rr.NS) { + zone = append(zone, *rr) + } + case SYNTH4Record: + if isSubdomain(name, rr.NS()) { + zone = append(zone, GLUE4Record{NS: rr.NS(), Address: rr.Address}) + } + case *SYNTH4Record: + if rr != nil && isSubdomain(name, rr.NS()) { + zone = append(zone, GLUE4Record{NS: rr.NS(), Address: rr.Address}) + } + case SYNTH6Record: + if isSubdomain(name, rr.NS()) { + zone = append(zone, GLUE6Record{NS: rr.NS(), Address: rr.Address}) + } + case *SYNTH6Record: + if rr != nil && isSubdomain(name, rr.NS()) { + zone = append(zone, GLUE6Record{NS: rr.NS(), Address: rr.Address}) + } + } + } + + return zone +} + +// GetToZone is an alias for ToZone. +func (r *Resource) GetToZone(name string) []ResourceRecord { + return r.ToZone(name) +} + // ToNSEC constructs the reference NSEC helper output for the resource. // // The bitmap selection matches the JS reference helper: @@ -485,6 +720,58 @@ func resourceRecordJSON(record ResourceRecord) any { } } +func resourceAuthorityNS(record ResourceRecord) (NSRecord, bool) { + switch rr := record.(type) { + case NSRecord: + return rr, true + case *NSRecord: + if rr != nil { + return *rr, true + } + case GLUE4Record: + return NSRecord{NS: rr.NS}, true + case *GLUE4Record: + if rr != nil { + return NSRecord{NS: rr.NS}, true + } + case GLUE6Record: + return NSRecord{NS: rr.NS}, true + case *GLUE6Record: + if rr != nil { + return NSRecord{NS: rr.NS}, true + } + case SYNTH4Record: + return NSRecord{NS: rr.NS()}, true + case *SYNTH4Record: + if rr != nil { + return NSRecord{NS: rr.NS()}, true + } + case SYNTH6Record: + return NSRecord{NS: rr.NS()}, true + case *SYNTH6Record: + if rr != nil { + return NSRecord{NS: rr.NS()}, true + } + } + + return NSRecord{}, false +} + +func isSubdomain(parent, child string) bool { + parent = strings.ToLower(fqdn(parent)) + child = strings.ToLower(fqdn(child)) + + if parent == "." { + return true + } + + if child == parent { + return true + } + + return strings.HasSuffix(trimFQDN(child), "."+trimFQDN(parent)) +} + func resourceRecordSize(record ResourceRecord) int { switch rr := record.(type) { case DSRecord: diff --git a/pkg/dns/resource_test.go b/pkg/dns/resource_test.go index f2db18e..cf9ddb3 100644 --- a/pkg/dns/resource_test.go +++ b/pkg/dns/resource_test.go @@ -303,6 +303,102 @@ func TestResourceToNSEC(t *testing.T) { } } +func TestResourceProjectionHelpers(t *testing.T) { + resource := NewResource() + resource.Records = []ResourceRecord{ + DSRecord{KeyTag: 7, Algorithm: 8, DigestType: 9, Digest: []byte{0xaa}}, + NSRecord{NS: "ns1.example."}, + GLUE4Record{NS: "ns1.example.", Address: netip.MustParseAddr("192.0.2.10")}, + GLUE6Record{NS: "ns2.other.", Address: netip.MustParseAddr("2001:db8::20")}, + SYNTH4Record{Address: netip.MustParseAddr("198.51.100.9")}, + TXTRecord{Entries: []string{"hello"}}, + } + + ns := resource.ToNS("example.") + if len(ns) != 3 { + t.Fatalf("ToNS returned %d records, want 3", len(ns)) + } + if ns[0].NS != "ns1.example." || ns[1].NS != "ns2.other." || ns[2].NS != "_oopm828._synth." { + t.Fatalf("ToNS returned %#v, want ns1.example./ns2.other./_oopm828._synth.", ns) + } + + aliasNS := resource.GetToNS("example.") + if len(aliasNS) != len(ns) { + t.Fatalf("GetToNS returned %d records, want %d", len(aliasNS), len(ns)) + } + + glue := resource.ToGlue("example.") + if len(glue) != 2 { + t.Fatalf("ToGlue returned %d records, want 2", len(glue)) + } + if rr, ok := glue[0].(GLUE4Record); !ok || rr.NS != "ns1.example." || rr.Address.String() != "192.0.2.10" { + t.Fatalf("ToGlue[0] = %#v, want GLUE4 ns1.example./192.0.2.10", glue[0]) + } + if rr, ok := glue[1].(GLUE4Record); !ok || rr.NS != "_oopm828._synth." || rr.Address.String() != "198.51.100.9" { + t.Fatalf("ToGlue[1] = %#v, want GLUE4 _oopm828._synth./198.51.100.9", glue[1]) + } + + aliasGlue := resource.GetToGlue("example.") + if len(aliasGlue) != len(glue) { + t.Fatalf("GetToGlue returned %d records, want %d", len(aliasGlue), len(glue)) + } + + ds := resource.ToDS("example.") + if len(ds) != 1 || ds[0].KeyTag != 7 || ds[0].Algorithm != 8 || ds[0].DigestType != 9 || !bytes.Equal(ds[0].Digest, []byte{0xaa}) { + t.Fatalf("ToDS returned %#v, want one DS record", ds) + } + + txt := resource.ToTXT("example.") + if len(txt) != 1 || len(txt[0].Entries) != 1 || txt[0].Entries[0] != "hello" { + t.Fatalf("ToTXT returned %#v, want one TXT record", txt) + } + + zone := resource.ToZone("example.") + if len(zone) != 6 { + t.Fatalf("ToZone returned %d records, want 6", len(zone)) + } + if rr, ok := zone[0].(DSRecord); !ok || rr.KeyTag != 7 { + t.Fatalf("ToZone[0] = %#v, want DS record", zone[0]) + } + if rr, ok := zone[1].(NSRecord); !ok || rr.NS != "ns1.example." { + t.Fatalf("ToZone[1] = %#v, want NS ns1.example.", zone[1]) + } + if rr, ok := zone[2].(NSRecord); !ok || rr.NS != "ns2.other." { + t.Fatalf("ToZone[2] = %#v, want NS ns2.other.", zone[2]) + } + if rr, ok := zone[3].(NSRecord); !ok || rr.NS != "_oopm828._synth." { + t.Fatalf("ToZone[3] = %#v, want NS _oopm828._synth.", zone[3]) + } + if rr, ok := zone[4].(TXTRecord); !ok || len(rr.Entries) != 1 || rr.Entries[0] != "hello" { + t.Fatalf("ToZone[4] = %#v, want TXT hello", zone[4]) + } + if rr, ok := zone[5].(GLUE4Record); !ok || rr.NS != "ns1.example." || rr.Address.String() != "192.0.2.10" { + t.Fatalf("ToZone[5] = %#v, want GLUE4 ns1.example./192.0.2.10", zone[5]) + } + + aliasZone := resource.GetToZone("example.") + if len(aliasZone) != len(zone) { + t.Fatalf("GetToZone returned %d records, want %d", len(aliasZone), len(zone)) + } + + var nilResource *Resource + if got := nilResource.ToNS("example."); got != nil { + t.Fatalf("nil ToNS = %#v, want nil", got) + } + if got := nilResource.ToGlue("example."); got != nil { + t.Fatalf("nil ToGlue = %#v, want nil", got) + } + if got := nilResource.ToDS("example."); got != nil { + t.Fatalf("nil ToDS = %#v, want nil", got) + } + if got := nilResource.ToTXT("example."); got != nil { + t.Fatalf("nil ToTXT = %#v, want nil", got) + } + if got := nilResource.ToZone("example."); got != nil { + t.Fatalf("nil ToZone = %#v, want nil", got) + } +} + func TestResourceDecodeStopsAtUnknownType(t *testing.T) { resource := NewResource() resource.Records = []ResourceRecord{TXTRecord{Entries: []string{"known"}}}