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

View File

@@ -772,9 +772,12 @@ func TestGrantViaSubnetFilterRules(t *testing.T) {
"without per-node filter compilation for via grants, these rules are missing")
}
// TestGrantViaExitNodeFilterRules verifies that exit nodes with via grants
// receive PacketFilter rules for exit traffic (0.0.0.0/0, ::/0).
func TestGrantViaExitNodeFilterRules(t *testing.T) {
// TestGrantViaExitNodeNoFilterRules verifies that exit nodes with via grants
// for autogroup:internet do NOT receive PacketFilter rules for exit traffic.
// Tailscale SaaS handles exit traffic forwarding through the client's exit
// node selection mechanism, not through PacketFilter rules. Verified by
// golden captures GRANT-V14 through GRANT-V36.
func TestGrantViaExitNodeNoFilterRules(t *testing.T) {
t.Parallel()
srv := servertest.NewServer(t)
@@ -842,51 +845,25 @@ func TestGrantViaExitNodeFilterRules(t *testing.T) {
require.NoError(t, err)
srv.App.Change(routeChange)
// Wait for clientA to see the exit routes in AllowedIPs.
clientA.WaitForCondition(t, "clientA sees exit routes via exit-a",
// Wait for routes to propagate.
exitA.WaitForCondition(t, "exit-a routes approved",
15*time.Second,
func(nm *netmap.NetworkMap) bool {
for _, p := range nm.Peers {
hi := p.Hostinfo()
if hi.Valid() && hi.Hostname() == "exit-a" {
for i := range p.AllowedIPs().Len() {
if p.AllowedIPs().At(i) == exitRouteV4 {
return true
}
}
}
}
return false
return nm != nil
})
// Critical: exit node's PacketFilter must contain rules for
// exit traffic (0.0.0.0/0 or ::/0) from the via grant.
// The exit node's PacketFilter must NOT contain rules for exit traffic.
// The only rules should be from the peer connectivity grant (tag:exit-a
// and tag:group-a can talk to each other at their Tailscale IPs).
exitNM := exitA.Netmap()
require.NotNil(t, exitNM)
require.NotNil(t, exitNM.PacketFilter,
"exit node PacketFilter should not be nil")
var foundExitDst bool
for _, m := range exitNM.PacketFilter {
for _, dst := range m.Dsts {
dstPrefix := netip.PrefixFrom(dst.Net.Addr(), dst.Net.Bits())
if dstPrefix == exitRouteV4 || dstPrefix == exitRouteV6 {
foundExitDst = true
}
}
}
assert.True(t, foundExitDst,
"exit node PacketFilter should contain destination rules for exit routes (0.0.0.0/0 or ::/0); "+
"via grant filter rules for exit traffic are missing")
// Log the actual PacketFilter for debugging.
if !foundExitDst {
for i, m := range exitNM.PacketFilter {
t.Logf("PacketFilter[%d]: Srcs=%v, Dsts=%v, Caps=%d",
i, m.Srcs, m.Dsts, len(m.Caps))
assert.Falsef(t, dstPrefix == exitRouteV4 || dstPrefix == exitRouteV6,
"exit node PacketFilter should NOT contain exit route destinations (0.0.0.0/0 or ::/0); "+
"autogroup:internet via grants do not produce filter rules on exit nodes (verified against Tailscale SaaS)")
}
}
}

View File

@@ -0,0 +1,380 @@
package servertest_test
import (
"context"
"encoding/json"
"net/netip"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
"github.com/juanfont/headscale/hscontrol/servertest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tailscale.com/tailcfg"
"tailscale.com/types/netmap"
)
// goldenFile represents a golden capture from Tailscale SaaS with full
// netmap data per node.
type goldenFile struct {
TestID string `json:"test_id"`
Error bool `json:"error"`
Input struct {
FullPolicy json.RawMessage `json:"full_policy"`
} `json:"input"`
Topology struct {
Nodes map[string]goldenNode `json:"nodes"`
} `json:"topology"`
Captures map[string]goldenCapture `json:"captures"`
}
type goldenNode struct {
Hostname string `json:"hostname"`
Tags []string `json:"tags"`
IPv4 string `json:"ipv4"`
IPv6 string `json:"ipv6"`
AdvertisedRoutes []string `json:"advertised_routes"`
IsExitNode bool `json:"is_exit_node"`
}
type goldenCapture struct {
PacketFilterRules json.RawMessage `json:"packet_filter_rules"`
Netmap *goldenNetmap `json:"netmap"`
Whois map[string]goldenWhois `json:"whois"`
}
type goldenNetmap struct {
SelfNode json.RawMessage `json:"SelfNode"`
Peers []goldenPeer `json:"Peers"`
PacketFilter json.RawMessage `json:"PacketFilter"`
PacketFilterRules json.RawMessage `json:"PacketFilterRules"`
DNS json.RawMessage `json:"DNS"`
SSHPolicy json.RawMessage `json:"SSHPolicy"`
Domain string `json:"Domain"`
UserProfiles json.RawMessage `json:"UserProfiles"`
}
type goldenPeer struct {
Name string `json:"Name"`
Addresses []string `json:"Addresses"`
AllowedIPs []string `json:"AllowedIPs"`
PrimaryRoutes []string `json:"PrimaryRoutes"`
Tags []string `json:"Tags"`
ExitNodeOption *bool `json:"ExitNodeOption"`
Online *bool `json:"Online"`
Cap int `json:"Cap"`
}
type goldenWhois struct {
PeerName string `json:"peer_name"`
Response *json.RawMessage `json:"response"`
}
// viaCompatTests lists golden captures that exercise via grant steering.
var viaCompatTests = []struct {
id string
desc string
}{
{"GRANT-V29", "crossed subnet steering: group-a via router-a, group-b via router-b"},
{"GRANT-V30", "crossed mixed: subnet via router-a/b, exit via exit-b/a"},
{"GRANT-V31", "peer connectivity + via exit A/B steering"},
{"GRANT-V36", "full complex: peer connectivity + crossed subnet + crossed exit"},
}
// TestViaGrantMapCompat loads golden captures from Tailscale SaaS and
// compares headscale's full MapResponse against the captured netmap.
//
// For each viewing node, it compares:
// - Peer set (which peers are visible)
// - Per-peer AllowedIPs (via steering changes which routes appear on which peer)
// - Per-peer PrimaryRoutes (which node is primary for a subnet)
// - PacketFilter rule count
func TestViaGrantMapCompat(t *testing.T) {
t.Parallel()
for _, tc := range viaCompatTests {
t.Run(tc.id, func(t *testing.T) {
t.Parallel()
path := filepath.Join(
"..", "policy", "v2", "testdata", "grant_results", tc.id+".json",
)
data, err := os.ReadFile(path)
require.NoError(t, err, "failed to read golden file %s", path)
var gf goldenFile
require.NoError(t, json.Unmarshal(data, &gf))
if gf.Error {
t.Skipf("test %s is an error case", tc.id)
return
}
runViaMapCompat(t, gf)
})
}
}
// taggedNodes are the nodes we create in the servertest. User-owned nodes
// are excluded because the servertest uses a single user for all tagged
// nodes, which doesn't map to the multi-user Tailscale topology.
var taggedNodes = []string{
"exit-a", "exit-b", "exit-node",
"group-a-client", "group-b-client",
"router-a", "router-b",
"subnet-router", "tagged-client",
"tagged-server", "tagged-prod",
"multi-exit-router",
}
func runViaMapCompat(t *testing.T, gf goldenFile) {
t.Helper()
srv := servertest.NewServer(t)
tagUser := srv.CreateUser(t, "tag-user")
policyJSON := convertViaPolicy(gf.Input.FullPolicy)
changed, err := srv.State().SetPolicy(policyJSON)
require.NoError(t, err, "failed to set policy")
if changed {
changes, err := srv.State().ReloadPolicy()
require.NoError(t, err)
srv.App.Change(changes...)
}
// Create tagged clients matching the golden topology.
clients := map[string]*servertest.TestClient{}
for _, name := range taggedNodes {
topoNode, exists := gf.Topology.Nodes[name]
if !exists || len(topoNode.Tags) == 0 {
continue
}
if _, inCaptures := gf.Captures[name]; !inCaptures {
continue
}
clients[name] = servertest.NewClient(t, srv, name,
servertest.WithUser(tagUser),
servertest.WithTags(topoNode.Tags...),
)
}
require.NotEmpty(t, clients, "no relevant nodes created")
// Compute expected peer counts from golden netmap.
expectedPeerCounts := map[string]int{}
for viewerName := range clients {
capture := gf.Captures[viewerName]
if capture.Netmap != nil {
// Count peers from golden netmap that are in our client set.
count := 0
for _, peer := range capture.Netmap.Peers {
peerName := extractHostname(peer.Name)
if _, isOurs := clients[peerName]; isOurs {
count++
}
}
expectedPeerCounts[viewerName] = count
}
}
// Wait for expected peers.
for name, c := range clients {
expected := expectedPeerCounts[name]
if expected > 0 {
c.WaitForPeers(t, expected, 30*time.Second)
}
}
// Advertise and approve routes.
for name, c := range clients {
topoNode := gf.Topology.Nodes[name]
if len(topoNode.AdvertisedRoutes) == 0 {
continue
}
var routes []netip.Prefix
for _, r := range topoNode.AdvertisedRoutes {
routes = append(routes, netip.MustParsePrefix(r))
}
c.Direct().SetHostinfo(&tailcfg.Hostinfo{
BackendLogID: "servertest-" + name,
Hostname: name,
RoutableIPs: routes,
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
_ = c.Direct().SendUpdate(ctx)
cancel()
nodeID := findNodeID(t, srv, name)
_, routeChange, err := srv.State().SetApprovedRoutes(nodeID, routes)
require.NoError(t, err)
srv.App.Change(routeChange)
}
// Wait for route propagation.
for _, c := range clients {
c.WaitForCondition(t, "routes settled", 15*time.Second,
func(nm *netmap.NetworkMap) bool {
return nm != nil
})
}
// Compare each viewer's MapResponse against the golden netmap.
for viewerName, c := range clients {
capture := gf.Captures[viewerName]
if capture.Netmap == nil {
continue
}
t.Run(viewerName, func(t *testing.T) {
nm := c.Netmap()
require.NotNil(t, nm, "netmap is nil")
compareNetmap(t, viewerName, nm, capture.Netmap, clients)
})
}
}
func compareNetmap(
t *testing.T,
_ string, // viewerName unused but kept for signature clarity
got *netmap.NetworkMap,
want *goldenNetmap,
clients map[string]*servertest.TestClient,
) {
t.Helper()
// Build golden peer map (only peers in our client set).
wantPeers := map[string]goldenPeer{}
for _, p := range want.Peers {
name := extractHostname(p.Name)
if _, isOurs := clients[name]; isOurs {
wantPeers[name] = p
}
}
// Build headscale peer map.
gotPeers := map[string]peerSummary{}
for _, peer := range got.Peers {
name := ""
if peer.Hostinfo().Valid() {
name = peer.Hostinfo().Hostname()
}
if name == "" {
for n := range clients {
if strings.Contains(peer.Name(), n+".") {
name = n
break
}
}
}
if name == "" {
continue
}
var aips []string
for i := range peer.AllowedIPs().Len() {
aips = append(aips, peer.AllowedIPs().At(i).String())
}
slices.Sort(aips)
var proutes []string
for i := range peer.PrimaryRoutes().Len() {
proutes = append(proutes, peer.PrimaryRoutes().At(i).String())
}
slices.Sort(proutes)
gotPeers[name] = peerSummary{
AllowedIPs: aips,
PrimaryRoutes: proutes,
}
}
// Compare peer visibility.
for name, wantPeer := range wantPeers {
gotPeer, visible := gotPeers[name]
if !visible {
t.Errorf("peer %s: visible in Tailscale SaaS (AllowedIPs=%v), missing in headscale",
name, wantPeer.AllowedIPs)
continue
}
// Compare AllowedIPs.
wantAIPs := make([]string, len(wantPeer.AllowedIPs))
copy(wantAIPs, wantPeer.AllowedIPs)
slices.Sort(wantAIPs)
assert.Equalf(t, wantAIPs, gotPeer.AllowedIPs,
"peer %s: AllowedIPs mismatch", name)
// Compare PrimaryRoutes.
assert.ElementsMatchf(t, wantPeer.PrimaryRoutes, gotPeer.PrimaryRoutes,
"peer %s: PrimaryRoutes mismatch", name)
}
// Check for extra peers headscale shows that Tailscale SaaS doesn't.
for name := range gotPeers {
if _, expected := wantPeers[name]; !expected {
t.Errorf("peer %s: visible in headscale but NOT in Tailscale SaaS", name)
}
}
// Compare PacketFilter rule count.
var wantFilterRules []tailcfg.FilterRule
if len(want.PacketFilterRules) > 0 &&
string(want.PacketFilterRules) != "null" {
_ = json.Unmarshal(want.PacketFilterRules, &wantFilterRules)
}
assert.Lenf(t, got.PacketFilter, len(wantFilterRules),
"PacketFilter rule count mismatch")
}
type peerSummary struct {
AllowedIPs []string
PrimaryRoutes []string
}
// extractHostname extracts the hostname from a Tailscale FQDN like
// "router-a.tail78f774.ts.net.".
func extractHostname(fqdn string) string {
if before, _, ok := strings.Cut(fqdn, "."); ok {
return before
}
return fqdn
}
// convertViaPolicy converts Tailscale SaaS policy emails to headscale format.
func convertViaPolicy(raw json.RawMessage) []byte {
s := string(raw)
s = strings.ReplaceAll(s, "kratail2tid@passkey", "tag-user@")
s = strings.ReplaceAll(s, "kristoffer@dalby.cc", "tag-user@")
s = strings.ReplaceAll(s, "monitorpasskeykradalby@passkey", "tag-user@")
return []byte(s)
}