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 <fran@tailscale.com>
This commit is contained in:
Fran Bull
2026-05-29 08:29:34 -07:00
committed by franbull
parent 412c812d76
commit 3d5102090f
3 changed files with 143 additions and 83 deletions
+31 -27
View File
@@ -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
}
+108 -55
View File
@@ -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."))
+4 -1
View File
@@ -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:<tag-name>.
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"`