From 3d5102090fb6f3e68fbb5e22f757d8b1c4d26f89 Mon Sep 17 00:00:00 2001 From: Fran Bull Date: Tue, 26 May 2026 09:26:59 -0700 Subject: [PATCH] feature/conn25: use new pool nodeattr We have been reading the pool config from the app nodeattr, but it is global config, not per app, so it needs to be its own thing. Updates tailscale/corp#39999 Signed-off-by: Fran Bull --- feature/conn25/conn25.go | 58 ++++++------ feature/conn25/conn25_test.go | 163 ++++++++++++++++++++++----------- types/appctype/appconnector.go | 5 +- 3 files changed, 143 insertions(+), 83 deletions(-) diff --git a/feature/conn25/conn25.go b/feature/conn25/conn25.go index 4fc311b12..6955b95d9 100644 --- a/feature/conn25/conn25.go +++ b/feature/conn25/conn25.go @@ -547,6 +547,7 @@ type ConnectorTransitIPResponse struct { } const AppConnectorsExperimentalAttrName = "tailscale.com/app-connectors-experimental" +const AppConnectorsExperimentalIPPoolsAttrName = "tailscale.com/app-connectors-experimental-ippools" // ipSets wraps all the IPSets the config needs. type ipSets struct { @@ -586,6 +587,14 @@ func configFromNodeView(n tailcfg.NodeView) (*config, error) { if len(apps) == 0 { return &config{}, nil } + poolsSlice, err := tailcfg.UnmarshalNodeCapViewJSON[appctype.Conn25PoolsAttr](n.CapMap(), AppConnectorsExperimentalIPPoolsAttrName) + if err != nil { + return &config{}, err + } + if len(poolsSlice) != 1 { + return &config{}, errors.New("must be one conn25 pools nodeattr") + } + pools := poolsSlice[0] selfTags := set.SetOf(n.Tags().AsSlice()) cfg := &config{ isConfigured: true, @@ -620,34 +629,29 @@ func configFromNodeView(n tailcfg.NodeView) (*config, error) { } - // TODO(fran) 2026-03-18 we don't yet have a proper way to communicate the - // global IP pool config. For now just take it from the first app. - if len(apps) != 0 { - app := apps[0] - v4Mipp, err := ipSetFromIPRanges(app.V4MagicIPPool) - if err != nil { - return &config{}, err - } - v4Tipp, err := ipSetFromIPRanges(app.V4TransitIPPool) - if err != nil { - return &config{}, err - } - v6Mipp, err := ipSetFromIPRanges(app.V6MagicIPPool) - if err != nil { - return &config{}, err - } - v6Tipp, err := ipSetFromIPRanges(app.V6TransitIPPool) - if err != nil { - return &config{}, err - } - ipSets := ipSets{ - v4Magic: v4Mipp, - v4Transit: v4Tipp, - v6Magic: v6Mipp, - v6Transit: v6Tipp, - } - cfg.ipSets = ipSets + v4Mipp, err := ipSetFromIPRanges(pools.V4MagicIPPool) + if err != nil { + return &config{}, err } + v4Tipp, err := ipSetFromIPRanges(pools.V4TransitIPPool) + if err != nil { + return &config{}, err + } + v6Mipp, err := ipSetFromIPRanges(pools.V6MagicIPPool) + if err != nil { + return &config{}, err + } + v6Tipp, err := ipSetFromIPRanges(pools.V6TransitIPPool) + if err != nil { + return &config{}, err + } + ipSets := ipSets{ + v4Magic: v4Mipp, + v4Transit: v4Tipp, + v6Magic: v6Mipp, + v6Transit: v6Tipp, + } + cfg.ipSets = ipSets return cfg, nil } diff --git a/feature/conn25/conn25_test.go b/feature/conn25/conn25_test.go index 4f169a12e..3386bdd0e 100644 --- a/feature/conn25/conn25_test.go +++ b/feature/conn25/conn25_test.go @@ -458,6 +458,9 @@ func TestReconfig(t *testing.T) { tailcfg.NodeCapability(AppConnectorsExperimentalAttrName): []tailcfg.RawMessage{ tailcfg.RawMessage(rawCfg), }, + tailcfg.NodeCapability(AppConnectorsExperimentalIPPoolsAttrName): []tailcfg.RawMessage{ + tailcfg.RawMessage("{}"), + }, } c := newConn25(logger.Discard) @@ -485,11 +488,26 @@ func TestReconfig(t *testing.T) { } } +func getRawMessages[T any](t *testing.T, attrs []T) []tailcfg.RawMessage { + t.Helper() + toRet := make([]tailcfg.RawMessage, 0, len(attrs)) + for _, attr := range attrs { + bs, err := json.Marshal(attr) + if err != nil { + t.Fatalf("unexpected error in test setup: %v", err) + } + toRet = append(toRet, tailcfg.RawMessage(bs)) + } + return toRet +} + func TestConfigFromNodeView(t *testing.T) { for _, tt := range []struct { name string - rawCfg string - cfg []appctype.Conn25Attr + rawAppCfg string + appCfg []appctype.Conn25Attr + rawPoolsCfg string + poolsCfg []appctype.Conn25PoolsAttr tags []string wantErr bool wantAppsByDomain map[dnsname.FQDN][]string @@ -497,17 +515,27 @@ func TestConfigFromNodeView(t *testing.T) { wantSelfAppNames set.Set[string] }{ { - name: "bad-config", - rawCfg: `bad`, - wantErr: true, + name: "bad-app-config", + rawAppCfg: `bad`, + rawPoolsCfg: "{}", + wantErr: true, + }, + { + name: "bad-pool-config", + appCfg: []appctype.Conn25Attr{ + {Name: "one", Domains: []string{"a.example.com"}, Connectors: []string{"tag:one"}}, + }, + rawPoolsCfg: "bad", + wantErr: true, }, { name: "simple", - cfg: []appctype.Conn25Attr{ + appCfg: []appctype.Conn25Attr{ {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"}, + rawPoolsCfg: "{}", + tags: []string{"tag:one"}, wantAppsByDomain: map[dnsname.FQDN][]string{ "a.example.com.": {"one"}, "b.example.com.": {"two"}, @@ -517,13 +545,14 @@ func TestConfigFromNodeView(t *testing.T) { }, { name: "more-complex-with-connector-self-domains", - cfg: []appctype.Conn25Attr{ + appCfg: []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"}, + rawPoolsCfg: "{}", + tags: []string{"tag:onea", "tag:four", "tag:unrelated"}, wantAppsByDomain: map[dnsname.FQDN][]string{ "1.a.example.com.": {"one"}, "1.b.example.com.": {"one", "three"}, @@ -538,11 +567,12 @@ func TestConfigFromNodeView(t *testing.T) { }, { name: "eligible-connector-no-matching-tag-no-self-domains", - cfg: []appctype.Conn25Attr{ + appCfg: []appctype.Conn25Attr{ {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:unrelated"}, + rawPoolsCfg: "{}", + tags: []string{"tag:unrelated"}, wantAppsByDomain: map[dnsname.FQDN][]string{ "a.example.com.": {"one"}, "b.example.com.": {"two"}, @@ -550,11 +580,12 @@ func TestConfigFromNodeView(t *testing.T) { wantAppsByWCDomain: map[dnsname.FQDN][]string{}}, { name: "wildcard-collapse-and-deduplication", - cfg: []appctype.Conn25Attr{ + appCfg: []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"}, + rawPoolsCfg: "{}", + tags: []string{"tag:one", "tag:two"}, wantAppsByDomain: map[dnsname.FQDN][]string{ "example.com.": {"one", "two"}, "sub.example.com.": {"two"}, @@ -569,10 +600,11 @@ func TestConfigFromNodeView(t *testing.T) { // 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{ + appCfg: []appctype.Conn25Attr{ {Name: "one", Domains: []string{"EXAMPLE.com", "example.COM", "Example.COM"}, Connectors: []string{"tag:one"}}, }, - tags: []string{"tag:one"}, + rawPoolsCfg: "{}", + tags: []string{"tag:one"}, wantAppsByDomain: map[dnsname.FQDN][]string{ "example.com.": {"one"}, }, @@ -583,29 +615,39 @@ func TestConfigFromNodeView(t *testing.T) { // 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{ + appCfg: []appctype.Conn25Attr{ {Name: "one", Domains: []string{"*.EXAMPLE.com", "*.example.COM"}, Connectors: []string{"tag:one"}}, }, + rawPoolsCfg: "{}", tags: []string{"tag:one"}, wantAppsByDomain: map[dnsname.FQDN][]string{}, wantAppsByWCDomain: map[dnsname.FQDN][]string{"example.com.": {"one"}}, wantSelfAppNames: set.SetOf([]string{"one"}), }, + { + name: "too-many-pools", + appCfg: []appctype.Conn25Attr{ + {Name: "one", Domains: []string{"a.example.com"}, Connectors: []string{"tag:one"}}, + }, + poolsCfg: []appctype.Conn25PoolsAttr{ + {}, + {}, + }, + wantErr: true, + }, } { t.Run(tt.name, func(t *testing.T) { - cfg := []tailcfg.RawMessage{tailcfg.RawMessage(tt.rawCfg)} - if tt.cfg != nil { - cfg = []tailcfg.RawMessage{} - for _, attr := range tt.cfg { - bs, err := json.Marshal(attr) - if err != nil { - t.Fatalf("unexpected error in test setup: %v", err) - } - cfg = append(cfg, tailcfg.RawMessage(bs)) - } + appCfg := []tailcfg.RawMessage{tailcfg.RawMessage(tt.rawAppCfg)} + if tt.appCfg != nil { + appCfg = getRawMessages(t, tt.appCfg) + } + poolsCfg := []tailcfg.RawMessage{tailcfg.RawMessage(tt.rawPoolsCfg)} + if tt.poolsCfg != nil { + poolsCfg = getRawMessages(t, tt.poolsCfg) } capMap := tailcfg.NodeCapMap{ - tailcfg.NodeCapability(AppConnectorsExperimentalAttrName): cfg, + tailcfg.NodeCapability(AppConnectorsExperimentalAttrName): appCfg, + tailcfg.NodeCapability(AppConnectorsExperimentalIPPoolsAttrName): poolsCfg, } sn := (&tailcfg.Node{ CapMap: capMap, @@ -639,6 +681,7 @@ func TestGetAppsForDomainName(t *testing.T) { {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"}}, }, + appctype.Conn25PoolsAttr{}, []string{"tag:self-routed"}, ) @@ -732,7 +775,7 @@ func TestGetAppsForDomainName(t *testing.T) { } } -func makeSelfNode(t *testing.T, attrs []appctype.Conn25Attr, tags []string) tailcfg.NodeView { +func makeSelfNode(t *testing.T, attrs []appctype.Conn25Attr, pools appctype.Conn25PoolsAttr, tags []string) tailcfg.NodeView { t.Helper() cfg := make([]tailcfg.RawMessage, 0, len(attrs)) for i, attr := range attrs { @@ -742,8 +785,13 @@ func makeSelfNode(t *testing.T, attrs []appctype.Conn25Attr, tags []string) tail } cfg = append(cfg, tailcfg.RawMessage(bs)) } + poolsBytes, err := json.Marshal(pools) + if err != nil { + t.Fatalf("unexpected error marshaling pools in test setup: %v", err) + } capMap := tailcfg.NodeCapMap{ - tailcfg.NodeCapability(AppConnectorsExperimentalAttrName): cfg, + tailcfg.NodeCapability(AppConnectorsExperimentalAttrName): cfg, + tailcfg.NodeCapability(AppConnectorsExperimentalIPPoolsAttrName): {tailcfg.RawMessage(poolsBytes)}, } return (&tailcfg.Node{ @@ -1095,14 +1143,15 @@ func TestMapDNSResponseAssignsAddrs(t *testing.T) { dnsResp = makeV6DNSResponse(t, tt.domain, tt.v6Addrs) } sn := makeSelfNode(t, []appctype.Conn25Attr{{ - Name: "app1", - Connectors: []string{"tag:woo"}, - Domains: tt.appDomains, + Name: "app1", + Connectors: []string{"tag:woo"}, + Domains: tt.appDomains, + }}, appctype.Conn25PoolsAttr{ V4MagicIPPool: []netipx.IPRange{v4RangeFrom("0", "10"), v4RangeFrom("20", "30")}, V6MagicIPPool: []netipx.IPRange{v6RangeFrom("0", "10"), v6RangeFrom("20", "30")}, V4TransitIPPool: []netipx.IPRange{v4RangeFrom("40", "50")}, V6TransitIPPool: []netipx.IPRange{v6RangeFrom("40", "50")}, - }}, tt.selfTags) + }, tt.selfTags) c := newConn25(logger.Discard) cfg := mustConfig(t, sn) @@ -1128,14 +1177,15 @@ func TestMapDNSResponseSetsExpiryBasedOnTTL(t *testing.T) { domainName := configuredDomain + "." dnsMessageName := dnsmessage.MustNewName(domainName) sn := makeSelfNode(t, []appctype.Conn25Attr{{ - Name: "app1", - Connectors: []string{"tag:woo"}, - Domains: []string{configuredDomain}, + Name: "app1", + Connectors: []string{"tag:woo"}, + Domains: []string{configuredDomain}, + }}, appctype.Conn25PoolsAttr{ V4MagicIPPool: []netipx.IPRange{v4RangeFrom("0", "10")}, V4TransitIPPool: []netipx.IPRange{v4RangeFrom("40", "50")}, V6MagicIPPool: []netipx.IPRange{netipx.IPRangeFrom(netip.MustParseAddr("2606:4700::6812:100"), netip.MustParseAddr("2606:4700::6812:1ff"))}, V6TransitIPPool: []netipx.IPRange{netipx.IPRangeFrom(netip.MustParseAddr("2606:4700::6813:100"), netip.MustParseAddr("2606:4700::6813:1ff"))}, - }}, nil) + }, nil) c := newConn25(logger.Discard) clock := tstest.NewClock(tstest.ClockOpts{Start: time.Now()}) @@ -1410,7 +1460,7 @@ func TestAddressAssignmentIsHandled(t *testing.T) { Name: "app1", Connectors: []string{"tag:woo"}, Domains: []string{"example.com"}, - }}, []string{}) + }}, appctype.Conn25PoolsAttr{}, []string{}) cfg := mustConfig(t, sn) ext.conn25.reconfig(cfg) @@ -1484,14 +1534,15 @@ func TestMapDNSResponseRewritesResponses(t *testing.T) { domainName := configuredDomain + "." dnsMessageName := dnsmessage.MustNewName(domainName) sn := makeSelfNode(t, []appctype.Conn25Attr{{ - Name: "app1", - Connectors: []string{"tag:connector"}, - Domains: []string{configuredDomain}, + Name: "app1", + Connectors: []string{"tag:connector"}, + Domains: []string{configuredDomain}, + }}, appctype.Conn25PoolsAttr{ V4MagicIPPool: []netipx.IPRange{v4RangeFrom("0", "10")}, V4TransitIPPool: []netipx.IPRange{v4RangeFrom("40", "50")}, V6MagicIPPool: []netipx.IPRange{netipx.IPRangeFrom(netip.MustParseAddr("2606:4700::6812:100"), netip.MustParseAddr("2606:4700::6812:1ff"))}, V6TransitIPPool: []netipx.IPRange{netipx.IPRangeFrom(netip.MustParseAddr("2606:4700::6813:100"), netip.MustParseAddr("2606:4700::6813:1ff"))}, - }}, []string{}) + }, []string{}) cfg := mustConfig(t, sn) @@ -2011,7 +2062,7 @@ func TestHandleAddressAssignmentStoresTransitIPs(t *testing.T) { Connectors: []string{"tag:hoo"}, Domains: []string{"hoo.example.com"}, }, - }, []string{}) + }, appctype.Conn25PoolsAttr{}, []string{}) cfg := mustConfig(t, sn) ext.conn25.reconfig(cfg) @@ -2187,10 +2238,10 @@ func TestTransitIPConnMapping(t *testing.T) { } func TestClientTransitIPForMagicIP(t *testing.T) { - sn := makeSelfNode(t, []appctype.Conn25Attr{{ + sn := makeSelfNode(t, []appctype.Conn25Attr{{Name: "app1"}}, appctype.Conn25PoolsAttr{ V4MagicIPPool: []netipx.IPRange{v4RangeFrom("0", "10")}, // 100.64.0.0 - 100.64.0.10 V6MagicIPPool: []netipx.IPRange{v6RangeFrom("0", "10")}, - }}, []string{}) + }, []string{}) cfg := mustConfig(t, sn) mappedMip := netip.MustParseAddr("100.64.0.0") @@ -2278,9 +2329,9 @@ func TestClientTransitIPForMagicIP(t *testing.T) { } func TestConnectorRealIPForTransitIPConnection(t *testing.T) { - sn := makeSelfNode(t, []appctype.Conn25Attr{{ + sn := makeSelfNode(t, []appctype.Conn25Attr{{Name: "app1"}}, appctype.Conn25PoolsAttr{ V4TransitIPPool: []netipx.IPRange{v4RangeFrom("40", "50")}, // 100.64.0.40 - 100.64.0.50 - }}, []string{}) + }, []string{}) cfg := mustConfig(t, sn) mappedSrc := netip.MustParseAddr("100.0.0.1") @@ -2421,12 +2472,13 @@ func TestConnectorPacketFilterAllow(t *testing.T) { func TestGetMagicRange(t *testing.T) { sn := makeSelfNode(t, []appctype.Conn25Attr{{ - Name: "app1", - Connectors: []string{"tag:woo"}, - Domains: []string{"example.com"}, + Name: "app1", + Connectors: []string{"tag:woo"}, + Domains: []string{"example.com"}, + }}, appctype.Conn25PoolsAttr{ V4MagicIPPool: []netipx.IPRange{netipx.IPRangeFrom(netip.MustParseAddr("0.0.0.1"), netip.MustParseAddr("0.0.0.3"))}, V6MagicIPPool: []netipx.IPRange{netipx.IPRangeFrom(netip.MustParseAddr("::1"), netip.MustParseAddr("::3"))}, - }}, []string{}) + }, []string{}) cfg := mustConfig(t, sn) c := newConn25(t.Logf) c.reconfig(cfg) @@ -2479,14 +2531,15 @@ func TestReconfigDoesNotReissueInUseAddresses(t *testing.T) { afterRangeV6 := mustRange("::4", "::7") makeNodeFromMagicRange := func(v4, v6 netipx.IPRange) tailcfg.NodeView { return makeSelfNode(t, []appctype.Conn25Attr{{ - Name: appName, - Connectors: []string{"tag:woo"}, - Domains: []string{"example.com"}, + Name: appName, + Connectors: []string{"tag:woo"}, + Domains: []string{"example.com"}, + }}, appctype.Conn25PoolsAttr{ V4MagicIPPool: []netipx.IPRange{v4}, V6MagicIPPool: []netipx.IPRange{v6}, V4TransitIPPool: []netipx.IPRange{mustRange("169.254.0.0", "169.254.0.10")}, V6TransitIPPool: []netipx.IPRange{mustRange("fd7a:115c:a1e0:a99c:0200::", "fd7a:115c:a1e0:a99c:0200::10")}, - }}, []string{}) + }, []string{}) } domain := must.Get(dnsname.ToFQDN("example.com.")) diff --git a/types/appctype/appconnector.go b/types/appctype/appconnector.go index b0fd5e65a..e92f62939 100644 --- a/types/appctype/appconnector.go +++ b/types/appctype/appconnector.go @@ -104,7 +104,10 @@ type Conn25Attr struct { // Connectors enumerates the app connectors which service these domains. // These can either be "*" to match any advertising connector, or a // tag of the form tag:. - Connectors []string `json:"connectors,omitempty"` + Connectors []string `json:"connectors,omitempty"` +} + +type Conn25PoolsAttr struct { V4MagicIPPool []netipx.IPRange `json:"v4MagicIPPool,omitempty"` V4TransitIPPool []netipx.IPRange `json:"v4TransitIPPool,omitempty"` V6MagicIPPool []netipx.IPRange `json:"v6MagicIPPool,omitempty"`