Files
headscale/hscontrol/policy/v2/tailscale_routes_compat_test.go
Kristoffer Dalby 835b7eb960 policy: autogroup:internet does not generate packet filters
According to Tailscale SaaS behavior, autogroup:internet is handled
by exit node routing via AllowedIPs, not by packet filtering. ACL
rules with autogroup:internet as destination should produce no
filter rules for any node.

Previously, Headscale expanded autogroup:internet to public CIDR
ranges and distributed filters to exit nodes (because 0.0.0.0/0
"covers" internet destinations). This was incorrect.

Add detection for AutoGroupInternet in filter compilation to skip
filter generation for this autogroup. Update test expectations
accordingly.
2026-02-05 19:29:16 +01:00

8287 lines
263 KiB
Go

package v2
// This file contains compatibility tests for subnet routes and exit nodes.
// It validates Headscale's ACL engine behavior against documented Tailscale
// SaaS behavior. Tests document behavioral differences with TODO comments.
//
// Source findings: /home/kradalby/acl-explore/findings/{10,11,12,13,14,15}-*.md
import (
"net/netip"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/juanfont/headscale/hscontrol/policy/policyutil"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"tailscale.com/net/tsaddr"
"tailscale.com/tailcfg"
)
// wildcardSrcIPs represents the SrcIPs used when wildcard (*) source is specified.
// In Tailscale, this includes the CGNAT range and IPv6 Tailscale range, plus any
// advertised subnet routes.
var wildcardSrcIPs = []string{
"100.64.0.0/10", // CGNAT range for Tailscale IPs
"fd7a:115c:a1e0::/48", // Tailscale IPv6 range
}
// memberSrcIPs represents the SrcIPs for autogroup:member (user-owned nodes).
// This includes client1, client2, and user1.
var memberSrcIPs = []string{
"100.116.73.38/32",
"100.89.42.23/32",
"100.90.199.68/32",
"fd7a:115c:a1e0::2d01:c747/128",
"fd7a:115c:a1e0::a801:4949/128",
"fd7a:115c:a1e0::d01:2a2e/128",
}
// wildcardDstPorts represents wildcard destination ports using {IP: "*"}.
var wildcardDstPorts = []tailcfg.NetPortRange{
{IP: "*", Ports: tailcfg.PortRangeAny},
}
// setupRouteCompatUsers returns the test users for route compatibility tests.
func setupRouteCompatUsers() types.Users {
return types.Users{
{Model: gorm.Model{ID: 1}, Name: "kratail2tid"},
}
}
// setupRouteCompatNodes returns the test nodes for route compatibility tests.
// The node configuration includes:
// - 2 client nodes (user-owned, no routes)
// - 1 subnet router (tag:router, 10.33.0.0/16)
// - 1 exit node (tag:exit, 0.0.0.0/0, ::/0)
// - 1 multi-router (tag:router + tag:exit, 172.16.0.0/24 + exit routes)
// - 2 HA routers (tag:ha, both advertise 192.168.1.0/24)
// - 1 big router (tag:router, 10.0.0.0/8)
// - 1 user-owned node (user1).
func setupRouteCompatNodes(users types.Users) types.Nodes {
// Node: client1 - User-owned client (no routes)
nodeClient1 := &types.Node{
ID: 1,
GivenName: "client1",
User: &users[0],
UserID: &users[0].ID,
IPv4: ptrAddr("100.116.73.38"),
IPv6: ptrAddr("fd7a:115c:a1e0::a801:4949"),
Hostinfo: &tailcfg.Hostinfo{},
ApprovedRoutes: []netip.Prefix{},
}
// Node: client2 - User-owned client (no routes)
nodeClient2 := &types.Node{
ID: 2,
GivenName: "client2",
User: &users[0],
UserID: &users[0].ID,
IPv4: ptrAddr("100.89.42.23"),
IPv6: ptrAddr("fd7a:115c:a1e0::d01:2a2e"),
Hostinfo: &tailcfg.Hostinfo{},
ApprovedRoutes: []netip.Prefix{},
}
// Node: subnet-router - Tagged with tag:router, advertises 10.33.0.0/16
nodeSubnetRouter := &types.Node{
ID: 3,
GivenName: "subnet-router",
IPv4: ptrAddr("100.119.139.79"),
IPv6: ptrAddr("fd7a:115c:a1e0::4001:8ba0"),
Tags: []string{"tag:router"},
Hostinfo: &tailcfg.Hostinfo{
RoutableIPs: []netip.Prefix{
netip.MustParsePrefix("10.33.0.0/16"),
},
},
ApprovedRoutes: []netip.Prefix{
netip.MustParsePrefix("10.33.0.0/16"),
},
}
// Node: exit-node - Tagged with tag:exit, advertises exit routes
nodeExitNode := &types.Node{
ID: 4,
GivenName: "exit-node",
IPv4: ptrAddr("100.121.32.1"),
IPv6: ptrAddr("fd7a:115c:a1e0::7f01:2004"),
Tags: []string{"tag:exit"},
Hostinfo: &tailcfg.Hostinfo{
RoutableIPs: tsaddr.ExitRoutes(),
},
ApprovedRoutes: tsaddr.ExitRoutes(),
}
// Node: multi-router - Tagged with tag:router AND tag:exit
// Advertises both subnet (172.16.0.0/24) and exit routes
multiRouterRoutes := append([]netip.Prefix{
netip.MustParsePrefix("172.16.0.0/24"),
}, tsaddr.ExitRoutes()...)
nodeMultiRouter := &types.Node{
ID: 5,
GivenName: "multi-router",
IPv4: ptrAddr("100.74.117.7"),
IPv6: ptrAddr("fd7a:115c:a1e0::c401:7508"),
Tags: []string{"tag:router", "tag:exit"},
Hostinfo: &tailcfg.Hostinfo{
RoutableIPs: multiRouterRoutes,
},
ApprovedRoutes: multiRouterRoutes,
}
// Node: ha-router1 - Tagged with tag:ha, advertises 192.168.1.0/24
nodeHARouter1 := &types.Node{
ID: 6,
GivenName: "ha-router1",
IPv4: ptrAddr("100.85.37.108"),
IPv6: ptrAddr("fd7a:115c:a1e0::f101:2597"),
Tags: []string{"tag:ha"},
Hostinfo: &tailcfg.Hostinfo{
RoutableIPs: []netip.Prefix{
netip.MustParsePrefix("192.168.1.0/24"),
},
},
ApprovedRoutes: []netip.Prefix{
netip.MustParsePrefix("192.168.1.0/24"),
},
}
// Node: ha-router2 - Tagged with tag:ha, advertises same 192.168.1.0/24
nodeHARouter2 := &types.Node{
ID: 7,
GivenName: "ha-router2",
IPv4: ptrAddr("100.119.130.32"),
IPv6: ptrAddr("fd7a:115c:a1e0::4501:82a9"),
Tags: []string{"tag:ha"},
Hostinfo: &tailcfg.Hostinfo{
RoutableIPs: []netip.Prefix{
netip.MustParsePrefix("192.168.1.0/24"),
},
},
ApprovedRoutes: []netip.Prefix{
netip.MustParsePrefix("192.168.1.0/24"),
},
}
// Node: big-router - Tagged with tag:router, advertises 10.0.0.0/8
nodeBigRouter := &types.Node{
ID: 8,
GivenName: "big-router",
IPv4: ptrAddr("100.100.100.1"),
IPv6: ptrAddr("fd7a:115c:a1e0::6401:6401"),
Tags: []string{"tag:router"},
Hostinfo: &tailcfg.Hostinfo{
RoutableIPs: []netip.Prefix{
netip.MustParsePrefix("10.0.0.0/8"),
},
},
ApprovedRoutes: []netip.Prefix{
netip.MustParsePrefix("10.0.0.0/8"),
},
}
// Node: user1 - User-owned node (no routes)
nodeUser1 := &types.Node{
ID: 9,
GivenName: "user1",
User: &users[0],
UserID: &users[0].ID,
IPv4: ptrAddr("100.90.199.68"),
IPv6: ptrAddr("fd7a:115c:a1e0::2d01:c747"),
Hostinfo: &tailcfg.Hostinfo{},
ApprovedRoutes: []netip.Prefix{},
}
return types.Nodes{
nodeClient1,
nodeClient2,
nodeSubnetRouter,
nodeExitNode,
nodeMultiRouter,
nodeHARouter1,
nodeHARouter2,
nodeBigRouter,
nodeUser1,
}
}
// routesPolicyPrefix provides the standard groups, tagOwners, and hosts
// for route compatibility tests.
const routesPolicyPrefix = `{
"groups": {
"group:admins": ["kratail2tid@"],
"group:empty": []
},
"tagOwners": {
"tag:router": ["kratail2tid@"],
"tag:exit": ["kratail2tid@"],
"tag:ha": ["kratail2tid@"]
},
"hosts": {
"internal": "10.0.0.0/8",
"subnet24": "192.168.1.0/24"
},
"acls": [`
const routesPolicySuffix = `
]
}`
// makeRoutesPolicy creates a full policy from just the ACL rules portion.
func makeRoutesPolicy(aclRules string) string {
return routesPolicyPrefix + aclRules + routesPolicySuffix
}
// routesCompatTest defines a test case for routes compatibility testing.
type routesCompatTest struct {
name string // Test name
policy string // HuJSON policy as multiline raw string
wantFilters map[string][]tailcfg.FilterRule // node GivenName -> expected filters
}
// TestTailscaleRoutesCompatSubnetBasics tests basic subnet route behavior (Category A).
// These tests verify that subnet routes are correctly included in SrcIPs for wildcard rules,
// that tag-based ACLs resolve to node IPs (not routes), and that explicit subnet filters
// are placed on the correct destination nodes.
func TestTailscaleRoutesCompatSubnetBasics(t *testing.T) {
t.Parallel()
users := setupRouteCompatUsers()
nodes := setupRouteCompatNodes(users)
tests := []routesCompatTest{
// A1: Wildcard ACL includes subnet routes in SrcIPs
{
name: "A1_wildcard_acl_includes_routes_in_srcips",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["*:*"]}
`),
// When using * -> *:*, SrcIPs should include advertised subnet routes
// (but NOT exit routes 0.0.0.0/0, ::/0).
// TODO: Verify Tailscale includes subnet routes 10.33.0.0/16, 172.16.0.0/24,
// 192.168.1.0/24, 10.0.0.0/8 in SrcIPs but NOT 0.0.0.0/0, ::/0
wantFilters: map[string][]tailcfg.FilterRule{
"client1": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
// TODO: Tailscale also includes these subnet routes:
// "10.0.0.0/8",
// "10.33.0.0/16",
// "172.16.0.0/24",
// "192.168.1.0/24",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"client2": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"subnet-router": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"exit-node": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"ha-router1": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"ha-router2": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"big-router": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"user1": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// TODO: Fix DstPorts expansion and exit node coverage for tag-based ACLs
//
// A2: Tag-based ACL resolves to node IPs only, NOT routes
//
// TAILSCALE BEHAVIOR:
// - tag:router includes: subnet-router, multi-router, big-router
// - Each tag:router node receives filter with ALL tag:router IPs in DstPorts
// - exit-node (tag:exit only) does NOT receive any filter
// - DstPorts contains ONLY node IPs, NOT advertised routes
//
// HEADSCALE BEHAVIOR:
// - INCORRECT: Each node only gets ITS OWN IPs in DstPorts (should be ALL tag IPs)
// - INCORRECT: exit-node receives a filter because multi-router has exit routes
// and Headscale treats 0.0.0.0/0 as covering node IPs for filter distribution
//
// ROOT CAUSE:
// 1. Filter compilation only adds destinations for the current node being filtered,
// not all nodes matching the tag
// 2. Exit routes (0.0.0.0/0, ::/0) are treated as covering all destinations
//
// FIX REQUIRED:
// 1. When dst is a tag, include ALL IPs of nodes with that tag in DstPorts
// 2. Exclude exit routes from filter coverage calculations
{
name: "A2_tag_based_acl_excludes_routes",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["tag:router"], "dst": ["tag:router:*"]}
`),
// EXPECTED (Tailscale) - commented out:
// wantFilters: map[string][]tailcfg.FilterRule{
// "client1": nil,
// "client2": nil,
// "exit-node": nil, // tag:exit only, not tag:router
// "ha-router1": nil,
// "ha-router2": nil,
// "user1": nil,
// "subnet-router": {
// {
// SrcIPs: []string{
// "100.100.100.1/32", "100.119.139.79/32", "100.74.117.7/32",
// "fd7a:115c:a1e0::4001:8ba0/128", "fd7a:115c:a1e0::6401:6401/128",
// "fd7a:115c:a1e0::c401:7508/128",
// },
// DstPorts: []tailcfg.NetPortRange{
// // ALL tag:router IPs, not just this node's IP
// {IP: "100.100.100.1/32", Ports: tailcfg.PortRangeAny},
// {IP: "100.119.139.79/32", Ports: tailcfg.PortRangeAny},
// {IP: "100.74.117.7/32", Ports: tailcfg.PortRangeAny},
// {IP: "fd7a:115c:a1e0::4001:8ba0/128", Ports: tailcfg.PortRangeAny},
// {IP: "fd7a:115c:a1e0::6401:6401/128", Ports: tailcfg.PortRangeAny},
// {IP: "fd7a:115c:a1e0::c401:7508/128", Ports: tailcfg.PortRangeAny},
// },
// IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
// },
// },
// // Same for multi-router and big-router...
// },
//
// ACTUAL (Headscale) - current behavior:
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"ha-router1": nil,
"ha-router2": nil,
"user1": nil,
// INCORRECT: subnet-router only gets its OWN IPs in DstPorts
// Tailscale includes ALL tag:router IPs
"subnet-router": {
{
SrcIPs: []string{
"100.100.100.1/32",
"100.119.139.79/32",
"100.74.117.7/32",
"fd7a:115c:a1e0::4001:8ba0/128",
"fd7a:115c:a1e0::6401:6401/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: []tailcfg.NetPortRange{
// INCORRECT: Only this node's IPs, should be ALL tag:router IPs
{IP: "100.119.139.79/32", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::4001:8ba0/128", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// multi-router has BOTH tag:router AND tag:exit
// Because of exit routes, filter merging includes all tag:router destinations
// This is actually the CORRECT Tailscale behavior for DstPorts (but wrong filter distribution)
"multi-router": {
{
SrcIPs: []string{
"100.100.100.1/32",
"100.119.139.79/32",
"100.74.117.7/32",
"fd7a:115c:a1e0::4001:8ba0/128",
"fd7a:115c:a1e0::6401:6401/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: []tailcfg.NetPortRange{
// All tag:router IPs due to exit route coverage + filter merging
{IP: "100.74.117.7/32", Ports: tailcfg.PortRangeAny},
{IP: "100.100.100.1/32", Ports: tailcfg.PortRangeAny},
{IP: "100.119.139.79/32", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::c401:7508/128", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::4001:8ba0/128", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::6401:6401/128", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// INCORRECT: big-router only gets its OWN IPs in DstPorts
// Tailscale includes ALL tag:router IPs
"big-router": {
{
SrcIPs: []string{
"100.100.100.1/32",
"100.119.139.79/32",
"100.74.117.7/32",
"fd7a:115c:a1e0::4001:8ba0/128",
"fd7a:115c:a1e0::6401:6401/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: []tailcfg.NetPortRange{
// INCORRECT: Only this node's IPs, should be ALL tag:router IPs
{IP: "100.100.100.1/32", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::6401:6401/128", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// INCORRECT: exit-node receives filter due to multi-router having exit routes
// and Headscale treating 0.0.0.0/0 as covering node IPs
// Tailscale would return nil here
"exit-node": {
{
SrcIPs: []string{
"100.74.117.7/32",
"100.100.100.1/32",
"100.119.139.79/32",
"fd7a:115c:a1e0::4001:8ba0/128",
"fd7a:115c:a1e0::6401:6401/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "100.74.117.7/32", Ports: tailcfg.PortRangeAny},
{IP: "100.100.100.1/32", Ports: tailcfg.PortRangeAny},
{IP: "100.119.139.79/32", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::4001:8ba0/128", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::6401:6401/128", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::c401:7508/128", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// TODO: Fix exit node route coverage to match Tailscale behavior
//
// A3: Explicit subnet destination - filter goes to router only
//
// TAILSCALE BEHAVIOR:
// - Filter placed ONLY on nodes whose routes cover the destination
// - subnet-router (10.33.0.0/16) gets filter - exact match
// - big-router (10.0.0.0/8) gets filter - parent covers child
// - exit-node and multi-router get NO filter - exit routes (0.0.0.0/0)
// do NOT count as "covering" subnet destinations for filter placement
//
// HEADSCALE BEHAVIOR:
// - Exit nodes (0.0.0.0/0) ARE treated as covering all destinations
// - exit-node and multi-router incorrectly receive the filter
//
// ROOT CAUSE:
// hscontrol/policy/v2/filter.go treats exit routes (0.0.0.0/0, ::/0) as
// covering all destinations, but Tailscale only uses exit routes for
// actual traffic routing, not for filter distribution.
//
// FIX REQUIRED:
// When determining which nodes receive a filter based on route coverage,
// exclude exit routes (0.0.0.0/0 and ::/0) from the coverage check.
// Exit nodes should only receive filters when explicitly targeted.
{
name: "A3_explicit_subnet_filter_to_router",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["10.33.0.0/16:*"]}
`),
// EXPECTED (Tailscale) - commented out:
// wantFilters: map[string][]tailcfg.FilterRule{
// "client1": nil,
// "client2": nil,
// "exit-node": nil, // Exit route does NOT cover for filter placement
// "ha-router1": nil,
// "ha-router2": nil,
// "user1": nil,
// "multi-router": nil, // Exit route does NOT cover for filter placement
// "subnet-router": {
// {
// SrcIPs: []string{"100.64.0.0/10", "fd7a:115c:a1e0::/48"},
// DstPorts: []tailcfg.NetPortRange{{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny}},
// IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
// },
// },
// "big-router": {
// {
// SrcIPs: []string{"100.64.0.0/10", "fd7a:115c:a1e0::/48"},
// DstPorts: []tailcfg.NetPortRange{{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny}},
// IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
// },
// },
// },
//
// ACTUAL (Headscale) - current behavior:
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"ha-router1": nil,
"ha-router2": nil,
"user1": nil,
// subnet-router owns 10.33.0.0/16 - exact match (CORRECT)
"subnet-router": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// big-router owns 10.0.0.0/8 which covers 10.33.0.0/16 (CORRECT)
"big-router": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// INCORRECT: exit-node gets filter because 0.0.0.0/0 "covers" destination
// Tailscale would return nil here
"exit-node": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// INCORRECT: multi-router gets filter because 0.0.0.0/0 "covers" destination
// Tailscale would return nil here
"multi-router": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// TODO: Fix exit node route coverage to match Tailscale behavior
//
// A3b: autogroup:member to subnet - SrcIPs = member IPs only
//
// TAILSCALE BEHAVIOR:
// - autogroup:member = user-owned nodes only (client1, client2, user1)
// - Filter goes to subnet-router (exact match) and big-router (parent route)
// - exit-node and multi-router get NO filter (exit routes don't cover)
//
// HEADSCALE BEHAVIOR:
// - exit-node and multi-router incorrectly receive filters because
// exit routes (0.0.0.0/0) are treated as covering all destinations
//
// ROOT CAUSE:
// Same as A3 - exit routes should not count for filter distribution
//
// FIX REQUIRED:
// Exclude exit routes (0.0.0.0/0 and ::/0) from coverage checks
{
name: "A3b_autogroup_member_to_subnet",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["autogroup:member"], "dst": ["10.33.0.0/16:*"]}
`),
// EXPECTED (Tailscale) - commented out:
// wantFilters: map[string][]tailcfg.FilterRule{
// "client1": nil,
// "client2": nil,
// "exit-node": nil, // Exit route does NOT cover
// "ha-router1": nil,
// "ha-router2": nil,
// "user1": nil,
// "multi-router": nil, // Exit route does NOT cover
// "subnet-router": { ... }, // Exact match
// "big-router": { ... }, // Parent route covers
// },
//
// ACTUAL (Headscale) - current behavior:
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"ha-router1": nil,
"ha-router2": nil,
"user1": nil,
// CORRECT: subnet-router gets filter (exact match)
"subnet-router": {
{
SrcIPs: []string{
"100.89.42.23/32", // client2
"100.90.199.68/32", // user1
"100.116.73.38/32", // client1
"fd7a:115c:a1e0::d01:2a2e/128",
"fd7a:115c:a1e0::2d01:c747/128",
"fd7a:115c:a1e0::a801:4949/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// CORRECT: big-router gets filter (parent route 10.0.0.0/8 covers)
"big-router": {
{
SrcIPs: []string{
"100.89.42.23/32",
"100.90.199.68/32",
"100.116.73.38/32",
"fd7a:115c:a1e0::d01:2a2e/128",
"fd7a:115c:a1e0::2d01:c747/128",
"fd7a:115c:a1e0::a801:4949/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// INCORRECT: exit-node receives filter due to 0.0.0.0/0 coverage
// Tailscale would return nil here
"exit-node": {
{
SrcIPs: []string{
"100.89.42.23/32",
"100.90.199.68/32",
"100.116.73.38/32",
"fd7a:115c:a1e0::d01:2a2e/128",
"fd7a:115c:a1e0::2d01:c747/128",
"fd7a:115c:a1e0::a801:4949/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// INCORRECT: multi-router receives filter due to 0.0.0.0/0 coverage
// Tailscale would return nil here
"multi-router": {
{
SrcIPs: []string{
"100.89.42.23/32",
"100.90.199.68/32",
"100.116.73.38/32",
"fd7a:115c:a1e0::d01:2a2e/128",
"fd7a:115c:a1e0::2d01:c747/128",
"fd7a:115c:a1e0::a801:4949/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// TODO: Fix exit node route coverage to match Tailscale behavior
//
// A4: Multiple routes on same router (172.16.0.0/24 destination)
//
// TAILSCALE BEHAVIOR:
// - multi-router has 172.16.0.0/24, should get filter (exact match)
// - exit-node has 0.0.0.0/0 but does NOT cover 172.16.0.0/24 for filter placement
//
// HEADSCALE BEHAVIOR:
// - multi-router correctly gets filter
// - exit-node incorrectly gets filter because 0.0.0.0/0 is treated as covering
//
// ROOT CAUSE:
// Same as A3 - exit routes should not count for filter distribution
//
// FIX REQUIRED:
// Exclude exit routes from coverage checks
{
name: "A4_multiple_routes_same_router",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["172.16.0.0/24:*"]}
`),
// EXPECTED (Tailscale) - commented out:
// wantFilters: map[string][]tailcfg.FilterRule{
// "client1": nil,
// "client2": nil,
// "subnet-router": nil,
// "exit-node": nil, // 0.0.0.0/0 does NOT cover for filter placement
// "ha-router1": nil,
// "ha-router2": nil,
// "big-router": nil,
// "user1": nil,
// "multi-router": { ... }, // Exact match
// },
//
// ACTUAL (Headscale) - current behavior:
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"subnet-router": nil,
"ha-router1": nil,
"ha-router2": nil,
"big-router": nil,
"user1": nil,
// CORRECT: multi-router gets filter (exact match for 172.16.0.0/24)
"multi-router": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "172.16.0.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// INCORRECT: exit-node receives filter due to 0.0.0.0/0 coverage
// Tailscale would return nil here
"exit-node": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "172.16.0.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// TODO: Fix exit node route coverage to match Tailscale behavior
//
// A5: Host alias to subnet (uses "internal" = "10.0.0.0/8")
//
// TAILSCALE BEHAVIOR:
// - "internal" resolves to 10.0.0.0/8 via hosts alias
// - big-router (10.0.0.0/8) gets filter - exact match
// - subnet-router (10.33.0.0/16) gets filter - child route
// - exit-node and multi-router get NO filter (exit routes don't cover)
//
// HEADSCALE BEHAVIOR:
// - big-router and subnet-router correctly get filters
// - exit-node and multi-router incorrectly get filters (exit route coverage)
//
// ROOT CAUSE:
// Same as A3 - exit routes should not count for filter distribution
//
// FIX REQUIRED:
// Exclude exit routes from coverage checks
{
name: "A5_host_alias_to_subnet",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["internal:22"]}
`),
// EXPECTED (Tailscale) - commented out:
// wantFilters: map[string][]tailcfg.FilterRule{
// "client1": nil,
// "client2": nil,
// "exit-node": nil, // Exit route does NOT cover
// "ha-router1": nil,
// "ha-router2": nil,
// "user1": nil,
// "multi-router": nil, // Exit route does NOT cover
// "subnet-router": { ... }, // Child route
// "big-router": { ... }, // Exact match
// },
//
// ACTUAL (Headscale) - current behavior:
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"ha-router1": nil,
"ha-router2": nil,
"user1": nil,
// CORRECT: subnet-router gets filter (child route 10.33.0.0/16 within 10.0.0.0/8)
"subnet-router": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.0.0.0/8", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// CORRECT: big-router gets filter (exact match for 10.0.0.0/8)
"big-router": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.0.0.0/8", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// INCORRECT: exit-node receives filter due to 0.0.0.0/0 coverage
// Tailscale would return nil here
"exit-node": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.0.0.0/8", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// INCORRECT: multi-router receives filter due to 0.0.0.0/0 coverage
// Tailscale would return nil here
"multi-router": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.0.0.0/8", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
pol, err := unmarshalPolicy([]byte(tt.policy))
require.NoError(t, err, "failed to parse policy")
err = pol.validate()
require.NoError(t, err, "policy validation failed")
for nodeName, wantFilters := range tt.wantFilters {
node := findNodeByGivenName(nodes, nodeName)
require.NotNil(t, node, "node %s not found", nodeName)
compiledFilters, err := pol.compileFilterRulesForNode(users, node.View(), nodes.ViewSlice())
require.NoError(t, err, "failed to compile filters for node %s", nodeName)
gotFilters := policyutil.ReduceFilterRules(node.View(), compiledFilters)
if len(wantFilters) == 0 && len(gotFilters) == 0 {
continue
}
if diff := cmp.Diff(wantFilters, gotFilters, cmpOptions()...); diff != "" {
t.Errorf("node %s filters mismatch (-want +got):\n%s", nodeName, diff)
}
}
})
}
}
// TestTailscaleRoutesCompatExitNodes tests exit node behavior (Category B).
// These tests verify that exit routes (0.0.0.0/0, ::/0) are NOT included in SrcIPs,
// that exit nodes can cover external destinations, and autogroup:internet behavior.
func TestTailscaleRoutesCompatExitNodes(t *testing.T) {
t.Parallel()
users := setupRouteCompatUsers()
nodes := setupRouteCompatNodes(users)
// Standard wildcard filter that all nodes receive for * -> *:* ACL
wildcardFilter := []tailcfg.FilterRule{
{
SrcIPs: wildcardSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
}
tests := []routesCompatTest{
// TODO: Verify Tailscale includes subnet routes in SrcIPs for wildcard ACLs
//
// B1: Exit routes NOT in SrcIPs with wildcard ACL
//
// TAILSCALE BEHAVIOR:
// - SrcIPs includes CGNAT + IPv6 Tailscale ranges
// - SrcIPs also includes advertised subnet routes (10.0.0.0/8, etc.)
// - Exit routes (0.0.0.0/0, ::/0) are NOT included in SrcIPs
//
// HEADSCALE BEHAVIOR:
// - SrcIPs only includes CGNAT + IPv6 Tailscale ranges
// - Subnet routes are NOT included in SrcIPs (might be a difference)
// - Exit routes correctly NOT included
//
// ROOT CAUSE:
// Headscale doesn't expand wildcard source to include subnet routes
//
// FIX REQUIRED (if needed):
// Add subnet routes to SrcIPs when source is wildcard
{
name: "B1_exit_routes_not_in_srcips",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["*:*"]}
`),
// All nodes receive the same wildcard filter
// Key verification: exit routes NOT in SrcIPs (they're not - correct!)
wantFilters: map[string][]tailcfg.FilterRule{
"exit-node": wildcardFilter,
"client1": wildcardFilter,
"client2": wildcardFilter,
"multi-router": wildcardFilter,
"subnet-router": wildcardFilter,
"ha-router1": wildcardFilter,
"ha-router2": wildcardFilter,
"big-router": wildcardFilter,
"user1": wildcardFilter,
},
},
// B2: tag:exit excludes exit routes from DstPorts
{
name: "B2_tag_exit_excludes_exit_routes",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["tag:exit"], "dst": ["tag:exit:*"]}
`),
// tag:exit includes: exit-node, multi-router
// DstPorts should contain ONLY their node IPs, NOT exit routes
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"subnet-router": nil,
"ha-router1": nil,
"ha-router2": nil,
"big-router": nil,
"user1": nil,
"exit-node": {
{
SrcIPs: []string{
"100.121.32.1/32", // exit-node IPv4
"100.74.117.7/32", // multi-router IPv4
"fd7a:115c:a1e0::7f01:2004/128", // exit-node IPv6
"fd7a:115c:a1e0::c401:7508/128", // multi-router IPv6
},
DstPorts: []tailcfg.NetPortRange{
// Node IPs only, NOT exit routes (0.0.0.0/0, ::/0)
{IP: "100.121.32.1/32", Ports: tailcfg.PortRangeAny},
{IP: "100.74.117.7/32", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::7f01:2004/128", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::c401:7508/128", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: []string{
"100.121.32.1/32",
"100.74.117.7/32",
"fd7a:115c:a1e0::7f01:2004/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "100.121.32.1/32", Ports: tailcfg.PortRangeAny},
{IP: "100.74.117.7/32", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::7f01:2004/128", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::c401:7508/128", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// TODO: Verify Tailscale includes subnet routes in SrcIPs
//
// B4: Multi-router has both subnet and exit routes
//
// TAILSCALE BEHAVIOR:
// - multi-router has 172.16.0.0/24 (subnet) + 0.0.0.0/0,::/0 (exit)
// - SrcIPs may include 172.16.0.0/24 but NOT 0.0.0.0/0 or ::/0
// - Only multi-router node may receive the filter (needs verification)
//
// HEADSCALE BEHAVIOR:
// - All nodes receive the same wildcard filter
// - SrcIPs is just CGNAT + IPv6 range, no subnet routes
//
// ROOT CAUSE:
// Headscale distributes wildcard filters to all nodes
{
name: "B4_multi_router_has_both_route_types",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["*:*"]}
`),
// EXPECTED (Tailscale) - commented out:
// wantFilters: map[string][]tailcfg.FilterRule{
// "multi-router": {
// {
// SrcIPs: []string{
// "100.64.0.0/10",
// "fd7a:115c:a1e0::/48",
// // Tailscale may include 172.16.0.0/24 here
// // but definitely NOT 0.0.0.0/0 or ::/0
// },
// DstPorts: []tailcfg.NetPortRange{
// {IP: "100.64.0.0/10", Ports: tailcfg.PortRangeAny},
// {IP: "fd7a:115c:a1e0::/48", Ports: tailcfg.PortRangeAny},
// },
// IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
// },
// },
// "client1": nil,
// "client2": nil,
// "subnet-router": nil,
// "exit-node": nil,
// "ha-router1": nil,
// "ha-router2": nil,
// "big-router": nil,
// "user1": nil,
// },
//
// ACTUAL (Headscale) - all nodes get wildcard filter:
wantFilters: map[string][]tailcfg.FilterRule{
"multi-router": wildcardFilter,
"client1": wildcardFilter,
"client2": wildcardFilter,
"subnet-router": wildcardFilter,
"exit-node": wildcardFilter,
"ha-router1": wildcardFilter,
"ha-router2": wildcardFilter,
"big-router": wildcardFilter,
"user1": wildcardFilter,
},
},
// B8: autogroup:internet generates no filters
//
// autogroup:internet is handled by exit node routing via AllowedIPs,
// not by packet filtering. ALL nodes should get null/empty filters.
{
name: "B8_autogroup_internet_no_filters",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["autogroup:member"], "dst": ["autogroup:internet:*"]}
`),
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"subnet-router": nil,
"exit-node": nil,
"multi-router": nil,
"ha-router1": nil,
"ha-router2": nil,
"big-router": nil,
"user1": nil,
},
},
// B3: Exit node advertises exit routes (verify RoutableIPs)
//
// This test verifies that exit-node has 0.0.0.0/0 and ::/0 in RoutableIPs.
// All nodes get wildcard filters with {IP: "*"} format matching Tailscale.
{
name: "B3_exit_node_advertises_routes",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["*:*"]}
`),
wantFilters: map[string][]tailcfg.FilterRule{
"client1": wildcardFilter,
"client2": wildcardFilter,
"subnet-router": wildcardFilter,
"exit-node": wildcardFilter,
"multi-router": wildcardFilter,
"ha-router1": wildcardFilter,
"ha-router2": wildcardFilter,
"big-router": wildcardFilter,
"user1": wildcardFilter,
},
},
// B5: Exit node with wildcard destination has ExitNodeOption
//
// Exit nodes should have ExitNodeOption=true in MapResponse.
// All nodes get wildcard filters with {IP: "*"} format matching Tailscale.
{
name: "B5_exit_with_wildcard_dst",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["*:*"]}
`),
wantFilters: map[string][]tailcfg.FilterRule{
"client1": wildcardFilter,
"client2": wildcardFilter,
"subnet-router": wildcardFilter,
"exit-node": wildcardFilter,
"multi-router": wildcardFilter,
"ha-router1": wildcardFilter,
"ha-router2": wildcardFilter,
"big-router": wildcardFilter,
"user1": wildcardFilter,
},
},
// TODO: Verify Tailscale filter distribution for tag source with wildcard destination
//
// B6: ExitNodeOption field verification
//
// ACL: tag:exit -> *:*
// Nodes with approved exit routes should have ExitNodeOption=true.
//
// TAILSCALE BEHAVIOR:
// - Need to verify if only exit-tagged nodes receive filters
// - Or if ALL nodes (destinations) receive filters
//
// HEADSCALE BEHAVIOR:
// - ALL nodes receive filters (they're all destinations)
// - SrcIPs = tag:exit node IPs
// - DstPorts = explicit CIDR ranges (not "*")
//
// ROOT CAUSE:
// The test expected only exit-tagged nodes to get filters, but with
// `tag:exit -> *:*`, all nodes are destinations and should get filters.
{
name: "B6_exit_node_option_field",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["tag:exit"], "dst": ["*:*"]}
`),
/* EXPECTED (Tailscale) - need verification:
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil, // Or filter?
"client2": nil, // Or filter?
"subnet-router": nil, // Or filter?
"ha-router1": nil, // Or filter?
"ha-router2": nil, // Or filter?
"big-router": nil, // Or filter?
"user1": nil, // Or filter?
"exit-node": {
{
SrcIPs: []string{
"100.121.32.1/32",
"100.74.117.7/32",
"fd7a:115c:a1e0::7f01:2004/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "*", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": { ... },
},
*/
// ACTUAL (Headscale):
// All nodes receive filters (they're all destinations)
wantFilters: map[string][]tailcfg.FilterRule{
"client1": {
{
SrcIPs: []string{
"100.74.117.7/32",
"100.121.32.1/32",
"fd7a:115c:a1e0::7f01:2004/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"client2": {
{
SrcIPs: []string{
"100.74.117.7/32",
"100.121.32.1/32",
"fd7a:115c:a1e0::7f01:2004/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"subnet-router": {
{
SrcIPs: []string{
"100.74.117.7/32",
"100.121.32.1/32",
"fd7a:115c:a1e0::7f01:2004/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"exit-node": {
{
SrcIPs: []string{
"100.74.117.7/32",
"100.121.32.1/32",
"fd7a:115c:a1e0::7f01:2004/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: []string{
"100.74.117.7/32",
"100.121.32.1/32",
"fd7a:115c:a1e0::7f01:2004/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"ha-router1": {
{
SrcIPs: []string{
"100.74.117.7/32",
"100.121.32.1/32",
"fd7a:115c:a1e0::7f01:2004/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"ha-router2": {
{
SrcIPs: []string{
"100.74.117.7/32",
"100.121.32.1/32",
"fd7a:115c:a1e0::7f01:2004/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"big-router": {
{
SrcIPs: []string{
"100.74.117.7/32",
"100.121.32.1/32",
"fd7a:115c:a1e0::7f01:2004/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"user1": {
{
SrcIPs: []string{
"100.74.117.7/32",
"100.121.32.1/32",
"fd7a:115c:a1e0::7f01:2004/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// TODO: Same as B6 - verify Tailscale filter distribution
//
// B7: Multiple exit nodes verification
//
// ACL: tag:exit -> *:*
// Both exit-node and multi-router have tag:exit.
// Same pattern as B6 - all nodes are destinations and receive filters.
//
// TAILSCALE BEHAVIOR:
// - Need to verify actual filter distribution
//
// HEADSCALE BEHAVIOR:
// - All nodes receive filters (same as B6)
{
name: "B7_multiple_exit_nodes",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["tag:exit"], "dst": ["*:*"]}
`),
/* EXPECTED (Tailscale) - need verification:
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil, // Or filter?
// ... same pattern as B6
"exit-node": {
{
SrcIPs: []string{
"100.121.32.1/32",
"100.74.117.7/32",
"fd7a:115c:a1e0::7f01:2004/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "*", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": { ... },
},
*/
// ACTUAL (Headscale):
// All nodes receive filters (same as B6)
wantFilters: map[string][]tailcfg.FilterRule{
"client1": {
{
SrcIPs: []string{
"100.74.117.7/32",
"100.121.32.1/32",
"fd7a:115c:a1e0::7f01:2004/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"client2": {
{
SrcIPs: []string{
"100.74.117.7/32",
"100.121.32.1/32",
"fd7a:115c:a1e0::7f01:2004/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"subnet-router": {
{
SrcIPs: []string{
"100.74.117.7/32",
"100.121.32.1/32",
"fd7a:115c:a1e0::7f01:2004/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"exit-node": {
{
SrcIPs: []string{
"100.74.117.7/32",
"100.121.32.1/32",
"fd7a:115c:a1e0::7f01:2004/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: []string{
"100.74.117.7/32",
"100.121.32.1/32",
"fd7a:115c:a1e0::7f01:2004/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"ha-router1": {
{
SrcIPs: []string{
"100.74.117.7/32",
"100.121.32.1/32",
"fd7a:115c:a1e0::7f01:2004/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"ha-router2": {
{
SrcIPs: []string{
"100.74.117.7/32",
"100.121.32.1/32",
"fd7a:115c:a1e0::7f01:2004/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"big-router": {
{
SrcIPs: []string{
"100.74.117.7/32",
"100.121.32.1/32",
"fd7a:115c:a1e0::7f01:2004/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"user1": {
{
SrcIPs: []string{
"100.74.117.7/32",
"100.121.32.1/32",
"fd7a:115c:a1e0::7f01:2004/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// B9: Exit routes appear in peer AllowedIPs
//
// When viewing exit-node as a peer, AllowedIPs should include exit routes.
// All nodes get wildcard filters with {IP: "*"} format matching Tailscale.
{
name: "B9_exit_routes_in_allowedips",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["*:*"]}
`),
wantFilters: map[string][]tailcfg.FilterRule{
"client1": wildcardFilter,
"client2": wildcardFilter,
"subnet-router": wildcardFilter,
"exit-node": wildcardFilter,
"multi-router": wildcardFilter,
"ha-router1": wildcardFilter,
"ha-router2": wildcardFilter,
"big-router": wildcardFilter,
"user1": wildcardFilter,
},
},
// B10: Exit routes NOT in PrimaryRoutes field
//
// PrimaryRoutes is for subnet routes only, not exit routes.
// Exit routes (0.0.0.0/0, ::/0) should NOT appear in PrimaryRoutes.
// All nodes get wildcard filters with {IP: "*"} format matching Tailscale.
{
name: "B10_exit_routes_not_in_primaryroutes",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["*:*"]}
`),
wantFilters: map[string][]tailcfg.FilterRule{
"client1": wildcardFilter,
"client2": wildcardFilter,
"subnet-router": wildcardFilter,
"exit-node": wildcardFilter,
"multi-router": wildcardFilter,
"ha-router1": wildcardFilter,
"ha-router2": wildcardFilter,
"big-router": wildcardFilter,
"user1": wildcardFilter,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
pol, err := unmarshalPolicy([]byte(tt.policy))
require.NoError(t, err, "failed to parse policy")
err = pol.validate()
require.NoError(t, err, "policy validation failed")
for nodeName, wantFilters := range tt.wantFilters {
node := findNodeByGivenName(nodes, nodeName)
require.NotNil(t, node, "node %s not found", nodeName)
compiledFilters, err := pol.compileFilterRulesForNode(users, node.View(), nodes.ViewSlice())
require.NoError(t, err, "failed to compile filters for node %s", nodeName)
gotFilters := policyutil.ReduceFilterRules(node.View(), compiledFilters)
if len(wantFilters) == 0 && len(gotFilters) == 0 {
continue
}
if diff := cmp.Diff(wantFilters, gotFilters, cmpOptions()...); diff != "" {
t.Errorf("node %s filters mismatch (-want +got):\n%s", nodeName, diff)
}
}
})
}
}
// TestTailscaleRoutesCompatHARouters tests HA router behavior (Category E).
// These tests verify that multiple routers can advertise the same subnet,
// and that both receive filters even though only one is primary.
func TestTailscaleRoutesCompatHARouters(t *testing.T) {
t.Parallel()
users := setupRouteCompatUsers()
nodes := setupRouteCompatNodes(users)
tests := []routesCompatTest{
// TODO: Fix exit node route coverage to match Tailscale behavior
//
// E1: Two HA routers advertise same subnet - both enabled
//
// ACL: * -> 192.168.1.0/24:*
// Both ha-router1 and ha-router2 advertise 192.168.1.0/24.
// Both should receive the filter (both are approved, one is primary).
//
// TAILSCALE BEHAVIOR:
// - Only HA routers get filters (exact route match)
// - Exit nodes do NOT get filters (exit routes don't cover for placement)
//
// HEADSCALE BEHAVIOR:
// - HA routers correctly get filters
// - Exit nodes also get filters because 0.0.0.0/0 "covers" destination
//
// ROOT CAUSE:
// Same as A3 - exit routes (0.0.0.0/0) are treated as covering all destinations
//
// FIX REQUIRED:
// Exclude exit routes from filter distribution coverage checks
{
name: "E1_ha_two_routers_same_subnet",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["192.168.1.0/24:*"]}
`),
/* EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"subnet-router": nil,
"exit-node": nil,
"multi-router": nil,
"big-router": nil,
"user1": nil,
"ha-router1": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "192.168.1.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"ha-router2": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "192.168.1.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
*/
// ACTUAL (Headscale):
// HA routers correctly get filters, but exit nodes also incorrectly get them
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"subnet-router": nil,
"big-router": nil,
"user1": nil,
// CORRECT: Both HA routers get the filter
"ha-router1": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "192.168.1.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"ha-router2": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "192.168.1.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// INCORRECT: Exit nodes get filters due to 0.0.0.0/0 coverage
// Tailscale would return nil here
"exit-node": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "192.168.1.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "192.168.1.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// TODO: Fix exit node route coverage to match Tailscale behavior
//
// E4: HA routers with host alias
//
// ACL: * -> subnet24:22 (subnet24 = 192.168.1.0/24)
// Same as E1 but uses host alias. Exit route coverage issue applies.
{
name: "E4_ha_both_get_filters_host_alias",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["subnet24:22"]}
`),
/* EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"subnet-router": nil,
"exit-node": nil,
"multi-router": nil,
"big-router": nil,
"user1": nil,
"ha-router1": { ... },
"ha-router2": { ... },
},
*/
// ACTUAL (Headscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"subnet-router": nil,
"big-router": nil,
"user1": nil,
"ha-router1": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "192.168.1.0/24", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"ha-router2": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "192.168.1.0/24", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// INCORRECT: Exit nodes get filters due to 0.0.0.0/0 coverage
"exit-node": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "192.168.1.0/24", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "192.168.1.0/24", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// TODO: Fix exit node route coverage to match Tailscale behavior
//
// E2: HA primary node appears in peer AllowedIPs
// Same exit route coverage issue as E1.
{
name: "E2_ha_primary_in_allowedips",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["192.168.1.0/24:*"]}
`),
/* EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"exit-node": nil,
"multi-router": nil,
// ... only HA routers get filters
},
*/
// ACTUAL (Headscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"subnet-router": nil,
"big-router": nil,
"user1": nil,
"ha-router1": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "192.168.1.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"ha-router2": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "192.168.1.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// INCORRECT: Exit nodes get filters
"exit-node": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "192.168.1.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "192.168.1.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// TODO: Fix exit node route coverage to match Tailscale behavior
//
// E3: HA secondary does NOT have route in AllowedIPs
// Same exit route coverage issue as E1.
{
name: "E3_ha_secondary_no_route_in_allowedips",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["192.168.1.0/24:*"]}
`),
/* EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"exit-node": nil,
"multi-router": nil,
// ... only HA routers get filters
},
*/
// ACTUAL (Headscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"subnet-router": nil,
"big-router": nil,
"user1": nil,
"ha-router1": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "192.168.1.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"ha-router2": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "192.168.1.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// INCORRECT: Exit nodes get filters
"exit-node": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "192.168.1.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "192.168.1.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// TODO: Fix exit node route coverage to match Tailscale behavior
//
// E5: First advertiser becomes primary, both HA routers get filters
//
// TAILSCALE BEHAVIOR:
// - Only HA routers get filters (they own 192.168.1.0/24)
// - Exit nodes do NOT get filters (exit routes don't count for coverage)
//
// HEADSCALE BEHAVIOR:
// - HA routers correctly get filters
// - Exit nodes also get filters because 0.0.0.0/0 "covers" destination
//
// ROOT CAUSE:
// Same as E1-E4 - exit routes (0.0.0.0/0) are treated as covering all destinations
//
// FIX REQUIRED:
// Exclude exit routes from filter distribution coverage checks
{
name: "E5_first_advertiser_is_primary",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["192.168.1.0/24:*"]}
`),
/* EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"subnet-router": nil,
"exit-node": nil,
"multi-router": nil,
"big-router": nil,
"user1": nil,
"ha-router1": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "192.168.1.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"ha-router2": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "192.168.1.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
*/
// ACTUAL (Headscale): Exit nodes incorrectly get filters
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"subnet-router": nil,
"big-router": nil,
"user1": nil,
"ha-router1": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "192.168.1.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"ha-router2": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "192.168.1.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// INCORRECT: Exit nodes get filters
"exit-node": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "192.168.1.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "192.168.1.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
}
runRoutesCompatTests(t, users, nodes, tests)
}
// TestTailscaleRoutesCompatFilterPlacement tests filter placement rules (Category F).
// These tests verify that filters go to DESTINATION nodes (route owners),
// not to source nodes, and that route coverage rules are applied correctly.
func TestTailscaleRoutesCompatFilterPlacement(t *testing.T) {
t.Parallel()
users := setupRouteCompatUsers()
nodes := setupRouteCompatNodes(users)
tests := []routesCompatTest{
// TODO: Fix exit node route coverage to match Tailscale behavior
//
// F1: Filter goes to destination node (route owner), not source
//
// TAILSCALE BEHAVIOR:
// - Filter placed on subnet-router (owns 10.33.0.0/16) and big-router (owns 10.0.0.0/8)
// - Source nodes (clients, user1) get null filters
// - Exit nodes do NOT get filters (exit routes don't count for coverage)
//
// HEADSCALE BEHAVIOR:
// - Correct for subnet-router and big-router
// - Exit nodes also get filters because 0.0.0.0/0 "covers" destination
//
// ROOT CAUSE:
// Exit routes (0.0.0.0/0) are treated as covering all destinations
//
// FIX REQUIRED:
// Exclude exit routes from filter distribution coverage checks
{
name: "F1_filter_on_destination_not_source",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["autogroup:member"], "dst": ["10.33.0.0/16:22"]}
`),
/* EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"exit-node": nil,
"multi-router": nil,
"ha-router1": nil,
"ha-router2": nil,
"subnet-router": {
{
SrcIPs: []string{
"100.116.73.38/32",
"100.89.42.23/32",
"100.90.199.68/32",
"fd7a:115c:a1e0::a801:4949/128",
"fd7a:115c:a1e0::d01:2a2e/128",
"fd7a:115c:a1e0::2d01:c747/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"big-router": {
{
SrcIPs: []string{
"100.116.73.38/32",
"100.89.42.23/32",
"100.90.199.68/32",
"fd7a:115c:a1e0::a801:4949/128",
"fd7a:115c:a1e0::d01:2a2e/128",
"fd7a:115c:a1e0::2d01:c747/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
*/
// ACTUAL (Headscale): Exit nodes incorrectly get filters
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"ha-router1": nil,
"ha-router2": nil,
"subnet-router": {
{
SrcIPs: []string{
"100.116.73.38/32",
"100.89.42.23/32",
"100.90.199.68/32",
"fd7a:115c:a1e0::a801:4949/128",
"fd7a:115c:a1e0::d01:2a2e/128",
"fd7a:115c:a1e0::2d01:c747/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"big-router": {
{
SrcIPs: []string{
"100.116.73.38/32",
"100.89.42.23/32",
"100.90.199.68/32",
"fd7a:115c:a1e0::a801:4949/128",
"fd7a:115c:a1e0::d01:2a2e/128",
"fd7a:115c:a1e0::2d01:c747/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// INCORRECT: Exit nodes get filters
"exit-node": {
{
SrcIPs: []string{
"100.116.73.38/32",
"100.89.42.23/32",
"100.90.199.68/32",
"fd7a:115c:a1e0::a801:4949/128",
"fd7a:115c:a1e0::d01:2a2e/128",
"fd7a:115c:a1e0::2d01:c747/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: []string{
"100.116.73.38/32",
"100.89.42.23/32",
"100.90.199.68/32",
"fd7a:115c:a1e0::a801:4949/128",
"fd7a:115c:a1e0::d01:2a2e/128",
"fd7a:115c:a1e0::2d01:c747/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// TODO: Fix DstPorts expansion for autogroup:member to match Tailscale behavior
//
// F2: Subnet as ACL source, autogroup:member as destination
//
// TAILSCALE BEHAVIOR:
// - Each member receives a filter with DstPorts containing ALL member IPs
// - client1's filter has DstPorts with client1, client2, user1 IPs
//
// HEADSCALE BEHAVIOR:
// - Each member receives filter with DstPorts containing ONLY its own IP
// - client1's filter has DstPorts with only client1's IP
//
// ROOT CAUSE:
// DstPorts is not expanded to include all autogroup:member IPs
//
// FIX REQUIRED:
// Expand autogroup:member in DstPorts to include all member IPs, not just self
{
name: "F2_subnet_as_acl_source",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["10.33.0.0/16"], "dst": ["autogroup:member:*"]}
`),
/* EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": {
{
SrcIPs: []string{"10.33.0.0/16"},
DstPorts: []tailcfg.NetPortRange{
{IP: "100.116.73.38/32", Ports: tailcfg.PortRangeAny},
{IP: "100.89.42.23/32", Ports: tailcfg.PortRangeAny},
{IP: "100.90.199.68/32", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::a801:4949/128", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::d01:2a2e/128", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"client2": {
{
SrcIPs: []string{"10.33.0.0/16"},
DstPorts: []tailcfg.NetPortRange{
{IP: "100.116.73.38/32", Ports: tailcfg.PortRangeAny},
{IP: "100.89.42.23/32", Ports: tailcfg.PortRangeAny},
{IP: "100.90.199.68/32", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::a801:4949/128", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::d01:2a2e/128", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"user1": {
{
SrcIPs: []string{"10.33.0.0/16"},
DstPorts: []tailcfg.NetPortRange{
{IP: "100.116.73.38/32", Ports: tailcfg.PortRangeAny},
{IP: "100.89.42.23/32", Ports: tailcfg.PortRangeAny},
{IP: "100.90.199.68/32", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::a801:4949/128", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::d01:2a2e/128", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"subnet-router": nil,
"exit-node": nil,
"multi-router": nil,
"ha-router1": nil,
"ha-router2": nil,
"big-router": nil,
},
*/
// ACTUAL (Headscale): DstPorts only contains self IP, not all member IPs
// Additionally, tagged nodes also incorrectly receive filters
wantFilters: map[string][]tailcfg.FilterRule{
// Members receive filters with ONLY self IP in DstPorts
"client1": {
{
SrcIPs: []string{"10.33.0.0/16"},
DstPorts: []tailcfg.NetPortRange{
// INCORRECT: Only client1's IPs, should include all members
{IP: "100.116.73.38/32", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::a801:4949/128", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"client2": {
{
SrcIPs: []string{"10.33.0.0/16"},
DstPorts: []tailcfg.NetPortRange{
// INCORRECT: Only client2's IPs
{IP: "100.89.42.23/32", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::d01:2a2e/128", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"user1": {
{
SrcIPs: []string{"10.33.0.0/16"},
DstPorts: []tailcfg.NetPortRange{
// INCORRECT: Only user1's IPs
{IP: "100.90.199.68/32", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// Tagged nodes should not get filters but do in Headscale
"subnet-router": nil,
"ha-router1": nil,
"ha-router2": nil,
"big-router": nil,
// INCORRECT: Exit nodes get filters with all member IPs in DstPorts
"exit-node": {
{
SrcIPs: []string{"10.33.0.0/16"},
DstPorts: []tailcfg.NetPortRange{
{IP: "100.89.42.23/32", Ports: tailcfg.PortRangeAny},
{IP: "100.90.199.68/32", Ports: tailcfg.PortRangeAny},
{IP: "100.116.73.38/32", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::a801:4949/128", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::d01:2a2e/128", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: []string{"10.33.0.0/16"},
DstPorts: []tailcfg.NetPortRange{
{IP: "100.89.42.23/32", Ports: tailcfg.PortRangeAny},
{IP: "100.90.199.68/32", Ports: tailcfg.PortRangeAny},
{IP: "100.116.73.38/32", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::a801:4949/128", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::d01:2a2e/128", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// TODO: Fix exit node route coverage to match Tailscale behavior
//
// F3: Wildcard source, specific subnet destination
//
// TAILSCALE BEHAVIOR:
// - Filter on subnet-router (owns 10.33.0.0/16) and big-router (owns 10.0.0.0/8)
// - Exit nodes do NOT get filters (exit routes don't count for coverage)
//
// HEADSCALE BEHAVIOR:
// - Correct for subnet-router and big-router
// - Exit nodes also get filters because 0.0.0.0/0 "covers" destination
//
// ROOT CAUSE:
// Exit routes (0.0.0.0/0) are treated as covering all destinations
//
// FIX REQUIRED:
// Exclude exit routes from filter distribution coverage checks
{
name: "F3_wildcard_src_specific_dst",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["10.33.0.0/16:22"]}
`),
/* EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"exit-node": nil,
"multi-router": nil,
"ha-router1": nil,
"ha-router2": nil,
"subnet-router": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"big-router": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
*/
// ACTUAL (Headscale): Exit nodes incorrectly get filters
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"ha-router1": nil,
"ha-router2": nil,
"subnet-router": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"big-router": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// INCORRECT: Exit nodes get filters
"exit-node": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// TODO: Fix exit node route coverage to match Tailscale behavior
//
// F7: Filter DstPorts shows ACL CIDR, not route CIDR
//
// TAILSCALE BEHAVIOR:
// - DstPorts.IP = ACL CIDR (10.33.1.0/24), not route CIDR
// - Only subnet-router and big-router get filters
// - Exit nodes do NOT get filters
//
// HEADSCALE BEHAVIOR:
// - DstPorts.IP correctly uses ACL CIDR (this part works)
// - Exit nodes also get filters because 0.0.0.0/0 "covers" destination
//
// ROOT CAUSE:
// Exit routes (0.0.0.0/0) are treated as covering all destinations
//
// FIX REQUIRED:
// Exclude exit routes from filter distribution coverage checks
{
name: "F7_filter_dstports_shows_acl_cidr",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["10.33.1.0/24:22"]}
`),
/* EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"exit-node": nil,
"multi-router": nil,
"ha-router1": nil,
"ha-router2": nil,
"subnet-router": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.1.0/24", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"big-router": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.1.0/24", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
*/
// ACTUAL (Headscale): Exit nodes incorrectly get filters
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"ha-router1": nil,
"ha-router2": nil,
"subnet-router": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.1.0/24", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"big-router": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.1.0/24", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// INCORRECT: Exit nodes get filters
"exit-node": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.1.0/24", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.1.0/24", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// TODO: Fix wildcard destination filter distribution to match Tailscale behavior
//
// F4: Specific source (tag:router), wildcard destination
//
// TAILSCALE BEHAVIOR:
// - Filter sent to all non-source nodes (all nodes except tag:router nodes)
// - Non-router nodes get filter, router nodes don't receive filter for their own traffic
//
// HEADSCALE BEHAVIOR:
// - All nodes get the filter, including the source nodes themselves
// - DstPorts uses expanded CGNAT ranges instead of "*"
//
// ROOT CAUSE:
// Wildcard destination distribution differs - Headscale sends to all nodes
// DstPorts format differs - Headscale expands "*" to CGNAT ranges
//
// FIX REQUIRED:
// Review wildcard destination distribution logic
{
name: "F4_specific_src_wildcard_dst",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["tag:router"], "dst": ["*:*"]}
`),
/* EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": {
{
SrcIPs: []string{
"100.100.100.1/32", // big-router
"100.119.139.79/32", // subnet-router
"100.74.117.7/32", // multi-router
"fd7a:115c:a1e0::4001:8ba0/128",
"fd7a:115c:a1e0::6401:6401/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "*", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"client2": nil,
"user1": nil,
"subnet-router": nil,
"exit-node": nil,
"multi-router": nil,
"ha-router1": nil,
"ha-router2": nil,
"big-router": nil,
},
*/
// ACTUAL (Headscale): All nodes get filters with expanded CGNAT ranges
wantFilters: map[string][]tailcfg.FilterRule{
"client1": {
{
SrcIPs: []string{
"100.74.117.7/32", // multi-router
"100.100.100.1/32", // big-router
"100.119.139.79/32", // subnet-router
"fd7a:115c:a1e0::4001:8ba0/128",
"fd7a:115c:a1e0::6401:6401/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"client2": {
{
SrcIPs: []string{
"100.74.117.7/32",
"100.100.100.1/32",
"100.119.139.79/32",
"fd7a:115c:a1e0::4001:8ba0/128",
"fd7a:115c:a1e0::6401:6401/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"user1": {
{
SrcIPs: []string{
"100.74.117.7/32",
"100.100.100.1/32",
"100.119.139.79/32",
"fd7a:115c:a1e0::4001:8ba0/128",
"fd7a:115c:a1e0::6401:6401/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"subnet-router": {
{
SrcIPs: []string{
"100.74.117.7/32",
"100.100.100.1/32",
"100.119.139.79/32",
"fd7a:115c:a1e0::4001:8ba0/128",
"fd7a:115c:a1e0::6401:6401/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"exit-node": {
{
SrcIPs: []string{
"100.74.117.7/32",
"100.100.100.1/32",
"100.119.139.79/32",
"fd7a:115c:a1e0::4001:8ba0/128",
"fd7a:115c:a1e0::6401:6401/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: []string{
"100.74.117.7/32",
"100.100.100.1/32",
"100.119.139.79/32",
"fd7a:115c:a1e0::4001:8ba0/128",
"fd7a:115c:a1e0::6401:6401/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"ha-router1": {
{
SrcIPs: []string{
"100.74.117.7/32",
"100.100.100.1/32",
"100.119.139.79/32",
"fd7a:115c:a1e0::4001:8ba0/128",
"fd7a:115c:a1e0::6401:6401/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"ha-router2": {
{
SrcIPs: []string{
"100.74.117.7/32",
"100.100.100.1/32",
"100.119.139.79/32",
"fd7a:115c:a1e0::4001:8ba0/128",
"fd7a:115c:a1e0::6401:6401/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"big-router": {
{
SrcIPs: []string{
"100.74.117.7/32",
"100.100.100.1/32",
"100.119.139.79/32",
"fd7a:115c:a1e0::4001:8ba0/128",
"fd7a:115c:a1e0::6401:6401/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// TODO: Fix bidirectional subnet access and DstPorts expansion to match Tailscale
//
// F5: Bidirectional subnet access
//
// TAILSCALE BEHAVIOR:
// - Rule 1 (member -> subnet): Filters on subnet-router and big-router only
// - Rule 2 (subnet -> member): All members get filter with all member IPs in DstPorts
// - Exit nodes do NOT get filters
//
// HEADSCALE BEHAVIOR:
// - All members get filters (rule 2 distribution to all)
// - DstPorts only contains self IP, not all member IPs
// - Exit nodes also get filters (exit route coverage issue)
//
// ROOT CAUSE:
// 1. autogroup:member DstPorts expansion only includes self
// 2. Exit routes treated as covering subnet destinations
//
// FIX REQUIRED:
// 1. Expand autogroup:member in DstPorts to all member IPs
// 2. Exclude exit routes from filter distribution coverage
{
name: "F5_bidirectional_subnet_access",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["autogroup:member"], "dst": ["10.33.0.0/16:*"]},
{"action": "accept", "src": ["10.33.0.0/16"], "dst": ["autogroup:member:*"]}
`),
/* EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": {
{
SrcIPs: []string{"10.33.0.0/16"},
DstPorts: []tailcfg.NetPortRange{
{IP: "100.116.73.38/32", Ports: tailcfg.PortRangeAny},
{IP: "100.89.42.23/32", Ports: tailcfg.PortRangeAny},
{IP: "100.90.199.68/32", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::a801:4949/128", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::d01:2a2e/128", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"client2": nil,
"user1": nil,
"exit-node": nil,
"multi-router": nil,
"ha-router1": nil,
"ha-router2": nil,
"subnet-router": {
{
SrcIPs: []string{
"100.116.73.38/32",
"100.89.42.23/32",
"100.90.199.68/32",
"fd7a:115c:a1e0::2d01:c747/128",
"fd7a:115c:a1e0::a801:4949/128",
"fd7a:115c:a1e0::d01:2a2e/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"big-router": {
{
SrcIPs: []string{
"100.116.73.38/32",
"100.89.42.23/32",
"100.90.199.68/32",
"fd7a:115c:a1e0::2d01:c747/128",
"fd7a:115c:a1e0::a801:4949/128",
"fd7a:115c:a1e0::d01:2a2e/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
*/
// ACTUAL (Headscale): Multiple issues
wantFilters: map[string][]tailcfg.FilterRule{
// All members get filters with self-only DstPorts
"client1": {
{
SrcIPs: []string{"10.33.0.0/16"},
DstPorts: []tailcfg.NetPortRange{
// INCORRECT: Only client1's IPs
{IP: "100.116.73.38/32", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::a801:4949/128", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"client2": {
{
SrcIPs: []string{"10.33.0.0/16"},
DstPorts: []tailcfg.NetPortRange{
{IP: "100.89.42.23/32", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::d01:2a2e/128", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"user1": {
{
SrcIPs: []string{"10.33.0.0/16"},
DstPorts: []tailcfg.NetPortRange{
{IP: "100.90.199.68/32", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"ha-router1": nil,
"ha-router2": nil,
"subnet-router": {
{
SrcIPs: []string{
"100.116.73.38/32",
"100.89.42.23/32",
"100.90.199.68/32",
"fd7a:115c:a1e0::2d01:c747/128",
"fd7a:115c:a1e0::a801:4949/128",
"fd7a:115c:a1e0::d01:2a2e/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"big-router": {
{
SrcIPs: []string{
"100.116.73.38/32",
"100.89.42.23/32",
"100.90.199.68/32",
"fd7a:115c:a1e0::2d01:c747/128",
"fd7a:115c:a1e0::a801:4949/128",
"fd7a:115c:a1e0::d01:2a2e/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// INCORRECT: Exit nodes get filters from BOTH rules
"exit-node": {
// First filter: from rule 1 (member -> subnet)
{
SrcIPs: []string{
"100.116.73.38/32",
"100.89.42.23/32",
"100.90.199.68/32",
"fd7a:115c:a1e0::2d01:c747/128",
"fd7a:115c:a1e0::a801:4949/128",
"fd7a:115c:a1e0::d01:2a2e/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
// Second filter: from rule 2 (subnet -> member)
// Exit node gets this because 0.0.0.0/0 "covers" member IPs
{
SrcIPs: []string{"10.33.0.0/16"},
DstPorts: []tailcfg.NetPortRange{
{IP: "100.89.42.23/32", Ports: tailcfg.PortRangeAny},
{IP: "100.90.199.68/32", Ports: tailcfg.PortRangeAny},
{IP: "100.116.73.38/32", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::d01:2a2e/128", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::a801:4949/128", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
// First filter: from rule 1 (member -> subnet)
{
SrcIPs: []string{
"100.116.73.38/32",
"100.89.42.23/32",
"100.90.199.68/32",
"fd7a:115c:a1e0::2d01:c747/128",
"fd7a:115c:a1e0::a801:4949/128",
"fd7a:115c:a1e0::d01:2a2e/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
// Second filter: from rule 2 (subnet -> member)
// Multi-router gets this because 0.0.0.0/0 "covers" member IPs
{
SrcIPs: []string{"10.33.0.0/16"},
DstPorts: []tailcfg.NetPortRange{
{IP: "100.89.42.23/32", Ports: tailcfg.PortRangeAny},
{IP: "100.90.199.68/32", Ports: tailcfg.PortRangeAny},
{IP: "100.116.73.38/32", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::d01:2a2e/128", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::a801:4949/128", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// TODO: Fix exit node route coverage to match Tailscale behavior
//
// F6: Filter SrcIPs expansion with autogroup:member
//
// TAILSCALE BEHAVIOR:
// - Only subnet-router and big-router get filters
// - Exit nodes do NOT get filters
//
// HEADSCALE BEHAVIOR:
// - Correct for subnet-router and big-router
// - Exit nodes also get filters because 0.0.0.0/0 "covers" destination
//
// ROOT CAUSE:
// Exit routes (0.0.0.0/0) are treated as covering all destinations
//
// FIX REQUIRED:
// Exclude exit routes from filter distribution coverage checks
{
name: "F6_filter_srcips_expansion",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["autogroup:member"], "dst": ["10.33.0.0/16:*"]}
`),
/* EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"exit-node": nil,
"multi-router": nil,
"ha-router1": nil,
"ha-router2": nil,
"subnet-router": {
{
SrcIPs: []string{
"100.116.73.38/32",
"100.89.42.23/32",
"100.90.199.68/32",
"fd7a:115c:a1e0::2d01:c747/128",
"fd7a:115c:a1e0::a801:4949/128",
"fd7a:115c:a1e0::d01:2a2e/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"big-router": {
{
SrcIPs: []string{
"100.116.73.38/32",
"100.89.42.23/32",
"100.90.199.68/32",
"fd7a:115c:a1e0::2d01:c747/128",
"fd7a:115c:a1e0::a801:4949/128",
"fd7a:115c:a1e0::d01:2a2e/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
*/
// ACTUAL (Headscale): Exit nodes incorrectly get filters
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"ha-router1": nil,
"ha-router2": nil,
"subnet-router": {
{
SrcIPs: []string{
"100.116.73.38/32",
"100.89.42.23/32",
"100.90.199.68/32",
"fd7a:115c:a1e0::2d01:c747/128",
"fd7a:115c:a1e0::a801:4949/128",
"fd7a:115c:a1e0::d01:2a2e/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"big-router": {
{
SrcIPs: []string{
"100.116.73.38/32",
"100.89.42.23/32",
"100.90.199.68/32",
"fd7a:115c:a1e0::2d01:c747/128",
"fd7a:115c:a1e0::a801:4949/128",
"fd7a:115c:a1e0::d01:2a2e/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// INCORRECT: Exit nodes get filters
"exit-node": {
{
SrcIPs: []string{
"100.116.73.38/32",
"100.89.42.23/32",
"100.90.199.68/32",
"fd7a:115c:a1e0::2d01:c747/128",
"fd7a:115c:a1e0::a801:4949/128",
"fd7a:115c:a1e0::d01:2a2e/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: []string{
"100.116.73.38/32",
"100.89.42.23/32",
"100.90.199.68/32",
"fd7a:115c:a1e0::2d01:c747/128",
"fd7a:115c:a1e0::a801:4949/128",
"fd7a:115c:a1e0::d01:2a2e/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// TODO: Fix policy validation to allow undefined tags (matching Tailscale behavior)
//
// F8: Route enabled but ACL source doesn't match any nodes
//
// TAILSCALE BEHAVIOR:
// - Policy is accepted even if tag doesn't exist (no nodes have that tag)
// - All nodes get null filters
//
// HEADSCALE BEHAVIOR:
// - Policy parsing fails with "Tag is not defined in the Policy"
// - Headscale requires all tags to be defined in tagOwners
//
// ROOT CAUSE:
// Headscale validates that all tags in ACLs are defined in tagOwners
// Tailscale allows undefined tags (they just match nothing)
//
// FIX REQUIRED:
// Either relax tag validation or accept that this is a stricter policy mode
// Using group:empty instead (defined but has no members)
{
name: "F8_route_enabled_acl_denies",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["group:empty"], "dst": ["10.33.0.0/16:*"]}
`),
// group:empty has no members, so no source IPs match
// All nodes should get null filters
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"subnet-router": nil,
"exit-node": nil,
"multi-router": nil,
"ha-router1": nil,
"ha-router2": nil,
"big-router": nil,
},
},
// TODO: Fix exit node route coverage to match Tailscale behavior
//
// F9: ACL allows traffic to subnet but no node has that route
//
// TAILSCALE BEHAVIOR:
// - No node has 10.99.0.0/16 route
// - No filters should be generated for any node
//
// HEADSCALE BEHAVIOR:
// - Exit nodes get filters because 0.0.0.0/0 "covers" 10.99.0.0/16
//
// ROOT CAUSE:
// Exit routes (0.0.0.0/0) are treated as covering all destinations
//
// FIX REQUIRED:
// Exclude exit routes from filter distribution coverage checks
{
name: "F9_route_disabled_acl_allows",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["10.99.0.0/16:*"]}
`),
/* EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"subnet-router": nil,
"exit-node": nil,
"multi-router": nil,
"ha-router1": nil,
"ha-router2": nil,
"big-router": nil,
},
*/
// ACTUAL (Headscale): Routers with covering routes get filters
// NOTE: big-router (10.0.0.0/8) covers 10.99.0.0/16, so it correctly gets filter
// Exit nodes also incorrectly get filters due to 0.0.0.0/0 coverage
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"subnet-router": nil,
"ha-router1": nil,
"ha-router2": nil,
// big-router (10.0.0.0/8) correctly covers 10.99.0.0/16
"big-router": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.99.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// INCORRECT: Exit nodes get filters
"exit-node": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.99.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.99.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
}
runRoutesCompatTests(t, users, nodes, tests)
}
// runRoutesCompatTests is a helper to run route compatibility tests.
func runRoutesCompatTests(t *testing.T, users types.Users, nodes types.Nodes, tests []routesCompatTest) {
t.Helper()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
pol, err := unmarshalPolicy([]byte(tt.policy))
require.NoError(t, err, "failed to parse policy")
err = pol.validate()
require.NoError(t, err, "policy validation failed")
for nodeName, wantFilters := range tt.wantFilters {
node := findNodeByGivenName(nodes, nodeName)
require.NotNil(t, node, "node %s not found", nodeName)
compiledFilters, err := pol.compileFilterRulesForNode(users, node.View(), nodes.ViewSlice())
require.NoError(t, err, "failed to compile filters for node %s", nodeName)
gotFilters := policyutil.ReduceFilterRules(node.View(), compiledFilters)
if len(wantFilters) == 0 && len(gotFilters) == 0 {
continue
}
if diff := cmp.Diff(wantFilters, gotFilters, cmpOptions()...); diff != "" {
t.Errorf("node %s filters mismatch (-want +got):\n%s", nodeName, diff)
}
}
})
}
}
// TestTailscaleRoutesCompatRouteCoverage tests route coverage rules (Category R).
// These tests verify that:
// - Route coverage: R.Bits() <= D.Bits() && R.Contains(D.Addr())
// - Exit nodes (0.0.0.0/0) receive filters for ANY destination
// - Parent routes cover child destinations.
func TestTailscaleRoutesCompatRouteCoverage(t *testing.T) {
t.Parallel()
users := setupRouteCompatUsers()
nodes := setupRouteCompatNodes(users)
tests := []routesCompatTest{
// R1: Exit route covers external destination
{
name: "R1_exit_covers_external_dest",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["8.8.8.0/24:53"]}
`),
// 8.8.8.0/24 is external (Google DNS range)
// Exit nodes (0.0.0.0/0) should receive the filter because they cover it
// TODO: Verify this is Tailscale behavior
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"subnet-router": nil, // 10.33.0.0/16 doesn't cover 8.8.8.0/24
"ha-router1": nil,
"ha-router2": nil,
"big-router": nil, // 10.0.0.0/8 doesn't cover 8.8.8.0/24
"user1": nil,
// Exit nodes cover 8.8.8.0/24
"exit-node": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "8.8.8.0/24", Ports: tailcfg.PortRange{First: 53, Last: 53}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "8.8.8.0/24", Ports: tailcfg.PortRange{First: 53, Last: 53}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// R2: Parent route covers child destination
// TODO: Exit route coverage issue - exit nodes get filters when they shouldn't.
// TAILSCALE BEHAVIOR: Exit nodes (0.0.0.0/0) do NOT receive filters for internal
// subnet destinations like 10.33.1.0/24. Only subnet-router and big-router get filters.
// HEADSCALE BEHAVIOR: Exit nodes also get filters because Headscale treats exit routes
// (0.0.0.0/0) as covering all IPv4 destinations, including internal ranges.
// ROOT CAUSE: routeCoversDestination() returns true for exit routes covering internal IPs.
{
name: "R2_parent_route_covers_child_dest",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["10.33.1.0/24:22"]}
`),
// big-router has 10.0.0.0/8 - covers 10.33.1.0/24
// subnet-router has 10.33.0.0/16 - also covers 10.33.1.0/24
// Both should receive the filter
/*
EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"exit-node": nil,
"multi-router": nil,
"ha-router1": nil,
"ha-router2": nil,
"user1": nil,
"subnet-router": { ... },
"big-router": { ... },
},
*/
// ACTUAL (Headscale): Exit nodes also get filters due to exit route coverage
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"ha-router1": nil,
"ha-router2": nil,
"user1": nil,
"subnet-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{{IP: "10.33.1.0/24", Ports: tailcfg.PortRange{First: 22, Last: 22}}},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"big-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{{IP: "10.33.1.0/24", Ports: tailcfg.PortRange{First: 22, Last: 22}}},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// Exit nodes incorrectly get filters (exit route 0.0.0.0/0 covers 10.33.1.0/24)
"exit-node": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{{IP: "10.33.1.0/24", Ports: tailcfg.PortRange{First: 22, Last: 22}}},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{{IP: "10.33.1.0/24", Ports: tailcfg.PortRange{First: 22, Last: 22}}},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// R3: Sibling routes don't cover each other
// TODO: Exit route coverage issue - exit nodes get filters when they shouldn't.
// TAILSCALE BEHAVIOR: Exit nodes do NOT receive filters for internal subnet destinations.
// subnet-router (10.33.0.0/16) correctly does NOT get filter (sibling doesn't cover sibling).
// HEADSCALE BEHAVIOR: Exit nodes get filters because 0.0.0.0/0 covers 10.34.0.0/16.
// ROOT CAUSE: routeCoversDestination() returns true for exit routes covering internal IPs.
{
name: "R3_sibling_routes_no_coverage",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["10.34.0.0/16:22"]}
`),
// 10.34.0.0/16 is a sibling to 10.33.0.0/16 (different /16 in 10.0.0.0/8)
// subnet-router (10.33.0.0/16) should NOT get filter
// big-router (10.0.0.0/8) SHOULD get filter (parent covers both)
/*
EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"subnet-router": nil,
"exit-node": nil,
"multi-router": nil,
"ha-router1": nil,
"ha-router2": nil,
"user1": nil,
"big-router": { ... },
},
*/
// ACTUAL (Headscale): Exit nodes also get filters due to exit route coverage
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"subnet-router": nil, // 10.33.0.0/16 doesn't cover 10.34.0.0/16 (correct)
"ha-router1": nil,
"ha-router2": nil,
"user1": nil,
// Only big-router covers 10.34.0.0/16
"big-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{{IP: "10.34.0.0/16", Ports: tailcfg.PortRange{First: 22, Last: 22}}},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// Exit nodes incorrectly get filters (exit route 0.0.0.0/0 covers 10.34.0.0/16)
"exit-node": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{{IP: "10.34.0.0/16", Ports: tailcfg.PortRange{First: 22, Last: 22}}},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{{IP: "10.34.0.0/16", Ports: tailcfg.PortRange{First: 22, Last: 22}}},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// R4: Exact match route
// TODO: Exit route coverage issue - exit nodes get filters when they shouldn't.
// TAILSCALE BEHAVIOR: Exit nodes do NOT receive filters for internal subnet destinations.
// HEADSCALE BEHAVIOR: Exit nodes get filters because 0.0.0.0/0 covers 10.33.0.0/16.
// ROOT CAUSE: routeCoversDestination() returns true for exit routes covering internal IPs.
{
name: "R4_exact_match_route",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["10.33.0.0/16:22"]}
`),
// Exact match: subnet-router has exactly 10.33.0.0/16
// big-router (10.0.0.0/8) also covers it
/*
EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"exit-node": nil,
"multi-router": nil,
"ha-router1": nil,
"ha-router2": nil,
"user1": nil,
"subnet-router": { ... },
"big-router": { ... },
},
*/
// ACTUAL (Headscale): Exit nodes also get filters due to exit route coverage
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"ha-router1": nil,
"ha-router2": nil,
"user1": nil,
"subnet-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 22, Last: 22}}},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"big-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 22, Last: 22}}},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// Exit nodes incorrectly get filters (exit route 0.0.0.0/0 covers 10.33.0.0/16)
"exit-node": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 22, Last: 22}}},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 22, Last: 22}}},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
}
runRoutesCompatTests(t, users, nodes, tests)
}
// TestTailscaleRoutesCompatOverlapping tests overlapping route behavior (Category O).
// These tests verify that multiple routers with overlapping routes all receive filters.
func TestTailscaleRoutesCompatOverlapping(t *testing.T) {
t.Parallel()
users := setupRouteCompatUsers()
nodes := setupRouteCompatNodes(users)
tests := []routesCompatTest{
// O2: HA routers both get filter
// TODO: Fix exit route coverage for HA route destinations
// TAILSCALE BEHAVIOR: Only ha-router1 and ha-router2 get filters.
// HEADSCALE BEHAVIOR: Exit nodes also get filters (0.0.0.0/0 covers 192.168.1.0/24).
// ROOT CAUSE: Exit route coverage.
// FIX REQUIRED: Exclude exit nodes from subnet-specific destinations.
{
name: "O2_ha_routers_both_get_filter",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["192.168.1.0/24:*"]}
`),
/*
EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"subnet-router": nil,
"exit-node": nil,
"multi-router": nil,
"big-router": nil,
"user1": nil,
"ha-router1": { filter with 192.168.1.0/24:* },
"ha-router2": { filter with 192.168.1.0/24:* },
},
*/
// ACTUAL (Headscale): Exit nodes also get filters
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"subnet-router": nil,
"big-router": nil,
"user1": nil,
"ha-router1": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "192.168.1.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"ha-router2": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "192.168.1.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// Exit nodes incorrectly get filters (exit route covers)
"exit-node": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "192.168.1.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "192.168.1.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// O3: Parent-child routes on different nodes
// TODO: Fix exit route coverage for subnet destinations
// TAILSCALE BEHAVIOR: Only subnet-router and big-router get filters.
// HEADSCALE BEHAVIOR: Exit nodes also get filters (0.0.0.0/0 covers 10.33.1.0/24).
// ROOT CAUSE: Exit route coverage.
// FIX REQUIRED: Exclude exit nodes from subnet-specific destinations.
{
name: "O3_parent_child_different_nodes",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["10.33.1.0/24:22"]}
`),
/*
EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"exit-node": nil,
"multi-router": nil,
"ha-router1": nil,
"ha-router2": nil,
"user1": nil,
"subnet-router": { filter with 10.33.1.0/24:22 },
"big-router": { filter with 10.33.1.0/24:22 },
},
*/
// ACTUAL (Headscale): Exit nodes also get filters
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"ha-router1": nil,
"ha-router2": nil,
"user1": nil,
"subnet-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.1.0/24", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"big-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.1.0/24", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// Exit nodes incorrectly get filters (exit route covers)
"exit-node": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.1.0/24", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.1.0/24", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// O6: Exit route expands filter distribution
{
name: "O6_exit_route_expands_filter_dist",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["8.8.8.0/24:53"]}
`),
// Only exit nodes cover 8.8.8.0/24
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"subnet-router": nil,
"ha-router1": nil,
"ha-router2": nil,
"big-router": nil,
"user1": nil,
"exit-node": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "8.8.8.0/24", Ports: tailcfg.PortRange{First: 53, Last: 53}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "8.8.8.0/24", Ports: tailcfg.PortRange{First: 53, Last: 53}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// O12: Filter dest is ACL CIDR, not route CIDR
// TODO: Fix exit route coverage for subnet destinations
// TAILSCALE BEHAVIOR: Only subnet-router and big-router get filters.
// HEADSCALE BEHAVIOR: Exit nodes also get filters (0.0.0.0/0 covers 10.33.1.0/24).
// ROOT CAUSE: Exit route coverage.
// FIX REQUIRED: Exclude exit nodes from subnet-specific destinations.
{
name: "O12_filter_dest_is_acl_cidr",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["10.33.1.0/24:22"]}
`),
/*
EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"exit-node": nil,
"multi-router": nil,
"ha-router1": nil,
"ha-router2": nil,
"user1": nil,
"subnet-router": { filter with 10.33.1.0/24:22 },
"big-router": { filter with 10.33.1.0/24:22 },
},
*/
// ACTUAL (Headscale): Exit nodes also get filters
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"ha-router1": nil,
"ha-router2": nil,
"user1": nil,
"subnet-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
// Must be ACL CIDR "10.33.1.0/24", NOT route "10.33.0.0/16"
{IP: "10.33.1.0/24", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"big-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
// Must be ACL CIDR "10.33.1.0/24", NOT route "10.0.0.0/8"
{IP: "10.33.1.0/24", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// Exit nodes incorrectly get filters (exit route covers)
"exit-node": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.1.0/24", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.1.0/24", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
}
runRoutesCompatTests(t, users, nodes, tests)
}
// TestTailscaleRoutesCompatTagResolution tests tag resolution behavior (Category T).
// These tests verify that tags resolve to node IPs only, NOT to routes.
func TestTailscaleRoutesCompatTagResolution(t *testing.T) {
t.Parallel()
users := setupRouteCompatUsers()
nodes := setupRouteCompatNodes(users)
tests := []routesCompatTest{
// TODO: Fix per-node DstPorts visibility and exit route coverage
//
// T1: Tags resolve to IPs, not routes
//
// TAILSCALE BEHAVIOR:
// - Only tag:router nodes (subnet-router, multi-router, big-router) get filters
// - DstPorts shows ALL tag:router node IPs to each node
// - exit-node does NOT get filter (not in tag:router)
//
// HEADSCALE BEHAVIOR:
// - Exit node also gets filter (0.0.0.0/0 route "covers" tag:router IPs)
// - Per-node DstPorts visibility: each node only sees its OWN IP in DstPorts
// (subnet-router sees only subnet-router IPs, big-router sees only big-router IPs)
//
// ROOT CAUSE:
// 1. Exit routes (0.0.0.0/0) are treated as covering all destinations
// 2. Filter reduction logic scopes DstPorts to per-node visibility
//
// FIX REQUIRED:
// 1. Exclude exit routes from tag-based filter distribution
// 2. Show full destination set to all destination nodes (not per-node scoped)
{
name: "T1_tags_resolve_to_ips_not_routes",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["tag:router"], "dst": ["tag:router:*"]}
`),
/* EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"exit-node": nil,
"ha-router1": nil,
"ha-router2": nil,
"user1": nil,
"subnet-router": { SrcIPs: all tag:router, DstPorts: ALL tag:router IPs },
"multi-router": { SrcIPs: all tag:router, DstPorts: ALL tag:router IPs },
"big-router": { SrcIPs: all tag:router, DstPorts: ALL tag:router IPs },
},
*/
// ACTUAL (Headscale): Exit gets filter, per-node DstPorts scoped to own IPs
// tag:router = subnet-router (100.119.139.79), multi-router (100.74.117.7), big-router (100.100.100.1)
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"ha-router1": nil,
"ha-router2": nil,
"user1": nil,
// INCORRECT: exit-node gets filter due to 0.0.0.0/0 coverage
"exit-node": {
{
SrcIPs: []string{
"100.100.100.1/32", // big-router
"100.119.139.79/32", // subnet-router
"100.74.117.7/32", // multi-router
"fd7a:115c:a1e0::4001:8ba0/128",
"fd7a:115c:a1e0::6401:6401/128",
"fd7a:115c:a1e0::c401:7508/128",
},
// exit-node sees all tag:router IPs (via 0.0.0.0/0 coverage)
DstPorts: []tailcfg.NetPortRange{
{IP: "100.100.100.1/32", Ports: tailcfg.PortRangeAny},
{IP: "100.119.139.79/32", Ports: tailcfg.PortRangeAny},
{IP: "100.74.117.7/32", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::4001:8ba0/128", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::6401:6401/128", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::c401:7508/128", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// INCORRECT: subnet-router only sees its own IPs in DstPorts
"subnet-router": {
{
SrcIPs: []string{
"100.100.100.1/32",
"100.119.139.79/32",
"100.74.117.7/32",
"fd7a:115c:a1e0::4001:8ba0/128",
"fd7a:115c:a1e0::6401:6401/128",
"fd7a:115c:a1e0::c401:7508/128",
},
// Per-node scoped: only subnet-router's own IPs
DstPorts: []tailcfg.NetPortRange{
{IP: "100.119.139.79/32", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::4001:8ba0/128", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// INCORRECT: multi-router sees ALL IPs (it has tag:router AND tag:exit with 0.0.0.0/0)
"multi-router": {
{
SrcIPs: []string{
"100.100.100.1/32",
"100.119.139.79/32",
"100.74.117.7/32",
"fd7a:115c:a1e0::4001:8ba0/128",
"fd7a:115c:a1e0::6401:6401/128",
"fd7a:115c:a1e0::c401:7508/128",
},
// multi-router sees ALL tag:router IPs (it has 0.0.0.0/0 exit route coverage)
DstPorts: []tailcfg.NetPortRange{
{IP: "100.100.100.1/32", Ports: tailcfg.PortRangeAny},
{IP: "100.119.139.79/32", Ports: tailcfg.PortRangeAny},
{IP: "100.74.117.7/32", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::4001:8ba0/128", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::6401:6401/128", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::c401:7508/128", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// INCORRECT: big-router only sees its own IPs in DstPorts
"big-router": {
{
SrcIPs: []string{
"100.100.100.1/32",
"100.119.139.79/32",
"100.74.117.7/32",
"fd7a:115c:a1e0::4001:8ba0/128",
"fd7a:115c:a1e0::6401:6401/128",
"fd7a:115c:a1e0::c401:7508/128",
},
// Per-node scoped: only big-router's own IPs
DstPorts: []tailcfg.NetPortRange{
{IP: "100.100.100.1/32", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::6401:6401/128", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// T2: tag:exit to tag:exit
{
name: "T2_tag_to_tag_with_exit",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["tag:exit"], "dst": ["tag:exit:*"]}
`),
// tag:exit = exit-node, multi-router
// DstPorts = node IPs only, NOT exit routes
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"subnet-router": nil,
"ha-router1": nil,
"ha-router2": nil,
"big-router": nil,
"user1": nil,
"exit-node": {
{
SrcIPs: []string{
"100.121.32.1/32",
"100.74.117.7/32",
"fd7a:115c:a1e0::7f01:2004/128",
"fd7a:115c:a1e0::c401:7508/128",
},
// Node IPs only - no exit routes 0.0.0.0/0, ::/0
DstPorts: []tailcfg.NetPortRange{
{IP: "100.121.32.1/32", Ports: tailcfg.PortRangeAny},
{IP: "100.74.117.7/32", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::7f01:2004/128", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::c401:7508/128", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: []string{
"100.121.32.1/32",
"100.74.117.7/32",
"fd7a:115c:a1e0::7f01:2004/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "100.121.32.1/32", Ports: tailcfg.PortRangeAny},
{IP: "100.74.117.7/32", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::7f01:2004/128", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::c401:7508/128", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// T5: Multi-tag node appears in both src and dst
{
name: "T5_multi_tag_node_in_both",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["tag:router"], "dst": ["tag:exit:*"]}
`),
// multi-router has BOTH tag:router and tag:exit
// It should appear in BOTH SrcIPs (as router) and DstPorts (as exit)
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"subnet-router": nil,
"ha-router1": nil,
"ha-router2": nil,
"big-router": nil,
"user1": nil,
"exit-node": {
{
// Source: tag:router nodes
SrcIPs: []string{
"100.100.100.1/32", // big-router
"100.119.139.79/32", // subnet-router
"100.74.117.7/32", // multi-router (has both tags)
"fd7a:115c:a1e0::4001:8ba0/128",
"fd7a:115c:a1e0::6401:6401/128",
"fd7a:115c:a1e0::c401:7508/128",
},
// Dest: tag:exit nodes (exit-node + multi-router)
DstPorts: []tailcfg.NetPortRange{
{IP: "100.121.32.1/32", Ports: tailcfg.PortRangeAny},
{IP: "100.74.117.7/32", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::7f01:2004/128", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::c401:7508/128", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: []string{
"100.100.100.1/32",
"100.119.139.79/32",
"100.74.117.7/32",
"fd7a:115c:a1e0::4001:8ba0/128",
"fd7a:115c:a1e0::6401:6401/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "100.121.32.1/32", Ports: tailcfg.PortRangeAny},
{IP: "100.74.117.7/32", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::7f01:2004/128", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::c401:7508/128", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
}
runRoutesCompatTests(t, users, nodes, tests)
}
// TestTailscaleRoutesCompatProtocolPort tests protocol and port restrictions on subnet routes.
// Category G: Tests from 13-route-acl-interactions.md focusing on protocol/port handling.
func TestTailscaleRoutesCompatProtocolPort(t *testing.T) {
t.Parallel()
users := setupRouteCompatUsers()
nodes := setupRouteCompatNodes(users)
tests := []routesCompatTest{
// TODO: Fix exit node route coverage to match Tailscale behavior
//
// G1: Port restriction on subnet (22 only)
//
// TAILSCALE BEHAVIOR:
// - Only subnet-router and big-router get filters
// - Exit nodes do NOT get filters for subnet destinations
//
// HEADSCALE BEHAVIOR:
// - Exit nodes also get filters because 0.0.0.0/0 "covers" everything
//
// ROOT CAUSE:
// Exit routes (0.0.0.0/0) are treated as covering all destinations
//
// FIX REQUIRED:
// Exclude exit routes from filter distribution coverage checks
{
name: "G1_port_restriction_subnet",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["autogroup:member"], "dst": ["10.33.0.0/16:22"]}
`),
/* EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"ha-router1": nil,
"ha-router2": nil,
"exit-node": nil,
"multi-router": nil,
"subnet-router": { filter with port 22 },
"big-router": { filter with port 22 },
},
*/
// ACTUAL (Headscale): Exit nodes also get filters
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"ha-router1": nil,
"ha-router2": nil,
"subnet-router": {
{
SrcIPs: []string{
"100.116.73.38/32",
"100.89.42.23/32",
"100.90.199.68/32",
"fd7a:115c:a1e0::2d01:c747/128",
"fd7a:115c:a1e0::a801:4949/128",
"fd7a:115c:a1e0::d01:2a2e/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"big-router": {
{
SrcIPs: []string{
"100.116.73.38/32",
"100.89.42.23/32",
"100.90.199.68/32",
"fd7a:115c:a1e0::2d01:c747/128",
"fd7a:115c:a1e0::a801:4949/128",
"fd7a:115c:a1e0::d01:2a2e/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// INCORRECT: Exit nodes get filters due to 0.0.0.0/0 coverage
"exit-node": {
{
SrcIPs: []string{
"100.116.73.38/32",
"100.89.42.23/32",
"100.90.199.68/32",
"fd7a:115c:a1e0::2d01:c747/128",
"fd7a:115c:a1e0::a801:4949/128",
"fd7a:115c:a1e0::d01:2a2e/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: []string{
"100.116.73.38/32",
"100.89.42.23/32",
"100.90.199.68/32",
"fd7a:115c:a1e0::2d01:c747/128",
"fd7a:115c:a1e0::a801:4949/128",
"fd7a:115c:a1e0::d01:2a2e/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// TODO: Fix exit node route coverage to match Tailscale behavior
//
// G2: Port range on subnet (80-443)
//
// TAILSCALE BEHAVIOR:
// - Only subnet-router and big-router get filters
// - Exit nodes do NOT get filters for subnet destinations
//
// HEADSCALE BEHAVIOR:
// - Exit nodes also get filters because 0.0.0.0/0 "covers" everything
//
// ROOT CAUSE:
// Exit routes (0.0.0.0/0) are treated as covering all destinations
//
// FIX REQUIRED:
// Exclude exit routes from filter distribution coverage checks
{
name: "G2_port_range_subnet",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["10.33.0.0/16:80-443"]}
`),
/* EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"exit-node": nil,
"multi-router": nil,
"subnet-router": { filter with port 80-443 },
"big-router": { filter with port 80-443 },
},
*/
// ACTUAL (Headscale): Exit nodes also get filters
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"ha-router1": nil,
"ha-router2": nil,
"subnet-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 80, Last: 443}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"big-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 80, Last: 443}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// INCORRECT: Exit nodes get filters due to 0.0.0.0/0 coverage
"exit-node": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 80, Last: 443}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 80, Last: 443}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// TODO: Fix exit node route coverage to match Tailscale behavior
//
// G7: All ports wildcard
//
// TAILSCALE BEHAVIOR:
// - Only subnet-router and big-router get filters
// - Exit nodes do NOT get filters for subnet destinations
//
// HEADSCALE BEHAVIOR:
// - Exit nodes also get filters because 0.0.0.0/0 "covers" everything
//
// ROOT CAUSE:
// Exit routes (0.0.0.0/0) are treated as covering all destinations
//
// FIX REQUIRED:
// Exclude exit routes from filter distribution coverage checks
{
name: "G7_all_ports_wildcard",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["autogroup:member"], "dst": ["10.33.0.0/16:*"]}
`),
/* EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"exit-node": nil,
"multi-router": nil,
"subnet-router": { filter with all ports },
"big-router": { filter with all ports },
},
*/
// ACTUAL (Headscale): Exit nodes also get filters
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"ha-router1": nil,
"ha-router2": nil,
"subnet-router": {
{
SrcIPs: []string{
"100.116.73.38/32",
"100.89.42.23/32",
"100.90.199.68/32",
"fd7a:115c:a1e0::2d01:c747/128",
"fd7a:115c:a1e0::a801:4949/128",
"fd7a:115c:a1e0::d01:2a2e/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"big-router": {
{
SrcIPs: []string{
"100.116.73.38/32",
"100.89.42.23/32",
"100.90.199.68/32",
"fd7a:115c:a1e0::2d01:c747/128",
"fd7a:115c:a1e0::a801:4949/128",
"fd7a:115c:a1e0::d01:2a2e/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// INCORRECT: Exit nodes get filters due to 0.0.0.0/0 coverage
"exit-node": {
{
SrcIPs: []string{
"100.116.73.38/32",
"100.89.42.23/32",
"100.90.199.68/32",
"fd7a:115c:a1e0::2d01:c747/128",
"fd7a:115c:a1e0::a801:4949/128",
"fd7a:115c:a1e0::d01:2a2e/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: []string{
"100.116.73.38/32",
"100.89.42.23/32",
"100.90.199.68/32",
"fd7a:115c:a1e0::2d01:c747/128",
"fd7a:115c:a1e0::a801:4949/128",
"fd7a:115c:a1e0::d01:2a2e/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
}
runRoutesCompatTests(t, users, nodes, tests)
}
// TestTailscaleRoutesCompatIPv6 tests IPv6-specific route behavior.
// Category I: Tests from 15-overlapping-subnets.md focusing on IPv6 handling.
func TestTailscaleRoutesCompatIPv6(t *testing.T) {
t.Parallel()
users := setupRouteCompatUsers()
// Create nodes with IPv6 subnet routes
nodeClient1 := &types.Node{
ID: 1,
GivenName: "client1",
User: &users[0],
UserID: &users[0].ID,
IPv4: ptrAddr("100.116.73.38"),
IPv6: ptrAddr("fd7a:115c:a1e0::a801:4949"),
Hostinfo: &tailcfg.Hostinfo{},
ApprovedRoutes: []netip.Prefix{},
}
// IPv6 subnet router
nodeIPv6Router := &types.Node{
ID: 2,
GivenName: "ipv6-router",
IPv4: ptrAddr("100.119.139.80"),
IPv6: ptrAddr("fd7a:115c:a1e0::4001:8ba1"),
Tags: []string{"tag:router"},
Hostinfo: &tailcfg.Hostinfo{
RoutableIPs: []netip.Prefix{
netip.MustParsePrefix("fd00::/48"),
},
},
ApprovedRoutes: []netip.Prefix{
netip.MustParsePrefix("fd00::/48"),
},
}
// IPv6 child route (more specific)
nodeIPv6ChildRouter := &types.Node{
ID: 3,
GivenName: "ipv6-child-router",
IPv4: ptrAddr("100.119.139.81"),
IPv6: ptrAddr("fd7a:115c:a1e0::4001:8ba2"),
Tags: []string{"tag:router"},
Hostinfo: &tailcfg.Hostinfo{
RoutableIPs: []netip.Prefix{
netip.MustParsePrefix("fd00:1::/64"),
},
},
ApprovedRoutes: []netip.Prefix{
netip.MustParsePrefix("fd00:1::/64"),
},
}
// IPv6 exit node (with ::/0)
nodeIPv6Exit := &types.Node{
ID: 4,
GivenName: "ipv6-exit",
IPv4: ptrAddr("100.121.32.2"),
IPv6: ptrAddr("fd7a:115c:a1e0::7f01:2005"),
Tags: []string{"tag:exit"},
Hostinfo: &tailcfg.Hostinfo{
RoutableIPs: []netip.Prefix{
netip.MustParsePrefix("::/0"),
},
},
ApprovedRoutes: []netip.Prefix{
netip.MustParsePrefix("::/0"),
},
}
nodes := types.Nodes{nodeClient1, nodeIPv6Router, nodeIPv6ChildRouter, nodeIPv6Exit}
tests := []routesCompatTest{
// TODO: Fix wildcard DstPorts format, SrcIPs to include subnet routes, and filter distribution
//
// I1: IPv6 subnet route with wildcard ACL
//
// TAILSCALE BEHAVIOR:
// - SrcIPs includes IPv6 subnet route (fd00::/48) in wildcard expansion
// - DstPorts uses {IP: "*"} for wildcard destinations
// - Only client1 receives a filter (filter placed on destination node)
// - Other nodes (routers) do NOT receive filters for wildcard dst
//
// HEADSCALE BEHAVIOR:
// - SrcIPs doesn't include subnet routes, only CGNAT ranges
// - DstPorts expands to CGNAT ranges instead of "*"
// - ALL nodes receive filters (incorrect filter distribution)
//
// ROOT CAUSE:
// 1. Headscale doesn't include subnet routes in wildcard SrcIPs
// 2. Headscale expands "*" to CGNAT ranges instead of using "*"
// 3. Headscale distributes filters to all nodes instead of only the destination
//
// FIX REQUIRED:
// 1. Include advertised subnet routes in wildcard SrcIPs
// 2. Use {IP: "*"} for wildcard destinations
// 3. Fix filter distribution to only send to destination nodes
{
name: "I1_ipv6_subnet_route",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["*:*"]}
`),
/* EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": {
{
// Wildcard ACL - SrcIPs should include IPv6 route (fd00::/48)
SrcIPs: []string{
"100.64.0.0/10",
"fd00::/48", // IPv6 subnet route
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "*", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"ipv6-router": nil,
"ipv6-child-router": nil,
"ipv6-exit": nil,
},
*/
// ACTUAL (Headscale): All nodes get filters with CGNAT DstPorts
wantFilters: map[string][]tailcfg.FilterRule{
"client1": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// INCORRECT: All routers get filters (should be nil)
"ipv6-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"ipv6-child-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"ipv6-exit": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// TODO: Fix IPv6 parent route coverage
//
// I4: IPv6 specific ACL targeting fd00:1::/64
//
// TAILSCALE BEHAVIOR:
// - ipv6-router (fd00::/48) covers fd00:1::/64 - should get filter
// - ipv6-child-router (fd00:1::/64) exact match - should get filter
// - ipv6-exit (::/0) covers everything - should get filter
//
// HEADSCALE BEHAVIOR:
// - ipv6-router (fd00::/48) does NOT get filter - Headscale doesn't recognize
// that fd00::/48 covers fd00:1::/64 (parent route coverage not working)
// - ipv6-child-router (fd00:1::/64) gets filter (exact match works)
// - ipv6-exit (::/0) gets filter (IPv6 exit route coverage works)
//
// ROOT CAUSE:
// Headscale's route coverage logic doesn't properly handle IPv6 parent routes.
// fd00::/48 should cover fd00:1::/64 but Headscale doesn't recognize this.
//
// FIX REQUIRED:
// Fix IPv6 parent route coverage in filter distribution logic.
{
name: "I4_ipv6_specific_acl",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["fd00:1::/64:443"]}
`),
/* EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
// ipv6-router should get filter (fd00::/48 covers fd00:1::/64)
"ipv6-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "fd00:1::/64", Ports: tailcfg.PortRange{First: 443, Last: 443}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// ipv6-child-router should also get filter (exact match)
"ipv6-child-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "fd00:1::/64", Ports: tailcfg.PortRange{First: 443, Last: 443}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// ipv6-exit should get filter (::/0 covers everything)
"ipv6-exit": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "fd00:1::/64", Ports: tailcfg.PortRange{First: 443, Last: 443}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
*/
// ACTUAL (Headscale): ipv6-router doesn't get filter (parent route coverage broken)
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
// INCORRECT: ipv6-router doesn't get filter (should based on parent coverage)
"ipv6-router": nil,
// ipv6-child-router gets filter (exact match works)
"ipv6-child-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "fd00:1::/64", Ports: tailcfg.PortRange{First: 443, Last: 443}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// ipv6-exit gets filter (::/0 covers everything)
"ipv6-exit": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "fd00:1::/64", Ports: tailcfg.PortRange{First: 443, Last: 443}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// TODO: Fix IPv6 parent route coverage
//
// I5: IPv6 parent/child route coverage
//
// TAILSCALE BEHAVIOR:
// - ipv6-router (fd00::/48) covers fd00:1:2::/80 - should get filter
// - ipv6-child-router (fd00:1::/64) does NOT cover fd00:1:2::/80
// (fd00:1::/64 = fd00:0001:0000::/64, fd00:1:2::/80 = fd00:0001:0002::/80 - different)
// - ipv6-exit (::/0) covers everything - should get filter
//
// HEADSCALE BEHAVIOR:
// - ipv6-router (fd00::/48) does NOT get filter - Headscale doesn't recognize
// that fd00::/48 covers fd00:1:2::/80 (parent route coverage not working)
// - ipv6-child-router correctly gets nil (fd00:1::/64 doesn't cover fd00:1:2::/80)
// - ipv6-exit gets filter (::/0 covers everything)
//
// ROOT CAUSE:
// Headscale's route coverage logic doesn't properly handle IPv6 parent routes.
// fd00::/48 should cover fd00:1:2::/80 but Headscale doesn't recognize this.
//
// FIX REQUIRED:
// Fix IPv6 parent route coverage in filter distribution logic.
{
name: "I5_ipv6_parent_child_routes",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["autogroup:member"], "dst": ["fd00:1:2::/80:*"]}
`),
/* EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
// ipv6-router (fd00::/48) covers fd00:1:2::/80 - should get filter
"ipv6-router": {
{
SrcIPs: []string{
"100.116.73.38/32",
"fd7a:115c:a1e0::a801:4949/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "fd00:1:2::/80", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// ipv6-child-router (fd00:1::/64) does NOT cover fd00:1:2::/80
"ipv6-child-router": nil,
// ipv6-exit (::/0) covers everything
"ipv6-exit": {
{
SrcIPs: []string{
"100.116.73.38/32",
"fd7a:115c:a1e0::a801:4949/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "fd00:1:2::/80", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
*/
// ACTUAL (Headscale): ipv6-router doesn't get filter (parent route coverage broken)
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
// INCORRECT: ipv6-router doesn't get filter (should based on parent coverage)
"ipv6-router": nil,
// ipv6-child-router correctly doesn't get filter
// (fd00:1::/64 doesn't cover fd00:1:2::/80)
"ipv6-child-router": nil,
// ipv6-exit gets filter (::/0 covers everything)
"ipv6-exit": {
{
SrcIPs: []string{
"100.116.73.38/32",
"fd7a:115c:a1e0::a801:4949/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "fd00:1:2::/80", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// I7: IPv6 exit route coverage (external IPv6 destination)
{
name: "I7_ipv6_exit_coverage",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["2001:db8::/32:443"]}
`),
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"ipv6-router": nil, // fd00::/48 doesn't cover 2001:db8::/32
"ipv6-child-router": nil, // fd00:1::/64 doesn't cover 2001:db8::/32
// Only ipv6-exit (::/0) should get filter
"ipv6-exit": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "2001:db8::/32", Ports: tailcfg.PortRange{First: 443, Last: 443}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
}
runRoutesCompatTests(t, users, nodes, tests)
}
// TestTailscaleRoutesCompatEdgeCases tests edge cases and unusual configurations.
// Category H: Edge cases from various findings documents.
func TestTailscaleRoutesCompatEdgeCases(t *testing.T) {
t.Parallel()
users := setupRouteCompatUsers()
nodes := setupRouteCompatNodes(users)
tests := []routesCompatTest{
// TODO: Fix wildcard SrcIPs to include subnet routes like Tailscale
//
// H1: Verify wildcard SrcIPs format
//
// TAILSCALE BEHAVIOR:
// - SrcIPs includes CGNAT range + all advertised subnet routes
// - Exit nodes do NOT get filters for tag:router destination
//
// HEADSCALE BEHAVIOR:
// - SrcIPs only includes CGNAT range (no subnet routes)
// - Exit nodes also get filters due to exit route coverage
//
// ROOT CAUSE:
// 1. Headscale doesn't include subnet routes in wildcard SrcIPs
// 2. Exit routes (0.0.0.0/0) treated as covering all destinations
//
// FIX REQUIRED:
// 1. Include advertised subnet routes in wildcard SrcIPs
// 2. Exclude exit routes from filter distribution
{
name: "H1_wildcard_srcips_format",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["tag:router:*"]}
`),
/* EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"exit-node": nil,
"ha-router1": nil,
"ha-router2": nil,
"subnet-router": {
{
SrcIPs: []string{
"100.64.0.0/10", "fd7a:115c:a1e0::/48",
"10.0.0.0/8", "10.33.0.0/16", "172.16.0.0/24", "192.168.1.0/24", // routes!
},
DstPorts: ... tag:router IPs,
},
},
// ... multi-router and big-router with same SrcIPs pattern
},
*/
// ACTUAL (Headscale):
// - SrcIPs missing routes
// - DstPorts only contains node's own IPs (not all tag:router IPs)
// - exit-node gets filter
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"ha-router1": nil,
"ha-router2": nil,
// INCORRECT: DstPorts only contains self IPs, not all tag:router IPs
"subnet-router": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
// Only subnet-router's own IPs
{IP: "100.119.139.79/32", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::4001:8ba0/128", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// multi-router has tag:router AND tag:exit, gets all tag:router IPs
"multi-router": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
// All tag:router IPs
{IP: "100.100.100.1/32", Ports: tailcfg.PortRangeAny},
{IP: "100.119.139.79/32", Ports: tailcfg.PortRangeAny},
{IP: "100.74.117.7/32", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::4001:8ba0/128", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::6401:6401/128", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::c401:7508/128", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"big-router": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
// Only big-router's own IPs
{IP: "100.100.100.1/32", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::6401:6401/128", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// INCORRECT: Exit node gets filter (should be nil)
// Exit-node has tag:exit, NOT tag:router, so shouldn't get filter
// But due to exit route coverage, it gets ALL tag:router IPs
"exit-node": {
{
SrcIPs: []string{
"100.64.0.0/10",
"fd7a:115c:a1e0::/48",
},
DstPorts: []tailcfg.NetPortRange{
// All tag:router IPs (exit-node sees all because of route coverage)
{IP: "100.100.100.1/32", Ports: tailcfg.PortRangeAny},
{IP: "100.119.139.79/32", Ports: tailcfg.PortRangeAny},
{IP: "100.74.117.7/32", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::4001:8ba0/128", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::6401:6401/128", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::c401:7508/128", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// H9: Large prefix (/8) subnet route
// TODO: Fix exit route coverage and child route coverage
// TAILSCALE BEHAVIOR: Only big-router (has 10.0.0.0/8) gets the filter.
// subnet-router (10.33.0.0/16) is a CHILD of 10.0.0.0/8 - doesn't cover parent.
// Exit nodes do NOT get filters for specific subnet destinations.
// HEADSCALE BEHAVIOR: Exit nodes get filters (0.0.0.0/0 covers). subnet-router also
// gets filter (Headscale incorrectly treats child routes as covering).
// ROOT CAUSE: Two issues: (1) Exit route coverage, (2) Child route coverage.
// FIX REQUIRED: Exclude exit nodes and fix route coverage to only include parents.
{
name: "H9_large_prefix_works",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["autogroup:member"], "dst": ["10.0.0.0/8:*"]}
`),
/*
EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"ha-router1": nil,
"ha-router2": nil,
"big-router": {
{
SrcIPs: memberSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.0.0.0/8", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"subnet-router": nil,
"exit-node": nil,
"multi-router": nil,
},
*/
// ACTUAL (Headscale): Exit nodes and child routes also get filters
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"ha-router1": nil,
"ha-router2": nil,
"big-router": {
{
SrcIPs: memberSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.0.0.0/8", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// subnet-router incorrectly gets filter (child route coverage)
"subnet-router": {
{
SrcIPs: memberSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.0.0.0/8", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// Exit nodes incorrectly get filters (exit route covers)
"exit-node": {
{
SrcIPs: memberSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.0.0.0/8", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: memberSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.0.0.0/8", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// H2: Wildcard DstPorts format
// TODO: Fix wildcard DstPorts format and filter distribution
// TAILSCALE BEHAVIOR: DstPorts uses {IP: "*"} for wildcard destinations.
// Only client1 receives a filter (filter placed on destination node).
// HEADSCALE BEHAVIOR: DstPorts expands to CGNAT ranges (100.64.0.0/10, fd7a:115c:a1e0::/48).
// ALL nodes receive filters.
// ROOT CAUSE: Two issues: (1) Headscale expands "*" to CGNAT ranges instead of using "*",
// (2) Headscale distributes filters to all nodes instead of only the destination.
// FIX REQUIRED: Use {IP: "*"} for wildcard destinations and fix filter distribution.
{
name: "H2_wildcard_dstports_format",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["autogroup:member"], "dst": ["*:*"]}
`),
/*
EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": {
{
SrcIPs: memberSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "*", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"client2": nil,
"subnet-router": nil,
"exit-node": nil,
"multi-router": nil,
"ha-router1": nil,
"ha-router2": nil,
"big-router": nil,
"user1": nil,
},
*/
// ACTUAL (Headscale): DstPorts expanded to CGNAT, all nodes get filters
wantFilters: map[string][]tailcfg.FilterRule{
"client1": {
{
SrcIPs: memberSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"client2": {
{
SrcIPs: memberSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"subnet-router": {
{
SrcIPs: memberSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"exit-node": {
{
SrcIPs: memberSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: memberSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"ha-router1": {
{
SrcIPs: memberSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"ha-router2": {
{
SrcIPs: memberSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"big-router": {
{
SrcIPs: memberSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"user1": {
{
SrcIPs: memberSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// H3: CGNAT range expansion in wildcard
// TODO: Fix filter distribution and exit route coverage for tag destinations
// TAILSCALE BEHAVIOR: Only tag:router nodes (subnet-router, multi-router, big-router)
// receive filters. Each receives DstPorts containing ALL tag:router node IPs.
// HEADSCALE BEHAVIOR: Exit nodes also get filters (exit route covers tag:router IPs).
// Each node only sees its OWN IPs in DstPorts, not all tag:router IPs.
// ROOT CAUSE: Two issues: (1) Exit route coverage gives filters to exit-node,
// (2) Per-node DstPorts filtering shows only self IPs instead of all tag:router IPs.
// FIX REQUIRED: Exclude exit nodes from tag-based destinations, fix DstPorts to include all.
{
name: "H3_cgnat_range_expansion",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["tag:router:*"]}
`),
/*
EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"exit-node": nil,
"ha-router1": nil,
"ha-router2": nil,
"subnet-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
// All tag:router node IPs
{IP: "100.100.100.1/32", Ports: tailcfg.PortRangeAny}, // big-router
{IP: "100.119.139.79/32", Ports: tailcfg.PortRangeAny}, // subnet-router
{IP: "100.74.117.7/32", Ports: tailcfg.PortRangeAny}, // multi-router
{IP: "fd7a:115c:a1e0::4001:8ba0/128", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::6401:6401/128", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::c401:7508/128", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": nil, // Also tag:router but expected to get filter
"big-router": nil, // Also tag:router but expected to get filter
},
*/
// ACTUAL (Headscale): Each node only sees its own IPs in DstPorts,
// multi-router and big-router also get filters, exit-node incorrectly gets filter
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"ha-router1": nil,
"ha-router2": nil,
"subnet-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
// Only self IPs
{IP: "100.119.139.79/32", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::4001:8ba0/128", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
// All tag:router IPs (multi-router sees all)
{IP: "100.100.100.1/32", Ports: tailcfg.PortRangeAny},
{IP: "100.119.139.79/32", Ports: tailcfg.PortRangeAny},
{IP: "100.74.117.7/32", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::4001:8ba0/128", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::6401:6401/128", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::c401:7508/128", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"big-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
// Only self IPs
{IP: "100.100.100.1/32", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::6401:6401/128", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// exit-node incorrectly gets filter (exit route covers tag:router IPs)
"exit-node": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "100.100.100.1/32", Ports: tailcfg.PortRangeAny},
{IP: "100.119.139.79/32", Ports: tailcfg.PortRangeAny},
{IP: "100.74.117.7/32", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::4001:8ba0/128", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::6401:6401/128", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::c401:7508/128", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// H4: IPv6 range in SrcIPs
// TODO: Fix wildcard DstPorts format and filter distribution
// TAILSCALE BEHAVIOR: DstPorts uses {IP: "*"} for wildcard destinations.
// SrcIPs includes fd7a:115c:a1e0::/48 (IPv6 Tailscale range). Only client1 receives filter.
// HEADSCALE BEHAVIOR: DstPorts expands to CGNAT ranges. ALL nodes receive filters.
// ROOT CAUSE: Same as H2 - Headscale expands "*" to CGNAT and distributes to all nodes.
// FIX REQUIRED: Use {IP: "*"} for wildcard destinations and fix filter distribution.
{
name: "H4_ipv6_range_in_srcips",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["*:*"]}
`),
/*
EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "*", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"client2": nil,
"subnet-router": nil,
"exit-node": nil,
"multi-router": nil,
"ha-router1": nil,
"ha-router2": nil,
"big-router": nil,
"user1": nil,
},
*/
// ACTUAL (Headscale): All nodes get filters with CGNAT DstPorts
wantFilters: map[string][]tailcfg.FilterRule{
"client1": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"client2": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"subnet-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"exit-node": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"ha-router1": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"ha-router2": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"big-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"user1": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// H7: Two nodes claiming same subnet - first is primary
// TODO: Fix exit route coverage for subnet destinations
// TAILSCALE BEHAVIOR: Only ha-router1 and ha-router2 (which have 192.168.1.0/24) get filters.
// Exit nodes do NOT get filters for specific subnet destinations.
// HEADSCALE BEHAVIOR: Exit nodes also get filters (0.0.0.0/0 covers 192.168.1.0/24).
// ROOT CAUSE: Exit route coverage gives filters to exit-node and multi-router.
// FIX REQUIRED: Exclude exit nodes from subnet-specific destinations.
{
name: "H7_two_nodes_same_subnet",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["192.168.1.0/24:*"]}
`),
/*
EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"subnet-router": nil,
"exit-node": nil,
"multi-router": nil,
"big-router": nil,
"user1": nil,
"ha-router1": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "192.168.1.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"ha-router2": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "192.168.1.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
*/
// ACTUAL (Headscale): Exit nodes also get filters
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"subnet-router": nil,
"big-router": nil,
"user1": nil,
"ha-router1": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "192.168.1.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"ha-router2": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "192.168.1.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// Exit nodes incorrectly get filters (exit route covers 192.168.1.0/24)
"exit-node": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "192.168.1.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "192.168.1.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// H10: Very small prefix (/32)
// TODO: Fix exit route coverage for /32 destinations
// TAILSCALE BEHAVIOR: Only subnet-router (10.33.0.0/16) and big-router (10.0.0.0/8) get filters.
// These routes cover 10.33.0.100/32. Exit nodes do NOT get filters.
// HEADSCALE BEHAVIOR: Exit nodes also get filters (0.0.0.0/0 covers 10.33.0.100/32).
// ROOT CAUSE: Exit route coverage gives filters to exit-node and multi-router.
// FIX REQUIRED: Exclude exit nodes from specific IP destinations.
{
name: "H10_very_small_prefix",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["10.33.0.100/32:80"]}
`),
/*
EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"ha-router1": nil,
"ha-router2": nil,
"exit-node": nil,
"multi-router": nil,
"subnet-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.100/32", Ports: tailcfg.PortRange{First: 80, Last: 80}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"big-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.100/32", Ports: tailcfg.PortRange{First: 80, Last: 80}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
*/
// ACTUAL (Headscale): Exit nodes also get filters
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"ha-router1": nil,
"ha-router2": nil,
"subnet-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.100/32", Ports: tailcfg.PortRange{First: 80, Last: 80}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"big-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.100/32", Ports: tailcfg.PortRange{First: 80, Last: 80}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// Exit nodes incorrectly get filters (exit route covers)
"exit-node": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.100/32", Ports: tailcfg.PortRange{First: 80, Last: 80}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.100/32", Ports: tailcfg.PortRange{First: 80, Last: 80}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
}
runRoutesCompatTests(t, users, nodes, tests)
}
// TestTailscaleRoutesCompatAdditionalR tests additional route coverage scenarios (Category R).
func TestTailscaleRoutesCompatAdditionalR(t *testing.T) {
t.Parallel()
users := setupRouteCompatUsers()
nodes := setupRouteCompatNodes(users)
tests := []routesCompatTest{
// R5: Route coverage check logic verification
// TODO: Exit route coverage issue - exit nodes get filters when they shouldn't.
// TAILSCALE BEHAVIOR: Exit nodes do NOT receive filters for internal subnet destinations.
// HEADSCALE BEHAVIOR: Exit nodes get filters because 0.0.0.0/0 covers 10.33.1.0/24.
// ROOT CAUSE: routeCoversDestination() returns true for exit routes covering internal IPs.
{
name: "R5_route_coverage_check_logic",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["10.33.1.0/24:22"]}
`),
// Route coverage: R.Bits() <= D.Bits() && R.Contains(D.Addr())
// 10.0.0.0/8 (bits=8) <= 24 && contains 10.33.1.0 -> YES
// 10.33.0.0/16 (bits=16) <= 24 && contains 10.33.1.0 -> YES
/*
EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"exit-node": nil,
"multi-router": nil,
"ha-router1": nil,
"ha-router2": nil,
"subnet-router": { ... },
"big-router": { ... },
},
*/
// ACTUAL (Headscale): Exit nodes also get filters due to exit route coverage
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"ha-router1": nil,
"ha-router2": nil,
"subnet-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{{IP: "10.33.1.0/24", Ports: tailcfg.PortRange{First: 22, Last: 22}}},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"big-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{{IP: "10.33.1.0/24", Ports: tailcfg.PortRange{First: 22, Last: 22}}},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// Exit nodes incorrectly get filters (exit route 0.0.0.0/0 covers 10.33.1.0/24)
"exit-node": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{{IP: "10.33.1.0/24", Ports: tailcfg.PortRange{First: 22, Last: 22}}},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{{IP: "10.33.1.0/24", Ports: tailcfg.PortRange{First: 22, Last: 22}}},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// R6: IPv6 route coverage
// TODO: Exit route coverage issue - exit nodes get filters for IPv6 Tailscale range.
// TAILSCALE BEHAVIOR: No nodes get filters for IPv6 addresses in the Tailscale range
// (fd7a:115c:a1e0::/48) as these are node IPs, not routed destinations.
// HEADSCALE BEHAVIOR: Exit nodes get filters because ::/0 covers all IPv6 addresses.
// ROOT CAUSE: routeCoversDestination() returns true for exit routes covering all IPs.
{
name: "R6_ipv6_route_coverage",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["fd7a:115c:a1e0::1/128:443"]}
`),
// Targeting a specific IPv6 in the Tailscale range
/*
EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"subnet-router": nil,
"exit-node": nil,
"multi-router": nil,
"ha-router1": nil,
"ha-router2": nil,
"big-router": nil,
},
*/
// ACTUAL (Headscale): Exit nodes get filters (exit route ::/0 covers all IPv6)
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"subnet-router": nil,
"ha-router1": nil,
"ha-router2": nil,
"big-router": nil,
// Exit nodes incorrectly get filters (exit route ::/0 covers fd7a:115c:a1e0::1)
"exit-node": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{{IP: "fd7a:115c:a1e0::1/128", Ports: tailcfg.PortRange{First: 443, Last: 443}}},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{{IP: "fd7a:115c:a1e0::1/128", Ports: tailcfg.PortRange{First: 443, Last: 443}}},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// R7: Exit node IPv6 coverage
{
name: "R7_exit_ipv6_coverage",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["2001:db8::1/128:443"]}
`),
// External IPv6 address - only exit nodes cover
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"subnet-router": nil,
"ha-router1": nil,
"ha-router2": nil,
"big-router": nil,
// Exit nodes cover all destinations
"exit-node": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "2001:db8::1/128", Ports: tailcfg.PortRange{First: 443, Last: 443}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "2001:db8::1/128", Ports: tailcfg.PortRange{First: 443, Last: 443}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// R8: Mixed IPv4/IPv6 coverage
// TODO: Multiple coverage issues in this test:
// 1. Exit route coverage: exit nodes get IPv4 filters (0.0.0.0/0 covers 10.33.0.0/16)
// 2. Node IP coverage: all nodes get IPv6 filters because their IPv6 addresses are in
// fd7a:115c:a1e0::/48 which overlaps with the destination fd7a:115c:a1e0::/64
// TAILSCALE BEHAVIOR: Only subnet-router and big-router get filters (IPv4 only).
// HEADSCALE BEHAVIOR:
// - All nodes get filters for IPv6 (node IPs are in fd7a:115c:a1e0::/48)
// - Exit nodes get filters for IPv4 (exit route covers 10.33.0.0/16)
// - subnet-router and big-router get both IPv4 and IPv6
// ROOT CAUSE: Node IP prefixes incorrectly treated as routes covering destinations.
{
name: "R8_mixed_ipv4_ipv6_coverage",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["10.33.0.0/16:*", "fd7a:115c:a1e0::/64:*"]}
`),
// Both IPv4 and IPv6 destinations
/*
EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"ha-router1": nil,
"ha-router2": nil,
"exit-node": nil,
"multi-router": nil,
"subnet-router": { IPv4 only: 10.33.0.0/16 },
"big-router": { IPv4 only: 10.33.0.0/16 },
},
*/
// ACTUAL (Headscale): Multiple issues - node IPs treated as routes, exit coverage
wantFilters: map[string][]tailcfg.FilterRule{
// All nodes get IPv6 filters because their IPs are in fd7a:115c:a1e0::/48
"client1": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{{IP: "fd7a:115c:a1e0::/64", Ports: tailcfg.PortRangeAny}},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"client2": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{{IP: "fd7a:115c:a1e0::/64", Ports: tailcfg.PortRangeAny}},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"user1": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{{IP: "fd7a:115c:a1e0::/64", Ports: tailcfg.PortRangeAny}},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"ha-router1": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{{IP: "fd7a:115c:a1e0::/64", Ports: tailcfg.PortRangeAny}},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"ha-router2": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{{IP: "fd7a:115c:a1e0::/64", Ports: tailcfg.PortRangeAny}},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// subnet-router and big-router get both IPv4 (from routes) and IPv6 (from node IPs)
"subnet-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::/64", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"big-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::/64", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// Exit nodes get both IPv4 (exit route) and IPv6 (exit route + node IPs)
"exit-node": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::/64", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::/64", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
}
runRoutesCompatTests(t, users, nodes, tests)
}
// TestTailscaleRoutesCompatAdditionalO tests additional overlapping route scenarios (Category O).
func TestTailscaleRoutesCompatAdditionalO(t *testing.T) {
t.Parallel()
users := setupRouteCompatUsers()
nodes := setupRouteCompatNodes(users)
tests := []routesCompatTest{
// O1: Overlapping routes not merged
// TODO: Fix wildcard destination handling for nodes with routes
// TAILSCALE BEHAVIOR: Only client1 gets filters (dst *:* only goes to primary node).
// HEADSCALE BEHAVIOR: All nodes get filters (dst *:* expands to Headscale IP ranges for all nodes).
// ROOT CAUSE: Wildcard destination expands to Headscale IP ranges, not literal "*".
// FIX REQUIRED: Limit *:* distribution to match Tailscale behavior.
{
name: "O1_overlapping_routes_not_merged",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["*:*"]}
`),
/*
EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": { filter with *:* },
"client2": nil,
"subnet-router": nil,
"exit-node": nil,
"multi-router": nil,
"ha-router1": nil,
"ha-router2": nil,
"big-router": nil,
"user1": nil,
},
*/
// ACTUAL (Headscale): All nodes get filters with expanded IP ranges
wantFilters: map[string][]tailcfg.FilterRule{
"client1": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"client2": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"user1": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// All routers get filters because *:* expands to all Headscale IP ranges
"subnet-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"exit-node": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"ha-router1": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"ha-router2": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"big-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// O4: Three-way hierarchy
// TODO: Fix exit route coverage for subnet destinations
// TAILSCALE BEHAVIOR: Only subnet-router and big-router get filters.
// HEADSCALE BEHAVIOR: Exit nodes also get filters (0.0.0.0/0 covers 10.33.1.128/25).
// ROOT CAUSE: Exit route coverage.
// FIX REQUIRED: Exclude exit nodes from subnet-specific destinations.
{
name: "O4_three_way_hierarchy",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["10.33.1.128/25:22"]}
`),
/*
EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"ha-router1": nil,
"ha-router2": nil,
"exit-node": nil,
"multi-router": nil,
"subnet-router": { filter with 10.33.1.128/25:22 },
"big-router": { filter with 10.33.1.128/25:22 },
},
*/
// ACTUAL (Headscale): Exit nodes also get filters
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"ha-router1": nil,
"ha-router2": nil,
"subnet-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.1.128/25", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"big-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.1.128/25", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// Exit nodes incorrectly get filters (exit route covers)
"exit-node": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.1.128/25", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.1.128/25", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// O5: Sibling routes with parent ACL
// TODO: Fix exit route coverage and subnet-router getting parent ACL filter
// TAILSCALE BEHAVIOR: Only big-router gets filters (exact /8 route match).
// HEADSCALE BEHAVIOR: Exit nodes get filters (0.0.0.0/0 covers 10.0.0.0/8),
// and subnet-router gets filters (10.33.0.0/16 is within /8).
// ROOT CAUSE: Exit route coverage + child routes get parent ACL filters.
// FIX REQUIRED: Exclude exit nodes and child routes from parent ACL.
{
name: "O5_sibling_routes_with_parent_acl",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["10.0.0.0/8:*"]}
`),
/*
EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"subnet-router": nil,
"ha-router1": nil,
"ha-router2": nil,
"exit-node": nil,
"multi-router": nil,
"big-router": { filter with 10.0.0.0/8:* },
},
*/
// ACTUAL (Headscale): Exit nodes and subnet-router also get filters
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"ha-router1": nil,
"ha-router2": nil,
"big-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.0.0.0/8", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// subnet-router incorrectly gets filter (child route within parent ACL)
"subnet-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.0.0.0/8", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// Exit nodes incorrectly get filters (exit route covers)
"exit-node": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.0.0.0/8", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.0.0.0/8", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// O7: Specific IP targeting with multiple covering routes
// TODO: Fix exit route coverage for specific IP destinations
// TAILSCALE BEHAVIOR: Only subnet-router and big-router get filters.
// HEADSCALE BEHAVIOR: Exit nodes also get filters (0.0.0.0/0 covers 10.33.0.100/32).
// ROOT CAUSE: Exit route coverage.
// FIX REQUIRED: Exclude exit nodes from subnet-specific destinations.
{
name: "O7_specific_ip_targeting",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["10.33.0.100/32:80"]}
`),
/*
EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"ha-router1": nil,
"ha-router2": nil,
"exit-node": nil,
"multi-router": nil,
"subnet-router": { filter with 10.33.0.100/32:80 },
"big-router": { filter with 10.33.0.100/32:80 },
},
*/
// ACTUAL (Headscale): Exit nodes also get filters
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"ha-router1": nil,
"ha-router2": nil,
"subnet-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.100/32", Ports: tailcfg.PortRange{First: 80, Last: 80}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"big-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.100/32", Ports: tailcfg.PortRange{First: 80, Last: 80}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// Exit nodes incorrectly get filters (exit route covers)
"exit-node": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.100/32", Ports: tailcfg.PortRange{First: 80, Last: 80}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.100/32", Ports: tailcfg.PortRange{First: 80, Last: 80}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// O10: ACL dest covered by multiple routes
// TODO: Fix exit route coverage for subnet destinations
// TAILSCALE BEHAVIOR: Only subnet-router and big-router get filters.
// HEADSCALE BEHAVIOR: Exit nodes also get filters (0.0.0.0/0 covers 10.33.1.0/24).
// ROOT CAUSE: Exit route coverage.
// FIX REQUIRED: Exclude exit nodes from subnet-specific destinations.
{
name: "O10_acl_dest_covered_by_multiple",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["10.33.1.0/24:22"]}
`),
/*
EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"ha-router1": nil,
"ha-router2": nil,
"exit-node": nil,
"multi-router": nil,
"subnet-router": { filter with 10.33.1.0/24:22 },
"big-router": { filter with 10.33.1.0/24:22 },
},
*/
// ACTUAL (Headscale): Exit nodes also get filters
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"ha-router1": nil,
"ha-router2": nil,
"subnet-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.1.0/24", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"big-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.1.0/24", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// Exit nodes incorrectly get filters (exit route covers)
"exit-node": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.1.0/24", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.1.0/24", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// O11: ACL dest not covered by any route
// TODO: Fix exit route coverage for uncovered destinations
// TAILSCALE BEHAVIOR: No nodes get filters (no route covers 192.168.99.0/24).
// HEADSCALE BEHAVIOR: Exit nodes get filters (0.0.0.0/0 covers 192.168.99.0/24).
// ROOT CAUSE: Exit route coverage.
// FIX REQUIRED: Exclude exit nodes from uncovered destinations.
{
name: "O11_acl_dest_not_covered",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["192.168.99.0/24:22"]}
`),
/*
EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"subnet-router": nil,
"ha-router1": nil,
"ha-router2": nil,
"exit-node": nil,
"multi-router": nil,
"big-router": nil,
},
*/
// ACTUAL (Headscale): Exit nodes get filters
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"subnet-router": nil,
"ha-router1": nil,
"ha-router2": nil,
"big-router": nil,
// Exit nodes incorrectly get filters (exit route covers)
"exit-node": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "192.168.99.0/24", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "192.168.99.0/24", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
}
runRoutesCompatTests(t, users, nodes, tests)
}
// TestTailscaleRoutesCompatAdditionalG tests additional protocol and port scenarios (Category G).
func TestTailscaleRoutesCompatAdditionalG(t *testing.T) {
t.Parallel()
users := setupRouteCompatUsers()
nodes := setupRouteCompatNodes(users)
tests := []routesCompatTest{
// G3: Multiple ports on subnet
// TODO: Fix exit route coverage for subnet destinations
// TAILSCALE BEHAVIOR: Only subnet-router and big-router get filters.
// HEADSCALE BEHAVIOR: Exit nodes also get filters (0.0.0.0/0 covers 10.33.0.0/16).
// ROOT CAUSE: Exit route coverage.
// FIX REQUIRED: Exclude exit nodes from subnet-specific destinations.
{
name: "G3_multiple_ports_subnet",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["10.33.0.0/16:22,80,443"]}
`),
/*
EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"ha-router1": nil,
"ha-router2": nil,
"exit-node": nil,
"multi-router": nil,
"subnet-router": { ... },
"big-router": { ... },
},
*/
// ACTUAL (Headscale): Exit nodes also get filters
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"ha-router1": nil,
"ha-router2": nil,
"subnet-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 22, Last: 22}},
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 80, Last: 80}},
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 443, Last: 443}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"big-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 22, Last: 22}},
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 80, Last: 80}},
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 443, Last: 443}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// Exit nodes incorrectly get filters (exit route covers)
"exit-node": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 22, Last: 22}},
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 80, Last: 80}},
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 443, Last: 443}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 22, Last: 22}},
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 80, Last: 80}},
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 443, Last: 443}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// G8: Default IPProto (all protocols)
// TODO: Fix exit route coverage for subnet destinations
// TAILSCALE BEHAVIOR: Only subnet-router and big-router get filters.
// HEADSCALE BEHAVIOR: Exit nodes also get filters (0.0.0.0/0 covers 10.33.0.0/16).
// ROOT CAUSE: Exit route coverage.
// FIX REQUIRED: Exclude exit nodes from subnet-specific destinations.
{
name: "G8_default_ipproto",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["10.33.0.0/16:22"]}
`),
/*
EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"exit-node": nil,
"multi-router": nil,
...
},
*/
// ACTUAL (Headscale): Exit nodes also get filters
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"ha-router1": nil,
"ha-router2": nil,
"subnet-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
// TCP=6, UDP=17, ICMP=1, ICMPv6=58
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"big-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// Exit nodes incorrectly get filters (exit route covers)
"exit-node": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
}
runRoutesCompatTests(t, users, nodes, tests)
}
// TestTailscaleRoutesCompatAdditionalT tests additional tag resolution scenarios (Category T).
func TestTailscaleRoutesCompatAdditionalT(t *testing.T) {
t.Parallel()
users := setupRouteCompatUsers()
nodes := setupRouteCompatNodes(users)
tests := []routesCompatTest{
// TODO: Fix wildcard destination expansion and filter distribution
//
// T3: Tag source includes all tagged nodes
//
// TAILSCALE BEHAVIOR:
// - Only client1 gets filter (user-owned, thus a valid destination)
// - DstPorts uses literal "*" for wildcard destination
//
// HEADSCALE BEHAVIOR:
// - ALL nodes get filters (wildcard destination distributed to everyone)
// - DstPorts expands to CGNAT ranges instead of "*"
//
// ROOT CAUSE:
// 1. Wildcard destination distributed to all nodes instead of only non-source nodes
// 2. DstPorts expands wildcards to explicit CGNAT ranges
//
// FIX REQUIRED:
// 1. Limit filter distribution for wildcard destinations
// 2. Use literal "*" in DstPorts for wildcard destinations
{
name: "T3_tag_src_includes_all_tagged",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["tag:router"], "dst": ["*:*"]}
`),
/* EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": {
{
SrcIPs: []string{ tag:router IPs },
DstPorts: []tailcfg.NetPortRange{
{IP: "*", Ports: tailcfg.PortRangeAny},
},
},
},
"client2": nil,
"subnet-router": nil,
"exit-node": nil,
"multi-router": nil,
"ha-router1": nil,
"ha-router2": nil,
"big-router": nil,
"user1": nil,
},
*/
// ACTUAL (Headscale): ALL nodes get filters, DstPorts expanded to CGNAT ranges
// tag:router = subnet-router, multi-router, big-router
wantFilters: map[string][]tailcfg.FilterRule{
// INCORRECT: All nodes get filters, not just destination nodes
"client1": {
{
SrcIPs: []string{
"100.100.100.1/32", // big-router
"100.119.139.79/32", // subnet-router
"100.74.117.7/32", // multi-router
"fd7a:115c:a1e0::4001:8ba0/128",
"fd7a:115c:a1e0::6401:6401/128",
"fd7a:115c:a1e0::c401:7508/128",
},
// INCORRECT: DstPorts uses CGNAT ranges instead of "*"
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"client2": {
{
SrcIPs: []string{
"100.100.100.1/32",
"100.119.139.79/32",
"100.74.117.7/32",
"fd7a:115c:a1e0::4001:8ba0/128",
"fd7a:115c:a1e0::6401:6401/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"subnet-router": {
{
SrcIPs: []string{
"100.100.100.1/32",
"100.119.139.79/32",
"100.74.117.7/32",
"fd7a:115c:a1e0::4001:8ba0/128",
"fd7a:115c:a1e0::6401:6401/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"exit-node": {
{
SrcIPs: []string{
"100.100.100.1/32",
"100.119.139.79/32",
"100.74.117.7/32",
"fd7a:115c:a1e0::4001:8ba0/128",
"fd7a:115c:a1e0::6401:6401/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: []string{
"100.100.100.1/32",
"100.119.139.79/32",
"100.74.117.7/32",
"fd7a:115c:a1e0::4001:8ba0/128",
"fd7a:115c:a1e0::6401:6401/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"ha-router1": {
{
SrcIPs: []string{
"100.100.100.1/32",
"100.119.139.79/32",
"100.74.117.7/32",
"fd7a:115c:a1e0::4001:8ba0/128",
"fd7a:115c:a1e0::6401:6401/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"ha-router2": {
{
SrcIPs: []string{
"100.100.100.1/32",
"100.119.139.79/32",
"100.74.117.7/32",
"fd7a:115c:a1e0::4001:8ba0/128",
"fd7a:115c:a1e0::6401:6401/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"big-router": {
{
SrcIPs: []string{
"100.100.100.1/32",
"100.119.139.79/32",
"100.74.117.7/32",
"fd7a:115c:a1e0::4001:8ba0/128",
"fd7a:115c:a1e0::6401:6401/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"user1": {
{
SrcIPs: []string{
"100.100.100.1/32",
"100.119.139.79/32",
"100.74.117.7/32",
"fd7a:115c:a1e0::4001:8ba0/128",
"fd7a:115c:a1e0::6401:6401/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// TODO: Fix per-node DstPorts visibility and exit route coverage
//
// T4: Tag destination includes all tagged nodes
//
// TAILSCALE BEHAVIOR:
// - Only ha-router1 and ha-router2 get filters (tag:ha nodes)
// - DstPorts shows ALL tag:ha node IPs to each node
// - exit-node and multi-router do NOT get filters
//
// HEADSCALE BEHAVIOR:
// - Exit nodes also get filter (0.0.0.0/0 route "covers" tag:ha IPs)
// - Per-node DstPorts visibility: each node only sees its OWN IP in DstPorts
//
// ROOT CAUSE:
// 1. Exit routes (0.0.0.0/0) are treated as covering all destinations
// 2. Filter reduction logic scopes DstPorts to per-node visibility
//
// FIX REQUIRED:
// 1. Exclude exit routes from tag-based filter distribution
// 2. Show full destination set to all destination nodes (not per-node scoped)
{
name: "T4_tag_dst_includes_all_tagged",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["tag:ha:*"]}
`),
/* EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"subnet-router": nil,
"exit-node": nil,
"multi-router": nil,
"big-router": nil,
"user1": nil,
"ha-router1": { DstPorts: ALL tag:ha IPs },
"ha-router2": { DstPorts: ALL tag:ha IPs },
},
*/
// ACTUAL (Headscale): Exit nodes get filters, per-node DstPorts scoped
// tag:ha = ha-router1 (100.85.37.108), ha-router2 (100.119.130.32)
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"subnet-router": nil,
"big-router": nil,
"user1": nil,
// INCORRECT: exit-node gets filter due to 0.0.0.0/0 coverage
"exit-node": {
{
SrcIPs: wildcardSrcIPs,
// exit-node sees ALL tag:ha IPs via exit route coverage
DstPorts: []tailcfg.NetPortRange{
{IP: "100.119.130.32/32", Ports: tailcfg.PortRangeAny},
{IP: "100.85.37.108/32", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::4501:82a9/128", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::f101:2597/128", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// INCORRECT: multi-router gets filter due to 0.0.0.0/0 coverage
"multi-router": {
{
SrcIPs: wildcardSrcIPs,
// multi-router sees ALL tag:ha IPs via exit route coverage
DstPorts: []tailcfg.NetPortRange{
{IP: "100.119.130.32/32", Ports: tailcfg.PortRangeAny},
{IP: "100.85.37.108/32", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::4501:82a9/128", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::f101:2597/128", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// INCORRECT: ha-router1 only sees its own IPs in DstPorts
"ha-router1": {
{
SrcIPs: wildcardSrcIPs,
// Per-node scoped: only ha-router1's own IPs
DstPorts: []tailcfg.NetPortRange{
{IP: "100.85.37.108/32", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::f101:2597/128", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// INCORRECT: ha-router2 only sees its own IPs in DstPorts
"ha-router2": {
{
SrcIPs: wildcardSrcIPs,
// Per-node scoped: only ha-router2's own IPs
DstPorts: []tailcfg.NetPortRange{
{IP: "100.119.130.32/32", Ports: tailcfg.PortRangeAny},
{IP: "fd7a:115c:a1e0::4501:82a9/128", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
}
runRoutesCompatTests(t, users, nodes, tests)
}
// TestTailscaleRoutesCompatAutoApprover tests autoApprover behavior (Category D).
// These tests validate automatic route approval based on tags and prefixes.
// NOTE: AutoApprover affects route ENABLING, not filter distribution.
// The filter tests here verify filters ASSUMING routes are enabled.
func TestTailscaleRoutesCompatAutoApprover(t *testing.T) {
t.Parallel()
users := setupRouteCompatUsers()
nodes := setupRouteCompatNodes(users)
tests := []routesCompatTest{
// D1: Basic route auto-approval with autoApprover
// 10.0.0.0/8 -> tag:router means routes within 10.0.0.0/8
// advertised by nodes with tag:router are auto-approved
{
name: "D1_basic_route_auto_approval",
// This test validates that with autoApprover configured,
// routes matching the prefix/tag combination are enabled.
// Filter distribution follows standard rules.
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["10.33.0.0/16:*"]}
`),
// Assuming route is auto-approved and enabled:
// Filter goes to subnet-router (route owner) + big-router (parent route)
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"ha-router1": nil,
"ha-router2": nil,
// subnet-router owns 10.33.0.0/16
"subnet-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// big-router owns 10.0.0.0/8 (covers 10.33.0.0/16)
"big-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// exit-node and multi-router also get filter (0.0.0.0/0 covers)
"exit-node": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// D2: Nested prefix approval - autoApprover for parent covers child
{
name: "D2_nested_prefix_approval",
// autoApprover 10.0.0.0/8 covers advertised 10.33.0.0/16
// This test verifies subset prefixes are approved
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["10.33.0.0/16:22"]}
`),
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"ha-router1": nil,
"ha-router2": nil,
"subnet-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"big-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"exit-node": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// D3: Exact prefix approval
{
name: "D3_exact_prefix_approval",
// autoApprover for exactly 10.33.0.0/16 matches advertised 10.33.0.0/16
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["10.33.0.0/16:*"]}
`),
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"ha-router1": nil,
"ha-router2": nil,
"subnet-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"big-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"exit-node": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// D4: Prefix not covered by autoApprover
// 192.168.0.0/16, but node advertises 10.0.0.0/8 - NOT approved
// Without approval, route not enabled, no filters distributed
{
name: "D4_prefix_not_covered",
// If autoApprover is 192.168.0.0/16 but we target 10.0.0.0/8
// the route would NOT be auto-approved
// This tests that only matching prefixes get filters
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["192.168.1.0/24:*"]}
`),
// Only HA routers own 192.168.1.0/24
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"subnet-router": nil,
"big-router": nil,
// exit-node and multi-router get filter (0.0.0.0/0 covers)
"exit-node": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "192.168.1.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "192.168.1.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"ha-router1": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "192.168.1.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"ha-router2": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "192.168.1.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// D5: Wrong tag not approved
// autoApprover 10.0.0.0/8 -> tag:router, but node is tag:ha
{
name: "D5_wrong_tag_not_approved",
// HA routers have tag:ha, not tag:router
// Their 192.168.1.0/24 route would not be auto-approved
// by an autoApprover for tag:router
// But we can still target the route in ACL if manually enabled
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["tag:router"], "dst": ["192.168.1.0/24:*"]}
`),
// tag:router sources: subnet-router, multi-router, big-router
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"subnet-router": nil, // Source, not destination
"big-router": nil, // Source, not destination
"exit-node": {
{
SrcIPs: []string{
"100.100.100.1/32", // big-router
"100.119.139.79/32", // subnet-router
"100.74.117.7/32", // multi-router
"fd7a:115c:a1e0::4001:8ba0/128",
"fd7a:115c:a1e0::6401:6401/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "192.168.1.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: []string{
"100.100.100.1/32",
"100.119.139.79/32",
"100.74.117.7/32",
"fd7a:115c:a1e0::4001:8ba0/128",
"fd7a:115c:a1e0::6401:6401/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "192.168.1.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"ha-router1": {
{
SrcIPs: []string{
"100.100.100.1/32",
"100.119.139.79/32",
"100.74.117.7/32",
"fd7a:115c:a1e0::4001:8ba0/128",
"fd7a:115c:a1e0::6401:6401/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "192.168.1.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"ha-router2": {
{
SrcIPs: []string{
"100.100.100.1/32",
"100.119.139.79/32",
"100.74.117.7/32",
"fd7a:115c:a1e0::4001:8ba0/128",
"fd7a:115c:a1e0::6401:6401/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "192.168.1.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// TODO: Fix wildcard DstPorts expansion to use "*" instead of CGNAT ranges
//
// D6: Exit node auto-approval - wildcard ACL with routes
//
// TAILSCALE BEHAVIOR:
// - DstPorts uses literal "*" for wildcard destination
// - All nodes get filter with DstPorts: [{IP: "*", Ports: 0-65535}]
//
// HEADSCALE BEHAVIOR:
// - DstPorts expands to CGNAT ranges instead of using "*"
// - Uses {IP: "100.64.0.0/10"} and {IP: "fd7a:115c:a1e0::/48"}
//
// ROOT CAUSE:
// Headscale expands wildcard destinations to explicit IP ranges
// instead of using the "*" shorthand that Tailscale uses
//
// FIX REQUIRED:
// Use literal "*" in DstPorts for wildcard destinations
{
name: "D6_exit_node_auto_approval",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["*:*"]}
`),
/* EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "*", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// ... all other nodes same pattern with DstPorts: [{IP: "*"}]
},
*/
// ACTUAL (Headscale): DstPorts expanded to CGNAT ranges
wantFilters: map[string][]tailcfg.FilterRule{
"client1": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"client2": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"user1": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"subnet-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"exit-node": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"ha-router1": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"ha-router2": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"big-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// TODO: Fix wildcard DstPorts expansion to use "*" instead of CGNAT ranges
//
// D7: Exit auto-approval wrong tag - tag:exit to wildcard destination
//
// TAILSCALE BEHAVIOR:
// - DstPorts uses literal "*" for wildcard destination
// - tag:exit (exit-node, multi-router) can access anywhere
//
// HEADSCALE BEHAVIOR:
// - DstPorts expands to CGNAT ranges instead of using "*"
// - Uses {IP: "100.64.0.0/10"} and {IP: "fd7a:115c:a1e0::/48"}
//
// ROOT CAUSE:
// Headscale expands wildcard destinations to explicit IP ranges
//
// FIX REQUIRED:
// Use literal "*" in DstPorts for wildcard destinations
{
name: "D7_exit_auto_approval_wrong_tag",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["tag:exit"], "dst": ["*:*"]}
`),
/* EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": {
{
SrcIPs: []string{
"100.121.32.1/32", "100.74.117.7/32",
"fd7a:115c:a1e0::7f01:2004/128", "fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "*", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// ... all other nodes same pattern with DstPorts: [{IP: "*"}]
},
*/
// ACTUAL (Headscale): DstPorts expanded to CGNAT ranges
// tag:exit = exit-node, multi-router
wantFilters: map[string][]tailcfg.FilterRule{
"client1": {
{
SrcIPs: []string{
"100.121.32.1/32", // exit-node
"100.74.117.7/32", // multi-router
"fd7a:115c:a1e0::7f01:2004/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"client2": {
{
SrcIPs: []string{
"100.121.32.1/32",
"100.74.117.7/32",
"fd7a:115c:a1e0::7f01:2004/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"user1": {
{
SrcIPs: []string{
"100.121.32.1/32",
"100.74.117.7/32",
"fd7a:115c:a1e0::7f01:2004/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"subnet-router": {
{
SrcIPs: []string{
"100.121.32.1/32",
"100.74.117.7/32",
"fd7a:115c:a1e0::7f01:2004/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"exit-node": {
{
SrcIPs: []string{
"100.121.32.1/32",
"100.74.117.7/32",
"fd7a:115c:a1e0::7f01:2004/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: []string{
"100.121.32.1/32",
"100.74.117.7/32",
"fd7a:115c:a1e0::7f01:2004/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"ha-router1": {
{
SrcIPs: []string{
"100.121.32.1/32",
"100.74.117.7/32",
"fd7a:115c:a1e0::7f01:2004/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"ha-router2": {
{
SrcIPs: []string{
"100.121.32.1/32",
"100.74.117.7/32",
"fd7a:115c:a1e0::7f01:2004/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"big-router": {
{
SrcIPs: []string{
"100.121.32.1/32",
"100.74.117.7/32",
"fd7a:115c:a1e0::7f01:2004/128",
"fd7a:115c:a1e0::c401:7508/128",
},
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// D8: Auto-approval enables route, but ACL still enforced
// Route is enabled via autoApprover, but restrictive ACL limits access
{
name: "D8_auto_approval_acl_interaction",
// Route auto-approved, but ACL only allows specific source
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["autogroup:member"], "dst": ["10.33.0.0/16:22"]}
`),
// Only autogroup:member sources (user-owned nodes)
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil, // Source, not destination
"client2": nil, // Source, not destination
"user1": nil, // Source, not destination
"ha-router1": nil,
"ha-router2": nil,
"subnet-router": {
{
SrcIPs: []string{
"100.116.73.38/32", // client1
"100.89.42.23/32", // client2
"100.90.199.68/32", // user1
"fd7a:115c:a1e0::a801:4949/128",
"fd7a:115c:a1e0::d01:2a2e/128",
"fd7a:115c:a1e0::2d01:c747/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"big-router": {
{
SrcIPs: []string{
"100.116.73.38/32",
"100.89.42.23/32",
"100.90.199.68/32",
"fd7a:115c:a1e0::a801:4949/128",
"fd7a:115c:a1e0::d01:2a2e/128",
"fd7a:115c:a1e0::2d01:c747/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"exit-node": {
{
SrcIPs: []string{
"100.116.73.38/32",
"100.89.42.23/32",
"100.90.199.68/32",
"fd7a:115c:a1e0::a801:4949/128",
"fd7a:115c:a1e0::d01:2a2e/128",
"fd7a:115c:a1e0::2d01:c747/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: []string{
"100.116.73.38/32",
"100.89.42.23/32",
"100.90.199.68/32",
"fd7a:115c:a1e0::a801:4949/128",
"fd7a:115c:a1e0::d01:2a2e/128",
"fd7a:115c:a1e0::2d01:c747/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// D9: Auto-approval triggers on advertise
// Policy exists first, then node advertises - triggers approval
// This is a state/timing test - filter distribution is the same
{
name: "D9_auto_approval_triggers_on_advertise",
// Same as D1 - validates consistent behavior
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["10.33.0.0/16:*"]}
`),
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"ha-router1": nil,
"ha-router2": nil,
"subnet-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"big-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"exit-node": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// D10: Auto-approval retroactive
// Node advertised first, policy added later - requires re-advertisement
// Same filter distribution as D1 when route is enabled
{
name: "D10_auto_approval_retroactive",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["10.33.0.0/16:443"]}
`),
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"ha-router1": nil,
"ha-router2": nil,
"subnet-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 443, Last: 443}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"big-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 443, Last: 443}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"exit-node": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 443, Last: 443}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 443, Last: 443}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// D11: Overlapping auto-approvers
// 10.0.0.0/8 -> tag:router, 10.33.0.0/16 -> tag:special
// Both are valid for their respective tags
{
name: "D11_overlapping_auto_approvers",
// Both big-router (10.0.0.0/8) and subnet-router (10.33.0.0/16)
// can be approved by different autoApprover rules
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["10.0.0.0/8:80"]}
`),
// Targeting 10.0.0.0/8 - only big-router exact match + exit nodes
// subnet-router's 10.33.0.0/16 is WITHIN 10.0.0.0/8 so also gets filter
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"ha-router1": nil,
"ha-router2": nil,
"subnet-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.0.0.0/8", Ports: tailcfg.PortRange{First: 80, Last: 80}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"big-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.0.0.0/8", Ports: tailcfg.PortRange{First: 80, Last: 80}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"exit-node": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.0.0.0/8", Ports: tailcfg.PortRange{First: 80, Last: 80}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.0.0.0/8", Ports: tailcfg.PortRange{First: 80, Last: 80}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
}
runRoutesCompatTests(t, users, nodes, tests)
}
// TestTailscaleRoutesCompatAdditionalProtocol tests additional protocol restrictions (G4-G6).
func TestTailscaleRoutesCompatAdditionalProtocol(t *testing.T) {
t.Parallel()
users := setupRouteCompatUsers()
nodes := setupRouteCompatNodes(users)
tests := []routesCompatTest{
// G4: Protocol ICMP only
// proto:icmp results in IPProto=[1] (ICMP only)
// NOTE: Exit nodes still get filters due to exit route coverage issue (separate TODO)
{
name: "G4_protocol_icmp_subnet",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["10.33.0.0/16:*"], "proto": "icmp"}
`),
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"ha-router1": nil,
"ha-router2": nil,
"subnet-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolICMP},
},
},
"big-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolICMP},
},
},
// Exit nodes also get filters (exit route coverage issue)
"exit-node": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolICMP},
},
},
"multi-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolICMP},
},
},
},
},
// G5: Protocol TCP only
{
name: "G5_protocol_tcp_only",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["10.33.0.0/16:22"], "proto": "tcp"}
`),
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"ha-router1": nil,
"ha-router2": nil,
"subnet-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
// TCP only
IPProto: []int{ProtocolTCP},
},
},
"big-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP},
},
},
"exit-node": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP},
},
},
"multi-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP},
},
},
},
},
// G6: Protocol UDP only
{
name: "G6_protocol_udp_only",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["10.33.0.0/16:53"], "proto": "udp"}
`),
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"ha-router1": nil,
"ha-router2": nil,
"subnet-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 53, Last: 53}},
},
// UDP only
IPProto: []int{ProtocolUDP},
},
},
"big-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 53, Last: 53}},
},
IPProto: []int{ProtocolUDP},
},
},
"exit-node": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 53, Last: 53}},
},
IPProto: []int{ProtocolUDP},
},
},
"multi-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRange{First: 53, Last: 53}},
},
IPProto: []int{ProtocolUDP},
},
},
},
},
}
runRoutesCompatTests(t, users, nodes, tests)
}
// TestTailscaleRoutesCompatAdditionalEdgeCases tests additional edge cases (H5, H6, H8, H11).
func TestTailscaleRoutesCompatAdditionalEdgeCases(t *testing.T) {
t.Parallel()
users := setupRouteCompatUsers()
nodes := setupRouteCompatNodes(users)
tests := []routesCompatTest{
// H5: Subnet overlaps CGNAT - cannot be enabled
// Route 100.64.0.0/24 overlaps with Tailscale CGNAT range
{
name: "H5_subnet_overlaps_cgnat",
// A route overlapping CGNAT cannot be enabled
// This test verifies no filters are distributed for such routes
// Using a normal subnet route as baseline
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["100.64.0.0/24:*"]}
`),
// TODO: Tailscale blocks routes overlapping CGNAT
// Headscale behavior may differ
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"ha-router1": nil,
"ha-router2": nil,
"subnet-router": nil,
"big-router": nil,
// Exit nodes might still get filter since 0.0.0.0/0 covers everything
"exit-node": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "100.64.0.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "100.64.0.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// H6: Loopback routes not distributed
// Route 127.0.0.1/32 can be advertised but NOT in peer AllowedIPs
{
name: "H6_loopback_routes_not_distributed",
// Loopback routes are not practical but test edge case handling
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["127.0.0.1/32:*"]}
`),
// TODO: Tailscale allows advertising loopback but doesn't distribute
// Verify Headscale behavior
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"ha-router1": nil,
"ha-router2": nil,
"subnet-router": nil,
"big-router": nil,
// Exit nodes might get filter
"exit-node": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "127.0.0.1/32", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "127.0.0.1/32", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// H8: CGNAT overlap blocked
// TODO: Fix CGNAT overlap route handling
// TAILSCALE BEHAVIOR: Routes overlapping CGNAT (100.64.0.0/10) are blocked.
// Only exit nodes get filters for destinations in the blocked range.
// HEADSCALE BEHAVIOR: big-router gets filter because its IP (100.100.100.1)
// is within the destination range 100.100.0.0/16.
// ROOT CAUSE: Headscale checks if node IPs are in destination range,
// not just if advertised routes cover the destination.
// FIX REQUIRED: May need to exclude nodes whose IPs are in destination range.
{
name: "H8_cgnat_overlap_blocked",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["100.100.0.0/16:*"]}
`),
/*
EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"ha-router1": nil,
"ha-router2": nil,
"subnet-router": nil,
"big-router": nil, // No filter expected
"exit-node": { ... },
"multi-router": { ... },
},
*/
// ACTUAL (Headscale): big-router gets filter (its IP 100.100.100.1 is in 100.100.0.0/16)
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"ha-router1": nil,
"ha-router2": nil,
"subnet-router": nil,
// big-router gets filter because its IP (100.100.100.1) is in destination range
"big-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "100.100.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"exit-node": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "100.100.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "100.100.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// H11: IPv6 small prefix /128
{
name: "H11_ipv6_small_prefix",
// /128 is a single IPv6 address - smallest possible prefix
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["fd00::1/128:443"]}
`),
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"ha-router1": nil,
"ha-router2": nil,
"subnet-router": nil,
"big-router": nil,
// Exit nodes with ::/0 cover all IPv6
"exit-node": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "fd00::1/128", Ports: tailcfg.PortRange{First: 443, Last: 443}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "fd00::1/128", Ports: tailcfg.PortRange{First: 443, Last: 443}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
}
runRoutesCompatTests(t, users, nodes, tests)
}
// TestTailscaleRoutesCompatAdditionalIPv6 tests additional IPv6 scenarios (I2, I3, I6).
func TestTailscaleRoutesCompatAdditionalIPv6(t *testing.T) {
t.Parallel()
users := setupRouteCompatUsers()
nodes := setupRouteCompatNodes(users)
tests := []routesCompatTest{
// TODO: Fix wildcard DstPorts format
//
// I2: IPv6 exit route ::/0 - verifies ::/0 NOT in SrcIPs
//
// TAILSCALE BEHAVIOR:
// - DstPorts uses {IP: "*"} for wildcard destinations
// - ::/0 does NOT appear in SrcIPs (exit routes excluded)
// - Filter distributed to all nodes
//
// HEADSCALE BEHAVIOR:
// - DstPorts expands to CGNAT ranges instead of "*"
// - ::/0 correctly excluded from SrcIPs
// - Filter distributed to all nodes (same as Tailscale)
//
// ROOT CAUSE:
// Headscale expands "*" to CGNAT ranges instead of using "*".
//
// FIX REQUIRED:
// Use {IP: "*"} for wildcard destinations.
{
name: "I2_ipv6_exit_route",
// ::/0 is the IPv6 exit route (like 0.0.0.0/0 for IPv4)
// Should NOT appear in SrcIPs
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["*:*"]}
`),
/* EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "*", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// ... same for all nodes with {IP: "*"}
},
*/
// ACTUAL (Headscale): DstPorts expanded to CGNAT ranges
wantFilters: map[string][]tailcfg.FilterRule{
"client1": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"client2": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"user1": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"subnet-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"exit-node": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"ha-router1": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"ha-router2": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"big-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: wildcardDstPorts,
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// TODO: Fix exit route coverage and per-node DstPorts filtering
//
// I3: IPv6 in wildcard SrcIPs
//
// TAILSCALE BEHAVIOR:
// - SrcIPs includes fd7a:115c:a1e0::/48 (IPv6 Tailscale range) - CORRECT
// - Only tag:router nodes receive filters (subnet-router, multi-router, big-router)
// - Exit-node (tag:exit only) does NOT get filter
// - Each tag:router node sees ALL tag:router IPs in DstPorts
//
// HEADSCALE BEHAVIOR:
// - SrcIPs correctly includes fd7a:115c:a1e0::/48 (IPv6 range works)
// - Exit-node incorrectly gets filter (exit route covers all tag:router IPs)
// - Each node only sees its OWN IPs in DstPorts (not all tag:router IPs)
//
// ROOT CAUSE:
// 1. Exit route coverage: exit-node's 0.0.0.0/0 + ::/0 covers tag:router IPs
// 2. Per-node DstPorts: Headscale only includes self IPs in DstPorts
//
// FIX REQUIRED:
// 1. Exclude exit nodes from tag-based destinations
// 2. Include all matching tag IPs in DstPorts for each destination node
{
name: "I3_ipv6_in_wildcard_srcips",
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["tag:router:22"]}
`),
/* EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"ha-router1": nil,
"ha-router2": nil,
"exit-node": nil, // tag:exit, NOT tag:router
"subnet-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
// ALL tag:router IPs
{IP: "100.100.100.1/32", Ports: tailcfg.PortRange{First: 22, Last: 22}},
{IP: "100.119.139.79/32", Ports: tailcfg.PortRange{First: 22, Last: 22}},
{IP: "100.74.117.7/32", Ports: tailcfg.PortRange{First: 22, Last: 22}},
{IP: "fd7a:115c:a1e0::4001:8ba0/128", Ports: tailcfg.PortRange{First: 22, Last: 22}},
{IP: "fd7a:115c:a1e0::6401:6401/128", Ports: tailcfg.PortRange{First: 22, Last: 22}},
{IP: "fd7a:115c:a1e0::c401:7508/128", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// ... multi-router and big-router with same ALL tag:router IPs
},
*/
// ACTUAL (Headscale): exit-node gets filter, each node sees only self IPs
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"ha-router1": nil,
"ha-router2": nil,
// INCORRECT: subnet-router only sees its own IPs
"subnet-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
// Only self IPs (missing big-router and multi-router IPs)
{IP: "100.119.139.79/32", Ports: tailcfg.PortRange{First: 22, Last: 22}},
{IP: "fd7a:115c:a1e0::4001:8ba0/128", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// multi-router has tag:router AND tag:exit, gets all tag:router IPs
"multi-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
// All tag:router IPs (multi-router sees all)
{IP: "100.100.100.1/32", Ports: tailcfg.PortRange{First: 22, Last: 22}},
{IP: "100.119.139.79/32", Ports: tailcfg.PortRange{First: 22, Last: 22}},
{IP: "100.74.117.7/32", Ports: tailcfg.PortRange{First: 22, Last: 22}},
{IP: "fd7a:115c:a1e0::4001:8ba0/128", Ports: tailcfg.PortRange{First: 22, Last: 22}},
{IP: "fd7a:115c:a1e0::6401:6401/128", Ports: tailcfg.PortRange{First: 22, Last: 22}},
{IP: "fd7a:115c:a1e0::c401:7508/128", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// INCORRECT: big-router only sees its own IPs
"big-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
// Only self IPs (missing subnet-router and multi-router IPs)
{IP: "100.100.100.1/32", Ports: tailcfg.PortRange{First: 22, Last: 22}},
{IP: "fd7a:115c:a1e0::6401:6401/128", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// INCORRECT: Exit-node gets filter (should be nil - tag:exit not tag:router)
// Due to exit route coverage, it sees all tag:router IPs
"exit-node": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "100.100.100.1/32", Ports: tailcfg.PortRange{First: 22, Last: 22}},
{IP: "100.119.139.79/32", Ports: tailcfg.PortRange{First: 22, Last: 22}},
{IP: "100.74.117.7/32", Ports: tailcfg.PortRange{First: 22, Last: 22}},
{IP: "fd7a:115c:a1e0::4001:8ba0/128", Ports: tailcfg.PortRange{First: 22, Last: 22}},
{IP: "fd7a:115c:a1e0::6401:6401/128", Ports: tailcfg.PortRange{First: 22, Last: 22}},
{IP: "fd7a:115c:a1e0::c401:7508/128", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// TODO: Fix exit route coverage for subnet destinations
//
// I6: Dual-stack node - targeting both IPv4 and IPv6 subnets
//
// TAILSCALE BEHAVIOR:
// - Only subnet-router (10.33.0.0/16) and big-router (10.0.0.0/8) get IPv4 filter
// - No node has fd00:1::/64 route, so no node gets IPv6 filter
// - Exit nodes do NOT get filters for specific subnet destinations
// - Multiple rules with same SrcIPs kept as separate rules
//
// HEADSCALE BEHAVIOR:
// - Exit nodes get filters (exit route covers both subnets)
// - Rules with same SrcIPs and IPProto are MERGED into single rule
// with combined DstPorts
// - No node owns fd00:1::/64, but exit nodes cover it via ::/0
//
// ROOT CAUSE:
// 1. Exit route coverage: 0.0.0.0/0 and ::/0 cover all subnets
// 2. Filter rule merging: Headscale merges rules with identical SrcIPs/IPProto
//
// FIX REQUIRED:
// Exclude exit nodes from specific subnet destinations.
{
name: "I6_dual_stack_node",
// Target both IPv4 and IPv6 subnets
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["10.33.0.0/16:*"]},
{"action": "accept", "src": ["*"], "dst": ["fd00:1::/64:*"]}
`),
/* EXPECTED (Tailscale):
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"ha-router1": nil,
"ha-router2": nil,
"exit-node": nil, // Exit nodes shouldn't get subnet filters
"multi-router": nil, // Only has 172.16.0.0/24 + exit routes
// subnet-router owns 10.33.0.0/16
"subnet-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// big-router covers 10.33.0.0/16 via 10.0.0.0/8
"big-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
*/
// ACTUAL (Headscale): Exit nodes cover both, rules merged
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"ha-router1": nil,
"ha-router2": nil,
// subnet-router owns 10.33.0.0/16
"subnet-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// big-router covers 10.33.0.0/16 via 10.0.0.0/8
"big-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// INCORRECT: Exit-node gets MERGED filter covering both subnets
"exit-node": {
{
SrcIPs: wildcardSrcIPs,
// Both destinations merged into single rule
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
{IP: "fd00:1::/64", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// INCORRECT: multi-router gets MERGED filter covering both subnets
"multi-router": {
{
SrcIPs: wildcardSrcIPs,
// Both destinations merged into single rule
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny},
{IP: "fd00:1::/64", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
}
runRoutesCompatTests(t, users, nodes, tests)
}
// TestTailscaleRoutesCompatAdditionalOverlapping tests additional overlapping route scenarios (O8, O9).
func TestTailscaleRoutesCompatAdditionalOverlapping(t *testing.T) {
t.Parallel()
users := setupRouteCompatUsers()
nodes := setupRouteCompatNodes(users)
tests := []routesCompatTest{
// O8: Same node overlapping routes
// Node with 10.0.0.0/8, 10.33.0.0/16, 10.33.1.0/24 - NOT merged
{
name: "O8_same_node_overlapping_routes",
// If a single node advertises multiple overlapping routes,
// they should all appear separately, not merged
// big-router has 10.0.0.0/8
// Let's target a specific child prefix
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["*"], "dst": ["10.33.1.0/24:*"]}
`),
// big-router (10.0.0.0/8) and subnet-router (10.33.0.0/16) both cover
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil,
"client2": nil,
"user1": nil,
"ha-router1": nil,
"ha-router2": nil,
"subnet-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.1.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"big-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.1.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"exit-node": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.1.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: wildcardSrcIPs,
DstPorts: []tailcfg.NetPortRange{
{IP: "10.33.1.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
// O9: Different nodes same route
// Two nodes with 192.168.1.0/24 - only first is primary
{
name: "O9_different_nodes_same_route",
// ha-router1 and ha-router2 both have 192.168.1.0/24
// Both should receive filters, but only one is primary
policy: makeRoutesPolicy(`
{"action": "accept", "src": ["autogroup:member"], "dst": ["192.168.1.0/24:*"]}
`),
wantFilters: map[string][]tailcfg.FilterRule{
"client1": nil, // Source
"client2": nil, // Source
"user1": nil, // Source
"subnet-router": nil,
"big-router": nil,
// Both HA routers get filter despite sharing route
"ha-router1": {
{
SrcIPs: []string{
"100.116.73.38/32",
"100.89.42.23/32",
"100.90.199.68/32",
"fd7a:115c:a1e0::a801:4949/128",
"fd7a:115c:a1e0::d01:2a2e/128",
"fd7a:115c:a1e0::2d01:c747/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "192.168.1.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"ha-router2": {
{
SrcIPs: []string{
"100.116.73.38/32",
"100.89.42.23/32",
"100.90.199.68/32",
"fd7a:115c:a1e0::a801:4949/128",
"fd7a:115c:a1e0::d01:2a2e/128",
"fd7a:115c:a1e0::2d01:c747/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "192.168.1.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
// Exit nodes also cover
"exit-node": {
{
SrcIPs: []string{
"100.116.73.38/32",
"100.89.42.23/32",
"100.90.199.68/32",
"fd7a:115c:a1e0::a801:4949/128",
"fd7a:115c:a1e0::d01:2a2e/128",
"fd7a:115c:a1e0::2d01:c747/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "192.168.1.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
"multi-router": {
{
SrcIPs: []string{
"100.116.73.38/32",
"100.89.42.23/32",
"100.90.199.68/32",
"fd7a:115c:a1e0::a801:4949/128",
"fd7a:115c:a1e0::d01:2a2e/128",
"fd7a:115c:a1e0::2d01:c747/128",
},
DstPorts: []tailcfg.NetPortRange{
{IP: "192.168.1.0/24", Ports: tailcfg.PortRangeAny},
},
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
},
},
},
},
}
runRoutesCompatTests(t, users, nodes, tests)
}