diff --git a/lns.go b/lns.go index f0f6e7b..430c18a 100644 --- a/lns.go +++ b/lns.go @@ -59,6 +59,9 @@ type ResourceRecord = dnspkg.ResourceRecord // ResourceJSON mirrors the DNS resource JSON representation. type ResourceJSON = dnspkg.ResourceJSON +// DNSMessage mirrors the DNS response shape used by pkg/dns projection helpers. +type DNSMessage = dnspkg.DNSMessage + // DSRecord mirrors the DNS DS payload entry. type DSRecord = dnspkg.DSRecord diff --git a/lns_package_test.go b/lns_package_test.go index 15761f8..8f25634 100644 --- a/lns_package_test.go +++ b/lns_package_test.go @@ -907,6 +907,7 @@ func TestPackageDNSCommonGetters(t *testing.T) { func TestPackageResourceAliases(t *testing.T) { _ = ResourceJSON{} + _ = DNSMessage{} _ = DSRecordJSON{} _ = NSRecordJSON{} _ = GLUE4RecordJSON{} diff --git a/pkg/dns/resource.go b/pkg/dns/resource.go index c37000a..2876a18 100644 --- a/pkg/dns/resource.go +++ b/pkg/dns/resource.go @@ -35,6 +35,18 @@ type ResourceJSON struct { Records []json.RawMessage `json:"records"` } +// DNSMessage mirrors the reference DNS response shape used by the JS helpers. +// +// The Go port keeps the payload lightweight and wire-agnostic: answer, +// authority, and additional sections carry the concrete record values that the +// reference helpers project, while AA preserves the authoritative-answer flag. +type DNSMessage struct { + AA bool + Answer []any + Authority []any + Additional []any +} + // DSRecord mirrors the JS DS payload entry. type DSRecord struct { KeyTag uint16 @@ -518,6 +530,85 @@ func (r *Resource) GetToZone(name string) []ResourceRecord { return r.ToZone(name) } +// ToReferral builds the referral/negative-answer message shape used by the JS +// reference helpers. +// +// The referral path returns authority NS/DS records plus glue when the +// resource has NS data. Otherwise, or when DS referrals are disallowed for a +// TLD lookup, the helper returns a negative proof with AA set and an NSEC +// record in authority. +func (r *Resource) ToReferral(name string, typ HSType, isTLD bool) DNSMessage { + msg := DNSMessage{} + badReferral := isTLD && typ == HSTypeDS + + if r != nil && r.HasNS() && !badReferral { + for _, record := range r.ToNS(name) { + msg.Authority = append(msg.Authority, record) + } + + for _, record := range r.ToDS(name) { + msg.Authority = append(msg.Authority, record) + } + + for _, record := range r.ToGlue(name) { + msg.Additional = append(msg.Additional, record) + } + + if !r.HasDS() { + msg.Authority = append(msg.Authority, r.ToNSEC(name)) + } + + return msg + } + + msg.AA = true + msg.Authority = append(msg.Authority, r.ToNSEC(name)) + return msg +} + +// GetToReferral is an alias for ToReferral. +func (r *Resource) GetToReferral(name string, typ HSType, isTLD bool) DNSMessage { + return r.ToReferral(name, typ, isTLD) +} + +// ToDNS projects the resource into the JS reference DNS response shape. +// +// Subdomain lookups are routed to the appropriate TLD referral. TLD TXT +// lookups answer directly when the resource has no NS records; DS lookups and +// all other cases fall through to the referral/negative-answer logic. +func (r *Resource) ToDNS(name string, typ HSType) DNSMessage { + name = fqdn(strings.ToLower(name)) + labels := strings.Split(strings.TrimSuffix(name, "."), ".") + if len(labels) > 1 { + return r.ToReferral(labels[len(labels)-1], typ, false) + } + + msg := DNSMessage{} + switch typ { + case HSTypeTXT: + if r == nil || !r.HasNS() { + msg.AA = true + for _, record := range r.ToTXT(name) { + msg.Answer = append(msg.Answer, record) + } + } + case HSTypeDS: + // DS TLD lookups intentionally fall through to the referral/negative + // proof path to match the JS reference helper. + } + + if len(msg.Answer) == 0 && len(msg.Authority) == 0 { + return r.ToReferral(name, typ, true) + } + + return msg +} + +// GetToDNS is an alias for ToDNS. +func (r *Resource) GetToDNS(name string, typ HSType) DNSMessage { + return r.ToDNS(name, typ) +} + // ToNSEC constructs the reference NSEC helper output for the resource. // // The bitmap selection matches the JS reference helper: diff --git a/pkg/dns/resource_test.go b/pkg/dns/resource_test.go index 5a0c795..fbb435c 100644 --- a/pkg/dns/resource_test.go +++ b/pkg/dns/resource_test.go @@ -499,6 +499,77 @@ func TestResourceProjectionHelpers(t *testing.T) { } } +func TestResourceToDNSAndReferral(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")}, + TXTRecord{Entries: []string{"hello"}}, + } + + referral := resource.ToReferral("example.", HSTypeNS, false) + if referral.AA { + t.Fatal("ToReferral should not set AA for a normal referral") + } + if len(referral.Authority) != 2 { + t.Fatalf("ToReferral authority = %d, want 2", len(referral.Authority)) + } + if ns, ok := referral.Authority[0].(NSRecord); !ok || ns.NS != "ns1.example." { + t.Fatalf("ToReferral authority[0] = %#v, want NS ns1.example.", referral.Authority[0]) + } + if ds, ok := referral.Authority[1].(DSRecord); !ok || ds.KeyTag != 7 { + t.Fatalf("ToReferral authority[1] = %#v, want DS keyTag=7", referral.Authority[1]) + } + if len(referral.Additional) != 1 { + t.Fatalf("ToReferral additional = %d, want 1", len(referral.Additional)) + } + if glue, ok := referral.Additional[0].(GLUE4Record); !ok || glue.NS != "ns1.example." || glue.Address.String() != "192.0.2.10" { + t.Fatalf("ToReferral additional[0] = %#v, want GLUE4 ns1.example./192.0.2.10", referral.Additional[0]) + } + + aliasReferral := resource.GetToReferral("example.", HSTypeNS, false) + if aliasReferral.AA != referral.AA || len(aliasReferral.Authority) != len(referral.Authority) || len(aliasReferral.Additional) != len(referral.Additional) { + t.Fatal("GetToReferral should alias ToReferral") + } + + negative := resource.ToDNS("example.", HSTypeDS) + if !negative.AA { + t.Fatal("ToDNS should set AA for a TLD DS negative proof") + } + if len(negative.Answer) != 0 { + t.Fatalf("ToDNS answer = %d, want 0", len(negative.Answer)) + } + if len(negative.Authority) != 1 { + t.Fatalf("ToDNS authority = %d, want 1", len(negative.Authority)) + } + if nsec, ok := negative.Authority[0].(NSECRecord); !ok || nsec.Name != "example." || nsec.NextDomain != "example\x00." { + t.Fatalf("ToDNS authority[0] = %#v, want NSEC example./example\\x00.", negative.Authority[0]) + } + + txtResource := NewResource() + txtResource.Records = []ResourceRecord{TXTRecord{Entries: []string{"hello"}}} + + answer := txtResource.ToDNS("example.", HSTypeTXT) + if !answer.AA { + t.Fatal("ToDNS should set AA for an in-zone TXT answer") + } + if len(answer.Answer) != 1 { + t.Fatalf("ToDNS answer = %d, want 1", len(answer.Answer)) + } + if txt, ok := answer.Answer[0].(TXTRecord); !ok || len(txt.Entries) != 1 || txt.Entries[0] != "hello" { + t.Fatalf("ToDNS answer[0] = %#v, want TXT hello", answer.Answer[0]) + } + if len(answer.Authority) != 0 || len(answer.Additional) != 0 { + t.Fatalf("ToDNS TXT answer should not populate authority/additional, got %+v", answer) + } + + aliasAnswer := txtResource.GetToDNS("example.", HSTypeTXT) + if aliasAnswer.AA != answer.AA || len(aliasAnswer.Answer) != len(answer.Answer) || len(aliasAnswer.Authority) != len(answer.Authority) || len(aliasAnswer.Additional) != len(answer.Additional) { + t.Fatal("GetToDNS should alias ToDNS") + } +} + func TestResourceDecodeStopsAtUnknownType(t *testing.T) { resource := NewResource() resource.Records = []ResourceRecord{TXTRecord{Entries: []string{"known"}}}