appc, feature/conn25: handle exact and wildcard domains correctly (#19202)

Installed SplitDNS routes are always treated as wildcard domains,
so the domains that we pass to the local resolver should be normalized
and have any leading *. wildcard prefix removed.

When looking at DNS responses to see if the domain matches, we need to
consider both exact matches and wildcard matches. We now keep separate
maps of exact-match domains and wildcard domains, and when we match we
check to see if there's a match in the exact-match map, otherwise we
check against the wild card match map until we find a match, removing
a label after each check.

Rather than looking for matching self-hosted domains (domains serviced
by the connector being run on the self-node), the apps that are being
serviced by the connector on the self-node are tracked instead. When
checking to see if a DNS response should be rewritten, it is ignored
if any of the matching apps for the domain are in the self-hosted apps set.

Fixes tailscale/corp#39272

Signed-off-by: George Jones <george@tailscale.com>
This commit is contained in:
George Jones 2026-05-01 17:33:21 -04:00 committed by GitHub
parent bdf3419e7d
commit 290a6cc03c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 394 additions and 84 deletions

View File

@ -6,6 +6,7 @@ package appc
import (
"cmp"
"slices"
"strings"
"tailscale.com/ipn/ipnext"
"tailscale.com/tailcfg"
@ -64,20 +65,28 @@ func PickSplitDNSPeers(hasCap func(c tailcfg.NodeCapability) bool, self tailcfg.
if err != nil {
return m
}
tagToDomain := make(map[string][]string)
// We strip the leading *. from any domains because the OS treats all domains
// that we pass to it as wildcard domains, and the OS would treat the * character
// as a literal domain component instead of treating it as a wildcard.
// We also use a Set to deduplicate the domains we pass to the OS in case removing
// the *. prefix resulted in duplicate entries.
tagToDomain := make(map[string]set.Set[string])
selfTags := set.SetOf(self.Tags().AsSlice())
selfRoutedDomains := set.Set[string]{}
for _, app := range apps {
domains := make(set.Set[string])
for _, domain := range app.Domains {
domains.Add(strings.ToLower(strings.TrimPrefix(domain, "*.")))
}
for _, tag := range app.Connectors {
domains := tagToDomain[tag]
domains = slices.Grow(domains, len(app.Domains))
for _, d := range app.Domains {
if isSelfEligibleConnector && selfTags.Contains(tag) {
selfRoutedDomains.Add(d)
}
domains = append(domains, d)
if tagToDomain[tag] == nil {
tagToDomain[tag] = set.Set[string]{}
}
tagToDomain[tag].AddSet(domains)
if isSelfEligibleConnector && selfTags.Contains(tag) {
selfRoutedDomains.AddSet(domains)
}
tagToDomain[tag] = domains
}
}
// NodeIDs are Comparable, and we have a map of NodeID to NodeView anyway, so
@ -89,7 +98,7 @@ func PickSplitDNSPeers(hasCap func(c tailcfg.NodeCapability) bool, self tailcfg.
}
for _, t := range peer.Tags().All() {
domains := tagToDomain[t]
for _, domain := range domains {
for domain := range domains {
if selfRoutedDomains.Contains(domain) {
continue
}

View File

@ -32,6 +32,8 @@ func TestPickSplitDNSPeers(t *testing.T) {
appTwoBytes := getBytesForAttr("app2", []string{"a.example.com"}, []string{"tag:two"})
appThreeBytes := getBytesForAttr("app3", []string{"woo.b.example.com", "hoo.b.example.com"}, []string{"tag:three1", "tag:three2"})
appFourBytes := getBytesForAttr("app4", []string{"woo.b.example.com", "c.example.com"}, []string{"tag:four1", "tag:four2"})
appFiveBytes := getBytesForAttr("app5", []string{"*.example.com", "example.com"}, []string{"tag:one"})
appSixBytes := getBytesForAttr("app6", []string{"*.Example.com", "EXAMPLE.com", "EXAMPLE.COM"}, []string{"tag:one"})
makeNodeView := func(id tailcfg.NodeID, name string, tags []string) tailcfg.NodeView {
return (&tailcfg.Node{
@ -192,6 +194,49 @@ func TestPickSplitDNSPeers(t *testing.T) {
"c.example.com": {nvp2, nvp4},
},
},
{
name: "wildcards-are-stripped-and-deduped",
config: []tailcfg.RawMessage{
tailcfg.RawMessage(appOneBytes),
tailcfg.RawMessage(appFiveBytes),
},
peers: []tailcfg.NodeView{
nvp1,
},
want: map[string][]tailcfg.NodeView{
// All the domains should be normalized to example.com
"example.com": {nvp1},
},
},
{
name: "domains-are-normalized-and-deduped",
config: []tailcfg.RawMessage{
tailcfg.RawMessage(appSixBytes),
},
peers: []tailcfg.NodeView{
nvp1,
},
want: map[string][]tailcfg.NodeView{
// All the domains should be normalized to example.com
"example.com": {nvp1},
},
},
{
name: "sub-domains-and-top-domains-do-not-collide",
config: []tailcfg.RawMessage{
tailcfg.RawMessage(appTwoBytes),
tailcfg.RawMessage(appFiveBytes),
},
peers: []tailcfg.NodeView{
nvp1,
nvp3,
},
want: map[string][]tailcfg.NodeView{
// The sub.example.com should remain distinct from example.com
"example.com": {nvp1},
"a.example.com": {nvp3},
},
},
} {
t.Run(tt.name, func(t *testing.T) {
selfNode := &tailcfg.Node{}

View File

@ -569,12 +569,13 @@ func emptyIPSets() ipSets {
// which includes the policy.
// config is not safe for concurrent use.
type config struct {
isConfigured bool
apps []appctype.Conn25Attr
appsByName map[string]appctype.Conn25Attr
appNamesByDomain map[dnsname.FQDN][]string
selfDomains set.Set[dnsname.FQDN]
ipSets ipSets
isConfigured bool
apps []appctype.Conn25Attr
appsByName map[string]appctype.Conn25Attr
appNamesByDomain map[dnsname.FQDN][]string
appNamesByWCDomain map[dnsname.FQDN][]string
selfAppNames set.Set[string]
ipSets ipSets
}
func configFromNodeView(n tailcfg.NodeView) (*config, error) {
@ -587,26 +588,36 @@ func configFromNodeView(n tailcfg.NodeView) (*config, error) {
}
selfTags := set.SetOf(n.Tags().AsSlice())
cfg := &config{
isConfigured: true,
apps: apps,
appsByName: map[string]appctype.Conn25Attr{},
appNamesByDomain: map[dnsname.FQDN][]string{},
selfDomains: set.Set[dnsname.FQDN]{},
ipSets: emptyIPSets(),
isConfigured: true,
apps: apps,
appsByName: map[string]appctype.Conn25Attr{},
appNamesByDomain: map[dnsname.FQDN][]string{},
appNamesByWCDomain: map[dnsname.FQDN][]string{},
selfAppNames: set.Set[string]{},
ipSets: emptyIPSets(),
}
for _, app := range apps {
selfMatchesTags := slices.ContainsFunc(app.Connectors, selfTags.Contains)
normalizedDomains := set.Set[dnsname.FQDN]{}
normalizedWCDomains := set.Set[dnsname.FQDN]{}
for _, d := range app.Domains {
fqdn, err := normalizeDNSName(d)
domain, isWild := strings.CutPrefix(d, "*.")
fqdn, err := normalizeDNSName(domain)
if err != nil {
return &config{}, err
}
mak.Set(&cfg.appNamesByDomain, fqdn, append(cfg.appNamesByDomain[fqdn], app.Name))
if selfMatchesTags {
cfg.selfDomains.Add(fqdn)
if isWild && !normalizedWCDomains.Contains(fqdn) {
normalizedWCDomains.Add(fqdn)
mak.Set(&cfg.appNamesByWCDomain, fqdn, append(cfg.appNamesByWCDomain[fqdn], app.Name))
} else if !isWild && !normalizedDomains.Contains(fqdn) {
normalizedDomains.Add(fqdn)
mak.Set(&cfg.appNamesByDomain, fqdn, append(cfg.appNamesByDomain[fqdn], app.Name))
}
}
mak.Set(&cfg.appsByName, app.Name, app)
if slices.ContainsFunc(app.Connectors, selfTags.Contains) {
cfg.selfAppNames.Add(app.Name)
}
}
// TODO(fran) 2026-03-18 we don't yet have a proper way to communicate the
@ -702,24 +713,42 @@ func (c *client) reconfig() {
c.v6TransitIPPool = newIPPool(ipSets.v6Transit)
}
// isConnectorDomain returns true if the domain is expected
// to be routed through a peer connector, but returns false
// if the self node is a connector responsible for routing the
// domain, and false in all other cases.
func (cfg *config) isConnectorDomain(domain dnsname.FQDN, prefsAdvertiseConnector bool) bool {
if prefsAdvertiseConnector && cfg.selfDomains.Contains(domain) {
return false
// getAppsForConnectorDomain returns the slice of app names which match the
// provided domain. Apps which match the domain exactly are preferred,
// otherwise the list of apps comes from the wildcard domain which matches
// the longest suffix of the specified domain. A nil or empty slice is returned
// if no match is found or if the list of matching apps would contain an app
// which is being handled by the self-node's connector.
func (cfg *config) getAppsForConnectorDomain(domain dnsname.FQDN, prefsAdvertiseConnector bool) []string {
// Lookup exact matches first
appNames := cfg.appNamesByDomain[domain]
if len(appNames) == 0 {
// No exact match, check wildcard domains
// We have made the decision that wildcards will match the base domain.
// So example.com will be a match for *.example.com, because we think that
// this is most likely what users will expect.
for d := domain; d != ""; d = d.Parent() {
if appNames = cfg.appNamesByWCDomain[d]; len(appNames) > 0 {
break
}
}
}
appNames, ok := cfg.appNamesByDomain[domain]
return ok && len(appNames) > 0
// If we have a candidate match, make sure that no candidate app is pointing
// at a connector on the self-node.
if len(appNames) == 0 || (prefsAdvertiseConnector && slices.ContainsFunc(appNames, cfg.selfAppNames.Contains)) {
return nil
}
return appNames
}
// reserveAddresses tries to make an assignment of addrs from the address pools
// for this domain+dst address, so that this client can use conn25 connectors.
// The name of the matching app is also provided, no validation is done to check whether or not
// the app name refers to a configured app.
// It checks that this domain should be routed and that this client is not itself a connector for the domain
// and generally if it is valid to make the assignment.
func (c *client) reserveAddresses(app string, domain dnsname.FQDN, dst netip.Addr) (addrs, error) {
func (c *client) reserveAddresses(appName string, domain dnsname.FQDN, dst netip.Addr) (addrs, error) {
if !dst.IsValid() {
return addrs{}, errors.New("dst is not valid")
}
@ -756,7 +785,7 @@ func (c *client) reserveAddresses(app string, domain dnsname.FQDN, dst netip.Add
dst: dst,
magic: mip,
transit: tip,
app: app,
app: appName,
domain: domain,
}
if err := c.assignments.insert(as); err != nil {
@ -962,16 +991,13 @@ func (c *Conn25) mapDNSResponse(buf []byte) []byte {
return buf
}
if !cfg.isConnectorDomain(queriedDomain, c.prefsAdvertiseConnector.Load()) {
return buf
}
appNames, _ := cfg.appNamesByDomain[queriedDomain]
appNames := cfg.getAppsForConnectorDomain(queriedDomain, c.prefsAdvertiseConnector.Load())
if len(appNames) == 0 {
return buf
}
// only reserve for first app
app := appNames[0]
// There is guaranteed to be at least one matching app, so just take the first one for now
appName := appNames[0]
// 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:
@ -981,7 +1007,7 @@ func (c *Conn25) mapDNSResponse(buf []byte) []byte {
var answers []dnsResponseRewrite
if question.Type != dnsmessage.TypeA && question.Type != dnsmessage.TypeAAAA {
c.logf("mapping dns response for connector domain, unsupported type: %v", question.Type)
newBuf, err := c.client.rewriteDNSResponse(app, hdr, questions, answers)
newBuf, err := c.client.rewriteDNSResponse(appName, hdr, questions, answers)
if err != nil {
c.logf("error writing empty response for unsupported type: %v", err)
return makeServFail(c.logf, hdr, question)
@ -1066,7 +1092,7 @@ func (c *Conn25) mapDNSResponse(buf []byte) []byte {
}
}
}
newBuf, err := c.client.rewriteDNSResponse(app, hdr, questions, answers)
newBuf, err := c.client.rewriteDNSResponse(appName, hdr, questions, answers)
if err != nil {
c.logf("error rewriting dns response: %v", err)
return makeServFail(c.logf, hdr, question)
@ -1074,7 +1100,7 @@ func (c *Conn25) mapDNSResponse(buf []byte) []byte {
return newBuf
}
func (c *client) rewriteDNSResponse(app string, hdr dnsmessage.Header, questions []dnsmessage.Question, answers []dnsResponseRewrite) ([]byte, error) {
func (c *client) rewriteDNSResponse(appName string, hdr dnsmessage.Header, questions []dnsmessage.Question, answers []dnsResponseRewrite) ([]byte, error) {
b := dnsmessage.NewBuilder(nil, hdr)
b.EnableCompression()
if err := b.StartQuestions(); err != nil {
@ -1091,7 +1117,7 @@ func (c *client) rewriteDNSResponse(app string, hdr dnsmessage.Header, questions
// make an answer for each rewrite
for _, rw := range answers {
as, err := c.reserveAddresses(app, rw.domain, rw.dst)
as, err := c.reserveAddresses(appName, rw.domain, rw.dst)
if err != nil {
return nil, err
}

View File

@ -393,13 +393,11 @@ func TestHandleConnectorTransitIPRequest(t *testing.T) {
func TestReserveIPs(t *testing.T) {
c := newConn25(logger.Discard)
app := "a"
const appName = "a"
domainStr := "example.com."
mbd := map[dnsname.FQDN][]string{}
mbd["example.com."] = []string{app}
cfg := &config{
isConfigured: true,
appNamesByDomain: mbd,
isConfigured: true,
appsByName: map[string]appctype.Conn25Attr{appName: {}},
ipSets: ipSets{
v4Magic: mustIPSetFromPrefix("100.64.0.0/24"),
v6Magic: mustIPSetFromPrefix("fd7a:115c:a1e0:a99c:0100::/80"),
@ -430,7 +428,7 @@ func TestReserveIPs(t *testing.T) {
},
} {
t.Run(tt.name, func(t *testing.T) {
addrs, err := c.client.reserveAddresses(app, domain, tt.dst)
addrs, err := c.client.reserveAddresses(appName, domain, tt.dst)
if err != nil {
t.Fatal(err)
}
@ -443,8 +441,8 @@ func TestReserveIPs(t *testing.T) {
if tt.wantTransit != addrs.transit {
t.Errorf("want %v, got %v", tt.wantTransit, addrs.transit)
}
if app != addrs.app {
t.Errorf("want %s, got %s", app, addrs.app)
if appName != addrs.app {
t.Errorf("want %s, got %s", appName, addrs.app)
}
if domain != addrs.domain {
t.Errorf("want %s, got %s", domain, addrs.domain)
@ -488,13 +486,14 @@ func TestReconfig(t *testing.T) {
func TestConfigFromNodeView(t *testing.T) {
for _, tt := range []struct {
name string
rawCfg string
cfg []appctype.Conn25Attr
tags []string
wantErr bool
wantAppsByDomain map[dnsname.FQDN][]string
wantSelfDomains set.Set[dnsname.FQDN]
name string
rawCfg string
cfg []appctype.Conn25Attr
tags []string
wantErr bool
wantAppsByDomain map[dnsname.FQDN][]string
wantAppsByWCDomain map[dnsname.FQDN][]string
wantSelfAppNames set.Set[string]
}{
{
name: "bad-config",
@ -512,7 +511,8 @@ func TestConfigFromNodeView(t *testing.T) {
"a.example.com.": {"one"},
"b.example.com.": {"two"},
},
wantSelfDomains: set.SetOf([]dnsname.FQDN{"a.example.com."}),
wantAppsByWCDomain: map[dnsname.FQDN][]string{},
wantSelfAppNames: set.SetOf([]string{"one"}),
},
{
name: "more-complex-with-connector-self-domains",
@ -532,7 +532,8 @@ func TestConfigFromNodeView(t *testing.T) {
"4.b.example.com.": {"four"},
"4.d.example.com.": {"four"},
},
wantSelfDomains: set.SetOf([]dnsname.FQDN{"1.a.example.com.", "1.b.example.com.", "4.b.example.com.", "4.d.example.com."}),
wantAppsByWCDomain: map[dnsname.FQDN][]string{},
wantSelfAppNames: set.SetOf([]string{"one", "four"}),
},
{
name: "eligible-connector-no-matching-tag-no-self-domains",
@ -545,6 +546,49 @@ func TestConfigFromNodeView(t *testing.T) {
"a.example.com.": {"one"},
"b.example.com.": {"two"},
},
wantAppsByWCDomain: map[dnsname.FQDN][]string{}},
{
name: "wildcard-collapse-and-deduplication",
cfg: []appctype.Conn25Attr{
{Name: "one", Domains: []string{"*.example.com", "example.com"}, Connectors: []string{"tag:one"}},
{Name: "two", Domains: []string{"example.com", "sub.example.com"}, Connectors: []string{"tag:two"}},
},
tags: []string{"tag:one", "tag:two"},
wantAppsByDomain: map[dnsname.FQDN][]string{
"example.com.": {"one", "two"},
"sub.example.com.": {"two"},
},
wantAppsByWCDomain: map[dnsname.FQDN][]string{
"example.com.": {"one"},
},
wantSelfAppNames: set.SetOf([]string{"one", "two"}),
},
{
// Domain names that differ only in case must be treated as the same
// domain and the app name must appear exactly once in appNamesByDomain,
// not once per case variant.
name: "case-variant-exact-domains-deduplicated-within-app",
cfg: []appctype.Conn25Attr{
{Name: "one", Domains: []string{"EXAMPLE.com", "example.COM", "Example.COM"}, Connectors: []string{"tag:one"}},
},
tags: []string{"tag:one"},
wantAppsByDomain: map[dnsname.FQDN][]string{
"example.com.": {"one"},
},
wantAppsByWCDomain: map[dnsname.FQDN][]string{},
wantSelfAppNames: set.SetOf([]string{"one"}),
},
{
// Same as above but for wildcard domains: *.EXAMPLE.com and *.example.COM
// must collapse to a single entry in appNamesByWCDomain.
name: "case-variant-wildcard-domains-deduplicated-within-app",
cfg: []appctype.Conn25Attr{
{Name: "one", Domains: []string{"*.EXAMPLE.com", "*.example.COM"}, Connectors: []string{"tag:one"}},
},
tags: []string{"tag:one"},
wantAppsByDomain: map[dnsname.FQDN][]string{},
wantAppsByWCDomain: map[dnsname.FQDN][]string{"example.com.": {"one"}},
wantSelfAppNames: set.SetOf([]string{"one"}),
},
} {
t.Run(tt.name, func(t *testing.T) {
@ -574,8 +618,114 @@ func TestConfigFromNodeView(t *testing.T) {
if diff := cmp.Diff(tt.wantAppsByDomain, c.appNamesByDomain); diff != "" {
t.Errorf("appsByDomain diff (-want, +got):\n%s", diff)
}
if diff := cmp.Diff(tt.wantSelfDomains, c.selfDomains); diff != "" {
t.Errorf("selfDomains diff (-want, +got):\n%s", diff)
if diff := cmp.Diff(tt.wantAppsByWCDomain, c.appNamesByWCDomain); diff != "" {
t.Errorf("appsByWCDomain diff (-want, +got):\n%s", diff)
}
if diff := cmp.Diff(tt.wantSelfAppNames, c.selfAppNames); diff != "" {
t.Errorf("selfAppNames diff (-want, +got):\n%s", diff)
}
})
}
}
func TestGetAppsForDomainName(t *testing.T) {
defaultSN := makeSelfNode(
t,
[]appctype.Conn25Attr{
{Name: "one", Domains: []string{"*.example.com", "example.com"}, Connectors: []string{"tag:one"}},
{Name: "two", Domains: []string{"sub.example.com", "example.com"}, Connectors: []string{"tag:two"}},
{Name: "three", Domains: []string{"*.sub.example.com"}, Connectors: []string{"tag:three"}},
{Name: "four", Domains: []string{"a.sub.example.com"}, Connectors: []string{"tag:four"}},
{Name: "self-routed", Domains: []string{"*.wildcard.com", "exact-match.com"}, Connectors: []string{"tag:self-routed"}},
},
[]string{"tag:self-routed"},
)
for _, tt := range []struct {
name string
isConnector bool
domain dnsname.FQDN
wantApps []string
}{
{
name: "no-match",
domain: "nomatch.com.",
wantApps: nil,
},
{
name: "exact-match",
domain: "example.com.",
wantApps: []string{"one", "two"},
},
{
name: "wildcard-subdomain-match",
domain: "a.example.com.",
wantApps: []string{"one"},
},
{
name: "exact-subdomain-match",
domain: "sub.example.com.",
wantApps: []string{"two"},
},
{
name: "wildcard-sub-of-subdomain-match",
domain: "b.sub.example.com.",
wantApps: []string{"three"},
},
{
name: "exact-sub-of-subdomain-match",
domain: "a.sub.example.com.",
wantApps: []string{"four"},
},
{
name: "exact-domain-matches-wildcard",
domain: "wildcard.com.",
wantApps: []string{"self-routed"},
},
{
name: "self-routed-exact-domain-suppressed",
isConnector: true,
domain: "exact-match.com.",
wantApps: nil,
},
{
// Self node is an eligible connector for "wildcard-self-app" via
// *.wildcard.com, so the wildcard match must also be suppressed.
name: "self-routed-wildcard-domain-suppressed",
isConnector: true,
domain: "sub.wildcard.com.",
wantApps: nil,
},
{
// "other-app" is not on a self-connector tag, so it must not be suppressed.
name: "non-self-routed-domain-not-suppressed",
isConnector: true,
domain: "example.com.",
wantApps: []string{"one", "two"},
},
{
// Even though the app's connector tag matches the self node's tags,
// if the node is not an eligible connector (Advertise=false) then
// isSelfRoutedApp returns false and the domain is forwarded normally.
name: "not-eligible-connector-not-suppressed",
domain: "exact-match.com.",
wantApps: []string{"self-routed"},
},
} {
t.Run(tt.name, func(t *testing.T) {
c := newConn25(logger.Discard)
if tt.isConnector {
c.prefsAdvertiseConnector.Store(true)
}
cfg := mustConfig(t, defaultSN)
c.reconfig(cfg)
cfg, ok := c.getConfig()
if !ok {
t.Fatal("could not get config")
}
gotApps := cfg.getAppsForConnectorDomain(tt.domain, tt.isConnector)
if diff := cmp.Diff(tt.wantApps, gotApps); diff != "" {
t.Errorf("unexpected appNames result: diff (-want, +got):\n%s", diff)
}
})
}
@ -753,6 +903,7 @@ func makeDNSResponseForSections(t *testing.T, questions []dnsmessage.Question, a
func TestMapDNSResponseAssignsAddrs(t *testing.T) {
for _, tt := range []struct {
name string
appDomains []string
domain string
v4Addrs []*dnsmessage.AResource
v6Addrs []*dnsmessage.AAAAResource
@ -761,9 +912,10 @@ func TestMapDNSResponseAssignsAddrs(t *testing.T) {
wantByMagicIP map[netip.Addr]addrs
}{
{
name: "one-ip-matches",
domain: "example.com.",
v4Addrs: []*dnsmessage.AResource{{A: [4]byte{1, 0, 0, 0}}},
name: "one-ip-matches",
appDomains: []string{"example.com"},
domain: "example.com.",
v4Addrs: []*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"): {
@ -776,8 +928,9 @@ func TestMapDNSResponseAssignsAddrs(t *testing.T) {
},
},
{
name: "v6-ip-matches",
domain: "example.com.",
name: "v6-ip-matches",
appDomains: []string{"example.com"},
domain: "example.com.",
v6Addrs: []*dnsmessage.AAAAResource{
{AAAA: [16]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}},
{AAAA: [16]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}},
@ -800,8 +953,9 @@ func TestMapDNSResponseAssignsAddrs(t *testing.T) {
},
},
{
name: "multiple-ip-matches",
domain: "example.com.",
name: "multiple-ip-matches",
appDomains: []string{"example.com"},
domain: "example.com.",
v4Addrs: []*dnsmessage.AResource{
{A: [4]byte{1, 0, 0, 0}},
{A: [4]byte{2, 0, 0, 0}},
@ -824,8 +978,9 @@ func TestMapDNSResponseAssignsAddrs(t *testing.T) {
},
},
{
name: "no-domain-match",
domain: "x.example.com.",
name: "no-domain-match",
appDomains: []string{"foo.example.com"},
domain: "bad.example.com.",
v4Addrs: []*dnsmessage.AResource{
{A: [4]byte{1, 0, 0, 0}},
{A: [4]byte{2, 0, 0, 0}},
@ -833,16 +988,18 @@ func TestMapDNSResponseAssignsAddrs(t *testing.T) {
},
{
name: "no-rewrite-self-routed-domain",
appDomains: []string{"example.com"},
domain: "example.com.",
v4Addrs: []*dnsmessage.AResource{{A: [4]byte{1, 0, 0, 0}}},
selfTags: []string{"tag:woo"},
isEligibleConnector: true,
},
{
name: "rewrite-tagged-but-not-eligible-connector",
domain: "example.com.",
v4Addrs: []*dnsmessage.AResource{{A: [4]byte{1, 0, 0, 0}}},
selfTags: []string{"tag:woo"},
name: "rewrite-tagged-but-not-eligible-connector",
appDomains: []string{"example.com"},
domain: "example.com.",
v4Addrs: []*dnsmessage.AResource{{A: [4]byte{1, 0, 0, 0}}},
selfTags: []string{"tag:woo"},
// isEligibleConnector is false: tag matches but prefs not set,
// so DNS response should be rewritten normally.
wantByMagicIP: map[netip.Addr]addrs{
@ -857,6 +1014,7 @@ func TestMapDNSResponseAssignsAddrs(t *testing.T) {
},
{
name: "rewrite-eligible-connector-no-matching-tag",
appDomains: []string{"example.com"},
domain: "example.com.",
v4Addrs: []*dnsmessage.AResource{{A: [4]byte{1, 0, 0, 0}}},
selfTags: []string{"tag:unrelated"},
@ -873,6 +1031,54 @@ func TestMapDNSResponseAssignsAddrs(t *testing.T) {
},
},
},
{
name: "subdomain-matches-wildcard",
appDomains: []string{"*.example.com"},
domain: "sub.example.com.",
v4Addrs: []*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"): {
domain: "sub.example.com.",
dst: netip.MustParseAddr("1.0.0.0"),
magic: netip.MustParseAddr("100.64.0.0"),
transit: netip.MustParseAddr("100.64.0.40"),
app: "app1",
},
},
},
{
name: "exact-subdomain-matches",
appDomains: []string{"example.com", "sub.example.com"},
domain: "sub.example.com.",
v4Addrs: []*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"): {
domain: "sub.example.com.",
dst: netip.MustParseAddr("1.0.0.0"),
magic: netip.MustParseAddr("100.64.0.0"),
transit: netip.MustParseAddr("100.64.0.40"),
app: "app1",
},
},
},
{
name: "wildcard-subdomain-matches-subdomain",
appDomains: []string{"example.com", "*.sub.example.com"},
domain: "a.sub.example.com.",
v4Addrs: []*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"): {
domain: "a.sub.example.com.",
dst: netip.MustParseAddr("1.0.0.0"),
magic: netip.MustParseAddr("100.64.0.0"),
transit: netip.MustParseAddr("100.64.0.40"),
app: "app1",
},
},
},
} {
t.Run(tt.name, func(t *testing.T) {
var dnsResp []byte
@ -884,7 +1090,7 @@ func TestMapDNSResponseAssignsAddrs(t *testing.T) {
sn := makeSelfNode(t, []appctype.Conn25Attr{{
Name: "app1",
Connectors: []string{"tag:woo"},
Domains: []string{"example.com"},
Domains: tt.appDomains,
V4MagicIPPool: []netipx.IPRange{v4RangeFrom("0", "10"), v4RangeFrom("20", "30")},
V6MagicIPPool: []netipx.IPRange{v6RangeFrom("0", "10"), v6RangeFrom("20", "30")},
V4TransitIPPool: []netipx.IPRange{v4RangeFrom("40", "50")},
@ -910,6 +1116,29 @@ func TestMapDNSResponseAssignsAddrs(t *testing.T) {
}
}
func TestNormalizedDNSNames(t *testing.T) {
tests := []struct {
name string
domain string
want dnsname.FQDN
}{
{name: "no-change", domain: "example.com.", want: "example.com."},
{name: "mixed-case", domain: "eXAmPle.COM", want: "example.com."},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := normalizeDNSName(tt.domain)
if err != nil {
t.Errorf("unexpected error %v", err)
}
if got != tt.want {
t.Errorf("Unexpected result, want %q, got %q", tt.want, got)
}
})
}
}
func TestReserveAddressesDeduplicated(t *testing.T) {
for _, tt := range []struct {
name string
@ -925,6 +1154,7 @@ func TestReserveAddressesDeduplicated(t *testing.T) {
},
} {
t.Run(tt.name, func(t *testing.T) {
const appName = "a"
conn25 := newConn25(t.Logf)
c := conn25.client
c.v4MagicIPPool = newIPPool(mustIPSetFromPrefix("100.64.0.0/24"))
@ -932,12 +1162,12 @@ func TestReserveAddressesDeduplicated(t *testing.T) {
c.v4TransitIPPool = newIPPool(mustIPSetFromPrefix("169.254.0.0/24"))
c.v6TransitIPPool = newIPPool(mustIPSetFromPrefix("fd7a:115c:a1e0:a99c:0200::/80"))
first, err := c.reserveAddresses("a", "example.com.", tt.dst)
first, err := c.reserveAddresses(appName, "example.com.", tt.dst)
if err != nil {
t.Fatal(err)
}
second, err := c.reserveAddresses("a", "example.com.", tt.dst)
second, err := c.reserveAddresses(appName, "example.com.", tt.dst)
if err != nil {
t.Fatal(err)
}