Files
headscale/hscontrol/policy/v2/tailscale_routes_data_compat_test.go
Kristoffer Dalby a7c9721faa policy/v2: overhaul compat test infrastructure
Reworks the ACL/routes/grant/SSH compat harnesses to read
testcapture.Capture typed files, per-scenario topologies, strict error
wording match, and shared helpers. Surfaces policy-engine drift against
Tailscale SaaS.

Updates #3157
2026-04-17 16:31:49 +01:00

1580 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,
// captured from Tailscale SaaS by tscap, and compares headscale's route-aware
// ACL engine output against the captured packet filter rules.
//
// Each capture file is a testcapture.Capture containing:
// - A full policy (groups, tagOwners, hosts, acls) in Input.FullPolicy
// - A Topology section with nodes, including RoutableIPs and ApprovedRoutes
// - Expected packet_filter_rules per node in Captures[name].PacketFilterRules
//
// 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
// Source format: github.com/juanfont/headscale/hscontrol/types/testcapture
package v2
import (
"fmt"
"net/netip"
"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/juanfont/headscale/hscontrol/types/testcapture"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go4.org/netipx"
"gorm.io/gorm"
"tailscale.com/tailcfg"
)
// loadRoutesTestFile loads and parses a single routes capture HuJSON file.
func loadRoutesTestFile(t *testing.T, path string) *testcapture.Capture {
t.Helper()
c, err := testcapture.Read(path)
require.NoError(t, err, "failed to read test file %s", path)
return c
}
// convertSaaSEmail used to map SaaS-side emails to @example.com placeholders.
// tscap now anonymizes captures at write time (norse-god names + pokémon
// hostnames), so the captured topology emails are already in their final
// form and this is a passthrough.
func convertSaaSEmail(email string) string {
return email
}
// buildRoutesUsersAndNodes constructs types.Users and types.Nodes from the
// captured topology. 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 testcapture.Topology,
) (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,
Email: convertSaaSEmail(u.Email),
})
}
} 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. Topology nodes are keyed by GivenName.
// Assign sequential IDs (the capture format does not store them);
// 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 := 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
}
// 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-*.hujson 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()
// 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) {
node := findNodeByGivenName(nodes, nodeName)
require.NotNilf(t, node,
"golden node %s not found in topology", nodeName)
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,
)
wantRules := capture.PacketFilterRules
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 *testcapture.Capture,
nodes types.Nodes,
) map[[2]string]bool {
t.Helper()
pairs := make(map[[2]string]bool)
for dstNodeName, capture := range tf.Captures {
if len(capture.PacketFilterRules) == 0 {
continue
}
rules := capture.PacketFilterRules
// 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 *testcapture.Capture,
nodes types.Nodes,
) map[[2]string]bool {
t.Helper()
pairs := make(map[[2]string]bool)
for dstNodeName, capture := range tf.Captures {
if len(capture.PacketFilterRules) == 0 {
continue
}
rules := capture.PacketFilterRules
// 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) {
// Peer visibility is satisfied if either direction of
// CanAccess returns true — that mirrors the
// ReduceNodes semantics used at runtime. An ACL may
// legitimately grant access in only one direction
// (tested in ReduceNodes below), and a symmetric
// assertion would reject the intentional asymmetry.
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],
)
matchersA, err := pm.MatchersForNode(nodeA.View())
require.NoError(t, err)
matchersB, err := pm.MatchersForNode(nodeB.View())
require.NoError(t, err)
canAccess := nodeA.View().CanAccess(
matchersA, nodeB.View(),
) || nodeB.View().CanAccess(
matchersB, 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 {
if len(capture.PacketFilterRules) == 0 {
continue
}
rules := capture.PacketFilterRules
// Extract destination prefixes from the rules.
var dstPrefixes []netip.Prefix
for _, rule := range rules {
for _, dp := range rule.DstPorts {
if dp.IP == "*" {
continue
}
prefix, err := netip.ParsePrefix(dp.IP)
if err != nil {
// DstPorts.IP can be a bare IP without /prefix.
addr, addrErr := netip.ParseAddr(dp.IP)
require.NoErrorf(t, addrErr,
"golden DstPorts.IP %q unparseable as prefix or addr", dp.IP)
prefix = netip.PrefixFrom(addr, addr.BitLen())
}
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 {
pfx, err := netip.ParsePrefix(r)
require.NoErrorf(t, err,
"golden approved_route %q for %s", r, nodeName)
approvedSet[pfx] = true
}
t.Run(nodeName, func(t *testing.T) {
for _, routeStr := range nodeDef.RoutableIPs {
route, err := netip.ParsePrefix(routeStr)
require.NoErrorf(t, err,
"golden routable_ip %q for %s", routeStr, nodeName)
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, check that
// source nodes can access the destination prefixes via
// CanAccessRoute. Match src↔dst pairs PER RULE — a
// source IP in rule N should only be tested against
// the destinations in rule N, not destinations from
// other rules. This matters when tag-based rules
// (dst=node-IPs) and CIDR rules (dst=subnets) coexist
// on the same node.
for dstNodeName, capture := range tf.Captures {
if len(capture.PacketFilterRules) == 0 {
continue
}
for ruleIdx, rule := range capture.PacketFilterRules {
// Extract destination prefixes from this rule.
var dstPrefixes []netip.Prefix
for _, dp := range rule.DstPorts {
if dp.IP == "*" {
continue
}
prefix, parseErr := netip.ParsePrefix(dp.IP)
if parseErr != nil {
addr, addrErr := netip.ParseAddr(dp.IP)
require.NoErrorf(t, addrErr,
"golden DstPorts.IP %q unparseable", dp.IP)
prefix = netip.PrefixFrom(addr, addr.BitLen())
}
if !slices.Contains(dstPrefixes, prefix) {
dstPrefixes = append(dstPrefixes, prefix)
}
}
if len(dstPrefixes) == 0 {
continue
}
// Build SrcIPs set from THIS rule only.
var srcBuilder netipx.IPSetBuilder
for _, srcIP := range rule.SrcIPs {
if srcIP == "*" {
continue
}
// SrcIPs can be CIDR prefixes, bare IPs, or
// dash-separated IP ranges (e.g. "100.115.94.0-100.127.255.255").
if strings.Contains(srcIP, "-") {
ipRange, rangeErr := netipx.ParseIPRange(srcIP)
require.NoErrorf(t, rangeErr,
"golden SrcIP range %q unparseable", srcIP)
srcBuilder.AddRange(ipRange)
continue
}
prefix, parseErr := netip.ParsePrefix(srcIP)
if parseErr != nil {
addr, parseErr2 := netip.ParseAddr(srcIP)
require.NoErrorf(t, parseErr2,
"golden SrcIP %q unparseable as prefix or addr", srcIP)
srcBuilder.Add(addr)
continue
}
srcBuilder.AddPrefix(prefix)
}
srcSet, err := srcBuilder.IPSet()
require.NoError(t, err)
for _, viewerNode := range nodes {
if viewerNode.GivenName == dstNodeName {
continue
}
nv := viewerNode.View()
// Check if viewer matches THIS rule's sources.
if !slices.ContainsFunc(nv.IPs(), srcSet.Contains) &&
!slices.ContainsFunc(nv.SubnetRoutes(), srcSet.OverlapsPrefix) {
continue
}
matchers, matchErr := pm.MatchersForNode(nv)
require.NoError(t, matchErr)
t.Run(
fmt.Sprintf(
"%s/rule%d/from_%s",
dstNodeName, ruleIdx,
viewerNode.GivenName,
),
func(t *testing.T) {
for _, route := range dstPrefixes {
canAccess := nv.CanAccessRoute(
matchers, route,
)
assert.Truef(t, canAccess,
"%s: viewer %s should "+
"access route %s on %s",
tf.TestID,
viewerNode.GivenName,
route, dstNodeName,
)
}
},
)
}
}
}
})
}
}
// 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
// expectedNullAll: if true, all captures should be null
expectedNullAll bool
}{
{testID: "routes-b2-tag-exit-excludes-exit-routes"},
{testID: "routes-b8-autogroup-internet-no-filters", expectedNullAll: true},
}
for _, tc := range exitNodeTests {
file := filepath.Join(
"testdata", "routes_results", tc.testID+".hujson",
)
tf := loadRoutesTestFile(t, file)
// Derive exit node names from the topology instead of
// hard-coding them. The topology's tag arrays are the
// source of truth for which nodes are exit nodes.
var exitNodeNames []string
for name, node := range tf.Topology.Nodes {
if slices.Contains(node.Tags, "tag:exit") {
exitNodeNames = append(exitNodeNames, name)
}
}
sort.Strings(exitNodeNames)
require.NotEmpty(t, exitNodeNames,
"%s: topology has no nodes with tag:exit", tf.TestID,
)
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 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 exitNodeNames {
for j := i + 1; j < len(exitNodeNames); j++ {
name2 := 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 exitNodeNames {
exitNode := findNodeByGivenName(
nodes, exitName,
)
require.NotNil(t, exitNode)
for _, other := range nodes {
if other.ID == exitNode.ID {
continue
}
isExit := slices.Contains(
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()
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 {
if len(capture.PacketFilterRules) == 0 {
continue
}
rules := capture.PacketFilterRules
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 {
if len(capture.PacketFilterRules) > 0 {
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,
)
}
}
})
}
}
// prefixStrings converts a slice of netip.Prefix to sorted strings for
// readable comparison in test assertions.
func prefixStrings(pfxs []netip.Prefix) []string {
out := make([]string, len(pfxs))
for i, p := range pfxs {
out[i] = p.String()
}
sort.Strings(out)
return out
}
// TestRoutesCompatPeerAllowedIPs validates that headscale computes the same
// peer AllowedIPs as Tailscale SaaS. For each golden file that contains
// netmap captures, the test compares the AllowedIPs that SaaS delivered
// for each peer against what headscale's ReduceRoutes (the core of
// RoutesForPeer) would produce.
//
// This is the authoritative proof that approved exit routes (0.0.0.0/0,
// ::/0) belong in peer AllowedIPs: the ea-series captures from SaaS show
// exit routes in every peer's AllowedIPs when they are approved, and
// absent when they are not (b9 series).
func TestRoutesCompatPeerAllowedIPs(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)
// Count how many files actually had netmap data to test.
testedFiles := 0
for _, file := range files {
tf := loadRoutesTestFile(t, file)
// Only test files that have netmap captures with peers.
hasNetmap := false
for _, capture := range tf.Captures {
if capture.Netmap != nil && len(capture.Netmap.Peers) > 0 {
hasNetmap = true
break
}
}
if !hasNetmap {
continue
}
testedFiles++
t.Run(tf.TestID, func(t *testing.T) {
t.Parallel()
// SaaS rejected this policy — verify headscale also rejects it.
if tf.Error {
testRoutesError(t, tf)
return
}
users, nodes := buildRoutesUsersAndNodes(t, tf.Topology)
policyJSON := convertPolicyUserEmails(tf.Input.FullPolicy)
pm, err := NewPolicyManager(policyJSON, users, nodes.ViewSlice())
require.NoError(t, err, "%s: failed to create policy manager", tf.TestID)
for viewerName, capture := range tf.Captures {
if capture.Netmap == nil || len(capture.Netmap.Peers) == 0 {
continue
}
viewer := findNodeByGivenName(nodes, viewerName)
if viewer == nil {
continue
}
t.Run(viewerName, func(t *testing.T) {
matchers, err := pm.MatchersForNode(viewer.View())
require.NoError(t, err)
for _, nmPeer := range capture.Netmap.Peers {
// Extract the short name from the FQDN.
peerName := strings.Split(nmPeer.Name(), ".")[0]
peer := findNodeByGivenName(nodes, peerName)
if peer == nil {
continue
}
// Compute what headscale would put in AllowedIPs.
//
// The SaaS netmap PrimaryRoutes tells us which
// subnet routes won HA election. Exit routes
// (0.0.0.0/0, ::/0) are never in PrimaryRoutes
// but DO appear in AllowedIPs when approved.
// This mirrors RoutesForPeer: primaryRoutes + exitRoutes
// filtered through ReduceRoutes.
peerPrimaries := nmPeer.PrimaryRoutes().AsSlice()
exitRoutes := peer.ExitRoutes()
allRoutes := slices.Concat(peerPrimaries, exitRoutes)
var reducedRoutes []netip.Prefix
for _, route := range allRoutes {
if viewer.View().CanAccessRoute(matchers, route) {
reducedRoutes = append(reducedRoutes, route)
}
}
gotAllowedIPs := slices.Concat(
peer.View().Prefixes(), reducedRoutes,
)
slices.SortFunc(gotAllowedIPs, netip.Prefix.Compare)
wantAllowedIPs := nmPeer.AllowedIPs().AsSlice()
slices.SortFunc(wantAllowedIPs, netip.Prefix.Compare)
assert.Equalf(t,
prefixStrings(wantAllowedIPs),
prefixStrings(gotAllowedIPs),
"%s/%s: peer %s AllowedIPs mismatch",
tf.TestID, viewerName, peerName,
)
}
})
}
})
}
require.Positive(t, testedFiles,
"no golden files with netmap data found — test is vacuous",
)
}
// testRoutesError verifies that an invalid policy produces the expected error.
func testRoutesError(t *testing.T, tf *testcapture.Capture) {
t.Helper()
policyJSON := convertPolicyUserEmails(tf.Input.FullPolicy)
pol, err := unmarshalPolicy(policyJSON)
if err != nil {
// Parse-time error.
if tf.Input.APIResponseBody != nil {
wantMsg := tf.Input.APIResponseBody.Message
if wantMsg != "" {
assertRoutesErrorContains(t, err, wantMsg, tf.TestID)
}
}
return
}
err = pol.validate()
if err != nil {
if tf.Input.APIResponseBody != nil {
wantMsg := tf.Input.APIResponseBody.Message
if wantMsg != "" {
assertRoutesErrorContains(t, err, wantMsg, tf.TestID)
}
}
return
}
t.Errorf(
"%s: expected error but policy parsed and validated successfully",
tf.TestID,
)
}
// assertRoutesErrorContains requires that headscale's error contains
// the Tailscale SaaS error message verbatim. Divergence means an
// emitter needs to be aligned, not papered over with a translation
// table.
func assertRoutesErrorContains(
t *testing.T,
err error,
wantMsg string,
testID string,
) {
t.Helper()
errStr := err.Error()
if strings.Contains(errStr, wantMsg) {
return
}
t.Errorf(
"%s: error message mismatch\n"+
" want (tailscale): %q\n"+
" got (headscale): %q",
testID,
wantMsg,
errStr,
)
}