This PR changes tags to be something that exists on nodes in addition to users, to being its own thing. It is part of moving our tags support towards the correct tailscale compatible implementation. There are probably rough edges in this PR, but the intention is to get it in, and then start fixing bugs from 0.28.0 milestone (long standing tags issue) to discover what works and what doesnt. Updates #2417 Closes #2619
609 lines
20 KiB
Go
609 lines
20 KiB
Go
package v2
|
|
|
|
import (
|
|
"net/netip"
|
|
"slices"
|
|
"testing"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
"github.com/juanfont/headscale/hscontrol/policy/matcher"
|
|
"github.com/juanfont/headscale/hscontrol/types"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/gorm"
|
|
"tailscale.com/tailcfg"
|
|
"tailscale.com/types/ptr"
|
|
)
|
|
|
|
func node(name, ipv4, ipv6 string, user types.User, hostinfo *tailcfg.Hostinfo) *types.Node {
|
|
return &types.Node{
|
|
ID: 0,
|
|
Hostname: name,
|
|
IPv4: ap(ipv4),
|
|
IPv6: ap(ipv6),
|
|
User: ptr.To(user),
|
|
UserID: ptr.To(user.ID),
|
|
Hostinfo: hostinfo,
|
|
}
|
|
}
|
|
|
|
func TestPolicyManager(t *testing.T) {
|
|
users := types.Users{
|
|
{Model: gorm.Model{ID: 1}, Name: "testuser", Email: "testuser@headscale.net"},
|
|
{Model: gorm.Model{ID: 2}, Name: "otheruser", Email: "otheruser@headscale.net"},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
pol string
|
|
nodes types.Nodes
|
|
wantFilter []tailcfg.FilterRule
|
|
wantMatchers []matcher.Match
|
|
}{
|
|
{
|
|
name: "empty-policy",
|
|
pol: "{}",
|
|
nodes: types.Nodes{},
|
|
wantFilter: tailcfg.FilterAllowAll,
|
|
wantMatchers: matcher.MatchesFromFilterRules(tailcfg.FilterAllowAll),
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
pm, err := NewPolicyManager([]byte(tt.pol), users, tt.nodes.ViewSlice())
|
|
require.NoError(t, err)
|
|
|
|
filter, matchers := pm.Filter()
|
|
if diff := cmp.Diff(tt.wantFilter, filter); diff != "" {
|
|
t.Errorf("Filter() filter mismatch (-want +got):\n%s", diff)
|
|
}
|
|
if diff := cmp.Diff(
|
|
tt.wantMatchers,
|
|
matchers,
|
|
cmp.AllowUnexported(matcher.Match{}),
|
|
); diff != "" {
|
|
t.Errorf("Filter() matchers mismatch (-want +got):\n%s", diff)
|
|
}
|
|
|
|
// TODO(kradalby): Test SSH Policy
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestInvalidateAutogroupSelfCache(t *testing.T) {
|
|
users := types.Users{
|
|
{Model: gorm.Model{ID: 1}, Name: "user1", Email: "user1@headscale.net"},
|
|
{Model: gorm.Model{ID: 2}, Name: "user2", Email: "user2@headscale.net"},
|
|
{Model: gorm.Model{ID: 3}, Name: "user3", Email: "user3@headscale.net"},
|
|
}
|
|
|
|
policy := `{
|
|
"acls": [
|
|
{
|
|
"action": "accept",
|
|
"src": ["autogroup:member"],
|
|
"dst": ["autogroup:self:*"]
|
|
}
|
|
]
|
|
}`
|
|
|
|
initialNodes := types.Nodes{
|
|
node("user1-node1", "100.64.0.1", "fd7a:115c:a1e0::1", users[0], nil),
|
|
node("user1-node2", "100.64.0.2", "fd7a:115c:a1e0::2", users[0], nil),
|
|
node("user2-node1", "100.64.0.3", "fd7a:115c:a1e0::3", users[1], nil),
|
|
node("user3-node1", "100.64.0.4", "fd7a:115c:a1e0::4", users[2], nil),
|
|
}
|
|
|
|
for i, n := range initialNodes {
|
|
n.ID = types.NodeID(i + 1)
|
|
}
|
|
|
|
pm, err := NewPolicyManager([]byte(policy), users, initialNodes.ViewSlice())
|
|
require.NoError(t, err)
|
|
|
|
// Add to cache by calling FilterForNode for each node
|
|
for _, n := range initialNodes {
|
|
_, err := pm.FilterForNode(n.View())
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
require.Equal(t, len(initialNodes), len(pm.filterRulesMap))
|
|
|
|
tests := []struct {
|
|
name string
|
|
newNodes types.Nodes
|
|
expectedCleared int
|
|
description string
|
|
}{
|
|
{
|
|
name: "no_changes",
|
|
newNodes: types.Nodes{
|
|
node("user1-node1", "100.64.0.1", "fd7a:115c:a1e0::1", users[0], nil),
|
|
node("user1-node2", "100.64.0.2", "fd7a:115c:a1e0::2", users[0], nil),
|
|
node("user2-node1", "100.64.0.3", "fd7a:115c:a1e0::3", users[1], nil),
|
|
node("user3-node1", "100.64.0.4", "fd7a:115c:a1e0::4", users[2], nil),
|
|
},
|
|
expectedCleared: 0,
|
|
description: "No changes should clear no cache entries",
|
|
},
|
|
{
|
|
name: "node_added",
|
|
newNodes: types.Nodes{
|
|
node("user1-node1", "100.64.0.1", "fd7a:115c:a1e0::1", users[0], nil),
|
|
node("user1-node2", "100.64.0.2", "fd7a:115c:a1e0::2", users[0], nil),
|
|
node("user1-node3", "100.64.0.5", "fd7a:115c:a1e0::5", users[0], nil), // New node
|
|
node("user2-node1", "100.64.0.3", "fd7a:115c:a1e0::3", users[1], nil),
|
|
node("user3-node1", "100.64.0.4", "fd7a:115c:a1e0::4", users[2], nil),
|
|
},
|
|
expectedCleared: 2, // user1's existing nodes should be cleared
|
|
description: "Adding a node should clear cache for that user's existing nodes",
|
|
},
|
|
{
|
|
name: "node_removed",
|
|
newNodes: types.Nodes{
|
|
node("user1-node1", "100.64.0.1", "fd7a:115c:a1e0::1", users[0], nil),
|
|
// user1-node2 removed
|
|
node("user2-node1", "100.64.0.3", "fd7a:115c:a1e0::3", users[1], nil),
|
|
node("user3-node1", "100.64.0.4", "fd7a:115c:a1e0::4", users[2], nil),
|
|
},
|
|
expectedCleared: 2, // user1's remaining node + removed node should be cleared
|
|
description: "Removing a node should clear cache for that user's remaining nodes",
|
|
},
|
|
{
|
|
name: "user_changed",
|
|
newNodes: types.Nodes{
|
|
node("user1-node1", "100.64.0.1", "fd7a:115c:a1e0::1", users[0], nil),
|
|
node("user1-node2", "100.64.0.2", "fd7a:115c:a1e0::2", users[2], nil), // Changed to user3
|
|
node("user2-node1", "100.64.0.3", "fd7a:115c:a1e0::3", users[1], nil),
|
|
node("user3-node1", "100.64.0.4", "fd7a:115c:a1e0::4", users[2], nil),
|
|
},
|
|
expectedCleared: 3, // user1's node + user2's node + user3's nodes should be cleared
|
|
description: "Changing a node's user should clear cache for both old and new users",
|
|
},
|
|
{
|
|
name: "ip_changed",
|
|
newNodes: types.Nodes{
|
|
node("user1-node1", "100.64.0.10", "fd7a:115c:a1e0::10", users[0], nil), // IP changed
|
|
node("user1-node2", "100.64.0.2", "fd7a:115c:a1e0::2", users[0], nil),
|
|
node("user2-node1", "100.64.0.3", "fd7a:115c:a1e0::3", users[1], nil),
|
|
node("user3-node1", "100.64.0.4", "fd7a:115c:a1e0::4", users[2], nil),
|
|
},
|
|
expectedCleared: 2, // user1's nodes should be cleared
|
|
description: "Changing a node's IP should clear cache for that user's nodes",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
for i, n := range tt.newNodes {
|
|
found := false
|
|
for _, origNode := range initialNodes {
|
|
if n.Hostname == origNode.Hostname {
|
|
n.ID = origNode.ID
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
n.ID = types.NodeID(len(initialNodes) + i + 1)
|
|
}
|
|
}
|
|
|
|
pm.filterRulesMap = make(map[types.NodeID][]tailcfg.FilterRule)
|
|
for _, n := range initialNodes {
|
|
_, err := pm.FilterForNode(n.View())
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
initialCacheSize := len(pm.filterRulesMap)
|
|
require.Equal(t, len(initialNodes), initialCacheSize)
|
|
|
|
pm.invalidateAutogroupSelfCache(initialNodes.ViewSlice(), tt.newNodes.ViewSlice())
|
|
|
|
// Verify the expected number of cache entries were cleared
|
|
finalCacheSize := len(pm.filterRulesMap)
|
|
clearedEntries := initialCacheSize - finalCacheSize
|
|
require.Equal(t, tt.expectedCleared, clearedEntries, tt.description)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestInvalidateGlobalPolicyCache tests the cache invalidation logic for global policies.
|
|
func TestInvalidateGlobalPolicyCache(t *testing.T) {
|
|
mustIPPtr := func(s string) *netip.Addr {
|
|
ip := netip.MustParseAddr(s)
|
|
return &ip
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
oldNodes types.Nodes
|
|
newNodes types.Nodes
|
|
initialCache map[types.NodeID][]tailcfg.FilterRule
|
|
expectedCacheAfter map[types.NodeID]bool // true = should exist, false = should not exist
|
|
}{
|
|
{
|
|
name: "node property changed - invalidates only that node",
|
|
oldNodes: types.Nodes{
|
|
&types.Node{ID: 1, IPv4: mustIPPtr("100.64.0.1")},
|
|
&types.Node{ID: 2, IPv4: mustIPPtr("100.64.0.2")},
|
|
},
|
|
newNodes: types.Nodes{
|
|
&types.Node{ID: 1, IPv4: mustIPPtr("100.64.0.99")}, // Changed
|
|
&types.Node{ID: 2, IPv4: mustIPPtr("100.64.0.2")}, // Unchanged
|
|
},
|
|
initialCache: map[types.NodeID][]tailcfg.FilterRule{
|
|
1: {},
|
|
2: {},
|
|
},
|
|
expectedCacheAfter: map[types.NodeID]bool{
|
|
1: false, // Invalidated
|
|
2: true, // Preserved
|
|
},
|
|
},
|
|
{
|
|
name: "multiple nodes changed",
|
|
oldNodes: types.Nodes{
|
|
&types.Node{ID: 1, IPv4: mustIPPtr("100.64.0.1")},
|
|
&types.Node{ID: 2, IPv4: mustIPPtr("100.64.0.2")},
|
|
&types.Node{ID: 3, IPv4: mustIPPtr("100.64.0.3")},
|
|
},
|
|
newNodes: types.Nodes{
|
|
&types.Node{ID: 1, IPv4: mustIPPtr("100.64.0.99")}, // Changed
|
|
&types.Node{ID: 2, IPv4: mustIPPtr("100.64.0.2")}, // Unchanged
|
|
&types.Node{ID: 3, IPv4: mustIPPtr("100.64.0.88")}, // Changed
|
|
},
|
|
initialCache: map[types.NodeID][]tailcfg.FilterRule{
|
|
1: {},
|
|
2: {},
|
|
3: {},
|
|
},
|
|
expectedCacheAfter: map[types.NodeID]bool{
|
|
1: false, // Invalidated
|
|
2: true, // Preserved
|
|
3: false, // Invalidated
|
|
},
|
|
},
|
|
{
|
|
name: "node deleted - removes from cache",
|
|
oldNodes: types.Nodes{
|
|
&types.Node{ID: 1, IPv4: mustIPPtr("100.64.0.1")},
|
|
&types.Node{ID: 2, IPv4: mustIPPtr("100.64.0.2")},
|
|
},
|
|
newNodes: types.Nodes{
|
|
&types.Node{ID: 2, IPv4: mustIPPtr("100.64.0.2")},
|
|
},
|
|
initialCache: map[types.NodeID][]tailcfg.FilterRule{
|
|
1: {},
|
|
2: {},
|
|
},
|
|
expectedCacheAfter: map[types.NodeID]bool{
|
|
1: false, // Deleted
|
|
2: true, // Preserved
|
|
},
|
|
},
|
|
{
|
|
name: "node added - no cache invalidation needed",
|
|
oldNodes: types.Nodes{
|
|
&types.Node{ID: 1, IPv4: mustIPPtr("100.64.0.1")},
|
|
},
|
|
newNodes: types.Nodes{
|
|
&types.Node{ID: 1, IPv4: mustIPPtr("100.64.0.1")},
|
|
&types.Node{ID: 2, IPv4: mustIPPtr("100.64.0.2")}, // New
|
|
},
|
|
initialCache: map[types.NodeID][]tailcfg.FilterRule{
|
|
1: {},
|
|
},
|
|
expectedCacheAfter: map[types.NodeID]bool{
|
|
1: true, // Preserved
|
|
2: false, // Not in cache (new node)
|
|
},
|
|
},
|
|
{
|
|
name: "no changes - preserves all cache",
|
|
oldNodes: types.Nodes{
|
|
&types.Node{ID: 1, IPv4: mustIPPtr("100.64.0.1")},
|
|
&types.Node{ID: 2, IPv4: mustIPPtr("100.64.0.2")},
|
|
},
|
|
newNodes: types.Nodes{
|
|
&types.Node{ID: 1, IPv4: mustIPPtr("100.64.0.1")},
|
|
&types.Node{ID: 2, IPv4: mustIPPtr("100.64.0.2")},
|
|
},
|
|
initialCache: map[types.NodeID][]tailcfg.FilterRule{
|
|
1: {},
|
|
2: {},
|
|
},
|
|
expectedCacheAfter: map[types.NodeID]bool{
|
|
1: true,
|
|
2: true,
|
|
},
|
|
},
|
|
{
|
|
name: "routes changed - invalidates that node only",
|
|
oldNodes: types.Nodes{
|
|
&types.Node{
|
|
ID: 1,
|
|
IPv4: mustIPPtr("100.64.0.1"),
|
|
Hostinfo: &tailcfg.Hostinfo{RoutableIPs: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/24"), netip.MustParsePrefix("192.168.0.0/24")}},
|
|
ApprovedRoutes: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/24")},
|
|
},
|
|
&types.Node{ID: 2, IPv4: mustIPPtr("100.64.0.2")},
|
|
},
|
|
newNodes: types.Nodes{
|
|
&types.Node{
|
|
ID: 1,
|
|
IPv4: mustIPPtr("100.64.0.1"),
|
|
Hostinfo: &tailcfg.Hostinfo{RoutableIPs: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/24"), netip.MustParsePrefix("192.168.0.0/24")}},
|
|
ApprovedRoutes: []netip.Prefix{netip.MustParsePrefix("192.168.0.0/24")}, // Changed
|
|
},
|
|
&types.Node{ID: 2, IPv4: mustIPPtr("100.64.0.2")},
|
|
},
|
|
initialCache: map[types.NodeID][]tailcfg.FilterRule{
|
|
1: {},
|
|
2: {},
|
|
},
|
|
expectedCacheAfter: map[types.NodeID]bool{
|
|
1: false, // Invalidated
|
|
2: true, // Preserved
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
pm := &PolicyManager{
|
|
nodes: tt.oldNodes.ViewSlice(),
|
|
filterRulesMap: tt.initialCache,
|
|
usesAutogroupSelf: false,
|
|
}
|
|
|
|
pm.invalidateGlobalPolicyCache(tt.newNodes.ViewSlice())
|
|
|
|
// Verify cache state
|
|
for nodeID, shouldExist := range tt.expectedCacheAfter {
|
|
_, exists := pm.filterRulesMap[nodeID]
|
|
require.Equal(t, shouldExist, exists, "node %d cache existence mismatch", nodeID)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestAutogroupSelfReducedVsUnreducedRules verifies that:
|
|
// 1. BuildPeerMap uses unreduced compiled rules for determining peer relationships
|
|
// 2. FilterForNode returns reduced compiled rules for packet filters
|
|
func TestAutogroupSelfReducedVsUnreducedRules(t *testing.T) {
|
|
user1 := types.User{Model: gorm.Model{ID: 1}, Name: "user1", Email: "user1@headscale.net"}
|
|
user2 := types.User{Model: gorm.Model{ID: 2}, Name: "user2", Email: "user2@headscale.net"}
|
|
users := types.Users{user1, user2}
|
|
|
|
// Create two nodes
|
|
node1 := node("node1", "100.64.0.1", "fd7a:115c:a1e0::1", user1, nil)
|
|
node1.ID = 1
|
|
node2 := node("node2", "100.64.0.2", "fd7a:115c:a1e0::2", user2, nil)
|
|
node2.ID = 2
|
|
nodes := types.Nodes{node1, node2}
|
|
|
|
// Policy with autogroup:self - all members can reach their own devices
|
|
policyStr := `{
|
|
"acls": [
|
|
{
|
|
"action": "accept",
|
|
"src": ["autogroup:member"],
|
|
"dst": ["autogroup:self:*"]
|
|
}
|
|
]
|
|
}`
|
|
|
|
pm, err := NewPolicyManager([]byte(policyStr), users, nodes.ViewSlice())
|
|
require.NoError(t, err)
|
|
require.True(t, pm.usesAutogroupSelf, "policy should use autogroup:self")
|
|
|
|
// Test FilterForNode returns reduced rules
|
|
// For node1: should have rules where node1 is in destinations (its own IP)
|
|
filterNode1, err := pm.FilterForNode(nodes[0].View())
|
|
require.NoError(t, err)
|
|
|
|
// For node2: should have rules where node2 is in destinations (its own IP)
|
|
filterNode2, err := pm.FilterForNode(nodes[1].View())
|
|
require.NoError(t, err)
|
|
|
|
// FilterForNode should return reduced rules - verify they only contain the node's own IPs as destinations
|
|
// For node1, destinations should only be node1's IPs
|
|
node1IPs := []string{"100.64.0.1/32", "100.64.0.1", "fd7a:115c:a1e0::1/128", "fd7a:115c:a1e0::1"}
|
|
for _, rule := range filterNode1 {
|
|
for _, dst := range rule.DstPorts {
|
|
require.Contains(t, node1IPs, dst.IP,
|
|
"node1 filter should only contain node1's IPs as destinations")
|
|
}
|
|
}
|
|
|
|
// For node2, destinations should only be node2's IPs
|
|
node2IPs := []string{"100.64.0.2/32", "100.64.0.2", "fd7a:115c:a1e0::2/128", "fd7a:115c:a1e0::2"}
|
|
for _, rule := range filterNode2 {
|
|
for _, dst := range rule.DstPorts {
|
|
require.Contains(t, node2IPs, dst.IP,
|
|
"node2 filter should only contain node2's IPs as destinations")
|
|
}
|
|
}
|
|
|
|
// Test BuildPeerMap uses unreduced rules
|
|
peerMap := pm.BuildPeerMap(nodes.ViewSlice())
|
|
|
|
// According to the policy, user1 can reach autogroup:self (which expands to node1's own IPs for node1)
|
|
// So node1 should be able to reach itself, but since we're looking at peer relationships,
|
|
// node1 should NOT have itself in the peer map (nodes don't peer with themselves)
|
|
// node2 should also not have any peers since user2 has no rules allowing it to reach anyone
|
|
|
|
// Verify peer relationships based on unreduced rules
|
|
// With unreduced rules, BuildPeerMap can properly determine that:
|
|
// - node1 can access autogroup:self (its own IPs)
|
|
// - node2 cannot access node1
|
|
require.Empty(t, peerMap[node1.ID], "node1 should have no peers (can only reach itself)")
|
|
require.Empty(t, peerMap[node2.ID], "node2 should have no peers")
|
|
}
|
|
|
|
// When separate ACL rules exist (one with autogroup:self, one with tag:router),
|
|
// the autogroup:self rule should not prevent the tag:router rule from working.
|
|
// This ensures that autogroup:self doesn't interfere with other ACL rules.
|
|
func TestAutogroupSelfWithOtherRules(t *testing.T) {
|
|
users := types.Users{
|
|
{Model: gorm.Model{ID: 1}, Name: "test-1", Email: "test-1@example.com"},
|
|
{Model: gorm.Model{ID: 2}, Name: "test-2", Email: "test-2@example.com"},
|
|
}
|
|
|
|
// test-1 has a regular device
|
|
test1Node := &types.Node{
|
|
ID: 1,
|
|
Hostname: "test-1-device",
|
|
IPv4: ap("100.64.0.1"),
|
|
IPv6: ap("fd7a:115c:a1e0::1"),
|
|
User: ptr.To(users[0]),
|
|
UserID: ptr.To(users[0].ID),
|
|
Hostinfo: &tailcfg.Hostinfo{},
|
|
}
|
|
|
|
// test-2 has a router device with tag:node-router
|
|
test2RouterNode := &types.Node{
|
|
ID: 2,
|
|
Hostname: "test-2-router",
|
|
IPv4: ap("100.64.0.2"),
|
|
IPv6: ap("fd7a:115c:a1e0::2"),
|
|
User: ptr.To(users[1]),
|
|
UserID: ptr.To(users[1].ID),
|
|
Tags: []string{"tag:node-router"},
|
|
Hostinfo: &tailcfg.Hostinfo{},
|
|
}
|
|
|
|
nodes := types.Nodes{test1Node, test2RouterNode}
|
|
|
|
// This matches the exact policy from issue #2838:
|
|
// - First rule: autogroup:member -> autogroup:self (allows users to see their own devices)
|
|
// - Second rule: group:home -> tag:node-router (should allow group members to see router)
|
|
policy := `{
|
|
"groups": {
|
|
"group:home": ["test-1@example.com", "test-2@example.com"]
|
|
},
|
|
"tagOwners": {
|
|
"tag:node-router": ["group:home"]
|
|
},
|
|
"acls": [
|
|
{
|
|
"action": "accept",
|
|
"src": ["autogroup:member"],
|
|
"dst": ["autogroup:self:*"]
|
|
},
|
|
{
|
|
"action": "accept",
|
|
"src": ["group:home"],
|
|
"dst": ["tag:node-router:*"]
|
|
}
|
|
]
|
|
}`
|
|
|
|
pm, err := NewPolicyManager([]byte(policy), users, nodes.ViewSlice())
|
|
require.NoError(t, err)
|
|
|
|
peerMap := pm.BuildPeerMap(nodes.ViewSlice())
|
|
|
|
// test-1 (in group:home) should see:
|
|
// 1. Their own node (from autogroup:self rule)
|
|
// 2. The router node (from group:home -> tag:node-router rule)
|
|
test1Peers := peerMap[test1Node.ID]
|
|
|
|
// Verify test-1 can see the router (group:home -> tag:node-router rule)
|
|
require.True(t, slices.ContainsFunc(test1Peers, func(n types.NodeView) bool {
|
|
return n.ID() == test2RouterNode.ID
|
|
}), "test-1 should see test-2's router via group:home -> tag:node-router rule, even when autogroup:self rule exists (issue #2838)")
|
|
|
|
// Verify that test-1 has filter rules (including autogroup:self and tag:node-router access)
|
|
rules, err := pm.FilterForNode(test1Node.View())
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, rules, "test-1 should have filter rules from both ACL rules")
|
|
}
|
|
|
|
// TestAutogroupSelfPolicyUpdateTriggersMapResponse verifies that when a policy with
|
|
// autogroup:self is updated, SetPolicy returns true to trigger MapResponse updates,
|
|
// even if the global filter hash didn't change (which is always empty for autogroup:self).
|
|
// This fixes the issue where policy updates would clear caches but not trigger updates,
|
|
// leaving nodes with stale filter rules until reconnect.
|
|
func TestAutogroupSelfPolicyUpdateTriggersMapResponse(t *testing.T) {
|
|
users := types.Users{
|
|
{Model: gorm.Model{ID: 1}, Name: "test-1", Email: "test-1@example.com"},
|
|
{Model: gorm.Model{ID: 2}, Name: "test-2", Email: "test-2@example.com"},
|
|
}
|
|
|
|
test1Node := &types.Node{
|
|
ID: 1,
|
|
Hostname: "test-1-device",
|
|
IPv4: ap("100.64.0.1"),
|
|
IPv6: ap("fd7a:115c:a1e0::1"),
|
|
User: users[0],
|
|
UserID: users[0].ID,
|
|
Hostinfo: &tailcfg.Hostinfo{},
|
|
}
|
|
|
|
test2Node := &types.Node{
|
|
ID: 2,
|
|
Hostname: "test-2-device",
|
|
IPv4: ap("100.64.0.2"),
|
|
IPv6: ap("fd7a:115c:a1e0::2"),
|
|
User: users[1],
|
|
UserID: users[1].ID,
|
|
Hostinfo: &tailcfg.Hostinfo{},
|
|
}
|
|
|
|
nodes := types.Nodes{test1Node, test2Node}
|
|
|
|
// Initial policy with autogroup:self
|
|
initialPolicy := `{
|
|
"acls": [
|
|
{
|
|
"action": "accept",
|
|
"src": ["autogroup:member"],
|
|
"dst": ["autogroup:self:*"]
|
|
}
|
|
]
|
|
}`
|
|
|
|
pm, err := NewPolicyManager([]byte(initialPolicy), users, nodes.ViewSlice())
|
|
require.NoError(t, err)
|
|
require.True(t, pm.usesAutogroupSelf, "policy should use autogroup:self")
|
|
|
|
// Get initial filter rules for test-1 (should be cached)
|
|
rules1, err := pm.FilterForNode(test1Node.View())
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, rules1, "test-1 should have filter rules")
|
|
|
|
// Update policy with a different ACL that still results in empty global filter
|
|
// (only autogroup:self rules, which compile to empty global filter)
|
|
// We add a comment/description change by adding groups (which don't affect filter compilation)
|
|
updatedPolicy := `{
|
|
"groups": {
|
|
"group:test": ["test-1@example.com"]
|
|
},
|
|
"acls": [
|
|
{
|
|
"action": "accept",
|
|
"src": ["autogroup:member"],
|
|
"dst": ["autogroup:self:*"]
|
|
}
|
|
]
|
|
}`
|
|
|
|
// SetPolicy should return true even though global filter hash didn't change
|
|
policyChanged, err := pm.SetPolicy([]byte(updatedPolicy))
|
|
require.NoError(t, err)
|
|
require.True(t, policyChanged, "SetPolicy should return true when policy content changes, even if global filter hash unchanged (autogroup:self)")
|
|
|
|
// Verify that caches were cleared and new rules are generated
|
|
// The cache should be empty, so FilterForNode will recompile
|
|
rules2, err := pm.FilterForNode(test1Node.View())
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, rules2, "test-1 should have filter rules after policy update")
|
|
|
|
// Verify that the policy hash tracking works - a second identical update should return false
|
|
policyChanged2, err := pm.SetPolicy([]byte(updatedPolicy))
|
|
require.NoError(t, err)
|
|
require.False(t, policyChanged2, "SetPolicy should return false when policy content hasn't changed")
|
|
}
|