diff --git a/appc/conn25.go b/appc/conn25.go index 9b44eb88c..62cb70017 100644 --- a/appc/conn25.go +++ b/appc/conn25.go @@ -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 } diff --git a/appc/conn25_test.go b/appc/conn25_test.go index ae5d59d63..dd98312ca 100644 --- a/appc/conn25_test.go +++ b/appc/conn25_test.go @@ -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{} diff --git a/feature/conn25/conn25.go b/feature/conn25/conn25.go index 38bfca1b2..9bdda1ceb 100644 --- a/feature/conn25/conn25.go +++ b/feature/conn25/conn25.go @@ -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 } diff --git a/feature/conn25/conn25_test.go b/feature/conn25/conn25_test.go index 1c56e9b83..2ed419025 100644 --- a/feature/conn25/conn25_test.go +++ b/feature/conn25/conn25_test.go @@ -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) }