From 75f1e02a20363f67499a142c555465310e5cabea Mon Sep 17 00:00:00 2001 From: Pat Whittingslow Date: Fri, 28 Aug 2026 21:39:12 -0300 Subject: [PATCH] dns: @hnw alternate proposal (#193) * fix(dns): resolve CNAME chains and prevent invalid IP parsing - Add automated in-band CNAME chain resolution to extract final A/AAAA IP addresses. - Replace `MaxResponseAnswers` with `MaxIPs` and `MaxCNAMEs` to explicitly bound resource decoding and prevent memory exhaustion. - Bound CNAME chain traversal to prevent infinite loops from cyclic records. * refactor(dns): eagerly decode CNAME target into r.data, drop target field Address review feedback on #189: - Remove Resource.target: Resource.Decode expands CNAME RDATA in place into r.data (uncompressed wire format) while the full message is still available and updates header.Length, storing the name bytes exactly once. - Add Resource.CNAMEView returning a length-bounded view of the expanded target; Message.WriteAnswers uses it. - Rename matchesHost to ResourceHeader.pertainsTo. - Make ResolveConfig.MaxCNAMEs explicit: drop the silent default in Client.StartResolve and let callers opt in (x/xnet). - Reduce test suite to regression coverage only. refs #189 * dns: simplify @hnw function and additional fixes --------- Co-authored-by: Yoshio HANAWA --- dns/client.go | 6 +- dns/definitions.go | 2 + dns/dns.go | 88 ++++++++++++++++++---- dns/dns_test.go | 150 +++++++++++++++++++++++++++++++++++++- http/httpraw/kvbuffer.go | 20 +---- http/httpraw/multipart.go | 17 +---- internal/slices.go | 34 +++++++++ x/xnet/stack-async.go | 6 +- x/xnet/xnet_dns_test.go | 102 ++++++++++++++++++++++++++ 9 files changed, 369 insertions(+), 56 deletions(-) diff --git a/dns/client.go b/dns/client.go index 0a62847..e0eee23 100644 --- a/dns/client.go +++ b/dns/client.go @@ -25,7 +25,9 @@ type ResolveConfig struct { Additional []Resource EnableRecursion bool // MaxResponseAnswers limits how many answer records are decoded from the - // DNS response. If zero it defaults to the number of Questions. + // DNS response. If zero it defaults to the number of Questions. Answers + // are decoded in wire order regardless of type, so a response resolved + // through CNAMEs needs room for the CNAME records as well as the addresses. MaxResponseAnswers uint16 } @@ -37,7 +39,7 @@ func (sudp *Client) ConnectionID() *uint64 { return &sudp.connID } func (c *Client) StartResolve(localPort, txid uint16, cfg ResolveConfig) error { nd := len(cfg.Questions) - if nd > math.MaxUint16 { + if nd > math.MaxUint16 || nd == 0 { return lneto.ErrInvalidConfig } maxAns := cfg.MaxResponseAnswers diff --git a/dns/definitions.go b/dns/definitions.go index 087ee13..08b2723 100644 --- a/dns/definitions.go +++ b/dns/definitions.go @@ -197,6 +197,8 @@ const ( TypeALL Type = 255 // ALL ) +func (tp Type) IsIPAddr() bool { return tp == TypeA || tp == TypeAAAA } + // A Class is a type of network. type Class uint16 diff --git a/dns/dns.go b/dns/dns.go index ba791fa..1ebd6ba 100644 --- a/dns/dns.go +++ b/dns/dns.go @@ -99,6 +99,12 @@ func NamesEqual(a, b Name) bool { return internal.BytesEqual(a.data, b.data) } +// NamesEqualFold reports whether two DNS names are equal under ASCII case +// folding, which is how DNS labels compare per RFC 1035 section 2.3.3. +func NamesEqualFold(a, b Name) bool { + return internal.BytesEqualFoldASCII(a.data, b.data) +} + type ZFlags uint16 func NewResource(name Name, typ Type, class Class, ttl uint32, data []byte) Resource { @@ -299,27 +305,57 @@ func (m *Message) AppendTo(buf []byte, txid uint16, flags HeaderFlags) (_ []byte return buf, nil } +// WriteAnswers writes the addresses answering host into dst, following the +// CNAME chain rooted at host. It returns the number of addresses written. func (m *Message) WriteAnswers(dst []netip.Addr, host string) (n uint16, err error) { - for i := range m.Answers { - if int(n) >= len(dst) { - return n, lneto.ErrExhausted + // Each round resolves one CNAME, which consumes an answer. Bounding the + // walk by the answer count is thus enough to reach the addresses, and + // terminates on cyclic chains. + var alias Name // Canonical name reached so far; zero means host itself. + for range m.Answers { + var next Name + for i := range m.Answers { + ans := &m.Answers[i] + if !ans.header.ownedBy(alias, host) { + continue + } + switch { + case ans.header.Type.IsIPAddr(): + if int(n) >= len(dst) { + return n, lneto.ErrExhausted + } + addr, ok := netip.AddrFromSlice(ans.RawData()) + if !ok { + err = lneto.ErrInvalidAddr + continue + } + dst[n] = addr + n++ + case ans.header.Type == TypeCNAME: + if cname := ans.CNAMEView(); cname.Len() != 0 { + next = cname + } + } } - ans := &m.Answers[i] - hdr := ans.Header() - if !hdr.Name.EqualString(host) { - continue - } - var ok bool - dst[n], ok = netip.AddrFromSlice(ans.RawData()) - if !ok { - err = lneto.ErrInvalidAddr - } else { - n++ + if n > 0 || next.Len() == 0 { + break } + alias = next } return n, err } +// ownedBy reports whether the record's owner name is the name being resolved: +// the alias reached by following CNAMEs, or host at the root of the chain. +func (h *ResourceHeader) ownedBy(alias Name, host string) bool { + if alias.Len() == 0 { + return h.Name.EqualString(host) + } + // Fold: the server chooses the case of both the CNAME target and the owner + // name of the records it aliases, and may randomize it (DNS 0x20). + return NamesEqualFold(h.Name, alias) +} + func (m *Message) Len() uint16 { return SizeHeader + m.lenResources() } @@ -411,6 +447,15 @@ func (r *Resource) RawData() []byte { return r.data[:length] } +// CNAMEView returns the canonical name held by a CNAME record, aliasing the +// Resource's buffer. It returns a zero Name for any other record type. +func (r *Resource) CNAMEView() Name { + if r.header.Type != TypeCNAME { + return Name{} + } + return Name{data: r.RawData()} +} + func (q *Question) Reset() { q.Name.Reset() *q = Question{Name: q.Name} // Reuse Name's buffer. @@ -460,8 +505,19 @@ func (r *Resource) Decode(b []byte, off uint16) (uint16, error) { if r.header.Length > uint16(len(b[off:])) { return off, errResourceLen } - r.data = append(r.data[:0], b[off:off+r.header.Length]...) - return off + r.header.Length, nil + end := off + r.header.Length + if r.header.Type == TypeCNAME { + // CNAME data is a name which may use message compression. Expand it now + // since r.data is detached from b, leaving pointers unresolvable later. + cname := Name{data: r.data[:0]} + if _, derr := cname.Decode(b, off); derr == nil { + r.data = cname.data + r.header.Length = uint16(len(r.data)) + return end, nil + } + } + r.data = append(r.data[:0], b[off:end]...) + return end, nil } func (r *Resource) appendTo(buf []byte) (_ []byte, err error) { diff --git a/dns/dns_test.go b/dns/dns_test.go index 75d4a6e..3eaebc3 100644 --- a/dns/dns_test.go +++ b/dns/dns_test.go @@ -239,6 +239,152 @@ func TestDecodeMessage(t *testing.T) { } } +// Regression test for CNAME-following: a response for www.yahoo.co.jp +// contains a CNAME record to edge12.g.yimg.jp (with compressed labels in its +// RDATA) followed by the A record for the canonical name. The CNAME RDATA +// must not be interpreted as an IP address and the A record must be returned. +func TestClient_CNAMEResponse(t *testing.T) { + const hostname = "www.yahoo.co.jp" + const txid = uint16(0x1234) + const clientPort = uint16(54321) + response := []byte{ + // Header: txid 0x1234, QR|RD|RA, QD=1 AN=2 NS=0 AR=0. + 0x12, 0x34, 0x81, 0x80, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, + // Question: www.yahoo.co.jp A IN. + 0x03, 'w', 'w', 'w', 0x05, 'y', 'a', 'h', 'o', 'o', 0x02, 'c', 'o', 0x02, 'j', 'p', 0x00, + 0x00, 0x01, 0x00, 0x01, + // Answer 1: (ptr to question) CNAME IN ttl=842 rdlen=16 + // rdata: edge12.g.yimg.jp with "jp" as compression pointer to offset 0x19. + 0xc0, 0x0c, 0x00, 0x05, 0x00, 0x01, 0x00, 0x00, 0x03, 0x4a, 0x00, 0x10, + 0x06, 'e', 'd', 'g', 'e', '1', '2', 0x01, 'g', 0x04, 'y', 'i', 'm', 'g', 0xc0, 0x19, + // Answer 2: (ptr into CNAME rdata) A IN ttl=36 rdlen=4 182.22.23.124. + 0xc0, 0x2d, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x24, 0x00, 0x04, 0xb6, 0x16, 0x17, 0x7c, + } + name := MustNewName(hostname) + var client Client + err := client.StartResolve(clientPort, txid, ResolveConfig{ + Questions: []Question{{ + Name: name, + Type: TypeA, + Class: ClassINET, + }}, + EnableRecursion: true, + MaxResponseAnswers: 6, + }) + if err != nil { + t.Fatal("failed to start DNS resolve:", err) + } + var queryBuf [512]byte + _, err = client.Encapsulate(queryBuf[:], 0, 0) + if err != nil { + t.Fatal("failed to encapsulate DNS query:", err) + } + if err := client.Demux(response, 0); err != nil { + t.Fatal("failed to demux DNS response:", err) + } + var addrs [4]netip.Addr + n, err := client.ResponseAnswerLookup(addrs[:], hostname) + if err != nil { + t.Fatal("failed to look up DNS response answers:", err) + } + if n != 1 { + t.Fatalf("expected 1 answer, got %d: %v", n, addrs[:n]) + } + if addrs[0] != (netip.AddrFrom4([4]byte{182, 22, 23, 124})) { + t.Fatalf("expected 182.22.23.124, got %v", addrs[0]) + } +} + +// Table-driven tests for Message.WriteAnswers covering answer reordering +// and cyclic CNAME aliases. +func TestMessage_WriteAnswers(t *testing.T) { + tests := []struct { + name string + host string + response []byte + want []netip.Addr + }{ + { + name: "A record before its CNAME", + host: "www.yahoo.co.jp", + // Answer 1 is the A record for edge12.g.yimg.jp, spelled out with + // a trailing compression pointer to "jp" in the question. Answer 2 + // is the CNAME from www.yahoo.co.jp whose RDATA is a single + // backward compression pointer to answer 1's owner name. + response: []byte{ + // Header: txid 0x1234, QR|RD|RA, QD=1 AN=2 NS=0 AR=0. + 0x12, 0x34, 0x81, 0x80, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, + // Question: www.yahoo.co.jp A IN. + 0x03, 'w', 'w', 'w', 0x05, 'y', 'a', 'h', 'o', 'o', 0x02, 'c', 'o', 0x02, 'j', 'p', 0x00, + 0x00, 0x01, 0x00, 0x01, + // Answer 1: edge12.g.yimg.jp A IN ttl=36 rdlen=4 182.22.23.124. + 0x06, 'e', 'd', 'g', 'e', '1', '2', 0x01, 'g', 0x04, 'y', 'i', 'm', 'g', 0xc0, 0x19, + 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x24, 0x00, 0x04, 0xb6, 0x16, 0x17, 0x7c, + // Answer 2: (ptr to question) CNAME IN ttl=842 rdlen=2, target + // is a pointer to answer 1's owner name at offset 0x21. + 0xc0, 0x0c, 0x00, 0x05, 0x00, 0x01, 0x00, 0x00, 0x03, 0x4a, 0x00, 0x02, 0xc0, 0x21, + }, + want: []netip.Addr{netip.AddrFrom4([4]byte{182, 22, 23, 124})}, + }, + { + name: "CNAME cycle terminates", + host: "a.com", + response: []byte{ + // Header: txid 0xabcd, QR|RD|RA, QD=1 AN=2 NS=0 AR=0. + 0xab, 0xcd, 0x81, 0x80, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, + // Question: a.com A IN. + 0x01, 'a', 0x03, 'c', 'o', 'm', 0x00, 0x00, 0x01, 0x00, 0x01, + // Answer 1: a.com CNAME b.com. + 0x01, 'a', 0x03, 'c', 'o', 'm', 0x00, 0x00, 0x05, 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, 0x00, 0x07, 0x01, 'b', 0x03, 'c', 'o', 'm', 0x00, + // Answer 2: b.com CNAME a.com. + 0x01, 'b', 0x03, 'c', 'o', 'm', 0x00, 0x00, 0x05, 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, 0x00, 0x07, 0x01, 'a', 0x03, 'c', 'o', 'm', 0x00, + }, + want: nil, + }, + { + name: "CNAME target case differs from owner name", + host: "a.com", + // A server picks the case of both the CNAME target and the owner + // name of the record it aliases, and may randomize it (DNS 0x20), + // so the two must compare under ASCII case folding. + response: []byte{ + // Header: txid 0xabcd, QR|RD|RA, QD=1 AN=2 NS=0 AR=0. + 0xab, 0xcd, 0x81, 0x80, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, + // Question: a.com A IN. + 0x01, 'a', 0x03, 'c', 'o', 'm', 0x00, 0x00, 0x01, 0x00, 0x01, + // Answer 1: a.com CNAME B.CoM. + 0x01, 'a', 0x03, 'c', 'o', 'm', 0x00, 0x00, 0x05, 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, 0x00, 0x07, 0x01, 'B', 0x03, 'C', 'o', 'M', 0x00, + // Answer 2: b.com A IN ttl=10 rdlen=4 1.2.3.4. + 0x01, 'b', 0x03, 'c', 'o', 'm', 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, 0x00, 0x04, 0x01, 0x02, 0x03, 0x04, + }, + want: []netip.Addr{netip.AddrFrom4([4]byte{1, 2, 3, 4})}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var msg Message + msg.LimitResourceDecoding(1, 4, 0, 0) + _, incomplete, err := msg.Decode(tt.response) + if incomplete || err != nil { + t.Fatal("decode:", incomplete, err) + } + var addrs [4]netip.Addr + n, err := msg.WriteAnswers(addrs[:], tt.host) + if err != nil { + t.Fatal("write answers:", err) + } + if n != uint16(len(tt.want)) { + t.Fatalf("expected %d addresses, got %d: %v", len(tt.want), n, addrs[:n]) + } + for i, want := range tt.want { + if addrs[i] != want { + t.Errorf("address %d: expected %v, got %v", i, want, addrs[i]) + } + } + }) + } +} + func TestClient_ReceivesDNSResponse(t *testing.T) { const hostname = "example.com" const txid = uint16(12345) @@ -254,7 +400,7 @@ func TestClient_ReceivesDNSResponse(t *testing.T) { tests := []struct { name string responseIPs [][4]byte - wantAnswers int + wantAnswers int // Addresses returned by ResponseAnswerLookup and copied by ResponseCopyTo. }{ {name: "single_answer", responseIPs: allIPs[:1], wantAnswers: 1}, {name: "multiple_answers", responseIPs: allIPs[:4], wantAnswers: 4}, @@ -312,7 +458,7 @@ func TestClient_ReceivesDNSResponse(t *testing.T) { if err != nil { t.Fatal("failed to look up DNS response answers:", err) } - if answers != uint16(tt.wantAnswers) { + if int(answers) != tt.wantAnswers { t.Fatalf("expected %d answers, got %d", tt.wantAnswers, answers) } for i := 0; i < tt.wantAnswers; i++ { diff --git a/http/httpraw/kvbuffer.go b/http/httpraw/kvbuffer.go index 7fd3005..279d64c 100644 --- a/http/httpraw/kvbuffer.go +++ b/http/httpraw/kvbuffer.go @@ -300,25 +300,7 @@ func (kvb *kvBuffer) getFoldIdx(key string) int { // EqualFoldASCII reports whether a and b are equal under ASCII case folding. // Unlike strings.EqualFold it does not fold non-ASCII runes, so no multi-byte // rune such as U+212A KELVIN SIGN can alias a header key. -func EqualFoldASCII(a, b string) bool { - if len(a) != len(b) { - return false - } - const asciiCapDiff = 'a' - 'A' - for i := 0; i < len(a); i++ { - ca, cb := a[i], b[i] - if ca >= 'A' && ca <= 'Z' { - ca += asciiCapDiff - } - if cb >= 'A' && cb <= 'Z' { - cb += asciiCapDiff - } - if ca != cb { - return false - } - } - return true -} +func EqualFoldASCII(a, b string) bool { return internal.EqualFoldASCII(a, b) } // reserve ensures need free bytes are available in the buffer, growing it when // permitted. It accounts for the byte-0 reservation on an empty buffer (see diff --git a/http/httpraw/multipart.go b/http/httpraw/multipart.go index e24672f..4661850 100644 --- a/http/httpraw/multipart.go +++ b/http/httpraw/multipart.go @@ -253,20 +253,7 @@ func trimOWS(b []byte) []byte { return b } -// equalFold compares b to the ASCII lowercase key, case insensitively. +// equalFold compares b to key, case insensitively. func equalFold(b []byte, key string) bool { - if len(b) != len(key) { - return false - } - const asciiCapDiff = 'a' - 'A' - for i := range b { - c := b[i] - if c >= 'A' && c <= 'Z' { - c += asciiCapDiff - } - if c != key[i] { - return false - } - } - return true + return EqualFoldASCII(b2s(b), key) } diff --git a/internal/slices.go b/internal/slices.go index 4126220..ef73bc0 100644 --- a/internal/slices.go +++ b/internal/slices.go @@ -14,6 +14,40 @@ func BytesEqual(a, b []byte) bool { return unsafe.String(&a[0], len(a)) == unsafe.String(&b[0], len(b)) } +// EqualFoldASCII reports whether a and b are equal under ASCII case folding. +// Unlike [strings.EqualFold] it does not fold non-ASCII runes, so no multi-byte +// rune such as U+212A KELVIN SIGN can alias an ASCII key. +func EqualFoldASCII(a, b string) bool { + if len(a) != len(b) { + return false + } + const asciiCapDiff = 'a' - 'A' + for i := 0; i < len(a); i++ { + ca, cb := a[i], b[i] + if ca >= 'A' && ca <= 'Z' { + ca += asciiCapDiff + } + if cb >= 'A' && cb <= 'Z' { + cb += asciiCapDiff + } + if ca != cb { + return false + } + } + return true +} + +// BytesEqualFoldASCII is the []byte form of [EqualFoldASCII]. Like [BytesEqual] +// it is heapless in tinygo, unlike [bytes.EqualFold] which also folds non-ASCII. +func BytesEqualFoldASCII(a, b []byte) bool { + if len(a) != len(b) { + return false + } else if len(a) == 0 { + return true + } + return EqualFoldASCII(unsafe.String(&a[0], len(a)), unsafe.String(&b[0], len(b))) +} + // IsZeroed returns true if all arguments are set to their zero value. func IsZeroed[T comparable](a ...T) bool { var z T diff --git a/x/xnet/stack-async.go b/x/xnet/stack-async.go index 83f711f..b555b41 100644 --- a/x/xnet/stack-async.go +++ b/x/xnet/stack-async.go @@ -650,8 +650,10 @@ func (s *StackAsync) StartLookupIPType(host string, qtype dns.Type) error { Additional: []dns.Resource{ s.ednsopt, }, - EnableRecursion: true, - MaxResponseAnswers: uint16(len(s.addrbufnip)), + EnableRecursion: true, + // Leave headroom above the address buffer for CNAME records, which + // occupy answer slots before the addresses they alias. + MaxResponseAnswers: uint16(len(s.addrbufnip)) + 8, }) if err != nil { return err diff --git a/x/xnet/xnet_dns_test.go b/x/xnet/xnet_dns_test.go index b6a6a8a..db4582d 100644 --- a/x/xnet/xnet_dns_test.go +++ b/x/xnet/xnet_dns_test.go @@ -156,6 +156,14 @@ func buildDNSResponsePacket(t *testing.T, txid uint16, dstPort uint16, hostname dns.NewResource(name, dns.TypeA, dns.ClassINET, 300, addr.AsSlice()), }, } + return buildDNSMsgResponsePacket(t, txid, dstPort, msg, srcIP, srcMAC, dstIP, dstMAC, buf) +} + +// buildDNSMsgResponsePacket wraps a DNS response message into a complete +// Ethernet+IP+UDP packet with valid checksums. +func buildDNSMsgResponsePacket(t *testing.T, txid uint16, dstPort uint16, msg dns.Message, + srcIP netip.Addr, srcMAC [6]byte, dstIP netip.Addr, dstMAC [6]byte, buf []byte) ([]byte, error) { + t.Helper() // Response flags: QR=1 (response), RD=1 (recursion desired), RA=1 (recursion available). responseFlags := dns.HeaderFlags(1<<15 | 1<<8 | 1<<7) @@ -237,3 +245,97 @@ var errBaseLenDNS = func() error { }() var errInvalidEtherType = errors.New("invalid ethernet type") + +// TestDNS_CNAMEResponse verifies that a DNS response containing a CNAME record +// followed by an A record for the canonical name resolves to the A record's +// address: the CNAME RDATA must not be misinterpreted as an IP address. +func TestDNS_CNAMEResponse(t *testing.T) { + const seed = 9876 + const MTU = ethernet.MaxMTU + + client := new(StackAsync) + dnsServerAddr := netip.AddrFrom4([4]byte{8, 8, 8, 8}) + clientAddr := netip.AddrFrom4([4]byte{10, 0, 0, 100}) + clientMAC := [6]byte{0xde, 0xad, 0xbe, 0xef, 0x00, 0x01} + dnsServerMAC := [6]byte{0x00, 0x11, 0x22, 0x33, 0x44, 0x55} + + err := client.Reset(StackConfig{ + Hostname: "DNSClient", + RandSeed: seed, + StaticAddress4: clientAddr.As4(), + DNSServer: dnsServerAddr, + HardwareAddress: clientMAC, + MTU: uint16(MTU), + }) + if err != nil { + t.Fatal("client Reset failed:", err) + } + client.SetGatewayHardwareAddr(dnsServerMAC) + + const hostname = "www.example.com" + const alias = "cdn.example.net" + wantAddr := netip.MustParseAddr("192.0.2.200") + + err = client.StartLookupIP(hostname) + if err != nil { + t.Fatal("StartLookupIP failed:", err) + } + + const carrierDataSize = ethernet.MaxFrameLength + var buf [carrierDataSize]byte + + // Client sends DNS query for www.example.com. + n, err := client.EgressEthernet(buf[:]) + if err != nil || n == 0 { + t.Fatal("expected DNS query packet from client:", err, n) + } + txid, clientPort, err := extractDNSTxIDAndPort(buf[:n]) + if err != nil { + t.Fatal("failed to extract DNS txid:", err) + } + + // Respond with a CNAME record www.example.com -> cdn.example.net + // followed by the A record for cdn.example.net. + owner, err := dns.NewName(hostname) + if err != nil { + t.Fatal(err) + } + aliasName, err := dns.NewName(alias) + if err != nil { + t.Fatal(err) + } + aliasWire, err := aliasName.AppendTo(nil) + if err != nil { + t.Fatal(err) + } + msg := dns.Message{ + Questions: []dns.Question{{ + Name: owner, + Type: dns.TypeA, + Class: dns.ClassINET, + }}, + Answers: []dns.Resource{ + dns.NewResource(owner, dns.TypeCNAME, dns.ClassINET, 300, aliasWire), + dns.NewResource(aliasName, dns.TypeA, dns.ClassINET, 300, wantAddr.AsSlice()), + }, + } + responsePkt, err := buildDNSMsgResponsePacket(t, txid, clientPort, msg, + dnsServerAddr, dnsServerMAC, clientAddr, clientMAC, buf[:]) + if err != nil { + t.Fatal("failed to build CNAME response packet:", err) + } + if err = client.IngressEthernet(responsePkt); err != nil { + t.Fatal("client Demux of CNAME response failed:", err) + } + + addrs, done, err := client.ResultLookupIP(hostname) + if err != nil { + t.Fatal("ResultLookupIP error:", err) + } + if !done { + t.Fatal("DNS lookup not done after receiving CNAME response") + } + if !slices.Contains(addrs, wantAddr) { + t.Errorf("expected address %s not found in result %v", wantAddr, addrs) + } +}