feature/conn25: follow CNAMEs when rewriting DNS response

If a DNS query for a domain that should be routed through a connector
results in CNAME records in the response, collapse the CNAME chain to an
A/AAAA record for the domain -> magic IP.

Fixes tailscale/corp#39978

Signed-off-by: Fran Bull <fran@tailscale.com>
This commit is contained in:
Fran Bull
2026-05-01 13:58:43 -07:00
committed by franbull
parent 24eb157448
commit ee2378b141
2 changed files with 228 additions and 15 deletions
+54 -15
View File
@@ -1005,6 +1005,7 @@ func (c *Conn25) mapDNSResponse(buf []byte) []byte {
// * not send through the additional section // * 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) // * 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 var answers []dnsResponseRewrite
var cnameChain map[dnsname.FQDN]dnsname.FQDN
if question.Type != dnsmessage.TypeA && question.Type != dnsmessage.TypeAAAA { if question.Type != dnsmessage.TypeA && question.Type != dnsmessage.TypeAAAA {
c.logf("mapping dns response for connector domain, unsupported type: %v", question.Type) c.logf("mapping dns response for connector domain, unsupported type: %v", question.Type)
newBuf, err := c.client.rewriteDNSResponse(appName, hdr, questions, answers) newBuf, err := c.client.rewriteDNSResponse(appName, hdr, questions, answers)
@@ -1034,15 +1035,32 @@ func (c *Conn25) mapDNSResponse(buf []byte) []byte {
} }
switch h.Type { switch h.Type {
case dnsmessage.TypeCNAME: 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 // A DNS response with CNAME records might look a bit like
// 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 // a.example.com. CNAME b.example.com.
// destination address (see eg appc/{appconnector,observe}.go). // b.example.com. CNAME example.com.
c.logf("not yet implemented CNAME answer: %v", queriedDomain) // example.com. A 1.1.1.1
if err := p.SkipAnswer(); err != nil { //
// We don't return CNAME records for our domains. We use them to build a
// cname chain so we can rewrite the final A/AAAA record to eg:
//
// a.example.com A (some magic IP that is associated with 1.1.1.1)
r, err := p.CNAMEResource()
if err != nil {
c.logf("error parsing dns response: %v", err) c.logf("error parsing dns response: %v", err)
return makeServFail(c.logf, hdr, question) return makeServFail(c.logf, hdr, question)
} }
src, err := normalizeDNSName(h.Name.String())
if err != nil {
c.logf("bad dnsname: %v", err)
return makeServFail(c.logf, hdr, question)
}
target, err := normalizeDNSName(r.CNAME.String())
if err != nil {
c.logf("bad dnsname: %v", err)
return makeServFail(c.logf, hdr, question)
}
mak.Set(&cnameChain, src, target)
case dnsmessage.TypeA, dnsmessage.TypeAAAA: case dnsmessage.TypeA, dnsmessage.TypeAAAA:
if h.Type != question.Type { if h.Type != question.Type {
// would not expect a v4 response to a v6 question or vice versa, don't add a rewrite for this. // would not expect a v4 response to a v6 question or vice versa, don't add a rewrite for this.
@@ -1052,19 +1070,40 @@ func (c *Conn25) mapDNSResponse(buf []byte) []byte {
} }
continue continue
} }
domain, err := normalizeDNSName(h.Name.String()) answerDomain, err := normalizeDNSName(h.Name.String())
if err != nil { if err != nil {
c.logf("bad dnsname: %v", err) c.logf("bad dnsname: %v", err)
return makeServFail(c.logf, hdr, question) return makeServFail(c.logf, hdr, question)
} }
// answers should be for the domain that was queried // If answerDomain is not the same domain as the domain that was queried for,
if domain != queriedDomain { // try to walk down the cname chain from the queried domain until we find the answerDomain.
c.logf("unexpected domain for connector domain dns response: %v %v", queriedDomain, domain) // If we can't, skip the answer.
if err := p.SkipAnswer(); err != nil { // If we can, then we will rewrite the dns response to an A/AAAA record pointing
c.logf("error parsing dns response: %v", err) // the queriedDomain to the magic IP.
return makeServFail(c.logf, hdr, question) if answerDomain != queriedDomain {
d := queriedDomain
found := false
seen := set.Set[dnsname.FQDN]{} // avoid following cname record loops
for {
target, ok := cnameChain[d]
if !ok || seen.Contains(target) {
break
}
if target == answerDomain {
found = true
break
}
seen.Add(target)
d = target
}
if !found {
c.logf("unexpected domain for connector domain dns response: %v %v", queriedDomain, answerDomain)
if err := p.SkipAnswer(); err != nil {
c.logf("error parsing dns response: %v", err)
return makeServFail(c.logf, hdr, question)
}
continue
} }
continue
} }
var dstAddr netip.Addr var dstAddr netip.Addr
if h.Type == dnsmessage.TypeA { if h.Type == dnsmessage.TypeA {
@@ -1082,7 +1121,7 @@ func (c *Conn25) mapDNSResponse(buf []byte) []byte {
} }
dstAddr = netip.AddrFrom16(r.AAAA) dstAddr = netip.AddrFrom16(r.AAAA)
} }
answers = append(answers, dnsResponseRewrite{domain: domain, dst: dstAddr}) answers = append(answers, dnsResponseRewrite{domain: queriedDomain, dst: dstAddr})
default: default:
// we already checked the question was for a supported type, this answer is unexpected // 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) c.logf("unexpected type for connector domain dns response: %v %v", queriedDomain, h.Type)
+174
View File
@@ -877,6 +877,12 @@ func makeDNSResponseForSections(t *testing.T, questions []dnsmessage.Question, a
t.Fatalf("unexpected answer type, update test") t.Fatalf("unexpected answer type, update test")
} }
b.AAAAResource(ans.Header, *body) b.AAAAResource(ans.Header, *body)
case dnsmessage.TypeCNAME:
body, ok := (ans.Body).(*dnsmessage.CNAMEResource)
if !ok {
t.Fatalf("unexpected answer type, update test")
}
b.CNAMEResource(ans.Header, *body)
default: default:
t.Fatalf("unhandled answer type, update test: %v", ans.Header.Type) t.Fatalf("unhandled answer type, update test: %v", ans.Header.Type)
} }
@@ -1663,6 +1669,174 @@ func TestMapDNSResponseRewritesResponses(t *testing.T) {
), ),
assertFx: assertParsesToAnswers([]netip.Addr{netip.MustParseAddr("2606:4700::6812:100")}), assertFx: assertParsesToAnswers([]netip.Addr{netip.MustParseAddr("2606:4700::6812:100")}),
}, },
{
name: "cname-resolves-to-magic-ip",
toMap: makeDNSResponseForSections(t,
[]dnsmessage.Question{{Name: dnsMessageName, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET}},
[]dnsmessage.Resource{
{
Header: dnsmessage.ResourceHeader{
Name: dnsMessageName,
Type: dnsmessage.TypeCNAME,
Class: dnsmessage.ClassINET,
},
Body: &dnsmessage.CNAMEResource{CNAME: dnsmessage.MustNewName("a.example.com.")},
},
{
Header: dnsmessage.ResourceHeader{
Name: dnsmessage.MustNewName("a.example.com."),
Type: dnsmessage.TypeCNAME,
Class: dnsmessage.ClassINET,
},
Body: &dnsmessage.CNAMEResource{CNAME: dnsmessage.MustNewName("b.example.com.")},
},
{
Header: dnsmessage.ResourceHeader{
Name: dnsmessage.MustNewName("b.example.com."),
Type: dnsmessage.TypeCNAME,
Class: dnsmessage.ClassINET,
},
Body: &dnsmessage.CNAMEResource{CNAME: dnsmessage.MustNewName("c.example.com.")},
},
{
Header: dnsmessage.ResourceHeader{
Name: dnsmessage.MustNewName("c.example.com."),
Type: dnsmessage.TypeA,
Class: dnsmessage.ClassINET,
},
Body: &dnsmessage.AResource{A: netip.MustParseAddr("1.2.3.4").As4()},
},
},
nil,
),
assertFx: assertParsesToAnswers([]netip.Addr{netip.MustParseAddr("100.64.0.0")}),
},
{
name: "cname-aaaa-resolves-to-magic-ip",
toMap: makeDNSResponseForSections(t,
[]dnsmessage.Question{
{
Name: dnsMessageName,
Type: dnsmessage.TypeAAAA,
Class: dnsmessage.ClassINET,
},
},
[]dnsmessage.Resource{
{
Header: dnsmessage.ResourceHeader{
Name: dnsMessageName,
Type: dnsmessage.TypeCNAME,
Class: dnsmessage.ClassINET,
},
Body: &dnsmessage.CNAMEResource{CNAME: dnsmessage.MustNewName("cdn.example.net.")},
},
{
Header: dnsmessage.ResourceHeader{
Name: dnsmessage.MustNewName("cdn.example.net."),
Type: dnsmessage.TypeAAAA,
Class: dnsmessage.ClassINET,
},
Body: &dnsmessage.AAAAResource{AAAA: netip.MustParseAddr("2606:4700::6812:1a78").As16()},
},
},
nil,
),
assertFx: assertParsesToAnswers([]netip.Addr{netip.MustParseAddr("2606:4700::6812:100")}),
},
{
name: "cname-broken-chain-skips-answer",
toMap: makeDNSResponseForSections(t,
[]dnsmessage.Question{{Name: dnsMessageName, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET}},
[]dnsmessage.Resource{
{
Header: dnsmessage.ResourceHeader{
Name: dnsMessageName,
Type: dnsmessage.TypeCNAME,
Class: dnsmessage.ClassINET,
},
Body: &dnsmessage.CNAMEResource{CNAME: dnsmessage.MustNewName("cdn.example.net.")},
},
{
Header: dnsmessage.ResourceHeader{
Name: dnsmessage.MustNewName("unrelated.com."),
Type: dnsmessage.TypeA,
Class: dnsmessage.ClassINET,
},
Body: &dnsmessage.AResource{A: netip.MustParseAddr("1.2.3.4").As4()},
},
},
nil,
),
assertFx: assertParsesToAnswers(nil),
},
{
name: "cname-multi-source-same-target",
toMap: makeDNSResponseForSections(t,
[]dnsmessage.Question{{Name: dnsMessageName, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET}},
[]dnsmessage.Resource{
{
Header: dnsmessage.ResourceHeader{
Name: dnsMessageName,
Type: dnsmessage.TypeCNAME,
Class: dnsmessage.ClassINET,
},
Body: &dnsmessage.CNAMEResource{CNAME: dnsmessage.MustNewName("z.example.com.")},
},
{
Header: dnsmessage.ResourceHeader{
Name: dnsmessage.MustNewName("a.example.com."),
Type: dnsmessage.TypeCNAME,
Class: dnsmessage.ClassINET,
},
Body: &dnsmessage.CNAMEResource{CNAME: dnsmessage.MustNewName("z.example.com.")},
},
{
Header: dnsmessage.ResourceHeader{
Name: dnsmessage.MustNewName("z.example.com."),
Type: dnsmessage.TypeA,
Class: dnsmessage.ClassINET,
},
Body: &dnsmessage.AResource{A: netip.MustParseAddr("1.2.3.4").As4()},
},
},
nil,
),
assertFx: assertParsesToAnswers([]netip.Addr{netip.MustParseAddr("100.64.0.0")}),
},
{
name: "cname-has-loop",
toMap: makeDNSResponseForSections(t,
[]dnsmessage.Question{{Name: dnsMessageName, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET}},
[]dnsmessage.Resource{
{
Header: dnsmessage.ResourceHeader{
Name: dnsMessageName,
Type: dnsmessage.TypeCNAME,
Class: dnsmessage.ClassINET,
},
Body: &dnsmessage.CNAMEResource{CNAME: dnsmessage.MustNewName("a.example.com.")},
},
{
Header: dnsmessage.ResourceHeader{
Name: dnsmessage.MustNewName("a.example.com."),
Type: dnsmessage.TypeCNAME,
Class: dnsmessage.ClassINET,
},
Body: &dnsmessage.CNAMEResource{CNAME: dnsMessageName},
},
{
Header: dnsmessage.ResourceHeader{
Name: dnsmessage.MustNewName("z.example.com."),
Type: dnsmessage.TypeA,
Class: dnsmessage.ClassINET,
},
Body: &dnsmessage.AResource{A: netip.MustParseAddr("1.2.3.4").As4()},
},
},
nil,
),
assertFx: assertParsesToAnswers(nil),
},
} { } {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
c := newConn25(logger.Discard) c := newConn25(logger.Discard)