// This file is "generated" by Claude. // It contains a large set of input ACL/Policy JSON configurations that // the AI agent has systematically applied to a Tailnet on Tailscale SaaS // and then observed the individual clients connected to the Tailnet // with a given policy and recorded the resulting Packet filter rules sent // to the clients. // // There is likely a lot of duplicate or overlapping tests, however, the main // exercise of this work was to create a comperehensive test set for comparing // the behaviour of our policy engine and the upstream one. // // We aim to keep these tests to make sure we do not regress as we evolve // and improve our policy implementation. // This file is NOT intended for developer/humans to change and should be // consider a "black box" test suite. package v2 import ( "net/netip" "testing" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/juanfont/headscale/hscontrol/policy/policyutil" "github.com/juanfont/headscale/hscontrol/types" "github.com/stretchr/testify/require" "gorm.io/gorm" "tailscale.com/tailcfg" ) // ptrAddr is a helper to create a pointer to a netip.Addr. func ptrAddr(s string) *netip.Addr { addr := netip.MustParseAddr(s) return &addr } // setupTailscaleCompatUsers returns the test users for compatibility tests. func setupTailscaleCompatUsers() types.Users { return types.Users{ {Model: gorm.Model{ID: 1}, Name: "kratail2tid"}, } } // setupTailscaleCompatNodes returns the test nodes for compatibility tests. // The node configuration matches the Tailscale test environment: // - 1 user-owned node (user1) // - 4 tagged nodes (tagged-server, tagged-client, tagged-db, tagged-web). func setupTailscaleCompatNodes(users types.Users) types.Nodes { // Node: user1 - User-owned by kratail2tid nodeUser1 := &types.Node{ ID: 1, 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{}, } // Node: tagged-server - Has tag:server nodeTaggedServer := &types.Node{ ID: 2, GivenName: "tagged-server", IPv4: ptrAddr("100.108.74.26"), IPv6: ptrAddr("fd7a:115c:a1e0::b901:4a87"), Tags: []string{"tag:server"}, Hostinfo: &tailcfg.Hostinfo{}, } // Node: tagged-client - Has tag:client nodeTaggedClient := &types.Node{ ID: 3, GivenName: "tagged-client", IPv4: ptrAddr("100.80.238.75"), IPv6: ptrAddr("fd7a:115c:a1e0::7901:ee86"), Tags: []string{"tag:client"}, Hostinfo: &tailcfg.Hostinfo{}, } // Node: tagged-db - Has tag:database nodeTaggedDB := &types.Node{ ID: 4, GivenName: "tagged-db", IPv4: ptrAddr("100.74.60.128"), IPv6: ptrAddr("fd7a:115c:a1e0::2f01:3c9c"), Tags: []string{"tag:database"}, Hostinfo: &tailcfg.Hostinfo{}, } // Node: tagged-web - Has tag:web nodeTaggedWeb := &types.Node{ ID: 5, GivenName: "tagged-web", IPv4: ptrAddr("100.94.92.91"), IPv6: ptrAddr("fd7a:115c:a1e0::ef01:5c81"), Tags: []string{"tag:web"}, Hostinfo: &tailcfg.Hostinfo{}, } return types.Nodes{ nodeUser1, nodeTaggedServer, nodeTaggedClient, nodeTaggedDB, nodeTaggedWeb, } } // findNodeByGivenName finds a node by its GivenName field. func findNodeByGivenName(nodes types.Nodes, name string) *types.Node { for _, n := range nodes { if n.GivenName == name { return n } } return nil } // tailscaleCompatTest defines a test case for Tailscale compatibility testing. type tailscaleCompatTest struct { name string // Test name policy string // HuJSON policy as multiline raw string wantFilters map[string][]tailcfg.FilterRule // node GivenName -> expected filters } // basePolicyTemplate provides the standard groups, tagOwners, and hosts // that are used in all Tailscale compatibility tests. const basePolicyPrefix = `{ "groups": { "group:admins": ["kratail2tid@"], "group:developers": ["kratail2tid@"], "group:empty": [] }, "tagOwners": { "tag:server": ["kratail2tid@"], "tag:client": ["kratail2tid@"], "tag:database": ["kratail2tid@"], "tag:web": ["kratail2tid@"] }, "hosts": { "webserver": "100.108.74.26", "database": "100.74.60.128", "internal": "10.0.0.0/8", "subnet24": "192.168.1.0/24" }, "acls": [` const basePolicySuffix = ` ] }` // makePolicy creates a full policy from just the ACL rules portion. func makePolicy(aclRules string) string { return basePolicyPrefix + aclRules + basePolicySuffix } // cmpOptions returns comparison options for FilterRule slices. // It sorts SrcIPs and DstPorts to handle ordering differences. func cmpOptions() []cmp.Option { return []cmp.Option{ cmpopts.SortSlices(func(a, b string) bool { return a < b }), cmpopts.SortSlices(func(a, b tailcfg.NetPortRange) bool { if a.IP != b.IP { return a.IP < b.IP } if a.Ports.First != b.Ports.First { return a.Ports.First < b.Ports.First } return a.Ports.Last < b.Ports.Last }), cmpopts.SortSlices(func(a, b int) bool { return a < b }), } } // Tailscale uses partitioned CGNAT CIDR ranges for wildcard source expansion // (excluding the ChromeOS VM range 100.115.92.0/23). Headscale uses the simpler // full CGNAT range (100.64.0.0/10) and Tailscale ULA range (fd7a:115c:a1e0::/48). // This is functionally equivalent for access control purposes. // // For reference, Tailscale's partitioned ranges are: // var tailscaleCGNATCIDRs = []string{ // "100.64.0.0/11", // "100.96.0.0/12", // "100.112.0.0/15", // "100.114.0.0/16", // "100.115.0.0/18", // "100.115.64.0/20", // "100.115.80.0/21", // "100.115.88.0/22", // "100.115.94.0/23", // "100.115.96.0/19", // "100.115.128.0/17", // "100.116.0.0/14", // "100.120.0.0/13", // "fd7a:115c:a1e0::/48", // } // TestTailscaleCompatWildcardACLs tests wildcard ACL rules (* source and destination). // These are the most fundamental tests for basic allow-all and IP-based rules. func TestTailscaleCompatWildcardACLs(t *testing.T) { t.Parallel() users := setupTailscaleCompatUsers() nodes := setupTailscaleCompatNodes(users) tests := []tailscaleCompatTest{ { name: "allow_all_wildcard", policy: makePolicy(` {"action": "accept", "src": ["*"], "dst": ["*:*"]} `), // All nodes receive the same filter for allow-all rule. // NOTE: Tailscale expands `*` source to partitioned CGNAT CIDR ranges: // 100.64.0.0/11, 100.96.0.0/12, 100.112.0.0/15, etc. plus fd7a:115c:a1e0::/48 // Headscale uses the full 100.64.0.0/10 and fd7a:115c:a1e0::/48 ranges. wantFilters: map[string][]tailcfg.FilterRule{ "user1": { { // NOTE: Tailscale uses partitioned CGNAT CIDRs, Headscale uses full range. SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-server": { { SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": { { SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-db": { { SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-web": { { SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, { name: "single_ip_as_source", policy: makePolicy(` {"action": "accept", "src": ["100.90.199.68"], "dst": ["*:*"]} `), // Single IP source: Headscale resolves the IP to a node and includes ALL of the // node's IPs (both IPv4 and IPv6). Tailscale uses only the literal IP specified. // TODO: Tailscale only includes the literal IP "100.90.199.68/32" without IPv6. wantFilters: map[string][]tailcfg.FilterRule{ "user1": { { // TODO: Tailscale only includes the literal IP: // SrcIPs: []string{"100.90.199.68/32"}, // Headscale: Resolves IP to node and includes ALL node IPs (IPv4+IPv6) SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-server": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-db": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-web": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, { name: "cidr_as_source", policy: makePolicy(` {"action": "accept", "src": ["100.64.0.0/16"], "dst": ["*:*"]} `), // CIDR source is passed through unchanged to the filter. wantFilters: map[string][]tailcfg.FilterRule{ "user1": { { SrcIPs: []string{"100.64.0.0/16"}, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-server": { { SrcIPs: []string{"100.64.0.0/16"}, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": { { SrcIPs: []string{"100.64.0.0/16"}, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-db": { { SrcIPs: []string{"100.64.0.0/16"}, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-web": { { SrcIPs: []string{"100.64.0.0/16"}, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, { name: "single_ip_as_destination", policy: makePolicy(` {"action": "accept", "src": ["*"], "dst": ["100.108.74.26:*"]} `), // Single IP destination: ONLY that node receives the filter. // KEY INSIGHT: Destination filters are only sent to nodes that ARE the destination. // NOTE: This IP (100.108.74.26) is tagged-server. // NOTE: Headscale resolves the IP to a node and includes ALL of the node's IPs. // TODO: Tailscale only includes the literal destination IP without IPv6. wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { // NOTE: Tailscale uses partitioned CGNAT CIDRs, Headscale uses full 100.64.0.0/10 SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, // TODO: Tailscale only includes the literal destination IP: // DstPorts: []tailcfg.NetPortRange{ // {IP: "100.108.74.26/32", Ports: tailcfg.PortRangeAny}, // }, // Headscale: Resolves IP to node and includes ALL node IPs (IPv4+IPv6) DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRangeAny}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "cidr_as_destination", policy: makePolicy(` {"action": "accept", "src": ["*"], "dst": ["100.64.0.0/12:*"]} `), // CIDR destination: only nodes with IPs in the CIDR range receive the filter. // 100.64.0.0/12 covers 100.64.0.0 - 100.79.255.255 // Of our test nodes, only tagged-db (100.74.60.128) falls in this range. wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, // 100.90.199.68 is NOT in 100.64.0.0/12 "tagged-server": nil, // 100.108.74.26 is NOT in 100.64.0.0/12 "tagged-client": nil, // 100.80.238.75 is NOT in 100.64.0.0/12 "tagged-db": { { // NOTE: Tailscale uses partitioned CGNAT CIDRs, Headscale uses full 100.64.0.0/10 SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.64.0.0/12", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-web": nil, // 100.94.92.91 is NOT in 100.64.0.0/12 }, }, } 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) } } }) } } // TestTailscaleCompatBasicTags tests basic tag-to-tag ACL rules. // These tests verify that tags are correctly expanded to node IPs // and that filters are distributed to the correct destination nodes. func TestTailscaleCompatBasicTags(t *testing.T) { t.Parallel() users := setupTailscaleCompatUsers() nodes := setupTailscaleCompatNodes(users) tests := []tailscaleCompatTest{ { name: "tag_client_to_tag_server_port_22", policy: makePolicy(` {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:22"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "tag_as_source_wildcard_dest", policy: makePolicy(` {"action": "accept", "src": ["tag:client"], "dst": ["*:*"]} `), // When dst is *, all nodes should receive the filter wantFilters: map[string][]tailcfg.FilterRule{ "user1": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-db": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-web": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, { name: "multiple_source_tags", policy: makePolicy(` {"action": "accept", "src": ["tag:client", "tag:web"], "dst": ["tag:server:22"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "100.94.92.91/32", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "tag_as_destination_only", policy: makePolicy(` {"action": "accept", "src": ["*"], "dst": ["tag:server:22"]} `), // When using wildcard source and tag destination, ONLY the tagged node receives the filter. // This is different from tag_as_source_wildcard_dest where all nodes receive the filter. wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { // NOTE: Tailscale uses partitioned CGNAT CIDRs, Headscale uses full 100.64.0.0/10 SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "multiple_destination_tags", policy: makePolicy(` {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:22", "tag:database:5432", "tag:web:80"]} `), // Multiple destination tags in a single rule. // Each tagged node receives ONLY its own destination portion. wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.74.60.128/32", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, {IP: "fd7a:115c:a1e0::2f01:3c9c/128", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-web": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.94.92.91/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::ef01:5c81/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, { name: "all_tagged_nodes_as_source_to_specific_destination", policy: makePolicy(` {"action": "accept", "src": ["autogroup:tagged"], "dst": ["tag:database:5432"]} `), // All tagged nodes as source (including the destination node itself). // Only the destination node receives the filter. wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": nil, "tagged-client": nil, "tagged-db": { { SrcIPs: []string{ "100.108.74.26/32", "100.74.60.128/32", "100.80.238.75/32", "100.94.92.91/32", "fd7a:115c:a1e0::2f01:3c9c/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::b901:4a87/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.74.60.128/32", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, {IP: "fd7a:115c:a1e0::2f01:3c9c/128", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-web": nil, }, }, } 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) // Get compiled filters for this specific node compiledFilters, err := pol.compileFilterRulesForNode(users, node.View(), nodes.ViewSlice()) require.NoError(t, err, "failed to compile filters for node %s", nodeName) // Reduce to only rules where this node is a destination gotFilters := policyutil.ReduceFilterRules(node.View(), compiledFilters) // Handle nil vs empty slice comparison 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) } } }) } } // TestTailscaleCompatUsersGroups tests user and group ACL rules. func TestTailscaleCompatUsersGroups(t *testing.T) { t.Parallel() users := setupTailscaleCompatUsers() nodes := setupTailscaleCompatNodes(users) tests := []tailscaleCompatTest{ { name: "user_as_source", policy: makePolicy(` {"action": "accept", "src": ["kratail2tid@"], "dst": ["*:*"]} `), // User as source expands to IPs of nodes owned by that user wantFilters: map[string][]tailcfg.FilterRule{ "user1": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-server": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-db": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-web": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, { name: "user_as_destination", policy: makePolicy(` {"action": "accept", "src": ["*"], "dst": ["kratail2tid@:*"]} `), // User as destination - only user-owned nodes receive the filter wantFilters: map[string][]tailcfg.FilterRule{ "user1": { { SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, 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}, }, }, "tagged-server": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "group_as_source", policy: makePolicy(` {"action": "accept", "src": ["group:admins"], "dst": ["*:*"]} `), // Group as source expands to IPs of nodes owned by group members wantFilters: map[string][]tailcfg.FilterRule{ "user1": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-server": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-db": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-web": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, { name: "group_as_destination", policy: makePolicy(` {"action": "accept", "src": ["*"], "dst": ["group:admins:*"]} `), // Group as destination - only nodes owned by group members receive the filter wantFilters: map[string][]tailcfg.FilterRule{ "user1": { { SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, 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}, }, }, "tagged-server": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "multiple_destinations_different_ports", policy: makePolicy(` {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:22", "tag:database:5432"]} `), // Each destination node receives ONLY its own destination portion wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.74.60.128/32", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, {IP: "fd7a:115c:a1e0::2f01:3c9c/128", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-web": nil, }, }, } 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) // Get compiled filters for this specific node compiledFilters, err := pol.compileFilterRulesForNode(users, node.View(), nodes.ViewSlice()) require.NoError(t, err, "failed to compile filters for node %s", nodeName) // Reduce to only rules where this node is a destination 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) } } }) } } // TestTailscaleCompatAutogroups tests autogroup ACL rules. func TestTailscaleCompatAutogroups(t *testing.T) { t.Parallel() users := setupTailscaleCompatUsers() nodes := setupTailscaleCompatNodes(users) tests := []tailscaleCompatTest{ { name: "autogroup_member_as_source", policy: makePolicy(` {"action": "accept", "src": ["autogroup:member"], "dst": ["*:*"]} `), // autogroup:member expands to IPs of user-owned nodes only wantFilters: map[string][]tailcfg.FilterRule{ "user1": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-server": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-db": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-web": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, { name: "autogroup_tagged_as_source", policy: makePolicy(` {"action": "accept", "src": ["autogroup:tagged"], "dst": ["*:*"]} `), // autogroup:tagged expands to IPs of all tagged nodes wantFilters: map[string][]tailcfg.FilterRule{ "user1": { { SrcIPs: []string{ "100.108.74.26/32", "100.74.60.128/32", "100.80.238.75/32", "100.94.92.91/32", "fd7a:115c:a1e0::2f01:3c9c/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::b901:4a87/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-server": { { SrcIPs: []string{ "100.108.74.26/32", "100.74.60.128/32", "100.80.238.75/32", "100.94.92.91/32", "fd7a:115c:a1e0::2f01:3c9c/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::b901:4a87/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": { { SrcIPs: []string{ "100.108.74.26/32", "100.74.60.128/32", "100.80.238.75/32", "100.94.92.91/32", "fd7a:115c:a1e0::2f01:3c9c/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::b901:4a87/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-db": { { SrcIPs: []string{ "100.108.74.26/32", "100.74.60.128/32", "100.80.238.75/32", "100.94.92.91/32", "fd7a:115c:a1e0::2f01:3c9c/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::b901:4a87/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-web": { { SrcIPs: []string{ "100.108.74.26/32", "100.74.60.128/32", "100.80.238.75/32", "100.94.92.91/32", "fd7a:115c:a1e0::2f01:3c9c/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::b901:4a87/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, { name: "autogroup_member_plus_tag_client", policy: makePolicy(` {"action": "accept", "src": ["autogroup:member", "tag:client"], "dst": ["tag:server:22"]} `), // Sources are merged into one Srcs array wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "autogroup_self_as_destination", policy: makePolicy(` {"action": "accept", "src": ["*"], "dst": ["autogroup:self:*"]} `), // autogroup:self allows a node to access ITSELF. // The source wildcard `*` is narrowed to the node's own IP for autogroup:self. // KEY INSIGHT: Tagged nodes do NOT receive autogroup:self filters. // Only user-owned nodes can use autogroup:self. // NOTE: For autogroup:self destinations, both Tailscale and Headscale narrow // the wildcard source to only the same-user untagged nodes. wantFilters: map[string][]tailcfg.FilterRule{ "user1": { { // Source is narrowed to the node's own IPs for autogroup:self. SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, // Tailscale uses CIDR format: "100.90.199.68/32" and "fd7a:115c:a1e0::2d01:c747/128" 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}, }, }, "tagged-server": nil, // Tagged nodes do NOT receive autogroup:self filters "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "autogroup_internet_as_destination", policy: makePolicy(` {"action": "accept", "src": ["*"], "dst": ["autogroup:internet:*"]} `), // autogroup:internet produces NO PacketFilter entries. // This autogroup relates to exit node routing, not direct node-to-node filters. // It controls what traffic can be routed through exit nodes to the internet. wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "autogroup_member_as_destination", policy: makePolicy(` {"action": "accept", "src": ["*"], "dst": ["autogroup:member:*"]} `), // autogroup:member as destination - only user-owned nodes receive the filter. // Tagged nodes do NOT receive this filter. wantFilters: map[string][]tailcfg.FilterRule{ "user1": { { // NOTE: Tailscale uses partitioned CGNAT CIDRs, Headscale uses full 100.64.0.0/10 SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, 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}, }, }, "tagged-server": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "autogroup_self_mixed_with_tag", policy: makePolicy(` {"action": "accept", "src": ["*"], "dst": ["autogroup:self:*", "tag:server:22"]} `), // KEY FINDING: Mixed destinations create SEPARATE filter entries with different Srcs! // - autogroup:self narrows Srcs to the user's own IPs // - tag:server keeps Srcs as full wildcard // user1 gets ONLY the self filter (narrowed Srcs to user1's IPs) // tagged-server gets ONLY the tag filter (full wildcard Srcs) wantFilters: map[string][]tailcfg.FilterRule{ "user1": { { // autogroup:self narrows Srcs to user's own IPs SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, 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}, }, }, "tagged-server": { { // tag:server keeps full wildcard Srcs // NOTE: Tailscale uses partitioned CGNAT CIDRs, Headscale uses full 100.64.0.0/10 SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, // Not in destination "tagged-db": nil, // Not in destination "tagged-web": nil, // Not in destination }, }, { name: "autogroup_tagged_as_destination", policy: makePolicy(` {"action": "accept", "src": ["*"], "dst": ["autogroup:tagged:*"]} `), // autogroup:tagged as destination - all tagged nodes receive the filter. // User-owned nodes do NOT receive this filter. // KEY INSIGHT: ReduceFilterRules filters DstPorts to only the current node's IPs. // So each tagged node only sees its OWN IPs in DstPorts after reduction. // TODO: Tailscale includes ALL tagged nodes' IPs in DstPorts for each node. // Headscale only includes the current node's IPs after ReduceFilterRules. wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { // NOTE: Tailscale uses partitioned CGNAT CIDRs, Headscale uses full 100.64.0.0/10 SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, // TODO: Tailscale includes ALL tagged nodes' IPs: // DstPorts: []tailcfg.NetPortRange{ // {IP: "100.108.74.26/32", Ports: tailcfg.PortRangeAny}, // {IP: "100.74.60.128/32", Ports: tailcfg.PortRangeAny}, // {IP: "100.80.238.75/32", Ports: tailcfg.PortRangeAny}, // {IP: "100.94.92.91/32", Ports: tailcfg.PortRangeAny}, // {IP: "fd7a:115c:a1e0::2f01:3c9c/128", Ports: tailcfg.PortRangeAny}, // {IP: "fd7a:115c:a1e0::7901:ee86/128", Ports: tailcfg.PortRangeAny}, // {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRangeAny}, // {IP: "fd7a:115c:a1e0::ef01:5c81/128", Ports: tailcfg.PortRangeAny}, // }, // Headscale: After ReduceFilterRules, only this node's IPs are in DstPorts DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRangeAny}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": { { SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, // TODO: Tailscale includes ALL tagged nodes' IPs (see tagged-server comment) // Headscale: Only this node's IPs after ReduceFilterRules DstPorts: []tailcfg.NetPortRange{ {IP: "100.80.238.75/32", Ports: tailcfg.PortRangeAny}, {IP: "fd7a:115c:a1e0::7901:ee86/128", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-db": { { SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, // TODO: Tailscale includes ALL tagged nodes' IPs (see tagged-server comment) // Headscale: Only this node's IPs after ReduceFilterRules DstPorts: []tailcfg.NetPortRange{ {IP: "100.74.60.128/32", Ports: tailcfg.PortRangeAny}, {IP: "fd7a:115c:a1e0::2f01:3c9c/128", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-web": { { SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, // TODO: Tailscale includes ALL tagged nodes' IPs (see tagged-server comment) // Headscale: Only this node's IPs after ReduceFilterRules DstPorts: []tailcfg.NetPortRange{ {IP: "100.94.92.91/32", Ports: tailcfg.PortRangeAny}, {IP: "fd7a:115c:a1e0::ef01:5c81/128", Ports: tailcfg.PortRangeAny}, }, 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) // Get compiled filters for this specific node compiledFilters, err := pol.compileFilterRulesForNode(users, node.View(), nodes.ViewSlice()) require.NoError(t, err, "failed to compile filters for node %s", nodeName) // Reduce to only rules where this node is a destination 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) } } }) } } // TestTailscaleCompatHosts tests host alias ACL rules. func TestTailscaleCompatHosts(t *testing.T) { t.Parallel() users := setupTailscaleCompatUsers() nodes := setupTailscaleCompatNodes(users) tests := []tailscaleCompatTest{ { name: "host_as_destination", policy: makePolicy(` {"action": "accept", "src": ["*"], "dst": ["webserver:80"]} `), // Host reference webserver = 100.108.74.26 = tagged-server wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { // NOTE: Tailscale uses partitioned CGNAT CIDRs, Headscale uses full 100.64.0.0/10 SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, // TODO: Tailscale only includes the literal IPv4 for host aliases: // DstPorts: []tailcfg.NetPortRange{ // {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, // }, // Headscale: Resolves host alias to node and includes ALL node IPs (IPv4+IPv6) DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "host_as_source", policy: makePolicy(` {"action": "accept", "src": ["webserver"], "dst": ["*:*"]} `), // Host as source resolves to the defined IP wantFilters: map[string][]tailcfg.FilterRule{ "user1": { { // TODO: Tailscale only includes the literal IPv4 for host aliases: // SrcIPs: []string{"100.108.74.26/32"}, // Headscale: Resolves host alias to node and includes ALL node IPs (IPv4+IPv6) SrcIPs: []string{ "100.108.74.26/32", "fd7a:115c:a1e0::b901:4a87/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-server": { { // TODO: Tailscale only includes the literal IPv4 for host aliases (see user1 comment) // Headscale: Resolves host alias to node and includes ALL node IPs (IPv4+IPv6) SrcIPs: []string{ "100.108.74.26/32", "fd7a:115c:a1e0::b901:4a87/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": { { // TODO: Tailscale only includes the literal IPv4 for host aliases (see user1 comment) // Headscale: Resolves host alias to node and includes ALL node IPs (IPv4+IPv6) SrcIPs: []string{ "100.108.74.26/32", "fd7a:115c:a1e0::b901:4a87/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-db": { { // TODO: Tailscale only includes the literal IPv4 for host aliases (see user1 comment) // Headscale: Resolves host alias to node and includes ALL node IPs (IPv4+IPv6) SrcIPs: []string{ "100.108.74.26/32", "fd7a:115c:a1e0::b901:4a87/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-web": { { // TODO: Tailscale only includes the literal IPv4 for host aliases (see user1 comment) // Headscale: Resolves host alias to node and includes ALL node IPs (IPv4+IPv6) SrcIPs: []string{ "100.108.74.26/32", "fd7a:115c:a1e0::b901:4a87/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, { name: "cidr_host_as_source", policy: makePolicy(` {"action": "accept", "src": ["internal"], "dst": ["*:*"]} `), // CIDR host definition (10.0.0.0/8) is passed through unchanged wantFilters: map[string][]tailcfg.FilterRule{ "user1": { { SrcIPs: []string{"10.0.0.0/8"}, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-server": { { SrcIPs: []string{"10.0.0.0/8"}, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": { { SrcIPs: []string{"10.0.0.0/8"}, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-db": { { SrcIPs: []string{"10.0.0.0/8"}, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-web": { { SrcIPs: []string{"10.0.0.0/8"}, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, 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) // Get compiled filters for this specific node compiledFilters, err := pol.compileFilterRulesForNode(users, node.View(), nodes.ViewSlice()) require.NoError(t, err, "failed to compile filters for node %s", nodeName) // Reduce to only rules where this node is a destination 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) } } }) } } // TestTailscaleCompatProtocolsPorts tests protocol and port ACL rules. func TestTailscaleCompatProtocolsPorts(t *testing.T) { t.Parallel() users := setupTailscaleCompatUsers() nodes := setupTailscaleCompatNodes(users) tests := []tailscaleCompatTest{ { name: "tcp_only_protocol", policy: makePolicy(` {"action": "accept", "src": ["*"], "proto": "tcp", "dst": ["tag:server:22"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "udp_only_protocol", policy: makePolicy(` {"action": "accept", "src": ["*"], "proto": "udp", "dst": ["tag:server:53"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 53, Last: 53}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 53, Last: 53}}, }, IPProto: []int{ProtocolUDP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "icmp_numeric_protocol", policy: makePolicy(` {"action": "accept", "src": ["*"], "proto": "1", "dst": ["tag:server:*"]} `), // Numeric protocol values work (e.g., "1" for ICMP) // Even for ICMP (which doesn't use ports), the ports field is 0-65535 wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { // NOTE: Tailscale uses partitioned CGNAT CIDRs, Headscale uses full 100.64.0.0/10 SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRangeAny}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "port_range", policy: makePolicy(` {"action": "accept", "src": ["*"], "dst": ["tag:server:80-443"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 80, Last: 443}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 80, Last: 443}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "multiple_comma_separated_ports", policy: makePolicy(` {"action": "accept", "src": ["*"], "dst": ["tag:server:22,80,443"]} `), // Comma-separated ports expand into separate DstPorts entries wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 443, Last: 443}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 443, Last: 443}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "wildcard_port", policy: makePolicy(` {"action": "accept", "src": ["*"], "dst": ["tag:server:*"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRangeAny}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, } 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) // Get compiled filters for this specific node compiledFilters, err := pol.compileFilterRulesForNode(users, node.View(), nodes.ViewSlice()) require.NoError(t, err, "failed to compile filters for node %s", nodeName) // Reduce to only rules where this node is a destination 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) } } }) } } // TestTailscaleCompatMixedSources tests mixing different source types in a single rule. // From findings/09-mixed-scenarios.md - Category 1: Mixed Sources (Single Rule). func TestTailscaleCompatMixedSources(t *testing.T) { t.Parallel() users := setupTailscaleCompatUsers() nodes := setupTailscaleCompatNodes(users) tests := []tailscaleCompatTest{ { name: "autogroup_tagged_plus_autogroup_member_full_tailnet", policy: makePolicy(` {"action": "accept", "src": ["autogroup:tagged", "autogroup:member"], "dst": ["tag:server:22"]} `), // Full tailnet coverage: autogroup:tagged (all 4 tagged) + autogroup:member (user1) // All 5 nodes' IPv4 and IPv6 addresses should be in Srcs (10 total entries) wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { SrcIPs: []string{ "100.108.74.26/32", "100.74.60.128/32", "100.80.238.75/32", "100.90.199.68/32", "100.94.92.91/32", "fd7a:115c:a1e0::2d01:c747/128", "fd7a:115c:a1e0::2f01:3c9c/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::b901:4a87/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "group_plus_tag", policy: makePolicy(` {"action": "accept", "src": ["group:admins", "tag:client"], "dst": ["tag:server:22"]} `), // group:admins → user1's IPs + tag:client → tagged-client's IPs // Both merged into single Srcs array (4 IPs total) wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "explicit_user_plus_tag", policy: makePolicy(` {"action": "accept", "src": ["kratail2tid@", "tag:client"], "dst": ["tag:server:22"]} `), // Explicit user kratail2tid@ → user1's IPs + tag:client → tagged-client's IPs // Both merged into single Srcs array (4 IPs total) wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "cidr_plus_tag", policy: makePolicy(` {"action": "accept", "src": ["10.0.0.0/8", "tag:client"], "dst": ["tag:server:22"]} `), // CIDR 10.0.0.0/8 + tag:client IPs merged into single Srcs array wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { SrcIPs: []string{ "10.0.0.0/8", "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "host_plus_tag", policy: makePolicy(` {"action": "accept", "src": ["internal", "tag:client"], "dst": ["tag:server:22"]} `), // Host alias "internal" (10.0.0.0/8) + tag:client IPs merged into single Srcs array wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { SrcIPs: []string{ "10.0.0.0/8", "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "webserver_host_plus_tag", // Test 1.5: webserver (host) + tag:client // Host aliases are IPv4 only; tags include IPv6. policy: makePolicy(` {"action": "accept", "src": ["webserver", "tag:client"], "dst": ["tag:server:22"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { // TODO: Tailscale: webserver host = 100.108.74.26/32 (IPv4 only) // Tailscale Srcs: ["100.108.74.26/32", "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128"] // Headscale: Host resolves to node and includes ALL node IPs SrcIPs: []string{ "100.108.74.26/32", "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::b901:4a87/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "raw_ip_plus_tag", // Test 1.6: 100.90.199.68 (raw IP) + tag:client // Raw IPs are treated as literal CIDRs policy: makePolicy(` {"action": "accept", "src": ["100.90.199.68", "tag:client"], "dst": ["tag:server:22"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { // Raw IP 100.90.199.68 resolves to user1 node - Headscale includes all node IPs // tag:client expands to tagged-client's IPs // TODO: Tailscale may treat raw IP as literal /32 only without IPv6 SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", // user1 IPv6 added by Headscale "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "same_user_three_ways", // Test 1.7: autogroup:member + group:admins + kratail2tid@ (same user 3 ways) // All three resolve to user1, should deduplicate to just user1's IPs policy: makePolicy(` {"action": "accept", "src": ["autogroup:member", "group:admins", "kratail2tid@"], "dst": ["tag:server:22"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { // All three sources resolve to user1 - should be deduplicated SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "same_ip_two_ways_as_source", // Test 1.8: tag:server + webserver (same IP via tag and host) // Both reference tagged-server's IP - should deduplicate policy: makePolicy(` {"action": "accept", "src": ["tag:server", "webserver"], "dst": ["tag:database:5432"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": nil, "tagged-client": nil, "tagged-db": { { // TODO: Tailscale: webserver host only adds IPv4 // Tailscale Srcs: ["100.108.74.26/32", "fd7a:115c:a1e0::b901:4a87/128"] // Headscale: Both tag:server and webserver resolve to all node IPs SrcIPs: []string{ "100.108.74.26/32", "fd7a:115c:a1e0::b901:4a87/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.74.60.128/32", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, {IP: "fd7a:115c:a1e0::2f01:3c9c/128", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-web": nil, }, }, } 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) // Get compiled filters for this specific node compiledFilters, err := pol.compileFilterRulesForNode(users, node.View(), nodes.ViewSlice()) require.NoError(t, err, "failed to compile filters for node %s", nodeName) // Reduce to only rules where this node is a destination 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) } } }) } } // TestTailscaleCompatComplexScenarios tests complex ACL rule combinations. func TestTailscaleCompatComplexScenarios(t *testing.T) { t.Parallel() users := setupTailscaleCompatUsers() nodes := setupTailscaleCompatNodes(users) tests := []tailscaleCompatTest{ { name: "empty_group_produces_no_filter", policy: makePolicy(` {"action": "accept", "src": ["group:empty"], "dst": ["*:*"]} `), // Empty groups produce no filter entries wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "multiple_rules_same_source_merged", policy: makePolicy(` {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:22"]}, {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:80,443"]} `), // KEY INSIGHT: In Tailscale, multiple rules with the SAME source are MERGED into a // single filter entry with all destination ports combined. // Headscale now merges rules with identical SrcIPs and IPProto. wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { // Merged: Both ACL rules combined into single filter entry { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 443, Last: 443}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 443, Last: 443}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "different_sources_same_destination_separate", policy: makePolicy(` {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:22"]}, {"action": "accept", "src": ["tag:web"], "dst": ["tag:server:22"]}, {"action": "accept", "src": ["tag:database"], "dst": ["tag:server:22"]} `), // KEY INSIGHT: Different sources are NEVER merged - always separate filter entries. // Each source gets its own filter entry even with identical destinations. wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, { SrcIPs: []string{ "100.94.92.91/32", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, { SrcIPs: []string{ "100.74.60.128/32", "fd7a:115c:a1e0::2f01:3c9c/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "mixed_overlapping_rules", policy: makePolicy(` {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:22"]}, {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:80"]}, {"action": "accept", "src": ["tag:web"], "dst": ["tag:server:22"]}, {"action": "accept", "src": ["tag:web"], "dst": ["tag:server:443"]} `), // In Tailscale: 4 rules → 2 filter entries (merged per-source) // - tag:client rules merged (ports 22, 80) // - tag:web rules merged (ports 22, 443) // Headscale now merges rules with identical SrcIPs and IPProto. wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { // Merged: tag:client rules (ports 22, 80) { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, // Merged: tag:web rules (ports 22, 443) { SrcIPs: []string{ "100.94.92.91/32", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 443, Last: 443}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 443, Last: 443}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "multiple_tag_destinations_distributed", policy: makePolicy(` {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:22", "tag:database:5432"]} `), // Multiple tag destinations are distributed to their respective nodes. // tagged-server gets port 22, tagged-db gets port 5432. wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.74.60.128/32", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, {IP: "fd7a:115c:a1e0::2f01:3c9c/128", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-web": nil, }, }, { name: "same_node_different_ports_via_tag_and_host", policy: makePolicy(` {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:22", "webserver:80"]} `), // KEY FINDING: Same IP can appear multiple times in Dsts with different ports // when referenced via different aliases (tag vs host). // - tag:server adds both IPv4 and IPv6 (port 22) // - webserver host adds only IPv4 (port 80) wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, // TODO: Tailscale includes webserver:80 BEFORE tag:server:22 in Dsts: // DstPorts: []tailcfg.NetPortRange{ // {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, // {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, // {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, // }, // Headscale: tag destinations come first, then host destinations DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, // Host alias "webserver" expands to node's IPs (IPv4 + IPv6) {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "group_and_tag_destinations_distributed", policy: makePolicy(` {"action": "accept", "src": ["tag:client"], "dst": ["group:admins:22", "tag:server:80"]} `), // Group:admins → user1, tag:server → tagged-server // Each destination type distributed to its respective nodes. wantFilters: map[string][]tailcfg.FilterRule{ "user1": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "wildcard_mixed_with_specific_source", policy: makePolicy(` {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:22"]}, {"action": "accept", "src": ["*"], "dst": ["tag:server:80"]} `), // Wildcard `*` is NOT merged with specific sources. // Each remains a separate filter entry. // Wildcard expands to CIDR ranges, specific tag expands to node IP. wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, { // NOTE: Tailscale uses partitioned CGNAT CIDRs, Headscale uses full 100.64.0.0/10 SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "same_src_different_dest_ports_merged", policy: makePolicy(` {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:22"]}, {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:80"]} `), // KEY FINDING: Same source, same dest node, different ports = MERGED // 2 rules → 1 filter entry with all ports combined (4 Dsts: 2 ports × 2 IPs) // Headscale now merges rules with identical SrcIPs and IPProto. wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { // Merged: Both rules combined { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "same_src_different_dest_nodes_separate", policy: makePolicy(` {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:22"]}, {"action": "accept", "src": ["tag:client"], "dst": ["tag:database:5432"]} `), // Same source, different destination nodes = separate filter entries per node. // Each destination node only receives its relevant filter. wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.74.60.128/32", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, {IP: "fd7a:115c:a1e0::2f01:3c9c/128", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-web": nil, }, }, // Category 2: Mixed Destinations - Additional tests { name: "tag_plus_raw_ip_same_node_different_ports", // Test 2.3: tag:server:22 + 100.108.74.26:80 (tag + raw IP, same node) // Same behavior as Test 2.2 - same IP can appear multiple times with different ports policy: makePolicy(` {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:22", "100.108.74.26:80"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ // tag:server adds both IPv4+IPv6 for port 22 {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, // Headscale resolves raw IP to node and includes all IPs (IPv4+IPv6) // TODO: Tailscale adds only IPv4 for raw IP destinations {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "user_via_email_and_group_different_ports", // Test 2.6: kratail2tid@:22 + group:admins:80 (same user via email + group) // Same user referenced via email and group creates separate Dst entries per port policy: makePolicy(` {"action": "accept", "src": ["tag:client"], "dst": ["kratail2tid@:22", "group:admins:80"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, // Same user via email and group with different ports - 4 Dst entries total DstPorts: []tailcfg.NetPortRange{ {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-server": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "multiple_host_destinations", // Test 2.7: webserver:22 + database:5432 (multiple hosts) // Host destinations are properly distributed to matching nodes policy: makePolicy(` {"action": "accept", "src": ["tag:client"], "dst": ["webserver:22", "database:5432"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, // Headscale resolves host alias to node and includes all IPs (IPv4+IPv6) // TODO: Tailscale host alias is IPv4-only DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, // Headscale resolves host alias to node and includes all IPs (IPv4+IPv6) // TODO: Tailscale host alias is IPv4-only DstPorts: []tailcfg.NetPortRange{ {IP: "100.74.60.128/32", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, {IP: "fd7a:115c:a1e0::2f01:3c9c/128", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-web": nil, }, }, // Category 3: Overlapping References - Same entity via different names { name: "same_ip_via_tag_and_host_source", // Test 3.1: src: [tag:server, webserver] - same IP via tag and host // Duplicate IPs should be deduplicated in Srcs policy: makePolicy(` {"action": "accept", "src": ["tag:server", "webserver"], "dst": ["tag:client:22"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": nil, "tagged-client": { { // tag:server gives IPv4+IPv6, webserver adds IPv4 again (but deduplicated) SrcIPs: []string{ "100.108.74.26/32", "fd7a:115c:a1e0::b901:4a87/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.80.238.75/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::7901:ee86/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-db": nil, "tagged-web": nil, }, }, { name: "same_ip_port_via_tag_and_host_dest", // Test 3.3: dst: [tag:server:22, webserver:22] - same IP:port via tag and host // Destinations are NOT deduplicated - same IP:port can appear multiple times policy: makePolicy(` {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:22", "webserver:22"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, // Destinations NOT deduplicated - same IP can appear twice // tag:server adds IPv4:22 + IPv6:22 // webserver adds IPv4:22 again + Headscale adds IPv6 too // TODO: Tailscale: webserver adds IPv4:22 only (duplicated with tag:server) DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "same_ip_port_via_tag_and_raw_ip_dest", // Test 3.4: dst: [tag:server:22, 100.108.74.26:22] - tag + raw IP (identical) // Same behavior as Test 3.3 - Dsts not deduplicated policy: makePolicy(` {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:22", "100.108.74.26:22"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, // Destinations NOT deduplicated // tag:server adds IPv4:22 + IPv6:22 // Raw IP adds IPv4:22 again + Headscale adds IPv6 too // TODO: Tailscale: raw IP adds IPv4:22 only (duplicated) DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "tag_database_plus_host_database_source", // Test 3.5: src: [tag:database, database] - tag:database + host database (same node) // Sources ARE deduplicated policy: makePolicy(` {"action": "accept", "src": ["tag:database", "database"], "dst": ["tag:server:22"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { // Sources deduplicated: tag:database (IPv4+IPv6) + database host (IPv4) SrcIPs: []string{ "100.74.60.128/32", "fd7a:115c:a1e0::2f01:3c9c/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, // Category 4: Cross-Type Source→Destination Combinations { name: "autogroup_tagged_to_user", // Test 4.2: autogroup:tagged → kratail2tid@:22 // Tagged nodes → user-owned nodes policy: makePolicy(` {"action": "accept", "src": ["autogroup:tagged"], "dst": ["kratail2tid@:22"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": { { // All 4 tagged nodes (8 IPs) can access user1:22 SrcIPs: []string{ "100.108.74.26/32", "100.74.60.128/32", "100.80.238.75/32", "100.94.92.91/32", "fd7a:115c:a1e0::2f01:3c9c/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::b901:4a87/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-server": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "group_to_host_alias", // Test 4.3: group:admins → webserver:22 // Group → host alias policy: makePolicy(` {"action": "accept", "src": ["group:admins"], "dst": ["webserver:22"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, // Headscale resolves host alias to node and adds IPv6 too // TODO: Tailscale host alias is IPv4-only DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, // Category 5: Order Effects - Order does NOT affect output { name: "source_order_independence", // Test 5.1: Order of sources doesn't affect output - they are sorted policy: makePolicy(` {"action": "accept", "src": ["tag:web", "tag:client"], "dst": ["tag:server:22"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { // Sources are sorted: IPv4 first (ascending), then IPv6 (ascending) SrcIPs: []string{ "100.80.238.75/32", "100.94.92.91/32", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, // Category 6: Edge Cases { name: "cidr_host_as_source", // Test 6.5: internal (10.0.0.0/8) → tag:server:22 // CIDR host definitions work as sources policy: makePolicy(` {"action": "accept", "src": ["internal"], "dst": ["tag:server:22"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { // CIDR host goes directly into SrcIPs SrcIPs: []string{ "10.0.0.0/8", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "cidr_host_as_destination_no_matching_nodes", // Test 6.6: tag:client → internal:22 (CIDR host as destination) // No nodes in 10.0.0.0/8 range, so no filters generated for any tailnet nodes policy: makePolicy(` {"action": "accept", "src": ["tag:client"], "dst": ["internal:22"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, // Category 7: Maximum Combinations { name: "multiple_tags_as_sources", // Test 7.x: Multiple tags as sources policy: makePolicy(` {"action": "accept", "src": ["tag:client", "tag:web", "tag:database"], "dst": ["tag:server:22"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { // All 3 tags' IPs SrcIPs: []string{ "100.74.60.128/32", "100.80.238.75/32", "100.94.92.91/32", "fd7a:115c:a1e0::2f01:3c9c/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "tag_to_multiple_destinations_ports", // Test 7.x: tag:client → multiple destinations with different ports policy: makePolicy(` {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:22", "tag:database:5432", "tag:web:80"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.74.60.128/32", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, {IP: "fd7a:115c:a1e0::2f01:3c9c/128", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-web": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.94.92.91/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::ef01:5c81/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Category 8: Redundancy Stress Tests { name: "user1_referenced_multiple_ways_as_source", // Test 8.1: user1 referenced 5 ways - all deduplicated // autogroup:member, kratail2tid@, group:admins, group:developers, 100.90.199.68 policy: makePolicy(` {"action": "accept", "src": ["autogroup:member", "kratail2tid@", "group:admins", "group:developers", "100.90.199.68"], "dst": ["tag:server:22"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { // All 5 references resolve to user1 - deduplicated SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, // Category 9: All Tags + All Autogroups { name: "all_four_tags_as_sources", // Test 9.1: All 4 tags as sources policy: makePolicy(` {"action": "accept", "src": ["tag:server", "tag:client", "tag:database", "tag:web"], "dst": ["kratail2tid@:22"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": { { // All 4 tagged nodes (8 IPs total) SrcIPs: []string{ "100.108.74.26/32", "100.74.60.128/32", "100.80.238.75/32", "100.94.92.91/32", "fd7a:115c:a1e0::2f01:3c9c/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::b901:4a87/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-server": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "all_four_tags_as_destinations", // Test 9.2: All 4 tags as destinations policy: makePolicy(` {"action": "accept", "src": ["kratail2tid@"], "dst": ["tag:server:22", "tag:client:22", "tag:database:22", "tag:web:22"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.80.238.75/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::7901:ee86/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-db": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.74.60.128/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::2f01:3c9c/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-web": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.94.92.91/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::ef01:5c81/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, { name: "both_autogroups_as_sources", // Test 9.3: autogroup:member + autogroup:tagged as sources (full tailnet) policy: makePolicy(` {"action": "accept", "src": ["autogroup:member", "autogroup:tagged"], "dst": ["tag:server:22"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { // All 5 nodes (10 IPs) SrcIPs: []string{ "100.108.74.26/32", "100.74.60.128/32", "100.80.238.75/32", "100.90.199.68/32", "100.94.92.91/32", "fd7a:115c:a1e0::2d01:c747/128", "fd7a:115c:a1e0::2f01:3c9c/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::b901:4a87/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, // Category 10: Multiple Rules with Mixed Types { name: "cross_type_separate_rules", // Test 10.1: Different source types in separate rules policy: makePolicy(` {"action": "accept", "src": ["autogroup:member"], "dst": ["tag:server:22"]}, {"action": "accept", "src": ["tag:client"], "dst": ["tag:database:5432"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.74.60.128/32", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, {IP: "fd7a:115c:a1e0::2f01:3c9c/128", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-web": nil, }, }, // Category 11: Port Variations with Mixed Types { name: "mixed_sources_with_port_range", // Test 11.2: Mixed sources with port range policy: makePolicy(` {"action": "accept", "src": ["autogroup:member", "tag:client"], "dst": ["tag:server:80-443"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 80, Last: 443}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 80, Last: 443}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, // Category 14: Multi-Rule Compounding { name: "same_src_different_dests_two_rules", // Test 14.1: Same src, different dests (2 rules) policy: makePolicy(` {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:22"]}, {"action": "accept", "src": ["tag:client"], "dst": ["tag:database:5432"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.74.60.128/32", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, {IP: "fd7a:115c:a1e0::2f01:3c9c/128", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-web": nil, }, }, { name: "different_srcs_same_dest_two_rules", // Test 14.6: Different srcs, same dest (2 rules) policy: makePolicy(` {"action": "accept", "src": ["autogroup:member"], "dst": ["tag:server:22"]}, {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:22"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { // Two separate filter rules for each ACL rule { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, // Category 12: CIDR Host Combinations { name: "cidr_host_plus_tag_as_sources", // Test 12.1: CIDR host + tag as sources // internal (10.0.0.0/8) + tag:client policy: makePolicy(` {"action": "accept", "src": ["internal", "tag:client"], "dst": ["tag:server:22"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { // CIDR host appears as-is in Srcs + tag:client IPs SrcIPs: []string{ "10.0.0.0/8", "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "multiple_cidr_hosts_as_sources", // Test 12.2: Multiple CIDR hosts as sources // internal (10.0.0.0/8) + subnet24 (192.168.1.0/24) policy: makePolicy(` {"action": "accept", "src": ["internal", "subnet24"], "dst": ["tag:server:22"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { // Both CIDR hosts appear in Srcs SrcIPs: []string{ "10.0.0.0/8", "192.168.1.0/24", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "same_cidr_via_host_and_raw", // Test 12.4: Same CIDR referenced via host alias and raw CIDR // internal (10.0.0.0/8) + 10.0.0.0/8 - should deduplicate policy: makePolicy(` {"action": "accept", "src": ["internal", "10.0.0.0/8"], "dst": ["tag:server:22"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { // Same CIDR referenced 2 ways should deduplicate SrcIPs: []string{ "10.0.0.0/8", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, // Category 13: autogroup:self Deep Dive - Tests where autogroup:self works { name: "wildcard_to_autogroup_self", // Test 13.1: * → autogroup:self:* // CRITICAL: autogroup:self NARROWS Srcs even when source is wildcard // Only user-owned nodes receive filters; tagged nodes get empty policy: makePolicy(` {"action": "accept", "src": ["*"], "dst": ["autogroup:self:*"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": { { // Srcs narrowed to user1's own IPs (NOT wildcard CIDRs) SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, // Dsts = user1's own IPs with all ports (no CIDR notation for autogroup:self) DstPorts: []tailcfg.NetPortRange{ {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, // Tagged nodes receive NO filters for autogroup:self "tagged-server": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "wildcard_to_autogroup_self_specific_port", // Test 13.2: * → autogroup:self:22 // Specific port with autogroup:self policy: makePolicy(` {"action": "accept", "src": ["*"], "dst": ["autogroup:self:22"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-server": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "autogroup_member_to_self", // Test 13.5: autogroup:member → autogroup:self:* // autogroup:member is a valid source for autogroup:self policy: makePolicy(` {"action": "accept", "src": ["autogroup:member"], "dst": ["autogroup:self:*"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-server": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "specific_user_to_self", // Test 13.8: kratail2tid@ → autogroup:self:* // Specific user email is a valid source for autogroup:self policy: makePolicy(` {"action": "accept", "src": ["kratail2tid@"], "dst": ["autogroup:self:*"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-server": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "group_to_self", // Test 13.9: group:admins → autogroup:self:* // Groups are valid sources for autogroup:self policy: makePolicy(` {"action": "accept", "src": ["group:admins"], "dst": ["autogroup:self:*"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-server": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "wildcard_to_self_plus_tag", // Test 13.16: * → [autogroup:self:*, tag:server:22] // Mixed destinations with autogroup:self - different Srcs for each policy: makePolicy(` {"action": "accept", "src": ["*"], "dst": ["autogroup:self:*", "tag:server:22"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": { { // Self filter gets narrowed Srcs (user1's IPs only) SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, // autogroup:self destinations use plain IPs (no CIDR notation) DstPorts: []tailcfg.NetPortRange{ {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-server": { { // Tag filter gets full wildcard Srcs // NOTE: Tailscale uses partitioned CGNAT CIDRs, Headscale uses full 100.64.0.0/10 SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, // Tag destinations use CIDR notation DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, // Category 14: More Multi-Rule Compounding { name: "same_src_same_dest_different_ports_two_rules", // Test 14.2: Same src, same dest, different ports (2 rules) // In Tailscale: MERGED into single filter entry with combined Dsts // Headscale now merges rules with identical SrcIPs and IPProto. policy: makePolicy(` {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:22"]}, {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:80"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { // Merged: Both rules combined { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "three_different_srcs_same_dest_different_ports", // Test 14.21: 3 different sources → same dest, different ports // Each rule becomes a separate filter entry policy: makePolicy(` {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:22"]}, {"action": "accept", "src": ["tag:web"], "dst": ["tag:server:80"]}, {"action": "accept", "src": ["tag:database"], "dst": ["tag:server:443"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, { SrcIPs: []string{ "100.94.92.91/32", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, { SrcIPs: []string{ "100.74.60.128/32", "fd7a:115c:a1e0::2f01:3c9c/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 443, Last: 443}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 443, Last: 443}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "overlapping_dests_same_src_different_rules", // Test 10.2: Overlapping destinations, different sources (2 rules) // Each rule creates its own filter entry on destination nodes policy: makePolicy(` {"action": "accept", "src": ["group:admins"], "dst": ["tag:server:*"]}, {"action": "accept", "src": ["autogroup:tagged"], "dst": ["tag:server:*"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { // Rule 1: group:admins → tag:server:* SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, { // Rule 2: autogroup:tagged → tag:server:* SrcIPs: []string{ "100.108.74.26/32", "100.74.60.128/32", "100.80.238.75/32", "100.94.92.91/32", "fd7a:115c:a1e0::2f01:3c9c/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::b901:4a87/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "mixed_sources_comma_ports", // Test 11.1: Mixed sources with comma-separated ports // Each port becomes a separate Dst entry policy: makePolicy(` {"action": "accept", "src": ["autogroup:member", "tag:client"], "dst": ["tag:server:22,80,443"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", "fd7a:115c:a1e0::7901:ee86/128", }, // Each port is a separate Dst entry (6 total: 3 ports × 2 IPs) DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 443, Last: 443}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 443, Last: 443}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "full_autogroups_with_wildcard_and_specific_port", // Test 11.4: Both autogroups with wildcard and specific port destinations policy: makePolicy(` {"action": "accept", "src": ["autogroup:tagged", "autogroup:member"], "dst": ["tag:server:*", "tag:database:5432"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { // All 5 nodes (10 IPs) as sources SrcIPs: []string{ "100.108.74.26/32", "100.74.60.128/32", "100.80.238.75/32", "100.90.199.68/32", "100.94.92.91/32", "fd7a:115c:a1e0::2d01:c747/128", "fd7a:115c:a1e0::2f01:3c9c/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::b901:4a87/128", "fd7a:115c:a1e0::ef01:5c81/128", }, // Wildcard port → 0-65535 DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": { { SrcIPs: []string{ "100.108.74.26/32", "100.74.60.128/32", "100.80.238.75/32", "100.90.199.68/32", "100.94.92.91/32", "fd7a:115c:a1e0::2d01:c747/128", "fd7a:115c:a1e0::2f01:3c9c/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::b901:4a87/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.74.60.128/32", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, {IP: "fd7a:115c:a1e0::2f01:3c9c/128", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-web": nil, }, }, // Category 13: More autogroup:self tests { name: "wildcard_to_self_comma_ports", // Test 13.3: * → autogroup:self:22,80,443 // Comma-separated ports create separate Dsts entries policy: makePolicy(` {"action": "accept", "src": ["*"], "dst": ["autogroup:self:22,80,443"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, // 6 Dsts: 3 ports × 2 IPs (autogroup:self uses plain IPs) DstPorts: []tailcfg.NetPortRange{ {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 443, Last: 443}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 443, Last: 443}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-server": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "wildcard_to_self_port_range", // Test 13.4: * → autogroup:self:80-443 // Port range preserved as First/Last policy: makePolicy(` {"action": "accept", "src": ["*"], "dst": ["autogroup:self:80-443"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, // Port range preserved (autogroup:self uses plain IPs) DstPorts: []tailcfg.NetPortRange{ {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 80, Last: 443}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 80, Last: 443}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-server": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "self_twice_separate_rules_merged", // Test 13.36: Self twice in separate rules (merged) // * → autogroup:self:22 // * → autogroup:self:80 // Tailscale MERGES these into a single filter entry with 4 Dsts policy: makePolicy(` {"action": "accept", "src": ["*"], "dst": ["autogroup:self:22"]}, {"action": "accept", "src": ["*"], "dst": ["autogroup:self:80"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": { // Merged: Both rules combined into 1 filter entry with 4 Dsts { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-server": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, // Category 14: More Multi-Rule Compounding { name: "same_src_different_dests_two_rules_distributed", // Test 14.1: Same src, different dests (2 rules) // Rules distributed to different destination nodes policy: makePolicy(` {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:22"]}, {"action": "accept", "src": ["tag:client"], "dst": ["tag:database:5432"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.74.60.128/32", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, {IP: "fd7a:115c:a1e0::2f01:3c9c/128", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-web": nil, }, }, { name: "different_srcs_same_dest_two_rules", // Test 14.6: Different srcs, same dest (2 rules) // Creates 2 SEPARATE filter entries (not merged) policy: makePolicy(` {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:22"]}, {"action": "accept", "src": ["tag:web"], "dst": ["tag:server:22"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, { SrcIPs: []string{ "100.94.92.91/32", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "group_and_user_same_person_same_dest", // Test 14.8: Group + user (same person) → same dest (2 rules) // Srcs DEDUPLICATED but Dsts NOT deduplicated policy: makePolicy(` {"action": "accept", "src": ["group:admins"], "dst": ["tag:server:22"]}, {"action": "accept", "src": ["kratail2tid@"], "dst": ["tag:server:22"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { // Merged: 1 filter entry with Srcs deduplicated and 4 Dsts (duplicated) { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "wildcard_to_self_plus_group", // Test 13.20: * → [autogroup:self:*, group:admins:22] // user1 gets TWO filter entries (different Srcs) policy: makePolicy(` {"action": "accept", "src": ["*"], "dst": ["autogroup:self:*", "group:admins:22"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": { // Entry 1: autogroup:self with narrowed Srcs { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, // Entry 2: group:admins with full wildcard Srcs // NOTE: Tailscale uses partitioned CGNAT CIDRs, Headscale uses full 100.64.0.0/10 { SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-server": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "same_src_same_dest_different_ports_two_rules_merged", // Test 14.2: Same src, same dest, different ports (2 rules) // MERGED into single filter entry with 4 Dsts policy: makePolicy(` {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:22"]}, {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:80"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { // Merged: Both rules combined { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "three_different_srcs_same_dest_different_ports", // Test 14.21: 3 different srcs → same dest, different ports (3 rules) // Creates 3 SEPARATE filter entries policy: makePolicy(` {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:22"]}, {"action": "accept", "src": ["tag:web"], "dst": ["tag:server:80"]}, {"action": "accept", "src": ["tag:database"], "dst": ["tag:server:443"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, { SrcIPs: []string{ "100.94.92.91/32", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, { SrcIPs: []string{ "100.74.60.128/32", "fd7a:115c:a1e0::2f01:3c9c/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 443, Last: 443}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 443, Last: 443}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "three_refs_same_user_same_dest_port", // Test 14.22: 3 refs to same user → same dest:port (3 rules) // Srcs DEDUPLICATED, Dsts NOT deduplicated (6 entries) policy: makePolicy(` {"action": "accept", "src": ["autogroup:member"], "dst": ["tag:server:22"]}, {"action": "accept", "src": ["group:admins"], "dst": ["tag:server:22"]}, {"action": "accept", "src": ["kratail2tid@"], "dst": ["tag:server:22"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { // Merged: 1 filter entry with Srcs deduplicated and 6 Dsts (not deduplicated) { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "same_src_three_different_dests", // Test 14.23: Same src → 3 different dests (3 rules) // Each destination node receives its own filter entry policy: makePolicy(` {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:22"]}, {"action": "accept", "src": ["tag:client"], "dst": ["tag:database:5432"]}, {"action": "accept", "src": ["tag:client"], "dst": ["tag:web:80"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.74.60.128/32", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, {IP: "fd7a:115c:a1e0::2f01:3c9c/128", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-web": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.94.92.91/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::ef01:5c81/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, { name: "full_wildcard_plus_specific_rule", // Test 14.36: Full wildcard + specific rule // BOTH rules create filter entries (wildcard does NOT subsume specific) policy: makePolicy(` {"action": "accept", "src": ["*"], "dst": ["*:*"]}, {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:22"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": { // Wildcard rule only { // NOTE: Tailscale uses partitioned CGNAT CIDRs and IPProto [0] (any). // Headscale uses full 100.64.0.0/10 and explicit IPProto list. SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-server": { // TODO: Tailscale produces 2 entries: wildcard (IPProto [0]) + specific (IPProto [6,17,1,58]) // Headscale produces 2 entries but with same IPProto { SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": { { SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-db": { { SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-web": { { SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, { name: "both_autogroups_to_wildcard", // Test 14.42: Both autogroups → wildcard (full network) // Different Srcs = separate entries, even with identical Dsts policy: makePolicy(` {"action": "accept", "src": ["autogroup:tagged"], "dst": ["*:*"]}, {"action": "accept", "src": ["autogroup:member"], "dst": ["*:*"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": { // Entry 1: autogroup:tagged Srcs { SrcIPs: []string{ "100.108.74.26/32", "100.74.60.128/32", "100.80.238.75/32", "100.94.92.91/32", "fd7a:115c:a1e0::2f01:3c9c/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::b901:4a87/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, // Entry 2: autogroup:member Srcs { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-server": { { SrcIPs: []string{ "100.108.74.26/32", "100.74.60.128/32", "100.80.238.75/32", "100.94.92.91/32", "fd7a:115c:a1e0::2f01:3c9c/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::b901:4a87/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": { { SrcIPs: []string{ "100.108.74.26/32", "100.74.60.128/32", "100.80.238.75/32", "100.94.92.91/32", "fd7a:115c:a1e0::2f01:3c9c/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::b901:4a87/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-db": { { SrcIPs: []string{ "100.108.74.26/32", "100.74.60.128/32", "100.80.238.75/32", "100.94.92.91/32", "fd7a:115c:a1e0::2f01:3c9c/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::b901:4a87/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-web": { { SrcIPs: []string{ "100.108.74.26/32", "100.74.60.128/32", "100.80.238.75/32", "100.94.92.91/32", "fd7a:115c:a1e0::2f01:3c9c/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::b901:4a87/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, { name: "triple_src_ref_each_rule", // Test 14.45: Triple src ref each rule // Sources deduplicated within each rule policy: makePolicy(` {"action": "accept", "src": ["autogroup:member", "group:admins", "kratail2tid@"], "dst": ["tag:server:22"]}, {"action": "accept", "src": ["tag:server", "webserver", "100.108.74.26"], "dst": ["group:admins:80"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": { // Rule 2: tag:server + webserver + raw IP → group:admins (user1) { // Srcs deduplicated to 1 IP + IPv6 (all resolve to same tagged-server) SrcIPs: []string{ "100.108.74.26/32", "fd7a:115c:a1e0::b901:4a87/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-server": { // Rule 1: autogroup:member + group:admins + user → tag:server { // Srcs deduplicated to user1's IPs (all 3 resolve to same user) SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "same_src_four_dests", // Test 14.47: Same src → 4 dests // Same Srcs across 4 rules = merged into single filter entry per destination node policy: makePolicy(` {"action": "accept", "src": ["autogroup:member"], "dst": ["tag:server:22"]}, {"action": "accept", "src": ["autogroup:member"], "dst": ["tag:database:5432"]}, {"action": "accept", "src": ["autogroup:member"], "dst": ["tag:web:80"]}, {"action": "accept", "src": ["autogroup:member"], "dst": ["webserver:443"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 443, Last: 443}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 443, Last: 443}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": nil, "tagged-db": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.74.60.128/32", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, {IP: "fd7a:115c:a1e0::2f01:3c9c/128", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-web": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.94.92.91/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::ef01:5c81/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, { name: "overlapping_destinations_different_sources", // Test 10.2: Overlapping destinations, different sources // Rules with same destination create SEPARATE filter entries, NOT merged policy: makePolicy(` {"action": "accept", "src": ["group:admins"], "dst": ["*:*"]}, {"action": "accept", "src": ["autogroup:tagged"], "dst": ["*:*"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": { // Entry 1: group:admins → *:* { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, // Entry 2: autogroup:tagged → *:* { SrcIPs: []string{ "100.108.74.26/32", "100.74.60.128/32", "100.80.238.75/32", "100.94.92.91/32", "fd7a:115c:a1e0::2f01:3c9c/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::b901:4a87/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-server": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, { SrcIPs: []string{ "100.108.74.26/32", "100.74.60.128/32", "100.80.238.75/32", "100.94.92.91/32", "fd7a:115c:a1e0::2f01:3c9c/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::b901:4a87/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, { SrcIPs: []string{ "100.108.74.26/32", "100.74.60.128/32", "100.80.238.75/32", "100.94.92.91/32", "fd7a:115c:a1e0::2f01:3c9c/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::b901:4a87/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-db": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, { SrcIPs: []string{ "100.108.74.26/32", "100.74.60.128/32", "100.80.238.75/32", "100.94.92.91/32", "fd7a:115c:a1e0::2f01:3c9c/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::b901:4a87/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-web": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, { SrcIPs: []string{ "100.108.74.26/32", "100.74.60.128/32", "100.80.238.75/32", "100.94.92.91/32", "fd7a:115c:a1e0::2f01:3c9c/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::b901:4a87/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, { name: "same_dest_node_via_tag_vs_host_source", // Test 10.3: Same dest node via tag vs host source // Same destination with different sources = separate entries policy: makePolicy(` {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:22"]}, {"action": "accept", "src": ["webserver"], "dst": ["tag:server:80"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, "tagged-server": { // Entry 1: tag:client → :22 { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, // Entry 2: webserver → :80 (host source expands to node IPs) { SrcIPs: []string{ "100.108.74.26/32", "fd7a:115c:a1e0::b901:4a87/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, { name: "three_rules_same_dest_different_sources", // Test 10.4: 3 rules, same dest, different sources // 3 separate filter entries on the same destination node policy: makePolicy(` {"action": "accept", "src": ["*"], "dst": ["tag:server:22"]}, {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:80"]}, {"action": "accept", "src": ["autogroup:member"], "dst": ["tag:server:443"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, "tagged-server": { // Entry 1: * → :22 { SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, // Entry 2: tag:client → :80 { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, // Entry 3: autogroup:member → :443 { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 443, Last: 443}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 443, Last: 443}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, { name: "mixed_sources_in_multiple_rules", // Test 10.5: Mixed sources in multiple rules // Sources within a rule are deduplicated policy: makePolicy(` {"action": "accept", "src": ["tag:client", "tag:web"], "dst": ["tag:server:22"]}, {"action": "accept", "src": ["autogroup:member", "group:admins"], "dst": ["tag:database:5432"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-web": nil, "tagged-server": { // Rule 1: [tag:client, tag:web] → tag:server:22 // Sources merged and deduplicated { SrcIPs: []string{ "100.80.238.75/32", "100.94.92.91/32", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-db": { // Rule 2: [autogroup:member, group:admins] → tag:database:5432 // Both resolve to user1, deduplicated { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.74.60.128/32", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, {IP: "fd7a:115c:a1e0::2f01:3c9c/128", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, { name: "mixed_sources_with_port_range_11_2", // Test 11.2: Mixed sources with port range // Port range preserved as First/Last policy: makePolicy(` {"action": "accept", "src": ["group:admins", "webserver"], "dst": ["tag:server:80-443"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, "tagged-server": { { // group:admins (IPv4+IPv6) + webserver (node IPs) = 4 Srcs SrcIPs: []string{ "100.90.199.68/32", "100.108.74.26/32", "fd7a:115c:a1e0::2d01:c747/128", "fd7a:115c:a1e0::b901:4a87/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 80, Last: 443}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 80, Last: 443}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, { name: "same_dest_node_different_ports_via_different_refs_2_2", // Test 2.2: Same node referenced via tag and host with different ports // Same IP can appear multiple times in Dsts with different ports policy: makePolicy(` {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:22", "webserver:80"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ // tag:server:22 adds IPv4 and IPv6 {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, // webserver:80 expands to node IPs (both IPv4 and IPv6) {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, { name: "same_user_different_ports_via_email_and_group_2_6", // Test 2.6: Same user referenced via email and group with different ports // Destinations are NOT deduplicated when ports differ policy: makePolicy(` {"action": "accept", "src": ["tag:client"], "dst": ["kratail2tid@:22", "group:admins:80"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "tagged-server": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, "user1": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, // 4 entries: user1's IPv4 and IPv6 for EACH port (22 and 80) DstPorts: []tailcfg.NetPortRange{ {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, { name: "diff_srcs_same_dest_14_6", // Test 14.6: Different srcs, same dest (2 rules) // Different sources, same destination = 2 SEPARATE filter entries policy: makePolicy(` {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:22"]}, {"action": "accept", "src": ["tag:web"], "dst": ["tag:server:22"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, "tagged-server": { // Entry 1: tag:client → :22 { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, // Entry 2: tag:web → :22 { SrcIPs: []string{ "100.94.92.91/32", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, { name: "group_plus_user_same_person_same_dest_14_8", // Test 14.8: Group + user (same person) → same dest (2 rules) // Same person via group + user email = 1 filter entry, Srcs MERGED, Dsts NOT merged policy: makePolicy(` {"action": "accept", "src": ["group:admins"], "dst": ["tag:server:22"]}, {"action": "accept", "src": ["kratail2tid@"], "dst": ["tag:server:22"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, "tagged-server": { // Merged: 1 filter entry with 4 Dsts (duplicated) { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, { name: "self_overlap_with_explicit_user_13_86", // Test 13.86: self:22 + user:22 (overlap on same node) // Different Srcs for self vs explicit user = separate entries // NOTE: Tailscale produces 2 entries, one with wildcard CGNAT Srcs, one with user1's IPs. // Headscale produces similar with full CGNAT range (100.64.0.0/10). // In Headscale, autogroup:self entry comes FIRST, explicit user SECOND. policy: makePolicy(` {"action": "accept", "src": ["*"], "dst": ["autogroup:self:22", "kratail2tid@:22"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": { // Entry 1: * → autogroup:self:22 (Srcs narrowed to user1's IPs, no CIDR in DstPorts) { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, // Entry 2: * → kratail2tid@:22 (wildcard Srcs, CIDR in DstPorts) { SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-server": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "self_twice_different_ports_13_36", // Test 13.36: Self twice in separate rules (merged) // Multiple self rules with same source = MERGED into single filter entry policy: makePolicy(` {"action": "accept", "src": ["*"], "dst": ["autogroup:self:22"]}, {"action": "accept", "src": ["*"], "dst": ["autogroup:self:80"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": { // Merged: 1 filter entry with 4 Dsts { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-server": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, { name: "six_rules_mixing_all_patterns", // Test 14.50: 6 rules mixing all patterns // Self-referential rules work, different Srcs create separate entries policy: makePolicy(` {"action": "accept", "src": ["tag:server"], "dst": ["tag:server:22"]}, {"action": "accept", "src": ["tag:client"], "dst": ["tag:client:22"]}, {"action": "accept", "src": ["tag:database"], "dst": ["tag:database:22"]}, {"action": "accept", "src": ["tag:web"], "dst": ["tag:web:22"]}, {"action": "accept", "src": ["autogroup:member"], "dst": ["*:80"]}, {"action": "accept", "src": ["*"], "dst": ["autogroup:member:443"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": { // Entry 1: autogroup:member → *:80 { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, // Entry 2: * → autogroup:member:443 (user1 is in autogroup:member) { SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 443, Last: 443}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 443, Last: 443}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-server": { // Entry 1: tag:server → tag:server:22 (self-reference) { SrcIPs: []string{ "100.108.74.26/32", "fd7a:115c:a1e0::b901:4a87/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, // Entry 2: autogroup:member → *:80 { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": { // Entry 1: tag:client → tag:client:22 (self-reference) { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.80.238.75/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::7901:ee86/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, // Entry 2: autogroup:member → *:80 { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-db": { // Entry 1: tag:database → tag:database:22 (self-reference) { SrcIPs: []string{ "100.74.60.128/32", "fd7a:115c:a1e0::2f01:3c9c/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.74.60.128/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::2f01:3c9c/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, // Entry 2: autogroup:member → *:80 { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-web": { // Entry 1: tag:web → tag:web:22 (self-reference) { SrcIPs: []string{ "100.94.92.91/32", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.94.92.91/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::ef01:5c81/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, // Entry 2: autogroup:member → *:80 { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Category 1: Mixed Sources { name: "autogroup_member_plus_tag_client_1_1", // Test 1.1: autogroup:member + tag:client // Sources are merged into single Srcs array policy: makePolicy(` {"action": "accept", "src": ["autogroup:member", "tag:client"], "dst": ["tag:server:22"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, "tagged-server": { { // autogroup:member (user1) + tag:client = merged SrcIPs: []string{ "100.80.238.75/32", "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, { name: "group_admins_plus_tag_client_1_3", // Test 1.3: group:admins + tag:client // Sources are merged into single Srcs array policy: makePolicy(` {"action": "accept", "src": ["group:admins", "tag:client"], "dst": ["tag:server:22"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, "tagged-server": { { // group:admins (user1) + tag:client = merged SrcIPs: []string{ "100.80.238.75/32", "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, { name: "user_email_plus_tag_client_1_4", // Test 1.4: kratail2tid@ + tag:client // User email expanded to IPs + tag = merged policy: makePolicy(` {"action": "accept", "src": ["kratail2tid@", "tag:client"], "dst": ["tag:server:22"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, { name: "host_plus_tag_client_1_5", // Test 1.5: webserver (host) + tag:client // Host expands to node IPs + tag = merged policy: makePolicy(` {"action": "accept", "src": ["webserver", "tag:client"], "dst": ["tag:database:5432"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-server": nil, "tagged-web": nil, "tagged-db": { { // webserver (tagged-server IPs) + tag:client = merged SrcIPs: []string{ "100.80.238.75/32", "100.108.74.26/32", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::b901:4a87/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.74.60.128/32", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, {IP: "fd7a:115c:a1e0::2f01:3c9c/128", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, { name: "raw_ip_plus_tag_client_1_6", // Test 1.6: 100.90.199.68 (raw IP) + tag:client // Raw IP expands to node's both IPs + tag = merged policy: makePolicy(` {"action": "accept", "src": ["100.90.199.68", "tag:client"], "dst": ["tag:server:22"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, "tagged-server": { { // Raw IP expands to user1's IPs + tag:client = merged (4 IPs) SrcIPs: []string{ "100.80.238.75/32", "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, { name: "user1_three_ways_1_7", // Test 1.7: autogroup:member + group:admins + kratail2tid@ // Same user referenced 3 ways = deduplicated to 2 IPs policy: makePolicy(` {"action": "accept", "src": ["autogroup:member", "group:admins", "kratail2tid@"], "dst": ["tag:server:22"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, "tagged-server": { { // All 3 references resolve to user1's IPs, deduplicated SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Category 2: Mixed Destinations { name: "tag_server_22_plus_tag_database_5432_2_1", // Test 2.1: tag:server:22 + tag:database:5432 // Multiple destinations in same rule, distributed to each node policy: makePolicy(` {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:22", "tag:database:5432"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-web": nil, "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-db": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.74.60.128/32", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, {IP: "fd7a:115c:a1e0::2f01:3c9c/128", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, { name: "tag_server_22_plus_raw_ip_80_2_3", // Test 2.3: tag:server:22 + 100.108.74.26:80 (tag + raw IP, same node) // Same node via tag and raw IP, different ports = NOT deduplicated in Dsts // Raw IP destination expands to include node's IPv6 policy: makePolicy(` {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:22", "100.108.74.26:80"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ // tag:server:22 adds IPv4 and IPv6 {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, // raw IP:80 expands to both IPs {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, { name: "group_admins_22_plus_tag_server_80_2_4", // Test 2.4: group:admins:22 + tag:server:80 // User destination on port 22, tag destination on port 80 policy: makePolicy(` {"action": "accept", "src": ["tag:client"], "dst": ["group:admins:22", "tag:server:80"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, "user1": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, { name: "webserver_22_plus_database_5432_2_7", // Test 2.7: webserver:22 + database:5432 (multiple hosts) // Multiple host destinations policy: makePolicy(` {"action": "accept", "src": ["tag:client"], "dst": ["webserver:22", "database:5432"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-web": nil, "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ // webserver host expands to tagged-server's IPs {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-db": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ // database host expands to tagged-db's IPs {IP: "100.74.60.128/32", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, {IP: "fd7a:115c:a1e0::2f01:3c9c/128", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Category 3: Overlapping References { name: "user1_three_ways_source_3_2", // Test 3.2: user1 referenced 3 ways as source // All resolve to same IPs, deduplicated policy: makePolicy(` {"action": "accept", "src": ["autogroup:member", "kratail2tid@", "group:admins"], "dst": ["tag:server:22"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, "tagged-server": { { // All 3 references resolve to user1, deduplicated to 2 IPs SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, { name: "same_ip_port_tag_and_host_dest_3_3", // Test 3.3: Same IP:port via tag and host as dest // Same IP:port referenced two ways = NOT deduplicated policy: makePolicy(` {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:22", "webserver:22"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ // tag:server:22 adds IPv4 and IPv6 {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, // webserver:22 also expands to same IPs - NOT deduplicated {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, { name: "same_ip_port_tag_and_raw_ip_dest_3_4", // Test 3.4: Same IP:port via tag and raw IP // Raw IP also expands to both IPs when matching a node policy: makePolicy(` {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:22", "100.108.74.26:22"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ // tag:server:22 adds IPv4 and IPv6 {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, // raw IP also expands to both IPs (NOT deduplicated) {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Category 4: Cross-Type Source→Destination Combinations { name: "raw_ip_to_tag_server_4_7", // Test 4.7: 100.90.199.68 → tag:server:22 // Raw IP as source, tag as destination // In Headscale, raw IP that matches a node expands to include IPv6 policy: makePolicy(` {"action": "accept", "src": ["100.90.199.68"], "dst": ["tag:server:22"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, "tagged-server": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, { name: "tag_client_to_raw_ip_4_8", // Test 4.8: tag:client → 100.108.74.26:22 // Tag as source, raw IP as destination // In Headscale, raw IP destination that matches a node expands to include IPv6 policy: makePolicy(` {"action": "accept", "src": ["tag:client"], "dst": ["100.108.74.26:22"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Category 7: Maximum Combinations ("Kitchen Sink") { name: "all_source_types_to_tag_server_7_1", // Test 7.1: ALL source types → tag:server:22 // Mix of all source types in one rule policy: makePolicy(` {"action": "accept", "src": ["autogroup:member", "autogroup:tagged", "group:admins", "tag:client", "webserver", "100.74.60.128"], "dst": ["tag:server:22"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, "tagged-server": { { // All sources merged: user1, all tagged, webserver, database IP SrcIPs: []string{ "100.108.74.26/32", "100.74.60.128/32", "100.80.238.75/32", "100.90.199.68/32", "100.94.92.91/32", "fd7a:115c:a1e0::2d01:c747/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::2f01:3c9c/128", "fd7a:115c:a1e0::b901:4a87/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Category 8: Redundancy Stress Tests { name: "user1_referenced_5_ways_8_1", // Test 8.1: user1 referenced 5 ways // All references deduplicated to user1's 2 IPs policy: makePolicy(` {"action": "accept", "src": ["autogroup:member", "group:admins", "group:developers", "kratail2tid@", "100.90.199.68"], "dst": ["tag:server:22"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, "tagged-server": { { // 5 references → deduplicated to user1's IPs + raw IP // Note: raw IP only adds IPv4, others add both SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, { name: "tagged_server_3_ways_source_8_2", // Test 8.2: tagged-server referenced 3 ways as source // tag:server + webserver + raw IP = deduplicated policy: makePolicy(` {"action": "accept", "src": ["tag:server", "webserver", "100.108.74.26"], "dst": ["tag:database:5432"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-server": nil, "tagged-web": nil, "tagged-db": { { // All 3 references resolve to tagged-server's IPs, deduplicated SrcIPs: []string{ "100.108.74.26/32", "fd7a:115c:a1e0::b901:4a87/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.74.60.128/32", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, {IP: "fd7a:115c:a1e0::2f01:3c9c/128", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, { name: "same_ip_port_3_ways_dest_8_5", // Test 8.5: Same IP:port referenced 3 ways as destination // tag:server:22 + webserver:22 + 100.108.74.26:22 // Destinations are NOT deduplicated, raw IP also expands policy: makePolicy(` {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:22", "webserver:22", "100.108.74.26:22"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ // tag:server:22 adds both IPs {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, // webserver:22 also adds both IPs (NOT deduplicated) {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, // raw IP also adds both IPs (NOT deduplicated) {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Category 12: CIDR Host Combinations { name: "cidr_subnet_plus_tag_as_sources_12_3", // Test 12.3: internal (CIDR host) + tag as sources // External CIDR doesn't match nodes, tag does policy: makePolicy(` {"action": "accept", "src": ["internal", "tag:client"], "dst": ["tag:server:22"]} `), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, "tagged-server": { { // internal (10.0.0.0/8) + tag:client IPs SrcIPs: []string{ "10.0.0.0/8", "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // =========================================== // Category 5: Order Effects // =========================================== // Test 5.1a: Source Order - [tag:client, tag:web] { name: "source_order_client_web_5_1a", // Test that order of sources doesn't affect output policy: makePolicy(`{"action": "accept", "src": ["tag:client", "tag:web"], "dst": ["tag:server:22"]}`), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, "tagged-server": { { // Sources merged and sorted: IPv4 first (sorted), then IPv6 (sorted) SrcIPs: []string{ "100.80.238.75/32", "100.94.92.91/32", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 5.1b: Source Order Reversed - [tag:web, tag:client] { name: "source_order_web_client_5_1b", // Same as 5.1a but reversed order - should produce identical output policy: makePolicy(`{"action": "accept", "src": ["tag:web", "tag:client"], "dst": ["tag:server:22"]}`), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, "tagged-server": { { // Should be identical to 5.1a - order doesn't matter SrcIPs: []string{ "100.80.238.75/32", "100.94.92.91/32", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 5.2a: Destination Order - [tag:server:22, tag:database:80] { name: "dest_order_server_db_5_2a", // Test destination order - each node should get only its portion policy: makePolicy(`{"action": "accept", "src": ["tag:client"], "dst": ["tag:server:22", "tag:database:80"]}`), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-web": nil, "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-db": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.74.60.128/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::2f01:3c9c/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 5.2b: Destination Order Reversed - [tag:database:80, tag:server:22] { name: "dest_order_db_server_5_2b", // Same as 5.2a but reversed - should produce identical per-node filters policy: makePolicy(`{"action": "accept", "src": ["tag:client"], "dst": ["tag:database:80", "tag:server:22"]}`), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-web": nil, "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-db": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.74.60.128/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::2f01:3c9c/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 5.3a: Mixed Source Types Order - [autogroup:member, tag:client] { name: "mixed_source_order_member_client_5_3a", // Test mixed source types order policy: makePolicy(`{"action": "accept", "src": ["autogroup:member", "tag:client"], "dst": ["tag:server:22"]}`), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, "tagged-server": { { // Sources sorted: IPv4 first, then IPv6 SrcIPs: []string{ "100.80.238.75/32", "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 5.3b: Mixed Source Types Order Reversed - [tag:client, autogroup:member] { name: "mixed_source_order_client_member_5_3b", // Same as 5.3a but reversed - should produce identical output policy: makePolicy(`{"action": "accept", "src": ["tag:client", "autogroup:member"], "dst": ["tag:server:22"]}`), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, "tagged-server": { { // Should be identical to 5.3a SrcIPs: []string{ "100.80.238.75/32", "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // =========================================== // Category 6: Edge Cases // =========================================== // Test 6.3: Empty group as source - no filters expected { name: "empty_group_source_6_3", // group:empty has no members, so no filters should be generated policy: makePolicy(`{"action": "accept", "src": ["group:empty"], "dst": ["tag:server:22"]}`), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": nil, // No filter because source group is empty "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, // Test 6.5: CIDR host (internal = 10.0.0.0/8) as source { name: "cidr_host_source_6_5", // Host "internal" defined as 10.0.0.0/8 - CIDR goes directly into Srcs policy: makePolicy(`{"action": "accept", "src": ["internal"], "dst": ["tag:server:22"]}`), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, "tagged-server": { { SrcIPs: []string{"10.0.0.0/8"}, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 6.6: CIDR host as destination - no tailnet nodes match { name: "cidr_host_dest_6_6", // internal (10.0.0.0/8) as destination - no tailnet nodes in this range policy: makePolicy(`{"action": "accept", "src": ["tag:client"], "dst": ["internal:22"]}`), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, // No nodes match 10.0.0.0/8, so no filters generated }, }, // =========================================== // Category 9: All Tags + All Autogroups // =========================================== // Test 9.1: All 4 tags as sources { name: "all_four_tags_sources_9_1", // All 4 tags combined as sources policy: makePolicy(`{"action": "accept", "src": ["tag:server", "tag:client", "tag:database", "tag:web"], "dst": ["tag:server:22"]}`), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, "tagged-server": { { // 4 tags = 8 IPs (4 IPv4 + 4 IPv6, deduplicated) SrcIPs: []string{ "100.108.74.26/32", "100.74.60.128/32", "100.80.238.75/32", "100.94.92.91/32", "fd7a:115c:a1e0::2f01:3c9c/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::b901:4a87/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 9.2: All 4 tags as destinations { name: "all_four_tags_dests_9_2", // All 4 tags as destinations - each node gets only its own IP:port policy: makePolicy(`{"action": "accept", "src": ["autogroup:member"], "dst": ["tag:server:22", "tag:client:22", "tag:database:22", "tag:web:22"]}`), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, // Not a destination "tagged-server": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.80.238.75/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::7901:ee86/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-db": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.74.60.128/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::2f01:3c9c/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-web": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.94.92.91/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::ef01:5c81/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 9.3: Both autogroups as sources { name: "both_autogroups_sources_9_3", // autogroup:member + autogroup:tagged = full tailnet coverage policy: makePolicy(`{"action": "accept", "src": ["autogroup:member", "autogroup:tagged"], "dst": ["tag:server:22"]}`), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, "tagged-server": { { // Full tailnet: 5 nodes = 10 IPs (5 IPv4 + 5 IPv6) SrcIPs: []string{ "100.108.74.26/32", "100.74.60.128/32", "100.80.238.75/32", "100.90.199.68/32", "100.94.92.91/32", "fd7a:115c:a1e0::2d01:c747/128", "fd7a:115c:a1e0::2f01:3c9c/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::b901:4a87/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // =========================================== // Category 10: Multiple Rules with Mixed Types // =========================================== // Test 10.1: Cross-type in separate rules { name: "cross_type_separate_rules_10_1", // Rule 1: autogroup:member → tag:server:22 // Rule 2: tag:client → group:admins:80 policy: `{ "groups": { "group:admins": ["kratail2tid@"], "group:developers": ["kratail2tid@"], "group:empty": [] }, "tagOwners": { "tag:server": ["kratail2tid@"], "tag:client": ["kratail2tid@"], "tag:database": ["kratail2tid@"], "tag:web": ["kratail2tid@"] }, "hosts": { "webserver": "100.108.74.26", "database": "100.74.60.128", "internal": "10.0.0.0/8", "subnet24": "192.168.1.0/24" }, "acls": [ {"action": "accept", "src": ["autogroup:member"], "dst": ["tag:server:22"]}, {"action": "accept", "src": ["tag:client"], "dst": ["group:admins:80"]} ] }`, wantFilters: map[string][]tailcfg.FilterRule{ "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, // user1 gets filter from Rule 2 (tag:client → group:admins:80) "user1": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, // tagged-server gets filter from Rule 1 (autogroup:member → tag:server:22) "tagged-server": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 10.2: Overlapping destinations, different sources { name: "overlapping_dests_diff_sources_10_2", // Rule 1: group:admins → tag:server:22 // Rule 2: autogroup:tagged → tag:server:22 // Same destination, different sources - creates separate filter entries policy: `{ "groups": { "group:admins": ["kratail2tid@"], "group:developers": ["kratail2tid@"], "group:empty": [] }, "tagOwners": { "tag:server": ["kratail2tid@"], "tag:client": ["kratail2tid@"], "tag:database": ["kratail2tid@"], "tag:web": ["kratail2tid@"] }, "hosts": { "webserver": "100.108.74.26", "database": "100.74.60.128", "internal": "10.0.0.0/8", "subnet24": "192.168.1.0/24" }, "acls": [ {"action": "accept", "src": ["group:admins"], "dst": ["tag:server:22"]}, {"action": "accept", "src": ["autogroup:tagged"], "dst": ["tag:server:22"]} ] }`, wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, // tagged-server gets TWO separate filter entries (one per rule) "tagged-server": { // Rule 1: group:admins { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, // Rule 2: autogroup:tagged { SrcIPs: []string{ "100.108.74.26/32", "100.74.60.128/32", "100.80.238.75/32", "100.94.92.91/32", "fd7a:115c:a1e0::2f01:3c9c/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::b901:4a87/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 10.3: Three rules to same destination { name: "three_rules_same_dest_10_3", // Rule 1: autogroup:member → tag:server:22 // Rule 2: tag:client → tag:server:22 // Rule 3: group:admins → tag:server:22 policy: `{ "groups": { "group:admins": ["kratail2tid@"], "group:developers": ["kratail2tid@"], "group:empty": [] }, "tagOwners": { "tag:server": ["kratail2tid@"], "tag:client": ["kratail2tid@"], "tag:database": ["kratail2tid@"], "tag:web": ["kratail2tid@"] }, "hosts": { "webserver": "100.108.74.26", "database": "100.74.60.128", "internal": "10.0.0.0/8", "subnet24": "192.168.1.0/24" }, "acls": [ {"action": "accept", "src": ["autogroup:member"], "dst": ["tag:server:22"]}, {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:22"]}, {"action": "accept", "src": ["group:admins"], "dst": ["tag:server:22"]} ] }`, wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, // tagged-server gets TWO filter entries (Rules 1+3 merged, Rule 2 separate) "tagged-server": { // Rules 1+3: autogroup:member and group:admins (same SrcIPs) merged // DstPorts combined from both rules (duplicates included) { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, // Rule 2: tag:client (different SrcIPs, not merged) { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // =========================================== // Category 11: Port Variations with Mixed Types // =========================================== // Test 11.1: Mixed sources with comma ports { name: "mixed_sources_comma_ports_11_1", // Comma-separated ports create separate Dsts entries policy: makePolicy(`{"action": "accept", "src": ["autogroup:member", "tag:client"], "dst": ["tag:server:22,80,443"]}`), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", "fd7a:115c:a1e0::7901:ee86/128", }, // 3 ports × 2 IPs = 6 Dsts entries DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 443, Last: 443}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 443, Last: 443}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 11.2: Mixed sources with port range { name: "mixed_sources_port_range_11_2", // Port ranges preserved as First/Last in Dsts policy: makePolicy(`{"action": "accept", "src": ["group:admins", "webserver"], "dst": ["tag:server:80-443"]}`), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, "tagged-server": { { // group:admins (IPv4+IPv6) + webserver (IPv4+IPv6 since it matches tagged-server node) SrcIPs: []string{ "100.108.74.26/32", "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", "fd7a:115c:a1e0::b901:4a87/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 80, Last: 443}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 80, Last: 443}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 11.4: Full autogroups with wildcard port { name: "autogroups_wildcard_port_11_4", // Wildcard port (*) expands to 0-65535 policy: makePolicy(`{"action": "accept", "src": ["autogroup:tagged", "autogroup:member"], "dst": ["tag:server:*"]}`), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, "tagged-server": { { // Full tailnet: 5 nodes = 10 IPs SrcIPs: []string{ "100.108.74.26/32", "100.74.60.128/32", "100.80.238.75/32", "100.90.199.68/32", "100.94.92.91/32", "fd7a:115c:a1e0::2d01:c747/128", "fd7a:115c:a1e0::2f01:3c9c/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::b901:4a87/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // =========================================== // Category 13: autogroup:self Deep Dive // =========================================== // Test 13.1: Wildcard → self:* { name: "wildcard_to_self_all_ports_13_1", // autogroup:self NARROWS Srcs even when source is wildcard policy: makePolicy(`{"action": "accept", "src": ["*"], "dst": ["autogroup:self:*"]}`), wantFilters: map[string][]tailcfg.FilterRule{ // Only user1 (user-owned) receives filter "user1": { { // Srcs NARROWED to user1's IPs only (not wildcard CIDRs!) SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, // Tagged nodes receive NO filters "tagged-server": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, // Test 13.2: Wildcard → self:22 { name: "wildcard_to_self_port_22_13_2", // Specific port with self policy: makePolicy(`{"action": "accept", "src": ["*"], "dst": ["autogroup:self:22"]}`), wantFilters: map[string][]tailcfg.FilterRule{ "user1": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-server": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, // Test 13.5: autogroup:member → self:* { name: "member_to_self_13_5", // autogroup:member works with autogroup:self policy: makePolicy(`{"action": "accept", "src": ["autogroup:member"], "dst": ["autogroup:self:*"]}`), wantFilters: map[string][]tailcfg.FilterRule{ "user1": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-server": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, // Test 13.8: Specific user → self:* { name: "specific_user_to_self_13_8", // Specific user email works with autogroup:self policy: makePolicy(`{"action": "accept", "src": ["kratail2tid@"], "dst": ["autogroup:self:*"]}`), wantFilters: map[string][]tailcfg.FilterRule{ "user1": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-server": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, // Test 13.9: group:admins → self:* { name: "group_to_self_13_9", // Groups work with autogroup:self policy: makePolicy(`{"action": "accept", "src": ["group:admins"], "dst": ["autogroup:self:*"]}`), wantFilters: map[string][]tailcfg.FilterRule{ "user1": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-server": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, }, }, // =========================================== // Category 14: Multi-Rule Compounding // =========================================== // Test 14.1: Same src, different dests (2 rules) { name: "same_src_diff_dests_14_1", // Same source, different destinations = separate filter entries per dest node policy: `{ "groups": { "group:admins": ["kratail2tid@"], "group:developers": ["kratail2tid@"], "group:empty": [] }, "tagOwners": { "tag:server": ["kratail2tid@"], "tag:client": ["kratail2tid@"], "tag:database": ["kratail2tid@"], "tag:web": ["kratail2tid@"] }, "hosts": { "webserver": "100.108.74.26", "database": "100.74.60.128", "internal": "10.0.0.0/8", "subnet24": "192.168.1.0/24" }, "acls": [ {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:22"]}, {"action": "accept", "src": ["tag:client"], "dst": ["tag:database:5432"]} ] }`, wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-web": nil, "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-db": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.74.60.128/32", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, {IP: "fd7a:115c:a1e0::2f01:3c9c/128", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 14.2: Same src, same dest, different ports (2 rules) { name: "same_src_same_dest_diff_ports_merged_14_2", // Same source + dest node + different ports // MERGED into 1 filter entry with 4 Dsts policy: `{ "groups": { "group:admins": ["kratail2tid@"], "group:developers": ["kratail2tid@"], "group:empty": [] }, "tagOwners": { "tag:server": ["kratail2tid@"], "tag:client": ["kratail2tid@"], "tag:database": ["kratail2tid@"], "tag:web": ["kratail2tid@"] }, "hosts": { "webserver": "100.108.74.26", "database": "100.74.60.128", "internal": "10.0.0.0/8", "subnet24": "192.168.1.0/24" }, "acls": [ {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:22"]}, {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:80"]} ] }`, wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, "tagged-server": { // Merged: 1 entry with 4 DstPorts { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 14.6: Different srcs, same dest (2 rules) { name: "diff_srcs_same_dest_14_6", // Different sources, same dest = 2 SEPARATE filter entries policy: `{ "groups": { "group:admins": ["kratail2tid@"], "group:developers": ["kratail2tid@"], "group:empty": [] }, "tagOwners": { "tag:server": ["kratail2tid@"], "tag:client": ["kratail2tid@"], "tag:database": ["kratail2tid@"], "tag:web": ["kratail2tid@"] }, "hosts": { "webserver": "100.108.74.26", "database": "100.74.60.128", "internal": "10.0.0.0/8", "subnet24": "192.168.1.0/24" }, "acls": [ {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:22"]}, {"action": "accept", "src": ["tag:web"], "dst": ["tag:server:22"]} ] }`, wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, // TWO separate filter entries "tagged-server": { // Entry 1: tag:client { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, // Entry 2: tag:web { SrcIPs: []string{ "100.94.92.91/32", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 14.8: Group + user (same person) → same dest (2 rules) { name: "group_user_same_person_same_dest_14_8", // Group + user (same person) // MERGED into 1 filter entry (Srcs deduplicated, Dsts NOT) policy: `{ "groups": { "group:admins": ["kratail2tid@"], "group:developers": ["kratail2tid@"], "group:empty": [] }, "tagOwners": { "tag:server": ["kratail2tid@"], "tag:client": ["kratail2tid@"], "tag:database": ["kratail2tid@"], "tag:web": ["kratail2tid@"] }, "hosts": { "webserver": "100.108.74.26", "database": "100.74.60.128", "internal": "10.0.0.0/8", "subnet24": "192.168.1.0/24" }, "acls": [ {"action": "accept", "src": ["group:admins"], "dst": ["tag:server:22"]}, {"action": "accept", "src": ["kratail2tid@"], "dst": ["tag:server:22"]} ] }`, wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, "tagged-server": { // Merged: 1 entry with deduplicated Srcs but duplicated Dsts { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // =========================================== // Category 7: Kitchen Sink Tests // =========================================== // Test 7.2: tag:client → ALL destination types { name: "all_dest_types_7_2", // Test ALL destination types from one source policy: `{ "groups": { "group:admins": ["kratail2tid@"], "group:developers": ["kratail2tid@"], "group:empty": [] }, "tagOwners": { "tag:server": ["kratail2tid@"], "tag:client": ["kratail2tid@"], "tag:database": ["kratail2tid@"], "tag:web": ["kratail2tid@"] }, "hosts": { "webserver": "100.108.74.26", "database": "100.74.60.128", "internal": "10.0.0.0/8", "subnet24": "192.168.1.0/24" }, "acls": [ {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:22", "tag:database:5432", "webserver:80", "database:443", "group:admins:8080", "kratail2tid@:3000", "100.108.74.26:9000"]} ] }`, wantFilters: map[string][]tailcfg.FilterRule{ "tagged-client": nil, "tagged-web": nil, // user1 gets entries for user:3000 and group:8080 "user1": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 8080, Last: 8080}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 8080, Last: 8080}}, {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 3000, Last: 3000}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 3000, Last: 3000}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, // tagged-server gets tag:server:22, webserver:80, raw IP:9000 // Note: Host aliases that match node IPs get expanded to include IPv6 "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 9000, Last: 9000}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 9000, Last: 9000}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, // tagged-db gets tag:database:5432 and database:443 // Note: Host aliases that match node IPs get expanded to include IPv6 "tagged-db": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.74.60.128/32", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, {IP: "fd7a:115c:a1e0::2f01:3c9c/128", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, {IP: "100.74.60.128/32", Ports: tailcfg.PortRange{First: 443, Last: 443}}, {IP: "fd7a:115c:a1e0::2f01:3c9c/128", Ports: tailcfg.PortRange{First: 443, Last: 443}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 7.3: 10 different sources → *:* { name: "ten_sources_to_wildcard_7_3", // 10 different source types all deduplicated policy: `{ "groups": { "group:admins": ["kratail2tid@"], "group:developers": ["kratail2tid@"], "group:empty": [] }, "tagOwners": { "tag:server": ["kratail2tid@"], "tag:client": ["kratail2tid@"], "tag:database": ["kratail2tid@"], "tag:web": ["kratail2tid@"] }, "hosts": { "webserver": "100.108.74.26", "database": "100.74.60.128", "internal": "10.0.0.0/8", "subnet24": "192.168.1.0/24" }, "acls": [ {"action": "accept", "src": ["autogroup:member", "autogroup:tagged", "group:admins", "group:developers", "kratail2tid@", "tag:client", "tag:web", "tag:database", "webserver", "database"], "dst": ["*:*"]} ] }`, wantFilters: map[string][]tailcfg.FilterRule{ // All nodes receive the deduplicated sources (including tagged-client since it's in *:*) // The sources are: autogroup:member, autogroup:tagged, group:admins, group:developers, // kratail2tid@, tag:client, tag:web, tag:database, webserver, database // autogroup:tagged includes ALL tagged nodes: tagged-server, tagged-client, tagged-db, tagged-web // All 5 nodes' IPs are included in the sources "user1": { { SrcIPs: []string{ "100.108.74.26/32", "100.74.60.128/32", "100.80.238.75/32", "100.90.199.68/32", "100.94.92.91/32", "fd7a:115c:a1e0::2d01:c747/128", "fd7a:115c:a1e0::2f01:3c9c/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::b901:4a87/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-server": { { SrcIPs: []string{ "100.108.74.26/32", "100.74.60.128/32", "100.80.238.75/32", "100.90.199.68/32", "100.94.92.91/32", "fd7a:115c:a1e0::2d01:c747/128", "fd7a:115c:a1e0::2f01:3c9c/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::b901:4a87/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": { { SrcIPs: []string{ "100.108.74.26/32", "100.74.60.128/32", "100.80.238.75/32", "100.90.199.68/32", "100.94.92.91/32", "fd7a:115c:a1e0::2d01:c747/128", "fd7a:115c:a1e0::2f01:3c9c/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::b901:4a87/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-db": { { SrcIPs: []string{ "100.108.74.26/32", "100.74.60.128/32", "100.80.238.75/32", "100.90.199.68/32", "100.94.92.91/32", "fd7a:115c:a1e0::2d01:c747/128", "fd7a:115c:a1e0::2f01:3c9c/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::b901:4a87/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-web": { { SrcIPs: []string{ "100.108.74.26/32", "100.74.60.128/32", "100.80.238.75/32", "100.90.199.68/32", "100.94.92.91/32", "fd7a:115c:a1e0::2d01:c747/128", "fd7a:115c:a1e0::2f01:3c9c/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::b901:4a87/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // =========================================== // Category 12: CIDR Host Combinations // =========================================== // Test 12.1: CIDR host + tag as sources { name: "cidr_host_plus_tag_sources_12_1", // CIDR host (10.0.0.0/8) combined with tag as sources policy: `{ "groups": { "group:admins": ["kratail2tid@"], "group:developers": ["kratail2tid@"], "group:empty": [] }, "tagOwners": { "tag:server": ["kratail2tid@"], "tag:client": ["kratail2tid@"], "tag:database": ["kratail2tid@"], "tag:web": ["kratail2tid@"] }, "hosts": { "webserver": "100.108.74.26", "database": "100.74.60.128", "internal": "10.0.0.0/8", "subnet24": "192.168.1.0/24" }, "acls": [ {"action": "accept", "src": ["internal", "tag:client"], "dst": ["tag:server:22"]} ] }`, wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, "tagged-server": { { SrcIPs: []string{ "10.0.0.0/8", "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 12.2: Multiple CIDR hosts as sources { name: "multiple_cidr_hosts_sources_12_2", // Multiple CIDR hosts (10.0.0.0/8 + 192.168.1.0/24) policy: `{ "groups": { "group:admins": ["kratail2tid@"], "group:developers": ["kratail2tid@"], "group:empty": [] }, "tagOwners": { "tag:server": ["kratail2tid@"], "tag:client": ["kratail2tid@"], "tag:database": ["kratail2tid@"], "tag:web": ["kratail2tid@"] }, "hosts": { "webserver": "100.108.74.26", "database": "100.74.60.128", "internal": "10.0.0.0/8", "subnet24": "192.168.1.0/24" }, "acls": [ {"action": "accept", "src": ["internal", "subnet24"], "dst": ["tag:server:22"]} ] }`, wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, "tagged-server": { { SrcIPs: []string{ "10.0.0.0/8", "192.168.1.0/24", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 12.4: Host CIDR + raw CIDR (same value) as sources { name: "host_cidr_plus_raw_cidr_same_12_4", // Same CIDR via host alias and raw value - should deduplicate policy: `{ "groups": { "group:admins": ["kratail2tid@"], "group:developers": ["kratail2tid@"], "group:empty": [] }, "tagOwners": { "tag:server": ["kratail2tid@"], "tag:client": ["kratail2tid@"], "tag:database": ["kratail2tid@"], "tag:web": ["kratail2tid@"] }, "hosts": { "webserver": "100.108.74.26", "database": "100.74.60.128", "internal": "10.0.0.0/8", "subnet24": "192.168.1.0/24" }, "acls": [ {"action": "accept", "src": ["internal", "10.0.0.0/8"], "dst": ["tag:server:22"]} ] }`, wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, // Deduplicated - only one 10.0.0.0/8 entry "tagged-server": { { SrcIPs: []string{ "10.0.0.0/8", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // =========================================== // Additional Missing Tests from 09-mixed-scenarios.md // =========================================== // Test 6.2: * → [webserver:22, database:5432] // Wildcard source + multiple host destinations { name: "wildcard_to_multiple_hosts_6_2", policy: makePolicy(`{"action": "accept", "src": ["*"], "dst": ["webserver:22", "database:5432"]}`), // Wildcard `*` expands to all nodes (Headscale uses 0.0.0.0/0 and ::/0) // Host destinations are properly distributed to matching nodes wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-web": nil, // tagged-server gets webserver:22 (since webserver = 100.108.74.26 = tagged-server) "tagged-server": { { SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, DstPorts: []tailcfg.NetPortRange{ // NOTE: Tailscale uses partitioned CGNAT CIDRs, Headscale uses full 100.64.0.0/10: // "100.115.94.0/23", "100.115.96.0/19", ..., "fd7a:115c:a1e0::/48" // TODO: Host destination is IPv4-only in Tailscale, but Headscale // resolves host aliases to node IPs and includes both IPv4+IPv6 {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, // tagged-db gets database:5432 (since database = 100.74.60.128 = tagged-db) "tagged-db": { { SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, DstPorts: []tailcfg.NetPortRange{ // TODO: Host destination is IPv4-only in Tailscale {IP: "100.74.60.128/32", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, {IP: "fd7a:115c:a1e0::2f01:3c9c/128", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 7.4: * → 9 destinations (multiple per node) // Destinations: tag:server:22, tag:server:80, tag:server:443, tag:database:5432, // tag:database:3306, tag:web:80, tag:web:443, webserver:8080, database:8080 { name: "wildcard_to_9_destinations_7_4", policy: makePolicy(`{"action": "accept", "src": ["*"], "dst": ["tag:server:22", "tag:server:80", "tag:server:443", "tag:database:5432", "tag:database:3306", "tag:web:80", "tag:web:443", "webserver:8080", "database:8080"]}`), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, // tagged-server gets: tag:server:22/80/443 + webserver:8080 "tagged-server": { { SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, DstPorts: []tailcfg.NetPortRange{ // webserver:8080 (host alias - Headscale includes IPv4+IPv6) // TODO: Tailscale host destinations are IPv4-only {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 8080, Last: 8080}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 8080, Last: 8080}}, // tag:server:22 (IPv4) {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, // tag:server:80 (IPv4) {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, // tag:server:443 (IPv4) {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 443, Last: 443}}, // tag:server:22 (IPv6) {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, // tag:server:80 (IPv6) {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, // tag:server:443 (IPv6) {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 443, Last: 443}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, // tagged-db gets: tag:database:5432/3306 + database:8080 "tagged-db": { { SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, DstPorts: []tailcfg.NetPortRange{ // database:8080 (host alias - Headscale includes IPv4+IPv6) // TODO: Tailscale host destinations are IPv4-only {IP: "100.74.60.128/32", Ports: tailcfg.PortRange{First: 8080, Last: 8080}}, {IP: "fd7a:115c:a1e0::2f01:3c9c/128", Ports: tailcfg.PortRange{First: 8080, Last: 8080}}, // tag:database:5432 (IPv4) {IP: "100.74.60.128/32", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, // tag:database:3306 (IPv4) {IP: "100.74.60.128/32", Ports: tailcfg.PortRange{First: 3306, Last: 3306}}, // tag:database:5432 (IPv6) {IP: "fd7a:115c:a1e0::2f01:3c9c/128", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, // tag:database:3306 (IPv6) {IP: "fd7a:115c:a1e0::2f01:3c9c/128", Ports: tailcfg.PortRange{First: 3306, Last: 3306}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, // tagged-web gets: tag:web:80/443 "tagged-web": { { SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, DstPorts: []tailcfg.NetPortRange{ // tag:web:80 (IPv4) {IP: "100.94.92.91/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, // tag:web:443 (IPv4) {IP: "100.94.92.91/32", Ports: tailcfg.PortRange{First: 443, Last: 443}}, // tag:web:80 (IPv6) {IP: "fd7a:115c:a1e0::ef01:5c81/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, // tag:web:443 (IPv6) {IP: "fd7a:115c:a1e0::ef01:5c81/128", Ports: tailcfg.PortRange{First: 443, Last: 443}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 7.5: MANY sources → MANY destinations // Sources: autogroup:member, group:admins, kratail2tid@, tag:client, tag:web, 100.80.238.75, 100.94.92.91 // Destinations: tag:server:22, webserver:80, 100.108.74.26:443, group:admins:8080, kratail2tid@:9000 { name: "many_sources_many_destinations_7_5", policy: makePolicy(`{"action": "accept", "src": ["autogroup:member", "group:admins", "kratail2tid@", "tag:client", "tag:web", "100.80.238.75", "100.94.92.91"], "dst": ["tag:server:22", "webserver:80", "100.108.74.26:443", "group:admins:8080", "kratail2tid@:9000"]}`), wantFilters: map[string][]tailcfg.FilterRule{ "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, // user1 gets: group:admins:8080 + kratail2tid@:9000 "user1": { { SrcIPs: []string{ "100.80.238.75/32", "100.90.199.68/32", "100.94.92.91/32", "fd7a:115c:a1e0::2d01:c747/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ // kratail2tid@:9000 {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 9000, Last: 9000}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 9000, Last: 9000}}, // group:admins:8080 {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 8080, Last: 8080}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 8080, Last: 8080}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, // tagged-server gets: tag:server:22 + webserver:80 + 100.108.74.26:443 "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "100.90.199.68/32", "100.94.92.91/32", "fd7a:115c:a1e0::2d01:c747/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ // webserver:80 (host alias matches tagged-server, includes IPv6) {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, // tag:server:22 {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, // 100.108.74.26:443 (raw IP matches node, so Headscale includes IPv6) // TODO: Tailscale raw IP destinations are IPv4-only {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 443, Last: 443}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 443, Last: 443}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 8.3: tagged-db referenced 3 ways as source // Sources: tag:database, database (host alias), 100.74.60.128 (raw IP) // All 3 resolve to tagged-db - should be deduplicated in Srcs { name: "tagged_db_3_ways_source_8_3", policy: makePolicy(`{"action": "accept", "src": ["tag:database", "database", "100.74.60.128"], "dst": ["tag:server:22"]}`), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, // tagged-server receives filter // Srcs should be deduplicated: tag adds IPv6, host/raw IP are IPv4-only "tagged-server": { { SrcIPs: []string{ "100.74.60.128/32", "fd7a:115c:a1e0::2f01:3c9c/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 8.4: autogroup:tagged + all 4 tags as sources // Sources: autogroup:tagged, tag:server, tag:client, tag:database, tag:web // autogroup:tagged covers all 4 tags, so individual tags are redundant // Should deduplicate to just 8 IPs (4 nodes × 2 IPs each) { name: "autogroup_tagged_plus_all_4_tags_8_4", policy: makePolicy(`{"action": "accept", "src": ["autogroup:tagged", "tag:server", "tag:client", "tag:database", "tag:web"], "dst": ["autogroup:member:22"]}`), wantFilters: map[string][]tailcfg.FilterRule{ "tagged-server": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, // user1 (autogroup:member) receives the filter // Srcs = all 4 tagged nodes deduplicated = 8 IPs "user1": { { SrcIPs: []string{ "100.108.74.26/32", "100.74.60.128/32", "100.80.238.75/32", "100.94.92.91/32", "fd7a:115c:a1e0::2f01:3c9c/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::b901:4a87/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // =========================================== // Additional Missing Tests - Batch 2 // =========================================== // Test 1.8: tag:server + webserver (same IP two ways as sources) { name: "tag_server_plus_webserver_same_ip_1_8", policy: makePolicy(`{"action": "accept", "src": ["tag:server", "webserver"], "dst": ["tag:client:22"]}`), // tag:server and webserver both resolve to tagged-server (100.108.74.26) // Sources should be deduplicated wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-server": nil, "tagged-db": nil, "tagged-web": nil, // tagged-client receives the filter "tagged-client": { { SrcIPs: []string{ // Deduplicated: tag:server adds IPv4+IPv6, webserver adds IPv4 only "100.108.74.26/32", "fd7a:115c:a1e0::b901:4a87/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.80.238.75/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::7901:ee86/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 4.3: group:admins → webserver:22 { name: "group_admins_to_webserver_4_3", policy: makePolicy(`{"action": "accept", "src": ["group:admins"], "dst": ["webserver:22"]}`), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, // tagged-server (webserver) receives the filter "tagged-server": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ // TODO: Tailscale only includes IPv4 for host alias {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 4.4: webserver → group:admins:22 { name: "webserver_to_group_admins_4_4", policy: makePolicy(`{"action": "accept", "src": ["webserver"], "dst": ["group:admins:22"]}`), wantFilters: map[string][]tailcfg.FilterRule{ "tagged-server": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, // user1 (group:admins member) receives the filter "user1": { { SrcIPs: []string{ // TODO: Tailscale only includes IPv4 for host source "100.108.74.26/32", "fd7a:115c:a1e0::b901:4a87/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 8.6: user1:22 referenced 4 ways as destination // Destinations: group:admins:22, group:developers:22, kratail2tid@:22, 100.90.199.68:22 { name: "user1_4_ways_dest_8_6", policy: makePolicy(`{"action": "accept", "src": ["tag:client"], "dst": ["group:admins:22", "group:developers:22", "kratail2tid@:22", "100.90.199.68:22"]}`), wantFilters: map[string][]tailcfg.FilterRule{ "tagged-server": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, // user1 receives the filter - Dsts NOT deduplicated "user1": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ // kratail2tid@:22 {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, // group:admins:22 {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, // group:developers:22 {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, // 100.90.199.68:22 (raw IP matches node, includes IPv6) {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 8.7: Same node, 5 ports via different references // Destinations: tag:server:22, tag:server:80, tag:server:443, webserver:8080, 100.108.74.26:9000 { name: "same_node_5_ports_different_refs_8_7", policy: makePolicy(`{"action": "accept", "src": ["tag:client"], "dst": ["tag:server:22", "tag:server:80", "tag:server:443", "webserver:8080", "100.108.74.26:9000"]}`), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, // tagged-server receives the filter "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ // webserver:8080 (host alias - includes IPv6) {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 8080, Last: 8080}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 8080, Last: 8080}}, // 100.108.74.26:9000 (raw IP - includes IPv6) {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 9000, Last: 9000}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 9000, Last: 9000}}, // tag:server:22 {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, // tag:server:80 {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, // tag:server:443 {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 443, Last: 443}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 443, Last: 443}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 9.4: Wildcard to autogroup:self { name: "wildcard_to_autogroup_self_9_4", policy: makePolicy(`{"action": "accept", "src": ["*"], "dst": ["autogroup:self:*"]}`), // Only user1 (user-owned) receives filter; tagged nodes don't support autogroup:self // Sources narrowed to user1's own IPs (not full wildcard) wantFilters: map[string][]tailcfg.FilterRule{ "tagged-server": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, "user1": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ // Note: autogroup:self destinations use raw IP format (no /32 suffix) {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 10.4: 3 rules, same dest, different sources // Rule 1: * → tag:server:22 // Rule 2: tag:client → tag:server:80 // Rule 3: autogroup:member → tag:server:443 { name: "three_rules_same_dest_different_sources_10_4", policy: `{ "groups": { "group:admins": ["kratail2tid@"], "group:developers": ["kratail2tid@"], "group:empty": [] }, "tagOwners": { "tag:server": ["kratail2tid@"], "tag:client": ["kratail2tid@"], "tag:database": ["kratail2tid@"], "tag:web": ["kratail2tid@"] }, "hosts": { "webserver": "100.108.74.26", "database": "100.74.60.128", "internal": "10.0.0.0/8", "subnet24": "192.168.1.0/24" }, "acls": [ {"action": "accept", "src": ["*"], "dst": ["tag:server:22"]}, {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:80"]}, {"action": "accept", "src": ["autogroup:member"], "dst": ["tag:server:443"]} ] }`, wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, // tagged-server receives 3 filter entries "tagged-server": { { SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 443, Last: 443}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 443, Last: 443}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 10.5: Mixed sources in multiple rules // Rule 1: [tag:client, tag:web] → tag:server:22 // Rule 2: [autogroup:member, group:admins] → tag:database:5432 { name: "mixed_sources_multiple_rules_10_5", policy: `{ "groups": { "group:admins": ["kratail2tid@"], "group:developers": ["kratail2tid@"], "group:empty": [] }, "tagOwners": { "tag:server": ["kratail2tid@"], "tag:client": ["kratail2tid@"], "tag:database": ["kratail2tid@"], "tag:web": ["kratail2tid@"] }, "hosts": { "webserver": "100.108.74.26", "database": "100.74.60.128", "internal": "10.0.0.0/8", "subnet24": "192.168.1.0/24" }, "acls": [ {"action": "accept", "src": ["tag:client", "tag:web"], "dst": ["tag:server:22"]}, {"action": "accept", "src": ["autogroup:member", "group:admins"], "dst": ["tag:database:5432"]} ] }`, wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-web": nil, // tagged-server receives filter from rule 1 "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "100.94.92.91/32", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, // tagged-db receives filter from rule 2 "tagged-db": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.74.60.128/32", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, {IP: "fd7a:115c:a1e0::2f01:3c9c/128", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 11.3: Mixed sources with mixed port formats // Destinations: tag:server:22, tag:server:80-443, tag:database:5432,3306 { name: "mixed_sources_mixed_port_formats_11_3", policy: makePolicy(`{"action": "accept", "src": ["tag:client", "tag:web"], "dst": ["tag:server:22", "tag:server:80-443", "tag:database:5432,3306"]}`), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-web": nil, // tagged-server receives :22 and :80-443 "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "100.94.92.91/32", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ // :22 {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, // :80-443 {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 80, Last: 443}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 80, Last: 443}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, // tagged-db receives :5432,3306 "tagged-db": { { SrcIPs: []string{ "100.80.238.75/32", "100.94.92.91/32", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ // :5432 {IP: "100.74.60.128/32", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, {IP: "fd7a:115c:a1e0::2f01:3c9c/128", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, // :3306 {IP: "100.74.60.128/32", Ports: tailcfg.PortRange{First: 3306, Last: 3306}}, {IP: "fd7a:115c:a1e0::2f01:3c9c/128", Ports: tailcfg.PortRange{First: 3306, Last: 3306}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 12.5: Multiple CIDR + tag destinations // Destinations: internal:22, subnet24:80, tag:server:443 // CIDR destinations don't match tailnet nodes { name: "multiple_cidr_plus_tag_destinations_12_5", policy: makePolicy(`{"action": "accept", "src": ["*"], "dst": ["internal:22", "subnet24:80", "tag:server:443"]}`), wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, // Only tag:server:443 is delivered (CIDRs don't match tailnet nodes) "tagged-server": { { SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 443, Last: 443}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 443, Last: 443}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 13.4: Wildcard → self:80-443 (port range) { name: "wildcard_to_self_port_range_13_4", policy: makePolicy(`{"action": "accept", "src": ["*"], "dst": ["autogroup:self:80-443"]}`), wantFilters: map[string][]tailcfg.FilterRule{ "tagged-server": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, "user1": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ // Note: autogroup:self destinations use raw IP format (no /32 suffix) {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 80, Last: 443}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 80, Last: 443}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 13.16: Wildcard → self + tag:server:22 (mixed destinations) { name: "wildcard_to_self_plus_tag_server_13_16", policy: makePolicy(`{"action": "accept", "src": ["*"], "dst": ["autogroup:self:*", "tag:server:22"]}`), wantFilters: map[string][]tailcfg.FilterRule{ "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, // user1: receives narrowed Srcs for autogroup:self "user1": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ // Note: autogroup:self destinations use raw IP format (no /32 suffix) {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, // tagged-server: receives full wildcard Srcs for tag:server:22 "tagged-server": { { SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 13.20: Wildcard → self + group:admins:22 (same dest node) { name: "wildcard_to_self_plus_group_admins_13_20", policy: makePolicy(`{"action": "accept", "src": ["*"], "dst": ["autogroup:self:*", "group:admins:22"]}`), wantFilters: map[string][]tailcfg.FilterRule{ "tagged-server": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, // user1 gets 2 filter entries: // Entry 1: autogroup:self:* with narrowed Srcs (processed first due to autogroup:self splitting) // Entry 2: group:admins:22 with full wildcard "user1": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ // Note: autogroup:self destinations use raw IP format (no /32 suffix) {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, { SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // ===== Category 14: Multi-Rule Tests ===== // Test 14.21: 3 different srcs → same dest, different ports (3 rules) { name: "three_diff_srcs_same_dest_diff_ports_14_21", policy: `{ "groups": {"group:admins": ["kratail2tid@"]}, "tagOwners": { "tag:server": ["kratail2tid@"], "tag:client": ["kratail2tid@"], "tag:database": ["kratail2tid@"], "tag:web": ["kratail2tid@"] }, "hosts": {"webserver": "100.108.74.26", "database": "100.74.60.128"}, "acls": [ {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:22"]}, {"action": "accept", "src": ["tag:web"], "dst": ["tag:server:80"]}, {"action": "accept", "src": ["tag:database"], "dst": ["tag:server:443"]} ] }`, wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, // tagged-server: receives 3 separate filter entries (different Srcs = separate) "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, { SrcIPs: []string{ "100.94.92.91/32", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, { SrcIPs: []string{ "100.74.60.128/32", "fd7a:115c:a1e0::2f01:3c9c/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 443, Last: 443}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 443, Last: 443}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 14.22: 3 refs to same user → same dest:port (3 rules) // MERGED into 1 entry with 6 Dsts (not deduplicated) { name: "three_refs_same_user_same_dest_14_22", policy: `{ "groups": {"group:admins": ["kratail2tid@"]}, "tagOwners": { "tag:server": ["kratail2tid@"], "tag:client": ["kratail2tid@"], "tag:database": ["kratail2tid@"], "tag:web": ["kratail2tid@"] }, "hosts": {"webserver": "100.108.74.26", "database": "100.74.60.128"}, "acls": [ {"action": "accept", "src": ["autogroup:member"], "dst": ["tag:server:22"]}, {"action": "accept", "src": ["group:admins"], "dst": ["tag:server:22"]}, {"action": "accept", "src": ["kratail2tid@"], "dst": ["tag:server:22"]} ] }`, wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, "tagged-server": { // Merged: 1 entry with 6 Dsts (not deduplicated) { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 14.23: Same src → 3 different dests (3 rules) { name: "same_src_three_diff_dests_14_23", policy: `{ "groups": {"group:admins": ["kratail2tid@"]}, "tagOwners": { "tag:server": ["kratail2tid@"], "tag:client": ["kratail2tid@"], "tag:database": ["kratail2tid@"], "tag:web": ["kratail2tid@"] }, "hosts": {"webserver": "100.108.74.26", "database": "100.74.60.128"}, "acls": [ {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:22"]}, {"action": "accept", "src": ["tag:client"], "dst": ["tag:database:5432"]}, {"action": "accept", "src": ["tag:client"], "dst": ["tag:web:80"]} ] }`, wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, // Each destination node receives its own filter (same Srcs per node) "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-db": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.74.60.128/32", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, {IP: "fd7a:115c:a1e0::2f01:3c9c/128", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-web": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.94.92.91/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::ef01:5c81/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 14.26: Same entity as both src and dst in 2 rules // MERGED into 1 entry with 4 Dsts (not deduplicated) { name: "same_entity_src_and_dst_14_26", policy: `{ "groups": {"group:admins": ["kratail2tid@"]}, "tagOwners": { "tag:server": ["kratail2tid@"], "tag:client": ["kratail2tid@"], "tag:database": ["kratail2tid@"], "tag:web": ["kratail2tid@"] }, "hosts": {"webserver": "100.108.74.26", "database": "100.74.60.128"}, "acls": [ {"action": "accept", "src": ["autogroup:member"], "dst": ["autogroup:member:22"]}, {"action": "accept", "src": ["group:admins"], "dst": ["group:admins:22"]} ] }`, wantFilters: map[string][]tailcfg.FilterRule{ "tagged-server": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, "user1": { // Merged: 1 entry with 4 Dsts (not deduplicated) { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 14.27: User→user:22, group→user:80 (same Srcs, different ports) // MERGED into 1 entry with 4 Dsts { name: "user_to_user_22_group_to_user_80_14_27", policy: `{ "groups": {"group:admins": ["kratail2tid@"]}, "tagOwners": { "tag:server": ["kratail2tid@"], "tag:client": ["kratail2tid@"], "tag:database": ["kratail2tid@"], "tag:web": ["kratail2tid@"] }, "hosts": {"webserver": "100.108.74.26", "database": "100.74.60.128"}, "acls": [ {"action": "accept", "src": ["kratail2tid@"], "dst": ["kratail2tid@:22"]}, {"action": "accept", "src": ["group:admins"], "dst": ["kratail2tid@:80"]} ] }`, wantFilters: map[string][]tailcfg.FilterRule{ "tagged-server": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, "user1": { // Merged: 1 entry with 4 Dsts { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 14.29: tagged→tagged:22, specific tags→tagged:80 { name: "tagged_to_tagged_specific_tags_14_29", policy: `{ "groups": {"group:admins": ["kratail2tid@"]}, "tagOwners": { "tag:server": ["kratail2tid@"], "tag:client": ["kratail2tid@"], "tag:database": ["kratail2tid@"], "tag:web": ["kratail2tid@"] }, "hosts": {"webserver": "100.108.74.26", "database": "100.74.60.128"}, "acls": [ {"action": "accept", "src": ["autogroup:tagged"], "dst": ["autogroup:tagged:22"]}, {"action": "accept", "src": ["tag:client", "tag:web"], "dst": ["autogroup:tagged:80"]} ] }`, wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, // Each tagged node receives 2 filter entries (different Srcs = separate) "tagged-server": { { SrcIPs: []string{ "100.108.74.26/32", "100.74.60.128/32", "100.80.238.75/32", "100.94.92.91/32", "fd7a:115c:a1e0::2f01:3c9c/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::b901:4a87/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, { SrcIPs: []string{ "100.80.238.75/32", "100.94.92.91/32", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": { { SrcIPs: []string{ "100.108.74.26/32", "100.74.60.128/32", "100.80.238.75/32", "100.94.92.91/32", "fd7a:115c:a1e0::2f01:3c9c/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::b901:4a87/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.80.238.75/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::7901:ee86/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, { SrcIPs: []string{ "100.80.238.75/32", "100.94.92.91/32", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.80.238.75/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::7901:ee86/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-db": { { SrcIPs: []string{ "100.108.74.26/32", "100.74.60.128/32", "100.80.238.75/32", "100.94.92.91/32", "fd7a:115c:a1e0::2f01:3c9c/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::b901:4a87/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.74.60.128/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::2f01:3c9c/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, { SrcIPs: []string{ "100.80.238.75/32", "100.94.92.91/32", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.74.60.128/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::2f01:3c9c/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-web": { { SrcIPs: []string{ "100.108.74.26/32", "100.74.60.128/32", "100.80.238.75/32", "100.94.92.91/32", "fd7a:115c:a1e0::2f01:3c9c/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::b901:4a87/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.94.92.91/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::ef01:5c81/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, { SrcIPs: []string{ "100.80.238.75/32", "100.94.92.91/32", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.94.92.91/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::ef01:5c81/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 14.42: Both autogroups → wildcard (full network) { name: "both_autogroups_to_wildcard_14_42", policy: `{ "groups": {"group:admins": ["kratail2tid@"]}, "tagOwners": { "tag:server": ["kratail2tid@"], "tag:client": ["kratail2tid@"], "tag:database": ["kratail2tid@"], "tag:web": ["kratail2tid@"] }, "hosts": {"webserver": "100.108.74.26", "database": "100.74.60.128"}, "acls": [ {"action": "accept", "src": ["autogroup:tagged"], "dst": ["*:*"]}, {"action": "accept", "src": ["autogroup:member"], "dst": ["*:*"]} ] }`, wantFilters: map[string][]tailcfg.FilterRule{ // All nodes receive 2 filter entries (different Srcs = separate entries) "user1": { { SrcIPs: []string{ "100.108.74.26/32", "100.74.60.128/32", "100.80.238.75/32", "100.94.92.91/32", "fd7a:115c:a1e0::2f01:3c9c/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::b901:4a87/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-server": { { SrcIPs: []string{ "100.108.74.26/32", "100.74.60.128/32", "100.80.238.75/32", "100.94.92.91/32", "fd7a:115c:a1e0::2f01:3c9c/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::b901:4a87/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": { { SrcIPs: []string{ "100.108.74.26/32", "100.74.60.128/32", "100.80.238.75/32", "100.94.92.91/32", "fd7a:115c:a1e0::2f01:3c9c/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::b901:4a87/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-db": { { SrcIPs: []string{ "100.108.74.26/32", "100.74.60.128/32", "100.80.238.75/32", "100.94.92.91/32", "fd7a:115c:a1e0::2f01:3c9c/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::b901:4a87/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-web": { { SrcIPs: []string{ "100.108.74.26/32", "100.74.60.128/32", "100.80.238.75/32", "100.94.92.91/32", "fd7a:115c:a1e0::2f01:3c9c/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::b901:4a87/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 14.45: Triple src ref each rule { name: "triple_src_ref_each_rule_14_45", policy: `{ "groups": {"group:admins": ["kratail2tid@"]}, "tagOwners": { "tag:server": ["kratail2tid@"], "tag:client": ["kratail2tid@"], "tag:database": ["kratail2tid@"], "tag:web": ["kratail2tid@"] }, "hosts": {"webserver": "100.108.74.26", "database": "100.74.60.128"}, "acls": [ {"action": "accept", "src": ["autogroup:member", "group:admins", "kratail2tid@"], "dst": ["tag:server:22"]}, {"action": "accept", "src": ["tag:server", "webserver", "100.108.74.26"], "dst": ["group:admins:80"]} ] }`, wantFilters: map[string][]tailcfg.FilterRule{ "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, // tagged-server: receives filter from rule 1 (triple user ref deduplicated to 1 IP) "tagged-server": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, // user1: receives filter from rule 2 (triple ref deduplicated to tag:server IP) "user1": { { SrcIPs: []string{ "100.108.74.26/32", "fd7a:115c:a1e0::b901:4a87/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 14.47: Same src → 4 dests (4 rules) { name: "same_src_four_dests_14_47", policy: `{ "groups": {"group:admins": ["kratail2tid@"]}, "tagOwners": { "tag:server": ["kratail2tid@"], "tag:client": ["kratail2tid@"], "tag:database": ["kratail2tid@"], "tag:web": ["kratail2tid@"] }, "hosts": {"webserver": "100.108.74.26", "database": "100.74.60.128"}, "acls": [ {"action": "accept", "src": ["autogroup:member"], "dst": ["tag:server:22"]}, {"action": "accept", "src": ["autogroup:member"], "dst": ["tag:database:5432"]}, {"action": "accept", "src": ["autogroup:member"], "dst": ["tag:web:80"]}, {"action": "accept", "src": ["autogroup:member"], "dst": ["webserver:443"]} ] }`, wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, // tagged-server: merged entry for :22 and :443 (same SrcIPs) "tagged-server": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 443, Last: 443}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 443, Last: 443}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-db": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.74.60.128/32", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, {IP: "fd7a:115c:a1e0::2f01:3c9c/128", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-web": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.94.92.91/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::ef01:5c81/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 14.50: 6 rules mixing all patterns { name: "six_rules_mixed_patterns_14_50", policy: `{ "groups": {"group:admins": ["kratail2tid@"]}, "tagOwners": { "tag:server": ["kratail2tid@"], "tag:client": ["kratail2tid@"], "tag:database": ["kratail2tid@"], "tag:web": ["kratail2tid@"] }, "hosts": {"webserver": "100.108.74.26", "database": "100.74.60.128"}, "acls": [ {"action": "accept", "src": ["tag:server"], "dst": ["tag:server:22"]}, {"action": "accept", "src": ["tag:client"], "dst": ["tag:client:22"]}, {"action": "accept", "src": ["tag:database"], "dst": ["tag:database:22"]}, {"action": "accept", "src": ["tag:web"], "dst": ["tag:web:22"]}, {"action": "accept", "src": ["autogroup:member"], "dst": ["*:80"]}, {"action": "accept", "src": ["*"], "dst": ["autogroup:member:443"]} ] }`, wantFilters: map[string][]tailcfg.FilterRule{ // user1: receives 2 entries: member→*:80 and *→user1:443 "user1": { { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, { SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 443, Last: 443}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 443, Last: 443}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, // tagged-server: receives self-ref + member→*:80 "tagged-server": { { SrcIPs: []string{ "100.108.74.26/32", "fd7a:115c:a1e0::b901:4a87/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, // tagged-client: receives self-ref + member→*:80 "tagged-client": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.80.238.75/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::7901:ee86/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, // tagged-db: receives self-ref + member→*:80 "tagged-db": { { SrcIPs: []string{ "100.74.60.128/32", "fd7a:115c:a1e0::2f01:3c9c/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.74.60.128/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::2f01:3c9c/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, // tagged-web: receives self-ref + member→*:80 "tagged-web": { { SrcIPs: []string{ "100.94.92.91/32", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.94.92.91/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::ef01:5c81/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, { SrcIPs: []string{ "100.90.199.68/32", "fd7a:115c:a1e0::2d01:c747/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 14.17: Wildcard → group and user (same person):22 // Test 14.17: * → group:admins:22 and * → kratail2tid@:22 // MERGED into 1 entry with 4 Dsts (duplicated) { name: "wildcard_to_group_and_user_same_14_17", policy: `{ "groups": {"group:admins": ["kratail2tid@"]}, "tagOwners": { "tag:server": ["kratail2tid@"], "tag:client": ["kratail2tid@"], "tag:database": ["kratail2tid@"], "tag:web": ["kratail2tid@"] }, "hosts": {"webserver": "100.108.74.26", "database": "100.74.60.128"}, "acls": [ {"action": "accept", "src": ["*"], "dst": ["group:admins:22"]}, {"action": "accept", "src": ["*"], "dst": ["kratail2tid@:22"]} ] }`, wantFilters: map[string][]tailcfg.FilterRule{ "tagged-server": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, "user1": { // Merged: 1 entry with 4 Dsts (duplicated) { SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 14.18: Tag → member and group (same):22 // MERGED into 1 entry with 4 Dsts (duplicated) { name: "tag_to_member_and_group_same_14_18", policy: `{ "groups": {"group:admins": ["kratail2tid@"]}, "tagOwners": { "tag:server": ["kratail2tid@"], "tag:client": ["kratail2tid@"], "tag:database": ["kratail2tid@"], "tag:web": ["kratail2tid@"] }, "hosts": {"webserver": "100.108.74.26", "database": "100.74.60.128"}, "acls": [ {"action": "accept", "src": ["tag:client"], "dst": ["autogroup:member:22"]}, {"action": "accept", "src": ["tag:client"], "dst": ["group:admins:22"]} ] }`, wantFilters: map[string][]tailcfg.FilterRule{ "tagged-server": nil, "tagged-client": nil, "tagged-db": nil, "tagged-web": nil, "user1": { // Merged: 1 entry with 4 Dsts (duplicated) { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 14.20: Two rules with multi-dest, partial dest overlap { name: "two_rules_multi_dest_partial_overlap_14_20", policy: `{ "groups": {"group:admins": ["kratail2tid@"]}, "tagOwners": { "tag:server": ["kratail2tid@"], "tag:client": ["kratail2tid@"], "tag:database": ["kratail2tid@"], "tag:web": ["kratail2tid@"] }, "hosts": {"webserver": "100.108.74.26", "database": "100.74.60.128"}, "acls": [ {"action": "accept", "src": ["*"], "dst": ["tag:server:22", "tag:database:5432"]}, {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:80", "tag:web:443"]} ] }`, wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, // tagged-server: receives both wildcard:22 and tag:client:80 "tagged-server": { { SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, // tagged-db: receives wildcard:5432 "tagged-db": { { SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.74.60.128/32", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, {IP: "fd7a:115c:a1e0::2f01:3c9c/128", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, // tagged-web: receives tag:client:443 "tagged-web": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.94.92.91/32", Ports: tailcfg.PortRange{First: 443, Last: 443}}, {IP: "fd7a:115c:a1e0::ef01:5c81/128", Ports: tailcfg.PortRange{First: 443, Last: 443}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 14.30: All→all subset, wildcard→wildcard { name: "all_to_all_subset_wildcard_wildcard_14_30", policy: `{ "groups": {"group:admins": ["kratail2tid@"]}, "tagOwners": { "tag:server": ["kratail2tid@"], "tag:client": ["kratail2tid@"], "tag:database": ["kratail2tid@"], "tag:web": ["kratail2tid@"] }, "hosts": {"webserver": "100.108.74.26", "database": "100.74.60.128"}, "acls": [ {"action": "accept", "src": ["autogroup:member", "autogroup:tagged"], "dst": ["autogroup:member:22", "autogroup:tagged:80"]}, {"action": "accept", "src": ["*"], "dst": ["*:443"]} ] }`, wantFilters: map[string][]tailcfg.FilterRule{ // user1: receives member:22 (first rule dst) + *:443 (second rule) "user1": { { SrcIPs: []string{ "100.108.74.26/32", "100.74.60.128/32", "100.80.238.75/32", "100.90.199.68/32", "100.94.92.91/32", "fd7a:115c:a1e0::2d01:c747/128", "fd7a:115c:a1e0::2f01:3c9c/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::b901:4a87/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.90.199.68/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, { SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRange{First: 443, Last: 443}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, // tagged-web: receives tagged:80 (first rule dst) + *:443 (second rule) "tagged-web": { { SrcIPs: []string{ "100.108.74.26/32", "100.74.60.128/32", "100.80.238.75/32", "100.90.199.68/32", "100.94.92.91/32", "fd7a:115c:a1e0::2d01:c747/128", "fd7a:115c:a1e0::2f01:3c9c/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::b901:4a87/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.94.92.91/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::ef01:5c81/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, { SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRange{First: 443, Last: 443}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, // Other tagged nodes: same pattern - tagged:80 + *:443 "tagged-server": { { SrcIPs: []string{ "100.108.74.26/32", "100.74.60.128/32", "100.80.238.75/32", "100.90.199.68/32", "100.94.92.91/32", "fd7a:115c:a1e0::2d01:c747/128", "fd7a:115c:a1e0::2f01:3c9c/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::b901:4a87/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, { SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRange{First: 443, Last: 443}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-client": { { SrcIPs: []string{ "100.108.74.26/32", "100.74.60.128/32", "100.80.238.75/32", "100.90.199.68/32", "100.94.92.91/32", "fd7a:115c:a1e0::2d01:c747/128", "fd7a:115c:a1e0::2f01:3c9c/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::b901:4a87/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.80.238.75/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::7901:ee86/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, { SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRange{First: 443, Last: 443}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-db": { { SrcIPs: []string{ "100.108.74.26/32", "100.74.60.128/32", "100.80.238.75/32", "100.90.199.68/32", "100.94.92.91/32", "fd7a:115c:a1e0::2d01:c747/128", "fd7a:115c:a1e0::2f01:3c9c/128", "fd7a:115c:a1e0::7901:ee86/128", "fd7a:115c:a1e0::b901:4a87/128", "fd7a:115c:a1e0::ef01:5c81/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.74.60.128/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::2f01:3c9c/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, { SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRange{First: 443, Last: 443}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 14.37: Multiple wildcard src rules // Rules with same SrcIPs going to the same node are MERGED { name: "multiple_wildcard_src_rules_14_37", policy: `{ "groups": {"group:admins": ["kratail2tid@"]}, "tagOwners": { "tag:server": ["kratail2tid@"], "tag:client": ["kratail2tid@"], "tag:database": ["kratail2tid@"], "tag:web": ["kratail2tid@"] }, "hosts": {"webserver": "100.108.74.26", "database": "100.74.60.128"}, "acls": [ {"action": "accept", "src": ["*"], "dst": ["tag:server:22"]}, {"action": "accept", "src": ["*"], "dst": ["tag:database:5432"]}, {"action": "accept", "src": ["*"], "dst": ["*:80"]} ] }`, wantFilters: map[string][]tailcfg.FilterRule{ "tagged-client": { { SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, // tagged-server: receives rule 1 (:22) and rule 3 (:80) - MERGED "tagged-server": { { SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "*", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, // tagged-db: receives rule 2 (:5432) and rule 3 (:80) - MERGED "tagged-db": { { SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.74.60.128/32", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, {IP: "fd7a:115c:a1e0::2f01:3c9c/128", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, {IP: "*", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-web": { { SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "user1": { { SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRange{First: 80, Last: 80}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 14.38: Wildcard dest + specific dest // TODO: Tailscale subsumes specific into wildcard (1 entry), Headscale creates 2 separate entries { name: "wildcard_dest_plus_specific_dest_14_38", policy: `{ "groups": {"group:admins": ["kratail2tid@"]}, "tagOwners": { "tag:server": ["kratail2tid@"], "tag:client": ["kratail2tid@"], "tag:database": ["kratail2tid@"], "tag:web": ["kratail2tid@"] }, "hosts": {"webserver": "100.108.74.26", "database": "100.74.60.128"}, "acls": [ {"action": "accept", "src": ["tag:client"], "dst": ["*:*"]}, {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:22"]} ] }`, wantFilters: map[string][]tailcfg.FilterRule{ // tagged-client: receives only wildcard (tag:server:22 doesn't apply to tagged-client) "tagged-client": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, // tagged-server: receives both wildcard and specific (specific is subset) "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "user1": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-db": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, "tagged-web": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 14.40: Wildcard in different positions { name: "wildcard_in_different_positions_14_40", policy: `{ "groups": {"group:admins": ["kratail2tid@"]}, "tagOwners": { "tag:server": ["kratail2tid@"], "tag:client": ["kratail2tid@"], "tag:database": ["kratail2tid@"], "tag:web": ["kratail2tid@"] }, "hosts": {"webserver": "100.108.74.26", "database": "100.74.60.128"}, "acls": [ {"action": "accept", "src": ["*"], "dst": ["tag:server:22", "tag:database:5432"]}, {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:80", "*:443"]} ] }`, wantFilters: map[string][]tailcfg.FilterRule{ // user1: receives only *:443 from rule 2 "user1": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRange{First: 443, Last: 443}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, // tagged-server: receives wildcard:22 and tag:client:80 and tag:client:443 "tagged-server": { { SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "*", Ports: tailcfg.PortRange{First: 443, Last: 443}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, // tagged-db: receives wildcard:5432 and tag:client:443 "tagged-db": { { SrcIPs: []string{ "100.64.0.0/10", "fd7a:115c:a1e0::/48", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.74.60.128/32", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, {IP: "fd7a:115c:a1e0::2f01:3c9c/128", Ports: tailcfg.PortRange{First: 5432, Last: 5432}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRange{First: 443, Last: 443}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, // tagged-web: receives only tag:client:443 "tagged-web": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRange{First: 443, Last: 443}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, // tagged-client: receives only tag:client:443 "tagged-client": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "*", Ports: tailcfg.PortRange{First: 443, Last: 443}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, }, }, // Test 14.49: Same src → 5 dests (some overlap) // TODO: Tailscale merges, Headscale creates separate entries but may deduplicate destinations { name: "same_src_five_dests_overlap_14_49", policy: `{ "groups": {"group:admins": ["kratail2tid@"]}, "tagOwners": { "tag:server": ["kratail2tid@"], "tag:client": ["kratail2tid@"], "tag:database": ["kratail2tid@"], "tag:web": ["kratail2tid@"] }, "hosts": {"webserver": "100.108.74.26", "database": "100.74.60.128"}, "acls": [ {"action": "accept", "src": ["tag:client"], "dst": ["tag:server:22"]}, {"action": "accept", "src": ["tag:client"], "dst": ["tag:database:22"]}, {"action": "accept", "src": ["tag:client"], "dst": ["tag:web:22"]}, {"action": "accept", "src": ["tag:client"], "dst": ["webserver:22"]}, {"action": "accept", "src": ["tag:client"], "dst": ["database:22"]} ] }`, wantFilters: map[string][]tailcfg.FilterRule{ "user1": nil, "tagged-client": nil, // tagged-server: receives rules 1 and 4 (tag:server:22 and webserver:22 resolve to same node) // Note: Host alias (webserver) also resolves to both IPv4 and IPv6 when it matches a node "tagged-server": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "100.108.74.26/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, // tagged-db: receives rules 2 and 5 (tag:database:22 and database:22 resolve to same node) "tagged-db": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.74.60.128/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::2f01:3c9c/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "100.74.60.128/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::2f01:3c9c/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, }, IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}, }, }, // tagged-web: receives rule 3 only "tagged-web": { { SrcIPs: []string{ "100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.94.92.91/32", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "fd7a:115c:a1e0::ef01:5c81/128", 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) // Get compiled filters for this specific node compiledFilters, err := pol.compileFilterRulesForNode(users, node.View(), nodes.ViewSlice()) require.NoError(t, err, "failed to compile filters for node %s", nodeName) // Reduce to only rules where this node is a destination 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) } } }) } } // TestTailscaleCompatErrorCases tests ACL configurations that should produce validation errors. // These tests verify that Headscale correctly rejects invalid policies, matching Tailscale's behavior // where the coordination server rejects the policy at update time (400 Bad Request). // // Reference: /home/kradalby/acl-explore/findings/09-mixed-scenarios.md. func TestTailscaleCompatErrorCases(t *testing.T) { t.Parallel() tests := []struct { name string policy string wantErr string reference string // Test case reference from findings }{ // Test 6.4: tag:nonexistent → tag:server:22 (ERROR) // Tailscale error: "src=tag not found: \"tag:nonexistent\" (400)" { name: "undefined_tag_source_6_4", policy: `{ "groups": { "group:admins": ["kratail2tid@"] }, "tagOwners": { "tag:server": ["kratail2tid@"] }, "acls": [ {"action": "accept", "src": ["tag:nonexistent"], "dst": ["tag:server:22"]} ] }`, wantErr: `tag not defined in policy: "tag:nonexistent"`, reference: "Test 6.4: tag:nonexistent → tag:server:22", }, // Test 13.41: autogroup:self as SOURCE (ERROR) // Tailscale error: "\"autogroup:self\" not valid on the src side of a rule (400)" { name: "self_as_source_13_41", policy: `{ "groups": { "group:admins": ["kratail2tid@"] }, "tagOwners": { "tag:server": ["kratail2tid@"] }, "acls": [ {"action": "accept", "src": ["autogroup:self"], "dst": ["tag:server:22"]} ] }`, wantErr: `autogroup:self can only be used in ACL destinations`, reference: "Test 13.41: autogroup:self as SOURCE", }, // Test 13.43: autogroup:self without port (ERROR) // Tailscale error: "dst=\"autogroup:self\": port range \"self\": invalid first integer (400)" { name: "self_without_port_13_43", policy: `{ "groups": { "group:admins": ["kratail2tid@"] }, "tagOwners": { "tag:server": ["kratail2tid@"] }, "acls": [ {"action": "accept", "src": ["*"], "dst": ["autogroup:self"]} ] }`, wantErr: `invalid port number`, reference: "Test 13.43: autogroup:self without port", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() pol, err := unmarshalPolicy([]byte(tt.policy)) // Check for parsing errors (some errors occur at parse time) if err != nil { require.ErrorContains(t, err, tt.wantErr, "test %s (%s): expected parse error containing %q, got %q", tt.name, tt.reference, tt.wantErr, err.Error()) return } // Check for validation errors err = pol.validate() require.Error(t, err, "test %s (%s): expected validation error, got none", tt.name, tt.reference) require.ErrorContains(t, err, tt.wantErr, "test %s (%s): expected error containing %q, got %q", tt.name, tt.reference, tt.wantErr, err.Error()) }) } } // TestTailscaleCompatErrorCasesHeadscaleDiffers validates that Headscale correctly rejects // policies that Tailscale also rejects. These tests verify that autogroup:self destination // validation for ACL rules matches Tailscale's behavior. // // Tailscale validates that autogroup:self can only be used when ALL sources are // users, groups, or autogroup:member. Headscale now performs this same validation. // // Reference: /home/kradalby/acl-explore/findings/09-mixed-scenarios.md. func TestTailscaleCompatErrorCasesHeadscaleDiffers(t *testing.T) { t.Parallel() // These tests verify that Headscale rejects policies the same way Tailscale does. // Tailscale rejects these policies at validation time (400 Bad Request), // and Headscale now does the same. tests := []struct { name string policy string tailscaleError string // What Tailscale returns (and Headscale should match) reference string }{ // Test 2.5: tag:client → autogroup:self:* + tag:server:22 // Tailscale REJECTS this - autogroup:self requires user/group sources { name: "tag_source_with_self_dest_2_5", policy: `{ "groups": { "group:admins": ["kratail2tid@"] }, "tagOwners": { "tag:server": ["kratail2tid@"], "tag:client": ["kratail2tid@"] }, "acls": [ {"action": "accept", "src": ["tag:client"], "dst": ["autogroup:self:*", "tag:server:22"]} ] }`, tailscaleError: "autogroup:self can only be used with users, groups, or supported autogroups (400)", reference: "Test 2.5: tag:client → autogroup:self:* + tag:server:22", }, // Test 4.5: tag:client → autogroup:self:* // Tailscale REJECTS this - autogroup:self requires user/group sources { name: "tag_source_to_self_dest_only_4_5", policy: `{ "groups": { "group:admins": ["kratail2tid@"] }, "tagOwners": { "tag:client": ["kratail2tid@"] }, "acls": [ {"action": "accept", "src": ["tag:client"], "dst": ["autogroup:self:*"]} ] }`, tailscaleError: "autogroup:self can only be used with users, groups, or supported autogroups (400)", reference: "Test 4.5: tag:client → autogroup:self:*", }, // Test 6.1: autogroup:tagged → autogroup:self:* // Tailscale REJECTS this - autogroup:tagged is NOT a valid source for autogroup:self { name: "autogroup_tagged_to_self_6_1", policy: `{ "groups": { "group:admins": ["kratail2tid@"] }, "tagOwners": { "tag:server": ["kratail2tid@"] }, "acls": [ {"action": "accept", "src": ["autogroup:tagged"], "dst": ["autogroup:self:*"]} ] }`, tailscaleError: "autogroup:self can only be used with users, groups, or supported autogroups (400)", reference: "Test 6.1: autogroup:tagged → autogroup:self:*", }, // Test 9.5: [autogroup:member, autogroup:tagged] → [autogroup:self:*, tag:server:22] // Tailscale REJECTS this - ANY invalid source (autogroup:tagged) invalidates the rule { name: "both_autogroups_to_self_plus_tag_9_5", policy: `{ "groups": { "group:admins": ["kratail2tid@"] }, "tagOwners": { "tag:server": ["kratail2tid@"] }, "acls": [ {"action": "accept", "src": ["autogroup:member", "autogroup:tagged"], "dst": ["autogroup:self:*", "tag:server:22"]} ] }`, tailscaleError: "autogroup:self can only be used with users, groups, or supported autogroups (400)", reference: "Test 9.5: [autogroup:member, autogroup:tagged] → [autogroup:self:*, tag:server:22]", }, // Test 13.6: autogroup:tagged → self:* // Tailscale REJECTS this - same as 6.1 { name: "autogroup_tagged_to_self_13_6", policy: `{ "groups": { "group:admins": ["kratail2tid@"] }, "tagOwners": { "tag:server": ["kratail2tid@"] }, "acls": [ {"action": "accept", "src": ["autogroup:tagged"], "dst": ["autogroup:self:*"]} ] }`, tailscaleError: "autogroup:self can only be used with users, groups, or supported autogroups (400)", reference: "Test 13.6: autogroup:tagged → self:*", }, // Test 13.10: tag:client → self:* // Tailscale REJECTS this - tags are not valid sources for autogroup:self { name: "tag_to_self_13_10", policy: `{ "groups": { "group:admins": ["kratail2tid@"] }, "tagOwners": { "tag:client": ["kratail2tid@"] }, "acls": [ {"action": "accept", "src": ["tag:client"], "dst": ["autogroup:self:*"]} ] }`, tailscaleError: "autogroup:self can only be used with users, groups, or supported autogroups (400)", reference: "Test 13.10: tag:client → self:*", }, // Test 13.13: Host → self:* // Tailscale REJECTS this - hosts are not valid sources for autogroup:self { name: "host_to_self_13_13", policy: `{ "groups": { "group:admins": ["kratail2tid@"] }, "tagOwners": { "tag:server": ["kratail2tid@"] }, "hosts": { "webserver": "100.108.74.26" }, "acls": [ {"action": "accept", "src": ["webserver"], "dst": ["autogroup:self:*"]} ] }`, tailscaleError: "autogroup:self can only be used with users, groups, or supported autogroups (400)", reference: "Test 13.13: Host → self:*", }, // Test 13.14: Raw IP → self:* // Tailscale REJECTS this - raw IPs are not valid sources for autogroup:self { name: "raw_ip_to_self_13_14", policy: `{ "groups": { "group:admins": ["kratail2tid@"] }, "tagOwners": { "tag:server": ["kratail2tid@"] }, "acls": [ {"action": "accept", "src": ["100.90.199.68"], "dst": ["autogroup:self:*"]} ] }`, tailscaleError: "autogroup:self can only be used with users, groups, or supported autogroups (400)", reference: "Test 13.14: Raw IP (user1) → self:*", }, // Test 13.25: [autogroup:member, tag:client] → self:* // Tailscale REJECTS this - ANY invalid source (tag:client) invalidates the rule { name: "mixed_valid_invalid_sources_to_self_13_25", policy: `{ "groups": { "group:admins": ["kratail2tid@"] }, "tagOwners": { "tag:client": ["kratail2tid@"] }, "acls": [ {"action": "accept", "src": ["autogroup:member", "tag:client"], "dst": ["autogroup:self:*"]} ] }`, tailscaleError: "autogroup:self can only be used with users, groups, or supported autogroups (400)", reference: "Test 13.25: [autogroup:member, tag:client] → self:*", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() // unmarshalPolicy calls validate() internally, so we expect it to fail // with our validation error _, err := unmarshalPolicy([]byte(tt.policy)) require.Error(t, err, "test %s (%s): should reject policy like Tailscale", tt.name, tt.reference) require.ErrorIs(t, err, ErrACLAutogroupSelfInvalidSource, "test %s (%s): expected autogroup:self validation error", tt.name, tt.reference) }) } }