feat(dns): add DNS referral helpers

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-04 07:54:29 +00:00
parent 887b409623
commit 1e0ae2cf07
4 changed files with 166 additions and 0 deletions

3
lns.go
View file

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

View file

@ -907,6 +907,7 @@ func TestPackageDNSCommonGetters(t *testing.T) {
func TestPackageResourceAliases(t *testing.T) {
_ = ResourceJSON{}
_ = DNSMessage{}
_ = DSRecordJSON{}
_ = NSRecordJSON{}
_ = GLUE4RecordJSON{}

View file

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

View file

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