Files
headscale/hscontrol/policy/v2/tailscale_routes_data_compat_test.go
T
Kristoffer Dalby 6e5508444f policy/v2: add false-positive peer test for all 98 golden files
TestRoutesCompatNoPeersBeyondCaptures extends the negative peer
assertion from 6 hardcoded files (f10-f15) to all 98 ROUTES golden
files using a generic, data-driven approach.

For each golden file, the test derives expected peer pairs from
capture SrcIPs (subnet routes, direct node IPs) and DstPorts
(destination node IPs, route CIDRs), handling IP dash-range format
(100.64.0.0-100.115.91.255) and wildcards. It then asserts that no
unexpected CanAccess=true pairs exist.

Also adds addSrcIPToBuilder helper and deriveAllPeerPairsFromCaptures
for comprehensive peer derivation from golden data.

Updates #3157
2026-04-04 12:51:32 +00:00

1602 lines
41 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// This file implements data-driven test runners for routes compatibility tests.
// It loads HuJSON golden files from testdata/routes_results/ROUTES-*.hujson and
// compares headscale's route-aware ACL engine output against the expected
// packet filter rules.
//
// Each HuJSON file contains:
// - A full policy (groups, tagOwners, hosts, acls)
// - A topology section with nodes, including routable_ips and approved_routes
// - Expected packet_filter_rules per node
//
// Two test runners use this data:
//
// - TestRoutesCompat: validates filter rule compilation (compileFilterRulesForNode
// + ReduceFilterRules) against golden file captures.
//
// - TestRoutesCompatPeerVisibility: validates peer visibility (CanAccess /
// ReduceNodes) for the subnet-to-subnet scenarios (f10f15). These tests
// derive expected peer relationships from the golden file captures: if
// Tailscale SaaS delivers filter rules to a node, then the subnet routers
// referenced in those rules must be visible as peers. This exercises the
// CanAccess fix from issue #3157.
//
// Test data source: testdata/routes_results/ROUTES-*.hujson
// Original source: Tailscale SaaS captures + headscale-generated expansions
package v2
import (
"encoding/json"
"net/netip"
"os"
"path/filepath"
"slices"
"sort"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/juanfont/headscale/hscontrol/policy/policyutil"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tailscale/hujson"
"go4.org/netipx"
"gorm.io/gorm"
"tailscale.com/net/tsaddr"
"tailscale.com/tailcfg"
)
// routesTestFile represents the JSON structure of a captured routes test file.
type routesTestFile struct {
TestID string `json:"test_id"`
Source string `json:"source"`
ParentTest string `json:"parent_test"`
Input struct {
FullPolicy json.RawMessage `json:"full_policy"`
} `json:"input"`
Topology routesTopology `json:"topology"`
Captures map[string]struct {
PacketFilterRules json.RawMessage `json:"packet_filter_rules"`
} `json:"captures"`
}
// routesTopology describes the node topology for a routes test.
type routesTopology struct {
Users []struct {
ID uint `json:"id"`
Name string `json:"name"`
} `json:"users"`
Nodes map[string]routesNodeDef `json:"nodes"`
}
// routesNodeDef describes a single node in the routes test topology.
type routesNodeDef struct {
ID int `json:"id"`
Hostname string `json:"hostname"`
IPv4 string `json:"ipv4"`
IPv6 string `json:"ipv6"`
Tags []string `json:"tags"`
User string `json:"user,omitempty"`
RoutableIPs []string `json:"routable_ips"`
ApprovedRoutes []string `json:"approved_routes"`
}
// loadRoutesTestFile loads and parses a single routes test JSON file.
func loadRoutesTestFile(t *testing.T, path string) routesTestFile {
t.Helper()
content, err := os.ReadFile(path)
require.NoError(t, err, "failed to read test file %s", path)
ast, err := hujson.Parse(content)
require.NoError(t, err, "failed to parse HuJSON in %s", path)
ast.Standardize()
var tf routesTestFile
err = json.Unmarshal(ast.Pack(), &tf)
require.NoError(t, err, "failed to unmarshal test file %s", path)
return tf
}
// buildRoutesUsersAndNodes constructs types.Users and types.Nodes from the
// JSON topology definition. This allows each test file to define its own
// topology (e.g., the IPv6 tests use different nodes than the standard tests).
func buildRoutesUsersAndNodes(
t *testing.T,
topo routesTopology,
) (types.Users, types.Nodes) {
t.Helper()
// Build users — if topology has users section, use it.
// Otherwise fall back to the standard 3-user setup matching
// the grant topology (used by Tailscale SaaS captures).
var users types.Users
if len(topo.Users) > 0 {
users = make(types.Users, 0, len(topo.Users))
for _, u := range topo.Users {
users = append(users, types.User{
Model: gorm.Model{ID: u.ID},
Name: u.Name,
})
}
} else {
users = types.Users{
{Model: gorm.Model{ID: 1}, Name: "kratail2tid", Email: "kratail2tid@example.com"},
{Model: gorm.Model{ID: 2}, Name: "kristoffer", Email: "kristoffer@example.com"},
{Model: gorm.Model{ID: 3}, Name: "monitorpasskeykradalby", Email: "monitorpasskeykradalby@example.com"},
}
}
// Build nodes.
// Auto-assign unique IDs when the JSON topology does not provide
// them (id defaults to 0). Unique IDs are required by ReduceNodes /
// BuildPeerMap which skip peers by comparing node.ID.
nodes := make(types.Nodes, 0, len(topo.Nodes))
autoID := 1
for _, nodeDef := range topo.Nodes {
nodeID := nodeDef.ID
if nodeID == 0 {
nodeID = autoID
autoID++
}
node := &types.Node{
ID: types.NodeID(nodeID), //nolint:gosec
GivenName: nodeDef.Hostname,
IPv4: ptrAddr(nodeDef.IPv4),
IPv6: ptrAddr(nodeDef.IPv6),
Tags: nodeDef.Tags,
}
// Set up Hostinfo with RoutableIPs
hostinfo := &tailcfg.Hostinfo{}
if len(nodeDef.RoutableIPs) > 0 {
routableIPs := make(
[]netip.Prefix,
0,
len(nodeDef.RoutableIPs),
)
for _, r := range nodeDef.RoutableIPs {
routableIPs = append(
routableIPs,
netip.MustParsePrefix(r),
)
}
hostinfo.RoutableIPs = routableIPs
}
node.Hostinfo = hostinfo
// Set ApprovedRoutes
if len(nodeDef.ApprovedRoutes) > 0 {
approvedRoutes := make(
[]netip.Prefix,
0,
len(nodeDef.ApprovedRoutes),
)
for _, r := range nodeDef.ApprovedRoutes {
approvedRoutes = append(
approvedRoutes,
netip.MustParsePrefix(r),
)
}
node.ApprovedRoutes = approvedRoutes
} else {
node.ApprovedRoutes = []netip.Prefix{}
}
// Assign user if specified
if nodeDef.User != "" {
for i := range users {
if users[i].Name == nodeDef.User {
node.User = &users[i]
node.UserID = &users[i].ID
break
}
}
}
nodes = append(nodes, node)
}
return users, nodes
}
// routesSkipReasons documents WHY tests are expected to fail.
var routesSkipReasons = map[string]string{}
// subnetToSubnetFiles lists the golden files that test subnet-to-subnet
// ACL scenarios. These are the scenarios where the fix for issue #3157
// (CanAccess considering subnet routes as source identity) is critical.
var subnetToSubnetFiles = []string{
"ROUTES-f10_subnet_to_subnet_issue3157",
"ROUTES-f11_subnet_to_subnet_bidirectional",
"ROUTES-f12_subnet_to_subnet_host_aliases",
"ROUTES-f13_subnet_to_subnet_disjoint",
"ROUTES-f14_subnet_to_subnet_overlapping_one_router",
"ROUTES-f15_subnet_to_subnet_cross_routers",
}
// TestRoutesCompat is a data-driven test that loads all ROUTES-*.json test
// files and compares headscale's route-aware ACL engine output against the
// expected behavior.
func TestRoutesCompat(t *testing.T) {
t.Parallel()
files, err := filepath.Glob(
filepath.Join("testdata", "routes_results", "ROUTES-*.hujson"),
)
require.NoError(t, err, "failed to glob test files")
require.NotEmpty(
t,
files,
"no ROUTES-*.hujson test files found in testdata/routes_results/",
)
t.Logf("Loaded %d routes test files", len(files))
for _, file := range files {
tf := loadRoutesTestFile(t, file)
t.Run(tf.TestID, func(t *testing.T) {
t.Parallel()
if reason, ok := routesSkipReasons[tf.TestID]; ok {
t.Skipf(
"TODO: %s — see routesSkipReasons for details",
reason,
)
return
}
// Build topology from JSON
users, nodes := buildRoutesUsersAndNodes(t, tf.Topology)
// Convert Tailscale SaaS user emails to headscale format
policyJSON := convertPolicyUserEmails(tf.Input.FullPolicy)
// Parse and validate policy
pol, err := unmarshalPolicy(policyJSON)
require.NoError(
t,
err,
"%s: policy should parse successfully",
tf.TestID,
)
err = pol.validate()
require.NoError(
t,
err,
"%s: policy should validate successfully",
tf.TestID,
)
for nodeName, capture := range tf.Captures {
t.Run(nodeName, func(t *testing.T) {
captureIsNull := len(capture.PacketFilterRules) == 0 ||
string(capture.PacketFilterRules) == "null" //nolint:goconst
node := findNodeByGivenName(nodes, nodeName)
if node == nil {
t.Skipf(
"node %s not found in topology",
nodeName,
)
return
}
compiledRules, err := pol.compileFilterRulesForNode(
users,
node.View(),
nodes.ViewSlice(),
)
require.NoError(
t,
err,
"%s/%s: failed to compile filter rules",
tf.TestID,
nodeName,
)
gotRules := policyutil.ReduceFilterRules(
node.View(),
compiledRules,
)
var wantRules []tailcfg.FilterRule
if !captureIsNull {
err = json.Unmarshal(
capture.PacketFilterRules,
&wantRules,
)
require.NoError(
t,
err,
"%s/%s: failed to unmarshal expected rules",
tf.TestID,
nodeName,
)
}
opts := append(
cmpOptions(),
cmpopts.EquateEmpty(),
)
if diff := cmp.Diff(
wantRules,
gotRules,
opts...,
); diff != "" {
t.Errorf(
"%s/%s: filter rules mismatch (-want +got):\n%s",
tf.TestID,
nodeName,
diff,
)
}
})
}
})
}
}
// derivePeerPairsFromCaptures builds the set of expected peer pairs from
// golden file captures. For each node that receives filter rules from
// Tailscale SaaS, the SrcIPs identify subnets whose traffic will arrive
// at this node. Any other node whose approved subnet routes overlap
// those SrcIPs must be peered with this node — otherwise the traffic
// cannot flow.
//
// Returns a set of unordered node-name pairs that must be peers.
func derivePeerPairsFromCaptures(
t *testing.T,
tf routesTestFile,
nodes types.Nodes,
) map[[2]string]bool {
t.Helper()
pairs := make(map[[2]string]bool)
for dstNodeName, capture := range tf.Captures {
captureIsNull := len(capture.PacketFilterRules) == 0 ||
string(capture.PacketFilterRules) == "null"
if captureIsNull {
continue
}
var rules []tailcfg.FilterRule
err := json.Unmarshal(capture.PacketFilterRules, &rules)
require.NoError(t, err,
"%s/%s: failed to unmarshal capture rules",
tf.TestID, dstNodeName,
)
// Build an IPSet of all SrcIPs from the capture's filter rules.
var srcBuilder netipx.IPSetBuilder
for _, rule := range rules {
for _, srcIP := range rule.SrcIPs {
addSrcIPToBuilder(t, &srcBuilder,
srcIP, tf.TestID, dstNodeName,
)
}
}
srcSet, err := srcBuilder.IPSet()
require.NoError(t, err)
// Find all nodes whose SubnetRoutes overlap srcSet.
for _, node := range nodes {
if node.GivenName == dstNodeName {
continue
}
if slices.ContainsFunc(node.SubnetRoutes(), srcSet.OverlapsPrefix) {
pair := orderedPair(dstNodeName, node.GivenName)
pairs[pair] = true
}
}
}
return pairs
}
// orderedPair returns a canonical [2]string with the names sorted
// so that (A,B) and (B,A) map to the same key.
func orderedPair(a, b string) [2]string {
if a > b {
return [2]string{b, a}
}
return [2]string{a, b}
}
// addSrcIPToBuilder parses a SrcIP string (CIDR, bare IP, wildcard "*",
// or IP range like "100.64.0.0-100.115.91.255") and adds it to the
// IPSetBuilder.
func addSrcIPToBuilder(
t *testing.T,
builder *netipx.IPSetBuilder,
srcIP, testID, nodeName string,
) {
t.Helper()
// Handle wildcard.
if srcIP == "*" {
builder.AddPrefix(netip.MustParsePrefix("0.0.0.0/0"))
builder.AddPrefix(netip.MustParsePrefix("::/0"))
return
}
// Try CIDR notation first.
prefix, prefixErr := netip.ParsePrefix(srcIP)
if prefixErr == nil {
builder.AddPrefix(prefix)
return
}
// Try IP range notation: "A.B.C.D-E.F.G.H"
if strings.Contains(srcIP, "-") {
parts := strings.SplitN(srcIP, "-", 2)
ip1, err1 := netip.ParseAddr(parts[0])
ip2, err2 := netip.ParseAddr(parts[1])
require.NoError(t, err1,
"%s/%s: cannot parse range start in %q",
testID, nodeName, srcIP,
)
require.NoError(t, err2,
"%s/%s: cannot parse range end in %q",
testID, nodeName, srcIP,
)
r := netipx.IPRangeFrom(ip1, ip2)
for _, pfx := range r.Prefixes() {
builder.AddPrefix(pfx)
}
return
}
// Try single IP address.
addr, err := netip.ParseAddr(srcIP)
require.NoError(t, err,
"%s/%s: cannot parse SrcIP %q",
testID, nodeName, srcIP,
)
builder.Add(addr)
}
// deriveAllPeerPairsFromCaptures extends derivePeerPairsFromCaptures
// to find ALL expected peer relationships from golden file captures,
// not just subnet-route-based ones. It checks:
// - Node's SubnetRoutes overlap the capture's SrcIPs (subnet-to-subnet)
// - Node's direct IPs (IPv4/IPv6) appear in the capture's SrcIPs
// (tag/user/group resolved sources)
func deriveAllPeerPairsFromCaptures(
t *testing.T,
tf routesTestFile,
nodes types.Nodes,
) map[[2]string]bool {
t.Helper()
pairs := make(map[[2]string]bool)
for dstNodeName, capture := range tf.Captures {
captureIsNull := len(capture.PacketFilterRules) == 0 ||
string(capture.PacketFilterRules) == "null"
if captureIsNull {
continue
}
var rules []tailcfg.FilterRule
err := json.Unmarshal(capture.PacketFilterRules, &rules)
require.NoError(t, err,
"%s/%s: failed to unmarshal capture rules",
tf.TestID, dstNodeName,
)
// Build an IPSet of all SrcIPs.
var srcBuilder netipx.IPSetBuilder
for _, rule := range rules {
for _, srcIP := range rule.SrcIPs {
addSrcIPToBuilder(t, &srcBuilder,
srcIP, tf.TestID, dstNodeName,
)
}
}
srcSet, err := srcBuilder.IPSet()
require.NoError(t, err)
for _, node := range nodes {
if node.GivenName == dstNodeName {
continue
}
// Check subnet routes overlap.
if slices.ContainsFunc(
node.SubnetRoutes(), srcSet.OverlapsPrefix,
) {
pairs[orderedPair(dstNodeName, node.GivenName)] = true
continue
}
// Check direct node IPs in SrcIPs.
if slices.ContainsFunc(
node.IPs(), srcSet.Contains,
) {
pairs[orderedPair(dstNodeName, node.GivenName)] = true
}
}
}
return pairs
}
// TestRoutesCompatPeerVisibility is a data-driven test that validates peer
// visibility (CanAccess) for subnet-to-subnet ACL scenarios using the same
// golden file data captured from Tailscale SaaS.
//
// Unlike TestRoutesCompat which tests filter rule compilation
// (compileFilterRulesForNode + ReduceFilterRules), this test exercises the
// CanAccess code path that determines whether two nodes should see each
// other as peers. This is the code path fixed in issue #3157: before the
// fix, CanAccess only checked node IPs against matcher sources, missing
// the case where a node's approved subnet routes overlap the source set.
//
// The test derives expected peer pairs from the golden file captures:
// if Tailscale SaaS delivers filter rules to node X with SrcIPs
// overlapping node Y's subnet routes, then Y must be able to CanAccess X
// (Y acts as source identity for its advertised subnets).
func TestRoutesCompatPeerVisibility(t *testing.T) {
t.Parallel()
for _, testID := range subnetToSubnetFiles {
file := filepath.Join(
"testdata", "routes_results", testID+".hujson",
)
tf := loadRoutesTestFile(t, file)
t.Run(tf.TestID, func(t *testing.T) {
t.Parallel()
// Build topology from JSON.
users, nodes := buildRoutesUsersAndNodes(t, tf.Topology)
// Convert Tailscale SaaS user emails to headscale format.
policyJSON := convertPolicyUserEmails(tf.Input.FullPolicy)
// Create a PolicyManager — this compiles the global filter
// rules and produces matchers used for peer visibility.
pm, err := NewPolicyManager(
policyJSON, users, nodes.ViewSlice(),
)
require.NoError(t, err,
"%s: failed to create policy manager", tf.TestID,
)
// Derive expected peer pairs from golden file captures.
wantPairs := derivePeerPairsFromCaptures(t, tf, nodes)
require.NotEmpty(t, wantPairs,
"%s: no peer pairs derived — golden file has no "+
"subnet-to-subnet relationships to test",
tf.TestID,
)
t.Run("CanAccess", func(t *testing.T) {
// For each expected pair, verify that at least one
// direction of CanAccess returns true.
for pair := range wantPairs {
nodeA := findNodeByGivenName(nodes, pair[0])
nodeB := findNodeByGivenName(nodes, pair[1])
require.NotNilf(t, nodeA,
"node %s not found", pair[0],
)
require.NotNilf(t, nodeB,
"node %s not found", pair[1],
)
// Get matchers — these are the unreduced global
// matchers used for peer relationship determination.
matchers, err := pm.MatchersForNode(nodeA.View())
require.NoError(t, err)
canAccess := nodeA.View().CanAccess(
matchers, nodeB.View(),
) || nodeB.View().CanAccess(
matchers, nodeA.View(),
)
assert.Truef(t, canAccess,
"%s: %s and %s should be peers "+
"(subnet routers must see each other "+
"when ACL references their subnets)",
tf.TestID, pair[0], pair[1],
)
}
})
t.Run("ReduceNodes", func(t *testing.T) {
// Build the complete peer map using CanAccess and
// verify it contains all expected pairs.
// This is equivalent to policy.ReduceNodes but
// inlined to avoid an import cycle with the policy
// package.
for _, node := range nodes {
matchers, err := pm.MatchersForNode(
node.View(),
)
require.NoError(t, err)
var peerNames []string
for _, peer := range nodes {
if peer.ID == node.ID {
continue
}
if node.View().CanAccess(
matchers, peer.View(),
) || peer.View().CanAccess(
matchers, node.View(),
) {
peerNames = append(
peerNames, peer.GivenName,
)
}
}
// Collect expected peers for this node.
var wantPeers []string
for pair := range wantPairs {
if pair[0] == node.GivenName {
wantPeers = append(
wantPeers, pair[1],
)
} else if pair[1] == node.GivenName {
wantPeers = append(
wantPeers, pair[0],
)
}
}
if len(wantPeers) == 0 {
continue
}
sort.Strings(peerNames)
sort.Strings(wantPeers)
for _, wantPeer := range wantPeers {
assert.Containsf(t, peerNames, wantPeer,
"%s: node %s should have peer %s "+
"in ReduceNodes result",
tf.TestID, node.GivenName, wantPeer,
)
}
}
})
t.Run("ReduceRoutes", func(t *testing.T) {
// For each node that has captures with filter rules,
// verify that CanAccessRoute returns true for the
// destination routes referenced in those rules, when
// called from a node whose subnet routes overlap the
// source CIDRs.
for dstNodeName, capture := range tf.Captures {
captureIsNull := len(
capture.PacketFilterRules,
) == 0 ||
string(
capture.PacketFilterRules,
) == "null"
if captureIsNull {
continue
}
var rules []tailcfg.FilterRule
err := json.Unmarshal(
capture.PacketFilterRules, &rules,
)
require.NoError(t, err)
// Extract destination prefixes from the rules.
var dstPrefixes []netip.Prefix
for _, rule := range rules {
for _, dp := range rule.DstPorts {
prefix, err := netip.ParsePrefix(
dp.IP,
)
if err != nil {
continue
}
dstPrefixes = append(
dstPrefixes, prefix,
)
}
}
// For each source node (whose subnets overlap
// the SrcIPs), verify it can access the dst
// routes.
for pair := range wantPairs {
var srcNodeName string
switch {
case pair[0] == dstNodeName:
srcNodeName = pair[1]
case pair[1] == dstNodeName:
srcNodeName = pair[0]
default:
continue
}
srcNode := findNodeByGivenName(
nodes, srcNodeName,
)
require.NotNil(t, srcNode)
matchers, err := pm.MatchersForNode(
srcNode.View(),
)
require.NoError(t, err)
for _, route := range dstPrefixes {
canAccess := srcNode.View().CanAccessRoute(
matchers, route,
)
assert.Truef(t, canAccess,
"%s: node %s (routing %v) "+
"should be able to access "+
"route %s on node %s",
tf.TestID, srcNodeName,
srcNode.SubnetRoutes(),
route, dstNodeName,
)
}
}
}
})
})
}
}
// TestRoutesCompatAutoApproval validates that headscale's auto-approval
// logic (NodeCanApproveRoute) produces the same approval decisions as
// captured in the golden files from Tailscale SaaS.
//
// For each node that has routable_ips, the test verifies:
// - Routes in approved_routes: NodeCanApproveRoute returns true
// - Routes in routable_ips but NOT in approved_routes: returns false
//
// This covers d1d11 scenarios (auto-approval edge cases) as well as
// every other golden file whose topology includes routable_ips — all 98
// files have autoApprovers and routable_ips defined.
func TestRoutesCompatAutoApproval(t *testing.T) {
t.Parallel()
files, err := filepath.Glob(
filepath.Join("testdata", "routes_results", "ROUTES-*.hujson"),
)
require.NoError(t, err, "failed to glob test files")
require.NotEmpty(
t,
files,
"no ROUTES-*.hujson test files found in testdata/routes_results/",
)
for _, file := range files {
tf := loadRoutesTestFile(t, file)
t.Run(tf.TestID, func(t *testing.T) {
t.Parallel()
// Build topology from JSON.
users, nodes := buildRoutesUsersAndNodes(t, tf.Topology)
// Convert Tailscale SaaS user emails to headscale format.
policyJSON := convertPolicyUserEmails(tf.Input.FullPolicy)
// Create a PolicyManager — this resolves autoApprovers
// and builds the auto-approve map.
pm, err := NewPolicyManager(
policyJSON, users, nodes.ViewSlice(),
)
require.NoError(t, err,
"%s: failed to create policy manager", tf.TestID,
)
// Track whether this test file had any testable nodes.
testedNodes := 0
for nodeName, nodeDef := range tf.Topology.Nodes {
if len(nodeDef.RoutableIPs) == 0 {
continue
}
node := findNodeByGivenName(nodes, nodeName)
if node == nil {
continue
}
testedNodes++
// Build the set of approved routes for quick lookup.
approvedSet := make(
map[netip.Prefix]bool,
len(nodeDef.ApprovedRoutes),
)
for _, r := range nodeDef.ApprovedRoutes {
approvedSet[netip.MustParsePrefix(r)] = true
}
t.Run(nodeName, func(t *testing.T) {
for _, routeStr := range nodeDef.RoutableIPs {
route := netip.MustParsePrefix(routeStr)
// Skip exit routes (0.0.0.0/0, ::/0).
// Tailscale SaaS stores exit routes
// under autoApprovers.routes alongside
// regular subnets, while headscale uses
// a separate autoApprovers.exitNode
// field. NodeCanApproveRoute checks the
// exitSet (from exitNode) first and
// never reaches the autoApproveMap
// (from routes), causing a known format
// mismatch. This is not a bug — just a
// structural difference in where exit
// routes are declared.
if tsaddr.IsExitRoute(route) {
continue
}
wantApproved := approvedSet[route]
gotApproved := pm.NodeCanApproveRoute(
node.View(), route,
)
if wantApproved {
assert.Truef(t, gotApproved,
"%s/%s: route %s is in "+
"approved_routes but "+
"NodeCanApproveRoute "+
"returned false",
tf.TestID, nodeName, route,
)
} else {
assert.Falsef(t, gotApproved,
"%s/%s: route %s is NOT in "+
"approved_routes but "+
"NodeCanApproveRoute "+
"returned true",
tf.TestID, nodeName, route,
)
}
}
})
}
if testedNodes == 0 {
t.Skipf(
"%s: no nodes with routable_ips found",
tf.TestID,
)
}
})
}
}
// TestRoutesCompatReduceRoutes validates that headscale's CanAccessRoute
// produces route visibility decisions consistent with the golden file
// captures from Tailscale SaaS.
//
// For each golden file, the test identifies nodes that received filter
// rules from Tailscale SaaS (non-null captures with DstPorts), then
// verifies that viewer nodes whose identity (IPs or subnet routes)
// overlaps the capture's SrcIPs can indeed access those destination
// route prefixes via CanAccessRoute.
//
// This extends the ReduceRoutes sub-test from TestRoutesCompatPeerVisibility
// (which only covers f10f15) to all 98 golden files.
func TestRoutesCompatReduceRoutes(t *testing.T) {
t.Parallel()
files, err := filepath.Glob(
filepath.Join("testdata", "routes_results", "ROUTES-*.hujson"),
)
require.NoError(t, err, "failed to glob test files")
require.NotEmpty(
t,
files,
"no ROUTES-*.hujson test files found in testdata/routes_results/",
)
for _, file := range files {
tf := loadRoutesTestFile(t, file)
t.Run(tf.TestID, func(t *testing.T) {
t.Parallel()
// Build topology from JSON.
users, nodes := buildRoutesUsersAndNodes(t, tf.Topology)
// Convert Tailscale SaaS user emails to headscale format.
policyJSON := convertPolicyUserEmails(tf.Input.FullPolicy)
// Create a PolicyManager.
pm, err := NewPolicyManager(
policyJSON, users, nodes.ViewSlice(),
)
require.NoError(t, err,
"%s: failed to create policy manager", tf.TestID,
)
// For each node that receives filter rules (non-null
// capture), extract the DstPort prefixes and SrcIPs.
// Then verify that viewer nodes with matching source
// identity can access those routes via CanAccessRoute.
for dstNodeName, capture := range tf.Captures {
captureIsNull := len(
capture.PacketFilterRules,
) == 0 ||
string(
capture.PacketFilterRules,
) == "null"
if captureIsNull {
continue
}
var rules []tailcfg.FilterRule
err := json.Unmarshal(
capture.PacketFilterRules, &rules,
)
require.NoError(t, err,
"%s/%s: failed to unmarshal capture rules",
tf.TestID, dstNodeName,
)
// Build the set of destination route prefixes.
var dstPrefixes []netip.Prefix
for _, rule := range rules {
for _, dp := range rule.DstPorts {
prefix, parseErr := netip.ParsePrefix(
dp.IP,
)
if parseErr != nil {
continue
}
if !slices.Contains(
dstPrefixes, prefix,
) {
dstPrefixes = append(
dstPrefixes, prefix,
)
}
}
}
if len(dstPrefixes) == 0 {
continue
}
// Build SrcIPs set from all rules in this capture.
var srcBuilder netipx.IPSetBuilder
for _, rule := range rules {
for _, srcIP := range rule.SrcIPs {
prefix, parseErr := netip.ParsePrefix(
srcIP,
)
if parseErr != nil {
addr, parseErr2 := netip.ParseAddr(
srcIP,
)
if parseErr2 != nil {
continue
}
srcBuilder.Add(addr)
continue
}
srcBuilder.AddPrefix(prefix)
}
}
srcSet, err := srcBuilder.IPSet()
require.NoError(t, err)
// For each peer node, check if it should be able
// to access the dst routes.
for _, viewerNode := range nodes {
if viewerNode.GivenName == dstNodeName {
continue
}
// Determine if this viewer has source identity
// that matches the capture's SrcIPs.
viewerMatchesSrc := false
nv := viewerNode.View()
if slices.ContainsFunc(nv.IPs(), srcSet.Contains) {
viewerMatchesSrc = true
}
if !viewerMatchesSrc {
if slices.ContainsFunc(nv.SubnetRoutes(), srcSet.OverlapsPrefix) {
viewerMatchesSrc = true
}
}
if !viewerMatchesSrc {
continue
}
matchers, matchErr := pm.MatchersForNode(nv)
require.NoError(t, matchErr)
t.Run(
dstNodeName+"/from_"+viewerNode.GivenName,
func(t *testing.T) {
for _, route := range dstPrefixes {
canAccess := nv.CanAccessRoute(
matchers, route,
)
assert.Truef(t, canAccess,
"%s: viewer %s (IPs=%v, "+
"subnets=%v) should be "+
"able to access route "+
"%s on node %s (SaaS "+
"delivered matching "+
"filter rules)",
tf.TestID,
viewerNode.GivenName,
nv.IPs(),
nv.SubnetRoutes(),
route,
dstNodeName,
)
}
},
)
}
}
})
}
}
// TestRoutesCompatGlobalEquivalence verifies that the global filter
// compilation path (compileFilterRules + ReduceFilterRules, used in
// production for ACL-only policies) produces the same output as the
// per-node path (compileFilterRulesForNode + ReduceFilterRules, used
// by TestRoutesCompat).
//
// All 98 ROUTES golden files use ACL-only policies (no autogroup:self,
// no via grants), so production code takes the global path. The
// TestRoutesCompat test always uses the per-node path. If these two
// paths ever diverge, this test will detect it.
func TestRoutesCompatGlobalEquivalence(t *testing.T) {
t.Parallel()
files, err := filepath.Glob(
filepath.Join("testdata", "routes_results", "ROUTES-*.hujson"),
)
require.NoError(t, err, "failed to glob test files")
require.NotEmpty(
t,
files,
"no ROUTES-*.hujson test files found",
)
for _, file := range files {
tf := loadRoutesTestFile(t, file)
t.Run(tf.TestID, func(t *testing.T) {
t.Parallel()
if reason, ok := routesSkipReasons[tf.TestID]; ok {
t.Skipf("TODO: %s", reason)
return
}
users, nodes := buildRoutesUsersAndNodes(t, tf.Topology)
policyJSON := convertPolicyUserEmails(tf.Input.FullPolicy)
// Per-node path: compile per-node, then reduce.
pol, err := unmarshalPolicy(policyJSON)
require.NoError(t, err)
err = pol.validate()
require.NoError(t, err)
// Global path: create PolicyManager (compiles global
// filter internally).
pm, err := NewPolicyManager(
policyJSON, users, nodes.ViewSlice(),
)
require.NoError(t, err,
"%s: failed to create PolicyManager", tf.TestID,
)
for _, node := range nodes {
nv := node.View()
// Per-node path (what TestRoutesCompat uses).
perNodeRules, err := pol.compileFilterRulesForNode(
users, nv, nodes.ViewSlice(),
)
require.NoError(t, err)
perNodeReduced := policyutil.ReduceFilterRules(
nv, perNodeRules,
)
// Global path (what production FilterForNode
// uses for ACL-only policies).
globalReduced, err := pm.FilterForNode(nv)
require.NoError(t, err)
opts := append(
cmpOptions(),
cmpopts.EquateEmpty(),
)
if diff := cmp.Diff(
perNodeReduced,
globalReduced,
opts...,
); diff != "" {
t.Errorf(
"%s/%s: global vs per-node filter "+
"mismatch (-perNode +global):\n%s",
tf.TestID,
node.GivenName,
diff,
)
}
}
})
}
}
// TestRoutesCompatExitNodePeerVisibility validates that CanAccess
// correctly handles exit node peer visibility for the b-series golden
// files. These files exercise exit route behaviors (b1-b10) which
// TestRoutesCompat validates for filter rule compilation, but peer
// visibility (CanAccess) was never tested.
//
// For b-series files, the test validates:
// - Nodes that receive non-null filter rules ARE visible as peers
// - Nodes that receive null filter rules may or may not be visible
// depending on the ACL structure
//
// This exercises the DestsIsTheInternet() + IsExitNode() code path
// in CanAccess (types/node.go:339) which had zero test coverage.
func TestRoutesCompatExitNodePeerVisibility(t *testing.T) {
t.Parallel()
// b2: tag:exit -> tag:exit:* — only exit nodes peer with each other
// b8: autogroup:member -> autogroup:internet:* — no filter rules at all
exitNodeTests := []struct {
testID string
// exitNodeNames are nodes with tag:exit
exitNodeNames []string
// expectedNullAll: if true, all captures should be null
expectedNullAll bool
}{
{
testID: "ROUTES-b2_tag_exit_excludes_exit_routes",
exitNodeNames: []string{"exit-node", "multi-router"},
},
{
testID: "ROUTES-b8_autogroup_internet_no_filters",
exitNodeNames: []string{"exit-node", "multi-router"},
expectedNullAll: true,
},
}
for _, tc := range exitNodeTests {
file := filepath.Join(
"testdata", "routes_results", tc.testID+".hujson",
)
tf := loadRoutesTestFile(t, file)
t.Run(tf.TestID, func(t *testing.T) {
t.Parallel()
users, nodes := buildRoutesUsersAndNodes(t, tf.Topology)
policyJSON := convertPolicyUserEmails(tf.Input.FullPolicy)
pm, err := NewPolicyManager(
policyJSON, users, nodes.ViewSlice(),
)
require.NoError(t, err)
if tc.expectedNullAll {
// All captures null: verify no CanAccess pairs
// involving exit nodes via this ACL alone.
for _, exitName := range tc.exitNodeNames {
exitNode := findNodeByGivenName(
nodes, exitName,
)
require.NotNil(t, exitNode)
matchers, err := pm.MatchersForNode(
exitNode.View(),
)
require.NoError(t, err)
for _, other := range nodes {
if other.ID == exitNode.ID {
continue
}
canAccess := exitNode.View().CanAccess(
matchers, other.View(),
)
assert.Falsef(t, canAccess,
"exit node %s should NOT "+
"CanAccess %s when "+
"autogroup:internet produces "+
"no filter rules",
exitName, other.GivenName,
)
}
}
return
}
// For b2: tag:exit -> tag:exit:*, only exit nodes
// should see each other. Verify exit<->exit pairs
// have CanAccess=true.
for i, name1 := range tc.exitNodeNames {
for j := i + 1; j < len(tc.exitNodeNames); j++ {
name2 := tc.exitNodeNames[j]
node1 := findNodeByGivenName(nodes, name1)
node2 := findNodeByGivenName(nodes, name2)
require.NotNil(t, node1)
require.NotNil(t, node2)
matchers1, err := pm.MatchersForNode(
node1.View(),
)
require.NoError(t, err)
matchers2, err := pm.MatchersForNode(
node2.View(),
)
require.NoError(t, err)
canAccess := node1.View().CanAccess(
matchers1, node2.View(),
) || node2.View().CanAccess(
matchers2, node1.View(),
)
assert.Truef(t, canAccess,
"exit nodes %s and %s should be "+
"peers (ACL: tag:exit -> "+
"tag:exit:*)",
name1, name2,
)
}
}
// Verify non-exit nodes don't peer with exit nodes
// through this restricted ACL.
for _, exitName := range tc.exitNodeNames {
exitNode := findNodeByGivenName(
nodes, exitName,
)
require.NotNil(t, exitNode)
for _, other := range nodes {
if other.ID == exitNode.ID {
continue
}
// Skip other exit nodes
isExit := slices.Contains(tc.exitNodeNames, other.GivenName)
if isExit {
continue
}
matchers, err := pm.MatchersForNode(
other.View(),
)
require.NoError(t, err)
canAccess := other.View().CanAccess(
matchers, exitNode.View(),
)
assert.Falsef(t, canAccess,
"non-exit node %s should NOT "+
"CanAccess exit node %s "+
"(ACL: tag:exit -> tag:exit:*)",
other.GivenName, exitName,
)
}
}
})
}
}
// TestRoutesCompatNoPeersBeyondCaptures verifies that headscale does not
// create peer relationships beyond what the golden file captures imply.
// For every pair of nodes NOT in the expected peer set (derived from
// the capture SrcIPs), CanAccess must return false.
//
// This extends TestRoutesCompatNoFalsePositivePeers (which only covers
// f10-f15 with hardcoded non-router names) to all 98 ROUTES golden
// files with a generic, data-driven approach.
func TestRoutesCompatNoPeersBeyondCaptures(t *testing.T) {
t.Parallel()
files, err := filepath.Glob(
filepath.Join("testdata", "routes_results", "ROUTES-*.hujson"),
)
require.NoError(t, err, "failed to glob test files")
require.NotEmpty(t, files)
for _, file := range files {
tf := loadRoutesTestFile(t, file)
t.Run(tf.TestID, func(t *testing.T) {
t.Parallel()
if reason, ok := routesSkipReasons[tf.TestID]; ok {
t.Skipf("TODO: %s", reason)
return
}
users, nodes := buildRoutesUsersAndNodes(t, tf.Topology)
policyJSON := convertPolicyUserEmails(tf.Input.FullPolicy)
pm, err := NewPolicyManager(
policyJSON, users, nodes.ViewSlice(),
)
require.NoError(t, err)
// Derive the complete set of expected peer pairs
// from the golden file captures.
expectedPairs := deriveAllPeerPairsFromCaptures(
t, tf, nodes,
)
// Also add pairs implied by DstPorts: if a node's IP
// appears in DstPorts of rules delivered to another
// node, they must be peers.
for dstNodeName, capture := range tf.Captures {
captureIsNull := len(
capture.PacketFilterRules,
) == 0 ||
string(
capture.PacketFilterRules,
) == "null"
if captureIsNull {
continue
}
var rules []tailcfg.FilterRule
err := json.Unmarshal(
capture.PacketFilterRules, &rules,
)
if err != nil {
continue
}
var dstBuilder netipx.IPSetBuilder
for _, rule := range rules {
for _, dp := range rule.DstPorts {
addSrcIPToBuilder(t, &dstBuilder,
dp.IP, tf.TestID, dstNodeName,
)
}
}
dstSet, dstErr := dstBuilder.IPSet()
if dstErr != nil {
continue
}
for _, node := range nodes {
if node.GivenName == dstNodeName {
continue
}
if slices.ContainsFunc(
node.IPs(), dstSet.Contains,
) {
expectedPairs[orderedPair(
dstNodeName, node.GivenName,
)] = true
}
if slices.ContainsFunc(
node.SubnetRoutes(),
dstSet.OverlapsPrefix,
) {
expectedPairs[orderedPair(
dstNodeName, node.GivenName,
)] = true
}
}
}
falsePositives := 0
for i, nodeA := range nodes {
matchersA, err := pm.MatchersForNode(
nodeA.View(),
)
require.NoError(t, err)
for j := i + 1; j < len(nodes); j++ {
nodeB := nodes[j]
pair := orderedPair(
nodeA.GivenName, nodeB.GivenName,
)
if expectedPairs[pair] {
continue
}
matchersB, err := pm.MatchersForNode(
nodeB.View(),
)
require.NoError(t, err)
canAccess := nodeA.View().CanAccess(
matchersA, nodeB.View(),
) || nodeB.View().CanAccess(
matchersB, nodeA.View(),
)
if canAccess {
t.Errorf(
"%s: unexpected peer "+
"relationship: %s <-> %s",
tf.TestID,
nodeA.GivenName,
nodeB.GivenName,
)
falsePositives++
}
}
}
if falsePositives == 0 && len(expectedPairs) == 0 {
// All-null captures: verify no peers at all.
t.Logf(
"%s: all-null captures, verified no "+
"false-positive peers among %d nodes",
tf.TestID, len(nodes),
)
}
})
}
}
// TestRoutesCompatNoFalsePositivePeers verifies that nodes which do NOT
// have subnet routes overlapping an ACL's source or destination CIDRs
// are NOT incorrectly peered with subnet routers.
//
// This is the negative counterpart to TestRoutesCompatPeerVisibility:
// while that test verifies subnet routers CAN see each other, this test
// verifies that unrelated nodes (tagged-server, user1, etc.) are NOT
// made peers of subnet routers solely because of subnet-to-subnet ACLs.
func TestRoutesCompatNoFalsePositivePeers(t *testing.T) {
t.Parallel()
// nodesWithoutRoutes lists nodes that have no subnet routes and whose
// IPs don't appear in any subnet-to-subnet ACL. They should never be
// peers of subnet routers through these ACLs alone.
nodesWithoutRoutes := []string{
"tagged-server",
"tagged-prod",
"tagged-client",
"user1",
"user-kris",
"user-mon",
}
for _, testID := range subnetToSubnetFiles {
file := filepath.Join(
"testdata", "routes_results", testID+".hujson",
)
tf := loadRoutesTestFile(t, file)
t.Run(tf.TestID, func(t *testing.T) {
t.Parallel()
users, nodes := buildRoutesUsersAndNodes(t, tf.Topology)
policyJSON := convertPolicyUserEmails(tf.Input.FullPolicy)
pm, err := NewPolicyManager(
policyJSON, users, nodes.ViewSlice(),
)
require.NoError(t, err)
// Collect the set of nodes that participate in the ACL
// (have non-null captures).
routerNodes := make(map[string]bool)
for nodeName, capture := range tf.Captures {
captureIsNull := len(capture.PacketFilterRules) == 0 ||
string(capture.PacketFilterRules) == "null"
if !captureIsNull {
routerNodes[nodeName] = true
}
}
for _, nonRouterName := range nodesWithoutRoutes {
nonRouter := findNodeByGivenName(
nodes, nonRouterName,
)
if nonRouter == nil {
continue
}
matchers, err := pm.MatchersForNode(
nonRouter.View(),
)
require.NoError(t, err)
for routerName := range routerNodes {
router := findNodeByGivenName(
nodes, routerName,
)
require.NotNil(t, router)
canAccess := nonRouter.View().CanAccess(
matchers, router.View(),
) || router.View().CanAccess(
matchers, nonRouter.View(),
)
assert.Falsef(t, canAccess,
"%s: non-router node %s should NOT "+
"be a peer of subnet router %s "+
"via subnet-to-subnet ACL alone",
tf.TestID, nonRouterName, routerName,
)
}
}
})
}
}