policy/v2: add via exit steering golden captures and tests

Add golden test data for via exit route steering and fix via exit grant compilation to match Tailscale SaaS behavior. Includes MapResponse golden tests for via grant route steering verification.

Updates #2180
This commit is contained in:
Kristoffer Dalby 2026-03-28 11:05:16 +00:00
parent bca6e6334d
commit 6a55f7d731
19 changed files with 241115 additions and 89 deletions

View file

@ -363,9 +363,11 @@ func (pol *Policy) compileViaGrant(
viaDstPrefixes = append(viaDstPrefixes, dstPrefix)
}
case *AutoGroup:
if d.Is(AutoGroupInternet) && len(nodeExitRoutes) > 0 {
viaDstPrefixes = append(viaDstPrefixes, nodeExitRoutes...)
}
// autogroup:internet via grants do NOT produce PacketFilter rules
// on the exit node. Tailscale SaaS handles exit traffic forwarding
// through the client's exit node selection mechanism (AllowedIPs +
// ExitNodeOption), not through PacketFilter rules. Verified by
// golden captures GRANT-V14 through GRANT-V36.
}
}

View file

@ -3710,7 +3710,11 @@ func TestCompileViaGrant(t *testing.T) {
},
},
{
name: "autogroup:internet with exit routes produces rules",
// autogroup:internet via grants do NOT produce PacketFilter rules
// on exit nodes. Tailscale SaaS handles exit traffic forwarding
// through the client's exit node mechanism, not PacketFilter.
// Verified by golden captures GRANT-V14 through GRANT-V36.
name: "autogroup:internet with exit routes produces no rules",
grant: Grant{
Sources: Aliases{up("testuser@")},
Destinations: Aliases{agp(string(AutoGroupInternet))},
@ -3720,15 +3724,7 @@ func TestCompileViaGrant(t *testing.T) {
node: exitNode,
nodes: types.Nodes{exitNode, srcNode},
pol: &Policy{},
want: []tailcfg.FilterRule{
{
SrcIPs: []string{"100.64.0.10"},
DstPorts: []tailcfg.NetPortRange{
{IP: "0.0.0.0/0", Ports: tailcfg.PortRangeAny},
{IP: "::/0", Ports: tailcfg.PortRangeAny},
},
},
},
want: nil,
},
{
name: "autogroup:internet without exit routes returns nil",

View file

@ -149,9 +149,118 @@ func setupGrantsCompatNodes(users types.Users) types.Nodes {
IPv4: ptrAddr("100.85.66.106"),
IPv6: ptrAddr("fd7a:115c:a1e0::7c37:426a"),
Tags: []string{"tag:exit"},
Hostinfo: &tailcfg.Hostinfo{
RoutableIPs: []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/0"),
netip.MustParsePrefix("::/0"),
},
},
ApprovedRoutes: []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/0"),
netip.MustParsePrefix("::/0"),
},
}
// --- New nodes for expanded via grant topology ---
nodeExitA := &types.Node{
ID: 9,
GivenName: "exit-a",
IPv4: ptrAddr("100.124.195.93"),
IPv6: ptrAddr("fd7a:115c:a1e0::7837:c35d"),
Tags: []string{"tag:exit-a"},
Hostinfo: &tailcfg.Hostinfo{
RoutableIPs: []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/0"),
netip.MustParsePrefix("::/0"),
},
},
ApprovedRoutes: []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/0"),
netip.MustParsePrefix("::/0"),
},
}
nodeExitB := &types.Node{
ID: 10,
GivenName: "exit-b",
IPv4: ptrAddr("100.116.18.24"),
IPv6: ptrAddr("fd7a:115c:a1e0::ff37:1218"),
Tags: []string{"tag:exit-b"},
Hostinfo: &tailcfg.Hostinfo{
RoutableIPs: []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/0"),
netip.MustParsePrefix("::/0"),
},
},
ApprovedRoutes: []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/0"),
netip.MustParsePrefix("::/0"),
},
}
nodeGroupA := &types.Node{
ID: 11,
GivenName: "group-a-client",
IPv4: ptrAddr("100.107.162.14"),
IPv6: ptrAddr("fd7a:115c:a1e0::a237:a20e"),
Tags: []string{"tag:group-a"},
Hostinfo: &tailcfg.Hostinfo{},
}
nodeGroupB := &types.Node{
ID: 12,
GivenName: "group-b-client",
IPv4: ptrAddr("100.77.135.18"),
IPv6: ptrAddr("fd7a:115c:a1e0::4b37:8712"),
Tags: []string{"tag:group-b"},
Hostinfo: &tailcfg.Hostinfo{},
}
nodeRouterA := &types.Node{
ID: 13,
GivenName: "router-a",
IPv4: ptrAddr("100.109.43.124"),
IPv6: ptrAddr("fd7a:115c:a1e0::a537:2b7c"),
Tags: []string{"tag:router-a"},
Hostinfo: &tailcfg.Hostinfo{
RoutableIPs: []netip.Prefix{netip.MustParsePrefix("10.44.0.0/16")},
},
ApprovedRoutes: []netip.Prefix{netip.MustParsePrefix("10.44.0.0/16")},
}
nodeRouterB := &types.Node{
ID: 14,
GivenName: "router-b",
IPv4: ptrAddr("100.65.172.123"),
IPv6: ptrAddr("fd7a:115c:a1e0::5a37:ac7c"),
Tags: []string{"tag:router-b"},
Hostinfo: &tailcfg.Hostinfo{
RoutableIPs: []netip.Prefix{netip.MustParsePrefix("10.55.0.0/16")},
},
ApprovedRoutes: []netip.Prefix{netip.MustParsePrefix("10.55.0.0/16")},
}
nodeMultiExitRouter := &types.Node{
ID: 15,
GivenName: "multi-exit-router",
IPv4: ptrAddr("100.105.127.107"),
IPv6: ptrAddr("fd7a:115c:a1e0::9537:7f6b"),
Tags: []string{"tag:exit", "tag:router"},
Hostinfo: &tailcfg.Hostinfo{
RoutableIPs: []netip.Prefix{
netip.MustParsePrefix("10.33.0.0/16"),
netip.MustParsePrefix("0.0.0.0/0"),
netip.MustParsePrefix("::/0"),
},
},
ApprovedRoutes: []netip.Prefix{
netip.MustParsePrefix("10.33.0.0/16"),
netip.MustParsePrefix("0.0.0.0/0"),
netip.MustParsePrefix("::/0"),
},
}
return types.Nodes{
nodeUser1,
nodeUserKris,
@ -161,6 +270,13 @@ func setupGrantsCompatNodes(users types.Users) types.Nodes {
nodeTaggedClient,
nodeSubnetRouter,
nodeExitNode,
nodeExitA,
nodeExitB,
nodeGroupA,
nodeGroupB,
nodeRouterA,
nodeRouterB,
nodeMultiExitRouter,
}
}
@ -264,7 +380,7 @@ func TestGrantsCompat(t *testing.T) {
t.Logf("Loaded %d grant test files", len(files))
users := setupGrantsCompatUsers()
nodes := setupGrantsCompatNodes(users)
allNodes := setupGrantsCompatNodes(users)
for _, file := range files {
tf := loadGrantTestFile(t, file)
@ -278,6 +394,16 @@ func TestGrantsCompat(t *testing.T) {
return
}
// Determine which node set to use based on the test's topology.
// Tests captured with the expanded 15-node topology (V26+) have
// nodes like exit-a, group-a-client, etc. Tests from the original
// 8-node topology should only use the first 8 nodes to avoid
// resolving extra IPs from nodes that weren't present during capture.
nodes := allNodes
if _, hasNewNodes := tf.Captures["exit-a"]; !hasNewNodes {
nodes = allNodes[:8]
}
// Convert Tailscale user emails to headscale @example.com format
policyJSON := convertPolicyUserEmails(tf.Input.FullPolicy)

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff