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"`