diff --git a/feature/conn25/conn25.go b/feature/conn25/conn25.go index 5318d2bdd..6c494c313 100644 --- a/feature/conn25/conn25.go +++ b/feature/conn25/conn25.go @@ -17,6 +17,7 @@ import ( "net/http" "net/netip" "slices" + "strings" "sync" "go4.org/netipx" @@ -55,6 +56,12 @@ func jsonDecode(target any, rc io.ReadCloser) error { return err } +func normalizeDNSName(name string) (dnsname.FQDN, error) { + // note that appconnector does this same thing, tsdns has its own custom lower casing + // it might be good to unify in a function in dnsname package. + return dnsname.ToFQDN(strings.ToLower(name)) +} + func init() { feature.Register(featureName) ipnext.RegisterExtension(featureName, func(logf logger.Logf, sb ipnext.SafeBackend) (ipnext.Extension, error) { @@ -438,7 +445,7 @@ func configFromNodeView(n tailcfg.NodeView) (config, error) { for _, app := range apps { selfMatchesTags := slices.ContainsFunc(app.Connectors, selfTags.Contains) for _, d := range app.Domains { - fqdn, err := dnsname.ToFQDN(d) + fqdn, err := normalizeDNSName(d) if err != nil { return config{}, err } @@ -641,16 +648,81 @@ func (e *extension) sendAddressAssignment(ctx context.Context, as addrs) error { return makePeerAPIReq(ctx, client, urlBase, as) } +type dnsResponseRewrite struct { + domain dnsname.FQDN + dst netip.Addr +} + +func makeServFail(logf logger.Logf, h dnsmessage.Header, q dnsmessage.Question) []byte { + h.Response = true + h.Authoritative = true + h.RCode = dnsmessage.RCodeServerFailure + b := dnsmessage.NewBuilder(nil, h) + err := b.StartQuestions() + if err != nil { + logf("error making servfail: %v", err) + return []byte{} + } + err = b.Question(q) + if err != nil { + logf("error making servfail: %v", err) + return []byte{} + } + bs, err := b.Finish() + if err != nil { + // If there's an error here there's a bug somewhere directly above. + // _possibly_ some kind of question that was parseable but not encodable?, + // otherwise we could panic. + logf("error making servfail: %v", err) + } + return bs +} + func (c *client) mapDNSResponse(buf []byte) []byte { var p dnsmessage.Parser - if _, err := p.Start(buf); err != nil { + hdr, err := p.Start(buf) + if err != nil { c.logf("error parsing dns response: %v", err) return buf } - if err := p.SkipAllQuestions(); err != nil { + questions, err := p.AllQuestions() + if err != nil { c.logf("error parsing dns response: %v", err) return buf } + // Any message we are interested in has one question (RFC 9619) + if len(questions) != 1 { + return buf + } + question := questions[0] + // The other Class types are not commonly used and supporting them hasn't been considered. + if question.Class != dnsmessage.ClassINET { + return buf + } + queriedDomain, err := normalizeDNSName(question.Name.String()) + if err != nil { + return buf + } + if !c.isConnectorDomain(queriedDomain) { + return buf + } + + // Now we know this is a dns response we think we should rewrite, we're going to provide our response which + // currently means we will: + // * write the questions through as they are + // * not send through the additional section + // * provide our answers, or no answers if we don't handle those answers (possibly in the future we should write through answers for eg TypeTXT) + var answers []dnsResponseRewrite + if question.Type != dnsmessage.TypeA { + // we plan to support TypeAAAA soon (2026-03-11) + c.logf("mapping dns response for connector domain, unsupported type: %v", question.Type) + newBuf, err := c.rewriteDNSResponse(hdr, questions, answers) + if err != nil { + c.logf("error writing empty response for unsupported type: %v", err) + return makeServFail(c.logf, hdr, question) + } + return newBuf + } for { h, err := p.AnswerHeader() if err == dnsmessage.ErrSectionDone { @@ -658,57 +730,108 @@ func (c *client) mapDNSResponse(buf []byte) []byte { } if err != nil { c.logf("error parsing dns response: %v", err) - return buf + return makeServFail(c.logf, hdr, question) } - + // other classes are unsupported, and we checked the question was for ClassINET already if h.Class != dnsmessage.ClassINET { + c.logf("unexpected class for connector domain dns response: %v %v", queriedDomain, h.Class) if err := p.SkipAnswer(); err != nil { c.logf("error parsing dns response: %v", err) - return buf + return makeServFail(c.logf, hdr, question) } continue } - switch h.Type { + case dnsmessage.TypeCNAME: + // An A record was asked for, and the answer is a CNAME, this answer will tell us which domain it's a CNAME for + // and a subsequent answer should tell us what the target domains address is (or possibly another CNAME). Drop + // this for now (2026-03-11) but in the near future we should collapse the CNAME chain and map to the ultimate + // destination address (see eg appc/{appconnector,observe}.go). + c.logf("not yet implemented CNAME answer: %v", queriedDomain) + if err := p.SkipAnswer(); err != nil { + c.logf("error parsing dns response: %v", err) + return makeServFail(c.logf, hdr, question) + } case dnsmessage.TypeA: - domain, err := dnsname.ToFQDN(h.Name.String()) + domain, err := normalizeDNSName(h.Name.String()) if err != nil { c.logf("bad dnsname: %v", err) - return buf + return makeServFail(c.logf, hdr, question) } - if !c.isConnectorDomain(domain) { + // answers should be for the domain that was queried + if domain != queriedDomain { + c.logf("unexpected domain for connector domain dns response: %v %v", queriedDomain, domain) if err := p.SkipAnswer(); err != nil { c.logf("error parsing dns response: %v", err) - return buf + return makeServFail(c.logf, hdr, question) } continue } r, err := p.AResource() if err != nil { c.logf("error parsing dns response: %v", err) - return buf - } - addrs, err := c.reserveAddresses(domain, netip.AddrFrom4(r.A)) - if err != nil { - c.logf("error assigning connector addresses: %v", err) - return buf - } - if !addrs.isValid() { - c.logf("assigned connector addresses unexpectedly empty: %v", err) - return buf + return makeServFail(c.logf, hdr, question) } + answers = append(answers, dnsResponseRewrite{domain: domain, dst: netip.AddrFrom4(r.A)}) default: + // we already checked the question was for a supported type, this answer is unexpected + c.logf("unexpected type for connector domain dns response: %v %v", queriedDomain, h.Type) if err := p.SkipAnswer(); err != nil { c.logf("error parsing dns response: %v", err) - return buf + return makeServFail(c.logf, hdr, question) } - continue } } + newBuf, err := c.rewriteDNSResponse(hdr, questions, answers) + if err != nil { + c.logf("error rewriting dns response: %v", err) + return makeServFail(c.logf, hdr, question) + } + return newBuf +} - // TODO(fran) 2026-01-21 return a dns response with addresses - // swapped out for the magic IPs to make conn25 work. - return buf +func (c *client) rewriteDNSResponse(hdr dnsmessage.Header, questions []dnsmessage.Question, answers []dnsResponseRewrite) ([]byte, error) { + // We are currently (2026-03-10) only doing this for AResource records, we know that if we are here + // with non-empty answers, the type was AResource. + b := dnsmessage.NewBuilder(nil, hdr) + b.EnableCompression() + if err := b.StartQuestions(); err != nil { + return nil, err + } + for _, q := range questions { + if err := b.Question(q); err != nil { + return nil, err + } + } + if err := b.StartAnswers(); err != nil { + return nil, err + } + // make an answer for each rewrite + for _, rw := range answers { + as, err := c.reserveAddresses(rw.domain, rw.dst) + if err != nil { + return nil, err + } + if !as.isValid() { + return nil, errors.New("connector addresses empty") + } + name, err := dnsmessage.NewName(rw.domain.WithTrailingDot()) + if err != nil { + return nil, err + } + // only handling TypeA right now + rhdr := dnsmessage.ResourceHeader{Name: name, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET, TTL: 0} + if err := b.AResource(rhdr, dnsmessage.AResource{A: as.magic.As4()}); err != nil { + return nil, err + } + } + // We do _not_ include the additional section in our rewrite. (We don't want to include + // eg DNSSEC info, or other extra info like related records). + out, err := b.Finish() + if err != nil { + return nil, err + } + return out, nil } type connector struct { diff --git a/feature/conn25/conn25_test.go b/feature/conn25/conn25_test.go index 574320af8..08789183d 100644 --- a/feature/conn25/conn25_test.go +++ b/feature/conn25/conn25_test.go @@ -8,7 +8,6 @@ import ( "net/http" "net/http/httptest" "net/netip" - "reflect" "testing" "time" @@ -547,61 +546,139 @@ func rangeFrom(from, to string) netipx.IPRange { ) } -func TestMapDNSResponse(t *testing.T) { - makeDNSResponse := func(domain string, addrs []dnsmessage.AResource) []byte { - b := dnsmessage.NewBuilder(nil, - dnsmessage.Header{ - ID: 1, - Response: true, - Authoritative: true, - RCode: dnsmessage.RCodeSuccess, - }) - b.EnableCompression() - - if err := b.StartQuestions(); err != nil { - t.Fatal(err) +func makeDNSResponse(t *testing.T, domain string, addrs []*dnsmessage.AResource) []byte { + t.Helper() + name := dnsmessage.MustNewName(domain) + questions := []dnsmessage.Question{ + { + Name: name, + Type: dnsmessage.TypeA, + Class: dnsmessage.ClassINET, + }, + } + var answers []dnsmessage.Resource + for _, addr := range addrs { + ans := dnsmessage.Resource{ + Header: dnsmessage.ResourceHeader{ + Name: name, + Type: dnsmessage.TypeA, + Class: dnsmessage.ClassINET, + }, + Body: addr, } + answers = append(answers, ans) + } + additional := []dnsmessage.Resource{ + { + Header: dnsmessage.ResourceHeader{ + Name: name, + Type: dnsmessage.TypeA, + Class: dnsmessage.ClassINET, + }, + Body: &dnsmessage.AResource{A: [4]byte{9, 9, 9, 9}}, + }, + } + return makeDNSResponseForSections(t, questions, answers, additional) +} - if err := b.Question(dnsmessage.Question{ - Name: dnsmessage.MustNewName(domain), - Type: dnsmessage.TypeA, +func makeV6DNSResponse(t *testing.T, domain string, addrs []*dnsmessage.AAAAResource) []byte { + t.Helper() + name := dnsmessage.MustNewName(domain) + questions := []dnsmessage.Question{ + { + Name: name, + Type: dnsmessage.TypeAAAA, Class: dnsmessage.ClassINET, - }); err != nil { - t.Fatal(err) + }, + } + var answers []dnsmessage.Resource + for _, addr := range addrs { + ans := dnsmessage.Resource{ + Header: dnsmessage.ResourceHeader{ + Name: name, + Type: dnsmessage.TypeAAAA, + Class: dnsmessage.ClassINET, + }, + Body: addr, } + answers = append(answers, ans) + } + return makeDNSResponseForSections(t, questions, answers, nil) +} + +func makeDNSResponseForSections(t *testing.T, questions []dnsmessage.Question, answers []dnsmessage.Resource, additional []dnsmessage.Resource) []byte { + t.Helper() + b := dnsmessage.NewBuilder(nil, + dnsmessage.Header{ + ID: 1, + Response: true, + Authoritative: true, + RCode: dnsmessage.RCodeSuccess, + }) + b.EnableCompression() + + if err := b.StartQuestions(); err != nil { + t.Fatal(err) + } - if err := b.StartAnswers(); err != nil { + for _, q := range questions { + if err := b.Question(q); err != nil { t.Fatal(err) } + } - for _, addr := range addrs { - b.AResource( - dnsmessage.ResourceHeader{ - Name: dnsmessage.MustNewName(domain), - Type: dnsmessage.TypeA, - Class: dnsmessage.ClassINET, - }, - addr, - ) + if err := b.StartAnswers(); err != nil { + t.Fatal(err) + } + + for _, ans := range answers { + switch ans.Header.Type { + case dnsmessage.TypeA: + body, ok := (ans.Body).(*dnsmessage.AResource) + if !ok { + t.Fatalf("unexpected answer type, update test") + } + b.AResource(ans.Header, *body) + case dnsmessage.TypeAAAA: + body, ok := (ans.Body).(*dnsmessage.AAAAResource) + if !ok { + t.Fatalf("unexpected answer type, update test") + } + b.AAAAResource(ans.Header, *body) + default: + t.Fatalf("unhandled answer type, update test: %v", ans.Header.Type) } + } - outbs, err := b.Finish() - if err != nil { - t.Fatal(err) + if err := b.StartAdditionals(); err != nil { + t.Fatal(err) + } + for _, add := range additional { + body, ok := (add.Body).(*dnsmessage.AResource) + if !ok { + t.Fatalf("unexpected additional type, update test") } - return outbs + b.AResource(add.Header, *body) } + outbs, err := b.Finish() + if err != nil { + t.Fatal(err) + } + return outbs +} + +func TestMapDNSResponseAssignsAddrs(t *testing.T) { for _, tt := range []struct { name string domain string - addrs []dnsmessage.AResource + addrs []*dnsmessage.AResource wantByMagicIP map[netip.Addr]addrs }{ { name: "one-ip-matches", domain: "example.com.", - addrs: []dnsmessage.AResource{{A: [4]byte{1, 0, 0, 0}}}, + addrs: []*dnsmessage.AResource{{A: [4]byte{1, 0, 0, 0}}}, // these are 'expected' because they are the beginning of the provided pools wantByMagicIP: map[netip.Addr]addrs{ netip.MustParseAddr("100.64.0.0"): { @@ -616,7 +693,7 @@ func TestMapDNSResponse(t *testing.T) { { name: "multiple-ip-matches", domain: "example.com.", - addrs: []dnsmessage.AResource{ + addrs: []*dnsmessage.AResource{ {A: [4]byte{1, 0, 0, 0}}, {A: [4]byte{2, 0, 0, 0}}, }, @@ -640,14 +717,14 @@ func TestMapDNSResponse(t *testing.T) { { name: "no-domain-match", domain: "x.example.com.", - addrs: []dnsmessage.AResource{ + addrs: []*dnsmessage.AResource{ {A: [4]byte{1, 0, 0, 0}}, {A: [4]byte{2, 0, 0, 0}}, }, }, } { t.Run(tt.name, func(t *testing.T) { - dnsResp := makeDNSResponse(tt.domain, tt.addrs) + dnsResp := makeDNSResponse(t, tt.domain, tt.addrs) sn := makeSelfNode(t, appctype.Conn25Attr{ Name: "app1", Connectors: []string{"tag:woo"}, @@ -658,10 +735,7 @@ func TestMapDNSResponse(t *testing.T) { c := newConn25(logger.Discard) c.reconfig(sn) - bs := c.mapDNSResponse(dnsResp) - if !reflect.DeepEqual(dnsResp, bs) { - t.Fatal("shouldn't be changing the bytes (yet)") - } + c.mapDNSResponse(dnsResp) if diff := cmp.Diff(tt.wantByMagicIP, c.client.assignments.byMagicIP, cmpopts.EquateComparable(addrs{}, netip.Addr{})); diff != "" { t.Errorf("byMagicIP diff (-want, +got):\n%s", diff) } @@ -821,3 +895,291 @@ func TestEnqueueAddress(t *testing.T) { t.Fatal("timed out waiting for connector to receive request") } } + +func parseResponse(t *testing.T, buf []byte) ([]dnsmessage.Resource, []dnsmessage.Resource) { + t.Helper() + var p dnsmessage.Parser + header, err := p.Start(buf) + if err != nil { + t.Fatalf("parsing DNS response: %v", err) + } + if header.RCode != dnsmessage.RCodeSuccess { + t.Fatalf("RCode want: %v, got: %v", dnsmessage.RCodeSuccess, header.RCode) + } + if err := p.SkipAllQuestions(); err != nil { + t.Fatalf("skipping questions: %v", err) + } + answers, err := p.AllAnswers() + if err != nil { + t.Fatalf("reading answers: %v", err) + } + if err := p.SkipAllAuthorities(); err != nil { + t.Fatalf("skipping questions: %v", err) + } + additionals, err := p.AllAdditionals() + if err != nil { + t.Fatalf("reading additionals: %v", err) + } + return answers, additionals +} + +func TestMapDNSResponseRewritesResponses(t *testing.T) { + configuredDomain := "example.com" + domainName := configuredDomain + "." + dnsMessageName := dnsmessage.MustNewName(domainName) + sn := makeSelfNode(t, appctype.Conn25Attr{ + Name: "app1", + Connectors: []string{"tag:connector"}, + Domains: []string{configuredDomain}, + MagicIPPool: []netipx.IPRange{rangeFrom("0", "10")}, + TransitIPPool: []netipx.IPRange{rangeFrom("40", "50")}, + }, []string{}) + + compareToRecords := func(t *testing.T, resources []dnsmessage.Resource, want []netip.Addr) { + t.Helper() + var got []netip.Addr + for _, r := range resources { + if b, ok := r.Body.(*dnsmessage.AResource); ok { + got = append(got, netip.AddrFrom4(b.A)) + } else if b, ok := r.Body.(*dnsmessage.AAAAResource); ok { + got = append(got, netip.AddrFrom16(b.AAAA)) + } + } + if diff := cmp.Diff(want, got, cmpopts.EquateComparable(netip.Addr{})); diff != "" { + t.Fatalf("A/AAAA records mismatch (-want +got):\n%s", diff) + } + } + + assertParsesToAnswers := func(want []netip.Addr) func(t *testing.T, bs []byte) { + return func(t *testing.T, bs []byte) { + t.Helper() + answers, _ := parseResponse(t, bs) + compareToRecords(t, answers, want) + } + } + + assertParsesToAdditionals := func(want []netip.Addr) func(t *testing.T, bs []byte) { + return func(t *testing.T, bs []byte) { + t.Helper() + _, additionals := parseResponse(t, bs) + compareToRecords(t, additionals, want) + } + } + + assertBytes := func(want []byte) func(t *testing.T, bs []byte) { + return func(t *testing.T, bs []byte) { + t.Helper() + if diff := cmp.Diff(want, bs); diff != "" { + t.Fatalf("bytes mismatch (-want +got):\n%s", diff) + } + } + } + assertServFail := func(t *testing.T, bs []byte) { + var p dnsmessage.Parser + header, err := p.Start(bs) + if err != nil { + t.Fatalf("parsing DNS response: %v", err) + } + if header.RCode != dnsmessage.RCodeServerFailure { + t.Fatalf("RCode want: %v, got: %v", dnsmessage.RCodeServerFailure, header.RCode) + } + } + + ipv6ResponseUnhandledDomain := makeV6DNSResponse(t, "tailscale.com.", []*dnsmessage.AAAAResource{ + {AAAA: netip.MustParseAddr("2606:4700::6812:1a78").As16()}, + {AAAA: netip.MustParseAddr("2606:4700::6812:1b78").As16()}, + }) + + ipv4ResponseUnhandledDomain := makeDNSResponse(t, "tailscale.com.", []*dnsmessage.AResource{ + {A: netip.MustParseAddr("1.2.3.4").As4()}, + {A: netip.MustParseAddr("5.6.7.8").As4()}, + }) + + nonINETQuestionResp := makeDNSResponseForSections(t, []dnsmessage.Question{ + { + Name: dnsMessageName, + Type: dnsmessage.TypeA, + Class: dnsmessage.ClassCHAOS, + }, + }, nil, nil) + + for _, tt := range []struct { + name string + toMap []byte + assertFx func(*testing.T, []byte) + }{ + { + name: "unparseable", + toMap: []byte{1, 2, 3, 4}, + assertFx: assertBytes([]byte{1, 2, 3, 4}), + }, + { + name: "maps-multi-typea-answers", + toMap: makeDNSResponse(t, domainName, []*dnsmessage.AResource{ + {A: netip.MustParseAddr("1.2.3.4").As4()}, + {A: netip.MustParseAddr("5.6.7.8").As4()}, + }), + assertFx: assertParsesToAnswers( + []netip.Addr{ + netip.MustParseAddr("100.64.0.0"), + netip.MustParseAddr("100.64.0.1"), + }, + ), + }, + { + name: "ipv6-no-answers", + toMap: makeV6DNSResponse(t, domainName, []*dnsmessage.AAAAResource{ + {AAAA: netip.MustParseAddr("2606:4700::6812:1a78").As16()}, + {AAAA: netip.MustParseAddr("2606:4700::6812:1b78").As16()}, + }), + assertFx: assertParsesToAnswers(nil), + }, + { + name: "not-our-domain", + toMap: ipv4ResponseUnhandledDomain, + assertFx: assertBytes(ipv4ResponseUnhandledDomain), + }, + { + name: "ipv6-not-our-domain", + toMap: ipv6ResponseUnhandledDomain, + assertFx: assertBytes(ipv6ResponseUnhandledDomain), + }, + { + name: "case-insensitive", + toMap: makeDNSResponse(t, "eXample.com.", []*dnsmessage.AResource{ + {A: netip.MustParseAddr("1.2.3.4").As4()}, + {A: netip.MustParseAddr("5.6.7.8").As4()}, + }), + assertFx: assertParsesToAnswers( + []netip.Addr{ + netip.MustParseAddr("100.64.0.0"), + netip.MustParseAddr("100.64.0.1"), + }, + ), + }, + { + name: "unhandled-keeps-additional-section", + toMap: makeDNSResponse(t, "tailscale.com.", []*dnsmessage.AResource{ + {A: netip.MustParseAddr("1.2.3.4").As4()}, + {A: netip.MustParseAddr("5.6.7.8").As4()}, + }), + assertFx: assertParsesToAdditionals( + // additionals are added in makeDNSResponse + []netip.Addr{ + netip.MustParseAddr("9.9.9.9"), + }, + ), + }, + { + name: "handled-strips-additional-section", + toMap: makeDNSResponse(t, domainName, []*dnsmessage.AResource{ + {A: netip.MustParseAddr("1.2.3.4").As4()}, + {A: netip.MustParseAddr("5.6.7.8").As4()}, + }), + assertFx: assertParsesToAdditionals(nil), + }, + { + name: "servfail-when-we-should-handle-but-cant", + // produced by + // makeDNSResponse(t, domainName, []*dnsmessage.AResource{{A: netip.MustParseAddr("1.2.3.4").As4()}}) + // and then taking 17 bytes off the end. So that the parsing of it breaks after we have decided we should handle it. + // Frozen like this so that it doesn't depend on the implementation of dnsmessage. + toMap: []byte{0, 1, 132, 0, 0, 1, 0, 1, 0, 0, 0, 1, 7, 101, 120, 97, 109, 112, 108, 101, 3, 99, 111, 109, 0, 0, 1, 0, 1, 192, 12, 0, 1, 0, 1, 0, 0, 0, 0, 0, 4, 1, 2, 3}, + assertFx: assertServFail, + }, + { + name: "not-inet-question", + toMap: nonINETQuestionResp, + assertFx: assertBytes(nonINETQuestionResp), + }, + { + name: "not-inet-answer", + toMap: makeDNSResponseForSections(t, + []dnsmessage.Question{ + { + Name: dnsMessageName, + Type: dnsmessage.TypeA, + Class: dnsmessage.ClassINET, + }, + }, + []dnsmessage.Resource{ + { + Header: dnsmessage.ResourceHeader{ + Name: dnsMessageName, + Type: dnsmessage.TypeA, + Class: dnsmessage.ClassCHAOS, + }, + Body: &dnsmessage.AResource{A: netip.MustParseAddr("1.2.3.4").As4()}, + }, + }, + nil, + ), + assertFx: assertParsesToAnswers(nil), + }, + { + name: "answer-domain-mismatch", + toMap: makeDNSResponseForSections(t, + []dnsmessage.Question{ + { + Name: dnsMessageName, + Type: dnsmessage.TypeA, + Class: dnsmessage.ClassINET, + }, + }, + []dnsmessage.Resource{ + { + Header: dnsmessage.ResourceHeader{ + Name: dnsmessage.MustNewName("tailscale.com."), + Type: dnsmessage.TypeA, + Class: dnsmessage.ClassINET, + }, + Body: &dnsmessage.AResource{A: netip.MustParseAddr("1.2.3.4").As4()}, + }, + }, + nil, + ), + assertFx: assertParsesToAnswers(nil), + }, + { + name: "answer-type-mismatch", + toMap: makeDNSResponseForSections(t, + []dnsmessage.Question{ + { + Name: dnsMessageName, + Type: dnsmessage.TypeA, + Class: dnsmessage.ClassINET, + }, + }, + []dnsmessage.Resource{ + { + Header: dnsmessage.ResourceHeader{ + Name: dnsMessageName, + Type: dnsmessage.TypeAAAA, + Class: dnsmessage.ClassINET, + }, + Body: &dnsmessage.AAAAResource{AAAA: netip.MustParseAddr("1.2.3.4").As16()}, + }, + { + Header: dnsmessage.ResourceHeader{ + Name: dnsMessageName, + Type: dnsmessage.TypeA, + Class: dnsmessage.ClassINET, + }, + Body: &dnsmessage.AResource{A: netip.MustParseAddr("5.6.7.8").As4()}, + }, + }, + nil, + ), + assertFx: assertParsesToAnswers([]netip.Addr{netip.MustParseAddr("100.64.0.0")}), + }, + } { + t.Run(tt.name, func(t *testing.T) { + c := newConn25(logger.Discard) + if err := c.reconfig(sn); err != nil { + t.Fatal(err) + } + bs := c.mapDNSResponse(tt.toMap) + tt.assertFx(t, bs) + }) + } +}