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) }