headscale/hscontrol/policy/v2/tailscale_compat_test.go

9937 lines
334 KiB
Go
Raw Normal View History

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