Files
headscale/hscontrol/policy/v2/tailscale_compat_test.go
2026-02-06 21:45:32 +01:00

9937 lines
334 KiB
Go
Raw Blame History

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