diff --git a/appc/conn25.go b/appc/conn25.go index fd1748fa6..add2c4714 100644 --- a/appc/conn25.go +++ b/appc/conn25.go @@ -7,6 +7,7 @@ "cmp" "slices" + "tailscale.com/ipn" "tailscale.com/ipn/ipnext" "tailscale.com/tailcfg" "tailscale.com/types/appctype" @@ -55,7 +56,7 @@ func PickConnector(nb ipnext.NodeBackend, app appctype.Conn25Attr) []tailcfg.Nod // PickSplitDNSPeers looks at the netmap peers capabilities and finds which peers // want to be connectors for which domains. -func PickSplitDNSPeers(hasCap func(c tailcfg.NodeCapability) bool, self tailcfg.NodeView, peers map[tailcfg.NodeID]tailcfg.NodeView) map[string][]tailcfg.NodeView { +func PickSplitDNSPeers(hasCap func(c tailcfg.NodeCapability) bool, self tailcfg.NodeView, peers map[tailcfg.NodeID]tailcfg.NodeView, prefs ipn.PrefsView) map[string][]tailcfg.NodeView { var m map[string][]tailcfg.NodeView if !hasCap(AppConnectorsExperimentalAttrName) { return m @@ -64,12 +65,34 @@ func PickSplitDNSPeers(hasCap func(c tailcfg.NodeCapability) bool, self tailcfg. if err != nil { return m } + + selfIsEligibleConnector := prefs.AppConnector().Advertise + var selfRoutedDomains set.Set[string] + + if selfIsEligibleConnector { + for _, app := range apps { + for _, tag := range app.Connectors { + if self.Tags().ContainsFunc(func(t string) bool { + return t == tag + }) { + if len(app.Domains) > 0 { + if selfRoutedDomains == nil { + selfRoutedDomains.Make() + } + selfRoutedDomains.AddSlice(app.Domains) + } + } + } + } + } + tagToDomain := make(map[string][]string) for _, app := range apps { for _, tag := range app.Connectors { tagToDomain[tag] = append(tagToDomain[tag], app.Domains...) } } + // NodeIDs are Comparable, and we have a map of NodeID to NodeView anyway, so // use a Set of NodeIDs to deduplicate, and populate into a []NodeView later. var work map[string]set.Set[tailcfg.NodeID] @@ -80,6 +103,9 @@ func PickSplitDNSPeers(hasCap func(c tailcfg.NodeCapability) bool, self tailcfg. for _, t := range peer.Tags().All() { domains := tagToDomain[t] for _, domain := range domains { + if selfIsEligibleConnector && selfRoutedDomains.Contains(domain) { + continue + } if work[domain] == nil { mak.Set(&work, domain, set.Set[tailcfg.NodeID]{}) } diff --git a/appc/conn25_test.go b/appc/conn25_test.go index fc14caf36..04b4ae83c 100644 --- a/appc/conn25_test.go +++ b/appc/conn25_test.go @@ -9,6 +9,7 @@ "testing" "github.com/google/go-cmp/cmp" + "tailscale.com/ipn" "tailscale.com/ipn/ipnext" "tailscale.com/tailcfg" "tailscale.com/types/appctype" @@ -47,10 +48,12 @@ func TestPickSplitDNSPeers(t *testing.T) { nvp4 := makeNodeView(4, "p4", []string{"tag:two", "tag:three2", "tag:four2"}) for _, tt := range []struct { - name string - want map[string][]tailcfg.NodeView - peers []tailcfg.NodeView - config []tailcfg.RawMessage + name string + peers []tailcfg.NodeView + config []tailcfg.RawMessage + selfIsConnector bool + selfTags []string + want map[string][]tailcfg.NodeView }{ { name: "empty", @@ -111,6 +114,59 @@ func TestPickSplitDNSPeers(t *testing.T) { "c.example.com": {nvp2, nvp4}, }, }, + { + name: "self-connector-exclude-self-domains", + config: []tailcfg.RawMessage{ + tailcfg.RawMessage(appOneBytes), + tailcfg.RawMessage(appTwoBytes), + tailcfg.RawMessage(appThreeBytes), + tailcfg.RawMessage(appFourBytes), + }, + peers: []tailcfg.NodeView{ + nvp1, + nvp2, + nvp3, + nvp4, + }, + selfIsConnector: true, + selfTags: []string{"tag:three1"}, + want: map[string][]tailcfg.NodeView{ + // woo.b.example.com and hoo.b.example.com are covered + // by tag:three1, and so is this self-node. + // So those domains should not be routed to peers. + // woo.b.example.com is also covered by another tag, + // but still not included since this connector can route to it. + "example.com": {nvp1}, + "a.example.com": {nvp3, nvp4}, + "c.example.com": {nvp2, nvp4}, + }, + }, + { + name: "self-not-connector-but-tagged-include-self-domains", + config: []tailcfg.RawMessage{ + tailcfg.RawMessage(appOneBytes), + tailcfg.RawMessage(appTwoBytes), + tailcfg.RawMessage(appThreeBytes), + tailcfg.RawMessage(appFourBytes), + }, + peers: []tailcfg.NodeView{ + nvp1, + nvp2, + nvp3, + nvp4, + }, + selfTags: []string{"tag:three1"}, + want: map[string][]tailcfg.NodeView{ + // Even though this self node has a tag for an app + // it doesn't have Hostinfo.AppConnector == true, so + // should still route through other connectors. + "example.com": {nvp1}, + "a.example.com": {nvp3, nvp4}, + "woo.b.example.com": {nvp2, nvp3, nvp4}, + "hoo.b.example.com": {nvp3, nvp4}, + "c.example.com": {nvp2, nvp4}, + }, + }, } { t.Run(tt.name, func(t *testing.T) { selfNode := &tailcfg.Node{} @@ -119,6 +175,11 @@ func TestPickSplitDNSPeers(t *testing.T) { tailcfg.NodeCapability(AppConnectorsExperimentalAttrName): tt.config, } } + prefs := ipn.Prefs{} + if tt.selfIsConnector { + prefs.AppConnector.Advertise = true + } + selfNode.Tags = append(selfNode.Tags, tt.selfTags...) selfView := selfNode.View() peers := map[tailcfg.NodeID]tailcfg.NodeView{} for _, p := range tt.peers { @@ -126,7 +187,7 @@ func TestPickSplitDNSPeers(t *testing.T) { } got := PickSplitDNSPeers(func(_ tailcfg.NodeCapability) bool { return true - }, selfView, peers) + }, selfView, peers, prefs.View()) if !reflect.DeepEqual(got, tt.want) { t.Fatalf("got %v, want %v", got, tt.want) } diff --git a/feature/conn25/conn25.go b/feature/conn25/conn25.go index eeb02c5f8..bfd50bff0 100644 --- a/feature/conn25/conn25.go +++ b/feature/conn25/conn25.go @@ -521,7 +521,7 @@ func configFromNodeView(n tailcfg.NodeView) (config, error) { return config{}, err } mak.Set(&cfg.appNamesByDomain, fqdn, append(cfg.appNamesByDomain[fqdn], app.Name)) - if selfMatchesTags { + if n.Hostinfo().AppConnector().EqualBool(true) && selfMatchesTags { cfg.selfRoutedDomains.Add(fqdn) } } @@ -850,6 +850,10 @@ func (c *client) mapDNSResponse(buf []byte) []byte { return buf } + if c.config.selfRoutedDomains.Contains(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 @@ -1023,6 +1027,7 @@ func (c *connector) lookupBySrcIPAndTransitIP(srcIP, transitIP netip.Addr) (appA return appAddr{}, false } v, ok := m[transitIP] + return v, ok } diff --git a/feature/conn25/conn25_test.go b/feature/conn25/conn25_test.go index 0a90c151a..5561ac8a8 100644 --- a/feature/conn25/conn25_test.go +++ b/feature/conn25/conn25_test.go @@ -433,9 +433,11 @@ func TestReconfig(t *testing.T) { }, } + var hostInfo tailcfg.Hostinfo c := newConn25(logger.Discard) sn := (&tailcfg.Node{ - CapMap: capMap, + CapMap: capMap, + Hostinfo: hostInfo.View(), }).View() err := c.reconfig(sn) @@ -454,6 +456,7 @@ func TestConfigReconfig(t *testing.T) { rawCfg string cfg []appctype.Conn25Attr tags []string + isEligibleConnector bool wantErr bool wantAppsByDomain map[dnsname.FQDN][]string wantSelfRoutedDomains set.Set[dnsname.FQDN] @@ -469,7 +472,8 @@ func TestConfigReconfig(t *testing.T) { {Name: "one", Domains: []string{"a.example.com"}, Connectors: []string{"tag:one"}}, {Name: "two", Domains: []string{"b.example.com"}, Connectors: []string{"tag:two"}}, }, - tags: []string{"tag:one"}, + tags: []string{"tag:one"}, + isEligibleConnector: true, wantAppsByDomain: map[dnsname.FQDN][]string{ "a.example.com.": {"one"}, "b.example.com.": {"two"}, @@ -477,7 +481,28 @@ func TestConfigReconfig(t *testing.T) { wantSelfRoutedDomains: set.SetOf([]dnsname.FQDN{"a.example.com."}), }, { - name: "more-complex", + name: "more-complex-with-connector-self-routed-domains", + cfg: []appctype.Conn25Attr{ + {Name: "one", Domains: []string{"1.a.example.com", "1.b.example.com"}, Connectors: []string{"tag:one", "tag:onea"}}, + {Name: "two", Domains: []string{"2.b.example.com", "2.c.example.com"}, Connectors: []string{"tag:two", "tag:twoa"}}, + {Name: "three", Domains: []string{"1.b.example.com", "1.c.example.com"}, Connectors: []string{}}, + {Name: "four", Domains: []string{"4.b.example.com", "4.d.example.com"}, Connectors: []string{"tag:four"}}, + }, + tags: []string{"tag:onea", "tag:four", "tag:unrelated"}, + isEligibleConnector: true, + wantAppsByDomain: map[dnsname.FQDN][]string{ + "1.a.example.com.": {"one"}, + "1.b.example.com.": {"one", "three"}, + "1.c.example.com.": {"three"}, + "2.b.example.com.": {"two"}, + "2.c.example.com.": {"two"}, + "4.b.example.com.": {"four"}, + "4.d.example.com.": {"four"}, + }, + wantSelfRoutedDomains: set.SetOf([]dnsname.FQDN{"1.a.example.com.", "1.b.example.com.", "4.b.example.com.", "4.d.example.com."}), + }, + { + name: "more-complex-non-connector-no-self-routed-domains", cfg: []appctype.Conn25Attr{ {Name: "one", Domains: []string{"1.a.example.com", "1.b.example.com"}, Connectors: []string{"tag:one", "tag:onea"}}, {Name: "two", Domains: []string{"2.b.example.com", "2.c.example.com"}, Connectors: []string{"tag:two", "tag:twoa"}}, @@ -494,7 +519,6 @@ func TestConfigReconfig(t *testing.T) { "4.b.example.com.": {"four"}, "4.d.example.com.": {"four"}, }, - wantSelfRoutedDomains: set.SetOf([]dnsname.FQDN{"1.a.example.com.", "1.b.example.com.", "4.b.example.com.", "4.d.example.com."}), }, } { t.Run(tt.name, func(t *testing.T) { @@ -512,9 +536,14 @@ func TestConfigReconfig(t *testing.T) { capMap := tailcfg.NodeCapMap{ tailcfg.NodeCapability(AppConnectorsExperimentalAttrName): cfg, } + var hostInfo tailcfg.Hostinfo + if tt.isEligibleConnector { + hostInfo.AppConnector.Set(true) + } sn := (&tailcfg.Node{ - CapMap: capMap, - Tags: tt.tags, + CapMap: capMap, + Tags: tt.tags, + Hostinfo: hostInfo.View(), }).View() c, err := configFromNodeView(sn) if (err != nil) != tt.wantErr { @@ -530,7 +559,7 @@ func TestConfigReconfig(t *testing.T) { } } -func makeSelfNode(t *testing.T, attrs []appctype.Conn25Attr, tags []string) tailcfg.NodeView { +func makeSelfNode(t *testing.T, attrs []appctype.Conn25Attr, tags []string, isConnector bool) tailcfg.NodeView { t.Helper() cfg := make([]tailcfg.RawMessage, 0, len(attrs)) for i, attr := range attrs { @@ -543,9 +572,14 @@ func makeSelfNode(t *testing.T, attrs []appctype.Conn25Attr, tags []string) tail capMap := tailcfg.NodeCapMap{ tailcfg.NodeCapability(AppConnectorsExperimentalAttrName): cfg, } + + var hostInfo tailcfg.Hostinfo + hostInfo.AppConnector.Set(isConnector) + return (&tailcfg.Node{ - CapMap: capMap, - Tags: tags, + CapMap: capMap, + Tags: tags, + Hostinfo: hostInfo.View(), }).View() } @@ -683,6 +717,8 @@ func TestMapDNSResponseAssignsAddrs(t *testing.T) { name string domain string addrs []*dnsmessage.AResource + selfTags []string + isConnector bool wantByMagicIP map[netip.Addr]addrs }{ { @@ -732,6 +768,13 @@ func TestMapDNSResponseAssignsAddrs(t *testing.T) { {A: [4]byte{2, 0, 0, 0}}, }, }, + { + name: "exclude-connector-self-routed-domain", + domain: "example.com.", + addrs: []*dnsmessage.AResource{{A: [4]byte{1, 0, 0, 0}}}, + selfTags: []string{"tag:woo"}, + isConnector: true, + }, } { t.Run(tt.name, func(t *testing.T) { dnsResp := makeDNSResponse(t, tt.domain, tt.addrs) @@ -741,7 +784,7 @@ func TestMapDNSResponseAssignsAddrs(t *testing.T) { Domains: []string{"example.com"}, MagicIPPool: []netipx.IPRange{rangeFrom("0", "10"), rangeFrom("20", "30")}, TransitIPPool: []netipx.IPRange{rangeFrom("40", "50")}, - }}, []string{}) + }}, tt.selfTags, tt.isConnector) c := newConn25(logger.Discard) c.reconfig(sn) @@ -886,7 +929,7 @@ func TestAddressAssignmentIsHandled(t *testing.T) { Name: "app1", Connectors: []string{"tag:woo"}, Domains: []string{"example.com"}, - }}, []string{}) + }}, []string{}, false) err := ext.conn25.reconfig(sn) if err != nil { t.Fatal(err) @@ -966,7 +1009,7 @@ func TestMapDNSResponseRewritesResponses(t *testing.T) { Domains: []string{configuredDomain}, MagicIPPool: []netipx.IPRange{rangeFrom("0", "10")}, TransitIPPool: []netipx.IPRange{rangeFrom("40", "50")}, - }}, []string{}) + }}, []string{}, false) compareToRecords := func(t *testing.T, resources []dnsmessage.Resource, want []netip.Addr) { t.Helper() @@ -1280,7 +1323,7 @@ func TestHandleAddressAssignmentStoresTransitIPs(t *testing.T) { Connectors: []string{"tag:hoo"}, Domains: []string{"hoo.example.com"}, }, - }, []string{}) + }, []string{}, false) err := ext.conn25.reconfig(sn) if err != nil { t.Fatal(err) @@ -1459,7 +1502,7 @@ func TestTransitIPConnMapping(t *testing.T) { func TestClientTransitIPForMagicIP(t *testing.T) { sn := makeSelfNode(t, []appctype.Conn25Attr{{ MagicIPPool: []netipx.IPRange{rangeFrom("0", "10")}, // 100.64.0.0 - 100.64.0.10 - }}, []string{}) + }}, []string{}, false) mappedMip := netip.MustParseAddr("100.64.0.0") mappedTip := netip.MustParseAddr("169.0.0.0") unmappedMip := netip.MustParseAddr("100.64.0.1") @@ -1512,7 +1555,7 @@ func TestClientTransitIPForMagicIP(t *testing.T) { func TestConnectorRealIPForTransitIPConnection(t *testing.T) { sn := makeSelfNode(t, []appctype.Conn25Attr{{ TransitIPPool: []netipx.IPRange{rangeFrom("40", "50")}, // 100.64.0.40 - 100.64.0.50 - }}, []string{}) + }}, []string{}, true) mappedSrc := netip.MustParseAddr("100.0.0.1") unmappedSrc := netip.MustParseAddr("100.0.0.2") mappedTip := netip.MustParseAddr("100.64.0.41") diff --git a/ipn/ipnlocal/node_backend.go b/ipn/ipnlocal/node_backend.go index 75550b3d5..33ec06903 100644 --- a/ipn/ipnlocal/node_backend.go +++ b/ipn/ipnlocal/node_backend.go @@ -864,7 +864,7 @@ func dnsConfigForNetmap(nm *netmap.NetworkMap, peers map[tailcfg.NodeID]tailcfg. addSplitDNSRoutes(nm.DNS.Routes) // Add split DNS routes for conn25 - conn25DNSTargets := appc.PickSplitDNSPeers(nm.HasCap, nm.SelfNode, peers) + conn25DNSTargets := appc.PickSplitDNSPeers(nm.HasCap, nm.SelfNode, peers, prefs) if conn25DNSTargets != nil { var m map[string][]*dnstype.Resolver for domain, candidateSplitDNSPeers := range conn25DNSTargets {