diff --git a/hscontrol/policy/v2/tailscale_compat_test.go b/hscontrol/policy/v2/tailscale_compat_test.go new file mode 100644 index 00000000..232c4dc3 --- /dev/null +++ b/hscontrol/policy/v2/tailscale_compat_test.go @@ -0,0 +1,10436 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// Copyright (c) Headscale AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +// tailscale_compat_test.go contains tests that verify Headscale's ACL-to-PacketFilter +// translation matches Tailscale's behavior. These tests are derived from empirical +// observations of Tailscale's actual filter generation. +// +// Test data source: https://github.com/kradalby/acl-explore/findings/ + +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 these CGNAT CIDR ranges for wildcard source expansion. +// Headscale uses simpler 0.0.0.0/0 and ::/0 ranges. +// TODO: Match Tailscale's CGNAT CIDR expansion for wildcard sources. +// 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 specific 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 simpler 0.0.0.0/0 and ::/0 ranges. + // TODO: Match Tailscale's CGNAT CIDR expansion for wildcard sources. + wantFilters: map[string][]tailcfg.FilterRule{ + "user1": { + { + // TODO: Tailscale uses specific CGNAT CIDRs: + // SrcIPs: tailscaleCGNATCIDRs, + SrcIPs: []string{ + "0.0.0.0/0", + "::/0", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "tagged-server": { + { + SrcIPs: []string{ + "0.0.0.0/0", + "::/0", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "tagged-client": { + { + SrcIPs: []string{ + "0.0.0.0/0", + "::/0", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "tagged-db": { + { + SrcIPs: []string{ + "0.0.0.0/0", + "::/0", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "tagged-web": { + { + SrcIPs: []string{ + "0.0.0.0/0", + "::/0", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + }, + }, + { + 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: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "tagged-server": { + { + SrcIPs: []string{ + "100.90.199.68/32", + "fd7a:115c:a1e0::2d01:c747/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "tagged-client": { + { + SrcIPs: []string{ + "100.90.199.68/32", + "fd7a:115c:a1e0::2d01:c747/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "tagged-db": { + { + SrcIPs: []string{ + "100.90.199.68/32", + "fd7a:115c:a1e0::2d01:c747/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "tagged-web": { + { + SrcIPs: []string{ + "100.90.199.68/32", + "fd7a:115c:a1e0::2d01:c747/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + }, + }, + { + 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: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "tagged-server": { + { + SrcIPs: []string{"100.64.0.0/16"}, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "tagged-client": { + { + SrcIPs: []string{"100.64.0.0/16"}, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "tagged-db": { + { + SrcIPs: []string{"100.64.0.0/16"}, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "tagged-web": { + { + SrcIPs: []string{"100.64.0.0/16"}, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + }, + }, + { + 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": { + { + // TODO: Tailscale uses specific CGNAT CIDRs for wildcard source + SrcIPs: []string{ + "0.0.0.0/0", + "::/0", + }, + // 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}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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": { + { + // TODO: Tailscale uses specific CGNAT CIDRs for wildcard source + SrcIPs: []string{ + "0.0.0.0/0", + "::/0", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "100.64.0.0/12", Ports: tailcfg.PortRangeAny}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}, + }, + }, + "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: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "tagged-server": { + { + SrcIPs: []string{ + "100.80.238.75/32", + "fd7a:115c:a1e0::7901:ee86/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "tagged-client": { + { + SrcIPs: []string{ + "100.80.238.75/32", + "fd7a:115c:a1e0::7901:ee86/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "tagged-db": { + { + SrcIPs: []string{ + "100.80.238.75/32", + "fd7a:115c:a1e0::7901:ee86/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "tagged-web": { + { + SrcIPs: []string{ + "100.80.238.75/32", + "fd7a:115c:a1e0::7901:ee86/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + }, + }, + { + 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}, + }, + }, + "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": { + { + // TODO: Tailscale uses specific CGNAT CIDRs for wildcard source + SrcIPs: []string{ + "0.0.0.0/0", + "::/0", + }, + 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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}, + }, + }, + "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}, + }, + }, + "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}, + }, + }, + }, + }, + { + 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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "tagged-server": { + { + SrcIPs: []string{ + "100.90.199.68/32", + "fd7a:115c:a1e0::2d01:c747/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "tagged-client": { + { + SrcIPs: []string{ + "100.90.199.68/32", + "fd7a:115c:a1e0::2d01:c747/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "tagged-db": { + { + SrcIPs: []string{ + "100.90.199.68/32", + "fd7a:115c:a1e0::2d01:c747/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "tagged-web": { + { + SrcIPs: []string{ + "100.90.199.68/32", + "fd7a:115c:a1e0::2d01:c747/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + }, + }, + { + 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{ + "0.0.0.0/0", + "::/0", + }, + 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}, + }, + }, + "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: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "tagged-server": { + { + SrcIPs: []string{ + "100.90.199.68/32", + "fd7a:115c:a1e0::2d01:c747/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "tagged-client": { + { + SrcIPs: []string{ + "100.90.199.68/32", + "fd7a:115c:a1e0::2d01:c747/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "tagged-db": { + { + SrcIPs: []string{ + "100.90.199.68/32", + "fd7a:115c:a1e0::2d01:c747/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "tagged-web": { + { + SrcIPs: []string{ + "100.90.199.68/32", + "fd7a:115c:a1e0::2d01:c747/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + }, + }, + { + 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{ + "0.0.0.0/0", + "::/0", + }, + 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}, + }, + }, + "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}, + }, + }, + "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}, + }, + }, + "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: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "tagged-server": { + { + SrcIPs: []string{ + "100.90.199.68/32", + "fd7a:115c:a1e0::2d01:c747/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "tagged-client": { + { + SrcIPs: []string{ + "100.90.199.68/32", + "fd7a:115c:a1e0::2d01:c747/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "tagged-db": { + { + SrcIPs: []string{ + "100.90.199.68/32", + "fd7a:115c:a1e0::2d01:c747/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "tagged-web": { + { + SrcIPs: []string{ + "100.90.199.68/32", + "fd7a:115c:a1e0::2d01:c747/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + }, + }, + { + 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: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + }, + }, + { + 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}, + }, + }, + "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. + // KEY INSIGHT: Tagged nodes do NOT receive autogroup:self filters. + // Only user-owned nodes can use autogroup:self. + // NOTE: In Tailscale, user1 receives a filter with both its IPs as src AND dst. + // TODO: Tailscale uses CGNAT CIDRs for the * source instead of narrowing. + wantFilters: map[string][]tailcfg.FilterRule{ + "user1": { + { + // TODO: In Tailscale, the source is narrowed to the node's own IPs: + // SrcIPs: []string{ + // "100.90.199.68/32", + // "fd7a:115c:a1e0::2d01:c747/128", + // }, + SrcIPs: []string{ + "100.90.199.68/32", + "fd7a:115c:a1e0::2d01:c747/128", + }, + // NOTE: Headscale uses raw IP addresses without CIDR suffix for autogroup:self destinations. + // TODO: Tailscale uses CIDR format: "100.90.199.68/32" and "fd7a:115c:a1e0::2d01:c747/128" + DstPorts: []tailcfg.NetPortRange{ + {IP: "100.90.199.68", Ports: tailcfg.PortRangeAny}, + {IP: "fd7a:115c:a1e0::2d01:c747", Ports: tailcfg.PortRangeAny}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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": { + { + // TODO: Tailscale uses specific CGNAT CIDRs for wildcard source + SrcIPs: []string{ + "0.0.0.0/0", + "::/0", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "100.90.199.68/32", Ports: tailcfg.PortRangeAny}, + {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRangeAny}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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", + }, + // NOTE: Headscale uses raw IP addresses without CIDR suffix for autogroup:self destinations. + // TODO: Tailscale uses CIDR format + DstPorts: []tailcfg.NetPortRange{ + {IP: "100.90.199.68", Ports: tailcfg.PortRangeAny}, + {IP: "fd7a:115c:a1e0::2d01:c747", Ports: tailcfg.PortRangeAny}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "tagged-server": { + { + // tag:server keeps full wildcard Srcs + // TODO: Tailscale uses specific CGNAT CIDRs for wildcard source + SrcIPs: []string{ + "0.0.0.0/0", + "::/0", + }, + 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}, + }, + }, + "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": { + { + // TODO: Tailscale uses specific CGNAT CIDRs for wildcard source + SrcIPs: []string{ + "0.0.0.0/0", + "::/0", + }, + // 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}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "tagged-client": { + { + SrcIPs: []string{ + "0.0.0.0/0", + "::/0", + }, + // 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}, + }, + }, + "tagged-db": { + { + SrcIPs: []string{ + "0.0.0.0/0", + "::/0", + }, + // 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}, + }, + }, + "tagged-web": { + { + SrcIPs: []string{ + "0.0.0.0/0", + "::/0", + }, + // 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}, + }, + }, + }, + }, + } + + 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": { + { + // TODO: Tailscale uses specific CGNAT CIDRs for wildcard source + SrcIPs: []string{ + "0.0.0.0/0", + "::/0", + }, + // 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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + }, + }, + { + 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: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "tagged-server": { + { + SrcIPs: []string{"10.0.0.0/8"}, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "tagged-client": { + { + SrcIPs: []string{"10.0.0.0/8"}, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "tagged-db": { + { + SrcIPs: []string{"10.0.0.0/8"}, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "tagged-web": { + { + SrcIPs: []string{"10.0.0.0/8"}, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + }, + }, + } + + 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{ + "0.0.0.0/0", + "::/0", + }, + 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{ + "0.0.0.0/0", + "::/0", + }, + 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": { + { + // TODO: Tailscale uses specific CGNAT CIDRs for wildcard source + SrcIPs: []string{ + "0.0.0.0/0", + "::/0", + }, + 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{ + "0.0.0.0/0", + "::/0", + }, + 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}, + }, + }, + "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{ + "0.0.0.0/0", + "::/0", + }, + 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}, + }, + }, + "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{ + "0.0.0.0/0", + "::/0", + }, + 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}, + }, + }, + "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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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. + // NOTE: Headscale does NOT merge rules - each ACL rule becomes a separate filter entry. + // TODO: Implement rule merging to match Tailscale behavior. + // Tailscale expected: + // { + // SrcIPs: ["100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128"], + // DstPorts: [all ports 22, 80, 443 combined], + // IPProto: [6, 17, 1, 58], + // } + wantFilters: map[string][]tailcfg.FilterRule{ + "user1": nil, + "tagged-server": { + // Headscale: First rule (port 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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + // Headscale: Second rule (ports 80, 443) + { + 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: "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}, + }, + }, + "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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + { + 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}, + }, + { + 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}, + }, + }, + "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) + // NOTE: Headscale does NOT merge rules - produces 4 separate filter entries. + // TODO: Implement rule merging to match Tailscale behavior. + wantFilters: map[string][]tailcfg.FilterRule{ + "user1": nil, + "tagged-server": { + // Headscale: Rule 1 (tag:client → port 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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + // Headscale: Rule 2 (tag:client → port 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}, + }, + // Headscale: Rule 3 (tag:web → port 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}, + }, + // Headscale: Rule 4 (tag:web → port 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: 443, Last: 443}}, + {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 443, Last: 443}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}, + }, + }, + "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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}, + }, + }, + "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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + { + // TODO: Tailscale uses specific CGNAT CIDRs for wildcard source + SrcIPs: []string{ + "0.0.0.0/0", + "::/0", + }, + 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}, + }, + }, + "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 in Tailscale! + // In Tailscale: 2 rules → 1 filter entry with all ports combined (4 Dsts: 2 ports × 2 IPs) + // NOTE: Headscale does NOT merge - produces 2 separate filter entries. + // TODO: Implement rule merging to match Tailscale behavior. + wantFilters: map[string][]tailcfg.FilterRule{ + "user1": nil, + "tagged-server": { + // Headscale: First rule (port 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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + // Headscale: Second rule (port 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}, + }, + // TODO: Tailscale merges these into a single entry: + // { + // SrcIPs: ["100.80.238.75/32", "fd7a:115c:a1e0::7901:ee86/128"], + // DstPorts: [ + // {IP: "100.108.74.26/32", Ports: {22, 22}}, + // {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: {22, 22}}, + // {IP: "100.108.74.26/32", Ports: {80, 80}}, + // {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: {80, 80}}, + // ], + // IPProto: [6, 17, 1, 58], + // } + }, + "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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}, + }, + }, + "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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + }, + }, + // 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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}, + }, + }, + "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}, + }, + }, + "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}, + }, + }, + "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}, + }, + }, + }, + }, + { + 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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + { + 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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + {IP: "fd7a:115c:a1e0::2d01:c747", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + // 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", Ports: tailcfg.PortRange{First: 22, Last: 22}}, + {IP: "fd7a:115c:a1e0::2d01:c747", Ports: tailcfg.PortRange{First: 22, Last: 22}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + {IP: "fd7a:115c:a1e0::2d01:c747", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + {IP: "fd7a:115c:a1e0::2d01:c747", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + {IP: "fd7a:115c:a1e0::2d01:c747", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + {IP: "fd7a:115c:a1e0::2d01:c747", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "tagged-server": { + { + // Tag filter gets full wildcard Srcs + // TODO: Tailscale uses CGNAT CIDRs for wildcard + SrcIPs: []string{ + "0.0.0.0/0", + "::/0", + }, + // 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}, + }, + }, + "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 + // NOTE: Headscale does NOT merge - produces 2 separate filter entries + 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": { + // Headscale: 2 separate rules (TODO: Tailscale merges into 1) + { + 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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + { + 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}, + }, + }, + "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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + { + 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}, + }, + { + 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}, + }, + }, + "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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + { + // 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}, + }, + }, + "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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}, + }, + }, + "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", Ports: tailcfg.PortRange{First: 22, Last: 22}}, + {IP: "100.90.199.68", Ports: tailcfg.PortRange{First: 80, Last: 80}}, + {IP: "100.90.199.68", Ports: tailcfg.PortRange{First: 443, Last: 443}}, + {IP: "fd7a:115c:a1e0::2d01:c747", Ports: tailcfg.PortRange{First: 22, Last: 22}}, + {IP: "fd7a:115c:a1e0::2d01:c747", Ports: tailcfg.PortRange{First: 80, Last: 80}}, + {IP: "fd7a:115c:a1e0::2d01:c747", Ports: tailcfg.PortRange{First: 443, Last: 443}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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", Ports: tailcfg.PortRange{First: 80, Last: 443}}, + {IP: "fd7a:115c:a1e0::2d01:c747", Ports: tailcfg.PortRange{First: 80, Last: 443}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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 + // NOTE: Headscale does NOT merge - produces 2 separate filter entries + policy: makePolicy(` + {"action": "accept", "src": ["*"], "dst": ["autogroup:self:22"]}, + {"action": "accept", "src": ["*"], "dst": ["autogroup:self:80"]} + `), + wantFilters: map[string][]tailcfg.FilterRule{ + "user1": { + // TODO: Tailscale merges into 1 filter entry with 4 Dsts + // Headscale produces 2 separate filter entries + { + SrcIPs: []string{ + "100.90.199.68/32", + "fd7a:115c:a1e0::2d01:c747/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "100.90.199.68", Ports: tailcfg.PortRange{First: 22, Last: 22}}, + {IP: "fd7a:115c:a1e0::2d01:c747", Ports: tailcfg.PortRange{First: 22, Last: 22}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + { + SrcIPs: []string{ + "100.90.199.68/32", + "fd7a:115c:a1e0::2d01:c747/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "100.90.199.68", Ports: tailcfg.PortRange{First: 80, Last: 80}}, + {IP: "fd7a:115c:a1e0::2d01:c747", Ports: tailcfg.PortRange{First: 80, Last: 80}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}, + }, + }, + "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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + { + 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}, + }, + }, + "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": { + // TODO: Tailscale merges into 1 filter entry with Srcs deduplicated and 4 Dsts + // Headscale produces 2 separate filter entries + { + 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}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + { + 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}, + }, + }, + "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", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + {IP: "fd7a:115c:a1e0::2d01:c747", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + }, + // TODO: Tailscale includes ICMP protocols: []int{6, 17, 1, 58} + IPProto: []int{protocolTCP, protocolUDP}, + }, + // Entry 2: group:admins with full wildcard Srcs + // TODO: Tailscale uses CGNAT CIDRs for wildcard + { + SrcIPs: []string{ + "0.0.0.0/0", + "::/0", + }, + 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}, + }, + }, + "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": { + // TODO: Tailscale MERGES into 1 filter entry with 4 Dsts + // Headscale produces 2 separate filter entries + { + 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}, + }, + { + 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}, + }, + }, + "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}, + }, + { + 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}, + }, + { + 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}, + }, + }, + "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": { + // TODO: Tailscale produces 1 filter entry with Srcs deduplicated and 6 Dsts + // Headscale produces 3 separate filter entries + { + 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}, + }, + { + 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}, + }, + { + 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}, + }, + }, + "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}, + }, + }, + "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}, + }, + }, + "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}, + }, + }, + }, + }, + { + 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 + { + // TODO: Tailscale uses CGNAT CIDRs, IPProto [0] + SrcIPs: []string{ + "0.0.0.0/0", + "::/0", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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{ + "0.0.0.0/0", + "::/0", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + { + 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}, + }, + }, + "tagged-client": { + { + SrcIPs: []string{ + "0.0.0.0/0", + "::/0", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "tagged-db": { + { + SrcIPs: []string{ + "0.0.0.0/0", + "::/0", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "tagged-web": { + { + SrcIPs: []string{ + "0.0.0.0/0", + "::/0", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + }, + }, + { + 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: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + // Entry 2: autogroup:member Srcs + { + SrcIPs: []string{ + "100.90.199.68/32", + "fd7a:115c:a1e0::2d01:c747/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + { + SrcIPs: []string{ + "100.90.199.68/32", + "fd7a:115c:a1e0::2d01:c747/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + { + SrcIPs: []string{ + "100.90.199.68/32", + "fd7a:115c:a1e0::2d01:c747/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + { + SrcIPs: []string{ + "100.90.199.68/32", + "fd7a:115c:a1e0::2d01:c747/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + { + SrcIPs: []string{ + "100.90.199.68/32", + "fd7a:115c:a1e0::2d01:c747/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRangeAny}, + {IP: "::/0", Ports: tailcfg.PortRangeAny}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + }, + }, + { + 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}, + }, + }, + "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}, + }, + }, + "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": { + // TODO: Tailscale merges rules to same node with same Srcs + // Headscale produces 2 separate entries for tag:server:22 and webserver: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: 22, Last: 22}}, + {IP: "fd7a:115c:a1e0::b901:4a87/128", Ports: tailcfg.PortRange{First: 22, Last: 22}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + { + 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}, + }, + }, + "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}, + }, + }, + "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}, + }, + }, + }, + }, + { + 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: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + // 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: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "tagged-server": { + { + SrcIPs: []string{ + "100.90.199.68/32", + "fd7a:115c:a1e0::2d01:c747/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + { + 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: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "tagged-client": { + { + SrcIPs: []string{ + "100.90.199.68/32", + "fd7a:115c:a1e0::2d01:c747/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + { + 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: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "tagged-db": { + { + SrcIPs: []string{ + "100.90.199.68/32", + "fd7a:115c:a1e0::2d01:c747/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + { + 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: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "tagged-web": { + { + SrcIPs: []string{ + "100.90.199.68/32", + "fd7a:115c:a1e0::2d01:c747/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + { + 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: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + }, + }, + { + 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}, + }, + // 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}, + }, + }, + }, + }, + { + 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{ + "0.0.0.0/0", + "::/0", + }, + 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}, + }, + // 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}, + }, + // 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}, + }, + }, + }, + }, + { + 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}, + }, + }, + "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}, + }, + }, + }, + }, + { + 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}, + }, + }, + }, + }, + { + 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}, + }, + }, + }, + }, + { + 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}, + }, + }, + }, + }, + { + 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}, + }, + // 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}, + }, + }, + }, + }, + { + 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 + // TODO: Tailscale merges these rules with 4 Dsts (2× duplicated) + // Headscale produces 2 separate filter entries + 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": { + // TODO: Tailscale merges these into single entry with 4 Dsts (duplicated) + // Headscale produces 2 separate filter entries + { + 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}, + }, + { + 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}, + }, + }, + }, + }, + { + 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 + // TODO: Tailscale produces 2 entries, one with wildcard CGNAT Srcs, one with user1's IPs + // Headscale produces similar but with 0.0.0.0/0 instead of CGNAT CIDRs + // 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", Ports: tailcfg.PortRange{First: 22, Last: 22}}, + {IP: "fd7a:115c:a1e0::2d01:c747", Ports: tailcfg.PortRange{First: 22, Last: 22}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + // Entry 2: * → kratail2tid@:22 (wildcard Srcs, CIDR in DstPorts) + { + SrcIPs: []string{ + "0.0.0.0/0", + "::/0", + }, + 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}, + }, + }, + "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": { + // TODO: Tailscale merges these into single entry with 4 Dsts + // Headscale may produce 2 entries (need to verify) + { + SrcIPs: []string{ + "100.90.199.68/32", + "fd7a:115c:a1e0::2d01:c747/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "100.90.199.68", Ports: tailcfg.PortRange{First: 22, Last: 22}}, + {IP: "fd7a:115c:a1e0::2d01:c747", Ports: tailcfg.PortRange{First: 22, Last: 22}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + { + SrcIPs: []string{ + "100.90.199.68/32", + "fd7a:115c:a1e0::2d01:c747/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "100.90.199.68", Ports: tailcfg.PortRange{First: 80, Last: 80}}, + {IP: "fd7a:115c:a1e0::2d01:c747", Ports: tailcfg.PortRange{First: 80, Last: 80}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 80, Last: 80}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 80, Last: 80}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + // Entry 2: * → autogroup:member:443 (user1 is in autogroup:member) + { + SrcIPs: []string{ + "0.0.0.0/0", + "::/0", + }, + 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}, + }, + }, + "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}, + }, + // Entry 2: autogroup:member → *:80 + { + SrcIPs: []string{ + "100.90.199.68/32", + "fd7a:115c:a1e0::2d01:c747/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 80, Last: 80}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 80, Last: 80}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}, + }, + // Entry 2: autogroup:member → *:80 + { + SrcIPs: []string{ + "100.90.199.68/32", + "fd7a:115c:a1e0::2d01:c747/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 80, Last: 80}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 80, Last: 80}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}, + }, + // Entry 2: autogroup:member → *:80 + { + SrcIPs: []string{ + "100.90.199.68/32", + "fd7a:115c:a1e0::2d01:c747/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 80, Last: 80}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 80, Last: 80}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}, + }, + // Entry 2: autogroup:member → *:80 + { + SrcIPs: []string{ + "100.90.199.68/32", + "fd7a:115c:a1e0::2d01:c747/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 80, Last: 80}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 80, Last: 80}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + }, + }, + // 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}, + }, + }, + }, + }, + { + 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}, + }, + }, + }, + }, + { + 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}, + }, + }, + }, + }, + { + 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}, + }, + }, + }, + }, + { + 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}, + }, + }, + }, + }, + { + 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}, + }, + }, + }, + }, + // 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}, + }, + }, + "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}, + }, + }, + }, + }, + { + 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}, + }, + }, + }, + }, + { + 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}, + }, + }, + "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}, + }, + }, + }, + }, + { + 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}, + }, + }, + "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}, + }, + }, + }, + }, + // 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}, + }, + }, + }, + }, + { + 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}, + }, + }, + }, + }, + { + 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}, + }, + }, + }, + }, + // 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}, + }, + }, + }, + }, + { + 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}, + }, + }, + }, + }, + // 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}, + }, + }, + }, + }, + // 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}, + }, + }, + }, + }, + { + 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}, + }, + }, + }, + }, + { + 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}, + }, + }, + }, + }, + // 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}, + }, + }, + }, + }, + + // =========================================== + // 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}, + }, + }, + }, + }, + // 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}, + }, + }, + }, + }, + // 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}, + }, + }, + "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}, + }, + }, + }, + }, + // 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}, + }, + }, + "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}, + }, + }, + }, + }, + // 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}, + }, + }, + }, + }, + // 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}, + }, + }, + }, + }, + + // =========================================== + // 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}, + }, + }, + }, + }, + // 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}, + }, + }, + }, + }, + // 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}, + }, + }, + "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}, + }, + }, + "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}, + }, + }, + "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}, + }, + }, + }, + }, + // 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}, + }, + }, + }, + }, + + // =========================================== + // 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}, + }, + }, + // 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}, + }, + }, + }, + }, + // 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}, + }, + // 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}, + }, + }, + }, + }, + // 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 THREE separate filter entries + "tagged-server": { + // Rule 1: autogroup:member + { + 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}, + }, + // Rule 2: 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}, + }, + // Rule 3: 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}, + }, + }, + }, + }, + + // =========================================== + // 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}, + }, + }, + }, + }, + // 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}, + }, + }, + }, + }, + // 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}, + }, + }, + }, + }, + + // =========================================== + // 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", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + {IP: "fd7a:115c:a1e0::2d01:c747", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + // 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", Ports: tailcfg.PortRange{First: 22, Last: 22}}, + {IP: "fd7a:115c:a1e0::2d01:c747", Ports: tailcfg.PortRange{First: 22, Last: 22}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + {IP: "fd7a:115c:a1e0::2d01:c747", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + {IP: "fd7a:115c:a1e0::2d01:c747", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + {IP: "fd7a:115c:a1e0::2d01:c747", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}, + }, + }, + "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}, + }, + }, + }, + }, + // 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 + // TODO(kradalby): Tailscale MERGES these into 1 filter entry with 4 Dsts + // Headscale creates 2 SEPARATE filter entries (one per rule) + 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, + // TODO(kradalby): Tailscale merges into 1 entry with 4 DstPorts: + // {IP: "100.108.74.26/32", Ports: 22}, {IP: "fd7a:...:4a87/128", Ports: 22}, + // {IP: "100.108.74.26/32", Ports: 80}, {IP: "fd7a:...:4a87/128", Ports: 80} + // Headscale: 2 SEPARATE filter entries + "tagged-server": { + // Entry 1: port 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}, + }, + // Entry 2: port 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}, + }, + }, + }, + }, + // 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}, + }, + // 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}, + }, + }, + }, + }, + // Test 14.8: Group + user (same person) → same dest (2 rules) + { + name: "group_user_same_person_same_dest_14_8", + // Group + user (same person) + // TODO(kradalby): Tailscale MERGES these into 1 filter entry (Srcs deduplicated, Dsts NOT) + // Headscale creates 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": ["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, + // TODO(kradalby): Tailscale merges into 1 entry with deduplicated Srcs but duplicated Dsts: + // Srcs: ["100.90.199.68/32", "fd7a:...:c747/128"] + // Dsts: [{IP: ".../32", Ports: 22}×2, {IP: ".../128", Ports: 22}×2] + // Headscale: 2 SEPARATE filter entries + "tagged-server": { + // Entry 1: group:admins (resolves to same IPs as 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}, + }, + // Entry 2: kratail2tid@ (same 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}, + }, + }, + }, + }, + + // =========================================== + // 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}, + }, + }, + // 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}, + }, + }, + // 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}, + }, + }, + }, + }, + // 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: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + }, + }, + + // =========================================== + // 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}, + }, + }, + }, + }, + // 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}, + }, + }, + }, + }, + // 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}, + }, + }, + }, + }, + // =========================================== + // 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{ + "0.0.0.0/0", + "::/0", + }, + DstPorts: []tailcfg.NetPortRange{ + // TODO: Tailscale uses CGNAT CIDRs for wildcard: + // "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}, + }, + }, + // tagged-db gets database:5432 (since database = 100.74.60.128 = tagged-db) + "tagged-db": { + { + SrcIPs: []string{ + "0.0.0.0/0", + "::/0", + }, + 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}, + }, + }, + }, + }, + // 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{ + "0.0.0.0/0", + "::/0", + }, + 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}, + }, + }, + // tagged-db gets: tag:database:5432/3306 + database:8080 + "tagged-db": { + { + SrcIPs: []string{ + "0.0.0.0/0", + "::/0", + }, + 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}, + }, + }, + // tagged-web gets: tag:web:80/443 + "tagged-web": { + { + SrcIPs: []string{ + "0.0.0.0/0", + "::/0", + }, + 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}, + }, + }, + }, + }, + // 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}, + }, + }, + // 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}, + }, + }, + }, + }, + // 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}, + }, + }, + }, + }, + // 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}, + }, + }, + }, + }, + // =========================================== + // 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}, + }, + }, + }, + }, + // 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}, + }, + }, + }, + }, + // 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}, + }, + }, + }, + }, + // 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}, + }, + }, + }, + }, + // 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}, + }, + }, + }, + }, + // 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", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + {IP: "fd7a:115c:a1e0::2d01:c747", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + }, + }, + // 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{ + "0.0.0.0/0", + "::/0", + }, + 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}, + }, + { + 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}, + }, + { + 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}, + }, + }, + }, + }, + // 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}, + }, + }, + // 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}, + }, + }, + }, + }, + // 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}, + }, + }, + // 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}, + }, + }, + }, + }, + // 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{ + "0.0.0.0/0", + "::/0", + }, + 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}, + }, + }, + }, + }, + // 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", Ports: tailcfg.PortRange{First: 80, Last: 443}}, + {IP: "fd7a:115c:a1e0::2d01:c747", Ports: tailcfg.PortRange{First: 80, Last: 443}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + }, + }, + // 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", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + {IP: "fd7a:115c:a1e0::2d01:c747", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + // tagged-server: receives full wildcard Srcs for tag:server:22 + "tagged-server": { + { + SrcIPs: []string{ + "0.0.0.0/0", + "::/0", + }, + 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}, + }, + }, + }, + }, + // 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", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + {IP: "fd7a:115c:a1e0::2d01:c747", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + { + SrcIPs: []string{ + "0.0.0.0/0", + "::/0", + }, + 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}, + }, + }, + }, + }, + + // ===== 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}, + }, + { + 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}, + }, + { + 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}, + }, + }, + }, + }, + // Test 14.22: 3 refs to same user → same dest:port (3 rules) + // TODO: Tailscale merges into 1 entry, Headscale creates 3 separate entries (one per ACL rule) + { + 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: Headscale creates 3 separate entries (one per ACL rule) + "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}, + }, + { + 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}, + }, + { + 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}, + }, + }, + }, + }, + // 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}, + }, + }, + "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}, + }, + }, + "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}, + }, + }, + }, + }, + // Test 14.26: Same entity as both src and dst in 2 rules + // TODO: Tailscale merges into 1 entry with 4 Dsts (not deduplicated), Headscale creates 2 entries with 2 Dsts each (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: Headscale creates 2 separate filter entries (unlike Tailscale which merges) + "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}, + }, + { + 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}, + }, + }, + }, + }, + // Test 14.27: User→user:22, group→user:80 (same Srcs, different ports) + // TODO: Tailscale merges into 1 entry with 4 Dsts, Headscale creates 2 separate entries + { + 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: Headscale creates 2 separate filter entries (unlike Tailscale which merges) + "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}, + }, + { + 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: 80, Last: 80}}, + {IP: "fd7a:115c:a1e0::2d01:c747/128", Ports: tailcfg.PortRange{First: 80, Last: 80}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + }, + }, + // 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}, + }, + { + 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}, + }, + }, + "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}, + }, + { + 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}, + }, + }, + "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}, + }, + { + 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}, + }, + }, + "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}, + }, + { + 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}, + }, + }, + }, + }, + // 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: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + { + SrcIPs: []string{ + "100.90.199.68/32", + "fd7a:115c:a1e0::2d01:c747/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + { + SrcIPs: []string{ + "100.90.199.68/32", + "fd7a:115c:a1e0::2d01:c747/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + { + SrcIPs: []string{ + "100.90.199.68/32", + "fd7a:115c:a1e0::2d01:c747/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + { + SrcIPs: []string{ + "100.90.199.68/32", + "fd7a:115c:a1e0::2d01:c747/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + { + SrcIPs: []string{ + "100.90.199.68/32", + "fd7a:115c:a1e0::2d01:c747/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + }, + }, + // 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}, + }, + }, + // 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}, + }, + }, + }, + }, + // Test 14.47: Same src → 4 dests (4 rules) + // TODO: Tailscale merges per-node DstPorts, Headscale creates separate entries per ACL rule + { + 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: 2 separate entries (ACL rule 1 for :22, ACL rule 4 for :443) + "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}, + }, + { + SrcIPs: []string{ + "100.90.199.68/32", + "fd7a:115c:a1e0::2d01:c747/128", + }, + DstPorts: []tailcfg.NetPortRange{ + // webserver:443 resolves to tagged-server + {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}, + }, + }, + "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}, + }, + }, + "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}, + }, + }, + }, + }, + // 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: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 80, Last: 80}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 80, Last: 80}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + { + SrcIPs: []string{ + "0.0.0.0/0", + "::/0", + }, + 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}, + }, + }, + // 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}, + }, + { + SrcIPs: []string{ + "100.90.199.68/32", + "fd7a:115c:a1e0::2d01:c747/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 80, Last: 80}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 80, Last: 80}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + // 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}, + }, + { + SrcIPs: []string{ + "100.90.199.68/32", + "fd7a:115c:a1e0::2d01:c747/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 80, Last: 80}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 80, Last: 80}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + // 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}, + }, + { + SrcIPs: []string{ + "100.90.199.68/32", + "fd7a:115c:a1e0::2d01:c747/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 80, Last: 80}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 80, Last: 80}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + // 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}, + }, + { + SrcIPs: []string{ + "100.90.199.68/32", + "fd7a:115c:a1e0::2d01:c747/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 80, Last: 80}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 80, Last: 80}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + }, + }, + // Test 14.17: Wildcard → group and user (same person):22 + // TODO: Tailscale merges into 1 entry with 4 Dsts (duplicated), Headscale creates 2 separate entries (deduplicated) + { + 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: Headscale creates 2 separate entries (unlike Tailscale which merges with duplicated Dsts) + "user1": { + { + SrcIPs: []string{ + "0.0.0.0/0", + "::/0", + }, + 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}, + }, + { + SrcIPs: []string{ + "0.0.0.0/0", + "::/0", + }, + 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}, + }, + }, + }, + }, + // Test 14.18: Tag → member and group (same):22 + // TODO: Tailscale merges into 1 entry with 4 Dsts (duplicated), Headscale creates 2 separate entries + { + 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: receives 2 separate entries (one per ACL rule) + "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}, + }, + { + 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}, + }, + }, + }, + }, + // 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{ + "0.0.0.0/0", + "::/0", + }, + 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}, + }, + { + 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}, + }, + }, + // tagged-db: receives wildcard:5432 + "tagged-db": { + { + SrcIPs: []string{ + "0.0.0.0/0", + "::/0", + }, + 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}, + }, + }, + // 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}, + }, + }, + }, + }, + // 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}, + }, + { + SrcIPs: []string{ + "0.0.0.0/0", + "::/0", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 443, Last: 443}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 443, Last: 443}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + // 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}, + }, + { + SrcIPs: []string{ + "0.0.0.0/0", + "::/0", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 443, Last: 443}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 443, Last: 443}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + // 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}, + }, + { + SrcIPs: []string{ + "0.0.0.0/0", + "::/0", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 443, Last: 443}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 443, Last: 443}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}, + }, + { + SrcIPs: []string{ + "0.0.0.0/0", + "::/0", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 443, Last: 443}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 443, Last: 443}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "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}, + }, + { + SrcIPs: []string{ + "0.0.0.0/0", + "::/0", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 443, Last: 443}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 443, Last: 443}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + }, + }, + // Test 14.37: Multiple wildcard src rules + // TODO: Tailscale merges into 1 entry, Headscale creates 3 separate entries (one per ACL rule) + { + 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{ + "0.0.0.0/0", + "::/0", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 80, Last: 80}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 80, Last: 80}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + // tagged-server: receives rule 1 (:22) and rule 3 (:80) + "tagged-server": { + { + SrcIPs: []string{ + "0.0.0.0/0", + "::/0", + }, + 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}, + }, + { + SrcIPs: []string{ + "0.0.0.0/0", + "::/0", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 80, Last: 80}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 80, Last: 80}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + // tagged-db: receives rule 2 (:5432) and rule 3 (:80) + "tagged-db": { + { + SrcIPs: []string{ + "0.0.0.0/0", + "::/0", + }, + 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}, + }, + { + SrcIPs: []string{ + "0.0.0.0/0", + "::/0", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 80, Last: 80}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 80, Last: 80}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "tagged-web": { + { + SrcIPs: []string{ + "0.0.0.0/0", + "::/0", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 80, Last: 80}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 80, Last: 80}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "user1": { + { + SrcIPs: []string{ + "0.0.0.0/0", + "::/0", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 80, Last: 80}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 80, Last: 80}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + }, + }, + // 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: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + // 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: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + { + 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}, + }, + }, + "user1": { + { + SrcIPs: []string{ + "100.80.238.75/32", + "fd7a:115c:a1e0::7901:ee86/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "tagged-db": { + { + SrcIPs: []string{ + "100.80.238.75/32", + "fd7a:115c:a1e0::7901:ee86/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + "tagged-web": { + { + SrcIPs: []string{ + "100.80.238.75/32", + "fd7a:115c:a1e0::7901:ee86/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 0, Last: 65535}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + }, + }, + // 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: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 443, Last: 443}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 443, Last: 443}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + // tagged-server: receives wildcard:22 and tag:client:80 and tag:client:443 + "tagged-server": { + { + SrcIPs: []string{ + "0.0.0.0/0", + "::/0", + }, + 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}, + }, + { + 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: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 443, Last: 443}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 443, Last: 443}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + // tagged-db: receives wildcard:5432 and tag:client:443 + "tagged-db": { + { + SrcIPs: []string{ + "0.0.0.0/0", + "::/0", + }, + 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}, + }, + { + SrcIPs: []string{ + "100.80.238.75/32", + "fd7a:115c:a1e0::7901:ee86/128", + }, + DstPorts: []tailcfg.NetPortRange{ + {IP: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 443, Last: 443}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 443, Last: 443}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + // 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: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 443, Last: 443}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 443, Last: 443}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + // 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: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 443, Last: 443}}, + {IP: "::/0", Ports: tailcfg.PortRange{First: 443, Last: 443}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + }, + }, + // 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}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + { + 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}, + }, + }, + // 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}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + { + 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}}, + }, + IPProto: []int{protocolTCP, protocolUDP}, + }, + }, + // 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}, + }, + }, + }, + }, + } + + 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 "tag:nonexistent" is not defined in the Policy`, + 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" used in source, it 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 documents cases where Tailscale produces +// validation errors but Headscale does NOT. These represent compatibility gaps. +// +// TODO: Tailscale validates that autogroup:self can only be used when ALL sources are +// users, groups, or autogroup:member. Headscale does NOT currently perform this validation +// for ACL rules (only for SSH rules). This means invalid policies like tag:client → autogroup:self:* +// will be accepted by Headscale but rejected by Tailscale. +// +// Reference: /home/kradalby/acl-explore/findings/09-mixed-scenarios.md. +func TestTailscaleCompatErrorCasesHeadscaleDiffers(t *testing.T) { + t.Parallel() + + // These tests document where Headscale behavior DIFFERS from Tailscale. + // Tailscale rejects these policies at validation time (400 Bad Request), + // but Headscale currently accepts them. + tests := []struct { + name string + policy string + tailscaleError string // What Tailscale would return + reference string + }{ + // Test 2.5: tag:client → autogroup:self:* + tag:server:22 + // TODO: 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:* + // TODO: 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:* + // TODO: 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] + // TODO: 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:* + // TODO: 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:* + // TODO: 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:* + // TODO: 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:* + // TODO: 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:* + // TODO: 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() + + pol, err := unmarshalPolicy([]byte(tt.policy)) + require.NoError(t, err, "test %s (%s): policy should parse", tt.name, tt.reference) + + // TODO: Headscale does NOT validate autogroup:self source restrictions for ACL rules. + // Tailscale would reject these policies with: %s + // For now, document that Headscale accepts these policies. + err = pol.validate() + require.NoError(t, err, + "test %s (%s): Headscale currently accepts this policy (Tailscale rejects with: %s)", + tt.name, tt.reference, tt.tailscaleError) + }) + } +}