mirror of
https://github.com/juanfont/headscale.git
synced 2025-09-16 17:50:44 +02:00
Initial work on a nodestore which stores all of the nodes and their relations in memory with relationship for peers precalculated. It is a copy-on-write structure, replacing the "snapshot" when a change to the structure occurs. It is optimised for reads, and while batches are not fast, they are grouped together to do less of the expensive peer calculation if there are many changes rapidly. Writes will block until commited, while reads are never blocked. Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
340 lines
9.7 KiB
Go
340 lines
9.7 KiB
Go
package policy
|
|
|
|
import (
|
|
"fmt"
|
|
"net/netip"
|
|
"testing"
|
|
|
|
policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2"
|
|
"github.com/juanfont/headscale/hscontrol/types"
|
|
"github.com/juanfont/headscale/hscontrol/util"
|
|
"github.com/stretchr/testify/assert"
|
|
"gorm.io/gorm"
|
|
"tailscale.com/net/tsaddr"
|
|
"tailscale.com/types/key"
|
|
"tailscale.com/types/ptr"
|
|
"tailscale.com/types/views"
|
|
)
|
|
|
|
func TestApproveRoutesWithPolicy_NeverRemovesApprovedRoutes(t *testing.T) {
|
|
user1 := types.User{
|
|
Model: gorm.Model{ID: 1},
|
|
Name: "testuser@",
|
|
}
|
|
user2 := types.User{
|
|
Model: gorm.Model{ID: 2},
|
|
Name: "otheruser@",
|
|
}
|
|
users := []types.User{user1, user2}
|
|
|
|
node1 := &types.Node{
|
|
ID: 1,
|
|
MachineKey: key.NewMachine().Public(),
|
|
NodeKey: key.NewNode().Public(),
|
|
Hostname: "test-node",
|
|
UserID: user1.ID,
|
|
User: user1,
|
|
RegisterMethod: util.RegisterMethodAuthKey,
|
|
IPv4: ptr.To(netip.MustParseAddr("100.64.0.1")),
|
|
ForcedTags: []string{"tag:test"},
|
|
}
|
|
|
|
node2 := &types.Node{
|
|
ID: 2,
|
|
MachineKey: key.NewMachine().Public(),
|
|
NodeKey: key.NewNode().Public(),
|
|
Hostname: "other-node",
|
|
UserID: user2.ID,
|
|
User: user2,
|
|
RegisterMethod: util.RegisterMethodAuthKey,
|
|
IPv4: ptr.To(netip.MustParseAddr("100.64.0.2")),
|
|
}
|
|
|
|
// Create a policy that auto-approves specific routes
|
|
policyJSON := `{
|
|
"groups": {
|
|
"group:test": ["testuser@"]
|
|
},
|
|
"tagOwners": {
|
|
"tag:test": ["testuser@"]
|
|
},
|
|
"acls": [
|
|
{
|
|
"action": "accept",
|
|
"src": ["*"],
|
|
"dst": ["*:*"]
|
|
}
|
|
],
|
|
"autoApprovers": {
|
|
"routes": {
|
|
"10.0.0.0/8": ["testuser@", "tag:test"],
|
|
"10.1.0.0/24": ["testuser@"],
|
|
"10.2.0.0/24": ["testuser@"],
|
|
"192.168.0.0/24": ["tag:test"]
|
|
}
|
|
}
|
|
}`
|
|
|
|
pm, err := policyv2.NewPolicyManager([]byte(policyJSON), users, views.SliceOf([]types.NodeView{node1.View(), node2.View()}))
|
|
assert.NoError(t, err)
|
|
|
|
tests := []struct {
|
|
name string
|
|
node *types.Node
|
|
currentApproved []netip.Prefix
|
|
announcedRoutes []netip.Prefix
|
|
wantApproved []netip.Prefix
|
|
wantChanged bool
|
|
description string
|
|
}{
|
|
{
|
|
name: "previously_approved_route_no_longer_advertised_should_remain",
|
|
node: node1,
|
|
currentApproved: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.0.0/24"),
|
|
netip.MustParsePrefix("192.168.0.0/24"),
|
|
},
|
|
announcedRoutes: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.0.0/24"), // Only this one is still advertised
|
|
},
|
|
wantApproved: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.0.0/24"),
|
|
netip.MustParsePrefix("192.168.0.0/24"), // Should still be here!
|
|
},
|
|
wantChanged: false,
|
|
description: "Previously approved routes should never be removed even when no longer advertised",
|
|
},
|
|
{
|
|
name: "add_new_auto_approved_route_keeps_old_approved",
|
|
node: node1,
|
|
currentApproved: []netip.Prefix{
|
|
netip.MustParsePrefix("10.5.0.0/24"), // This was manually approved
|
|
},
|
|
announcedRoutes: []netip.Prefix{
|
|
netip.MustParsePrefix("10.1.0.0/24"), // New route that should be auto-approved
|
|
},
|
|
wantApproved: []netip.Prefix{
|
|
netip.MustParsePrefix("10.1.0.0/24"), // New auto-approved route (subset of 10.0.0.0/8)
|
|
netip.MustParsePrefix("10.5.0.0/24"), // Old approved route kept
|
|
},
|
|
wantChanged: true,
|
|
description: "New auto-approved routes should be added while keeping old approved routes",
|
|
},
|
|
{
|
|
name: "no_announced_routes_keeps_all_approved",
|
|
node: node1,
|
|
currentApproved: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.0.0/24"),
|
|
netip.MustParsePrefix("192.168.0.0/24"),
|
|
netip.MustParsePrefix("172.16.0.0/16"),
|
|
},
|
|
announcedRoutes: []netip.Prefix{}, // No routes announced
|
|
wantApproved: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.0.0/24"),
|
|
netip.MustParsePrefix("172.16.0.0/16"),
|
|
netip.MustParsePrefix("192.168.0.0/24"),
|
|
},
|
|
wantChanged: false,
|
|
description: "All approved routes should remain when no routes are announced",
|
|
},
|
|
{
|
|
name: "no_changes_when_announced_equals_approved",
|
|
node: node1,
|
|
currentApproved: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.0.0/24"),
|
|
},
|
|
announcedRoutes: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.0.0/24"),
|
|
},
|
|
wantApproved: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.0.0/24"),
|
|
},
|
|
wantChanged: false,
|
|
description: "No changes should occur when announced routes match approved routes",
|
|
},
|
|
{
|
|
name: "auto_approve_multiple_new_routes",
|
|
node: node1,
|
|
currentApproved: []netip.Prefix{
|
|
netip.MustParsePrefix("172.16.0.0/24"), // This was manually approved
|
|
},
|
|
announcedRoutes: []netip.Prefix{
|
|
netip.MustParsePrefix("10.2.0.0/24"), // Should be auto-approved (subset of 10.0.0.0/8)
|
|
netip.MustParsePrefix("192.168.0.0/24"), // Should be auto-approved for tag:test
|
|
},
|
|
wantApproved: []netip.Prefix{
|
|
netip.MustParsePrefix("10.2.0.0/24"), // New auto-approved
|
|
netip.MustParsePrefix("172.16.0.0/24"), // Original kept
|
|
netip.MustParsePrefix("192.168.0.0/24"), // New auto-approved
|
|
},
|
|
wantChanged: true,
|
|
description: "Multiple new routes should be auto-approved while keeping existing approved routes",
|
|
},
|
|
{
|
|
name: "node_without_permission_no_auto_approval",
|
|
node: node2, // Different node without the tag
|
|
currentApproved: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.0.0/24"),
|
|
},
|
|
announcedRoutes: []netip.Prefix{
|
|
netip.MustParsePrefix("192.168.0.0/24"), // This requires tag:test
|
|
},
|
|
wantApproved: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.0.0/24"), // Only the original approved route
|
|
},
|
|
wantChanged: false,
|
|
description: "Routes should not be auto-approved for nodes without proper permissions",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
gotApproved, gotChanged := ApproveRoutesWithPolicy(pm, tt.node.View(), tt.currentApproved, tt.announcedRoutes)
|
|
|
|
assert.Equal(t, tt.wantChanged, gotChanged, "changed flag mismatch: %s", tt.description)
|
|
|
|
// Sort for comparison since ApproveRoutesWithPolicy sorts the results
|
|
tsaddr.SortPrefixes(tt.wantApproved)
|
|
assert.Equal(t, tt.wantApproved, gotApproved, "approved routes mismatch: %s", tt.description)
|
|
|
|
// Verify that all previously approved routes are still present
|
|
for _, prevRoute := range tt.currentApproved {
|
|
assert.Contains(t, gotApproved, prevRoute,
|
|
"previously approved route %s was removed - this should never happen", prevRoute)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestApproveRoutesWithPolicy_NilAndEmptyCases(t *testing.T) {
|
|
// Create a basic policy for edge case testing
|
|
aclPolicy := `
|
|
{
|
|
"acls": [
|
|
{"action": "accept", "src": ["*"], "dst": ["*:*"]},
|
|
],
|
|
"autoApprovers": {
|
|
"routes": {
|
|
"10.1.0.0/24": ["test@"],
|
|
},
|
|
},
|
|
}`
|
|
|
|
pmfs := PolicyManagerFuncsForTest([]byte(aclPolicy))
|
|
|
|
tests := []struct {
|
|
name string
|
|
currentApproved []netip.Prefix
|
|
announcedRoutes []netip.Prefix
|
|
wantApproved []netip.Prefix
|
|
wantChanged bool
|
|
}{
|
|
{
|
|
name: "nil_policy_manager",
|
|
currentApproved: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.0.0/24"),
|
|
},
|
|
announcedRoutes: []netip.Prefix{
|
|
netip.MustParsePrefix("192.168.0.0/24"),
|
|
},
|
|
wantApproved: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.0.0/24"),
|
|
},
|
|
wantChanged: false,
|
|
},
|
|
{
|
|
name: "nil_current_approved",
|
|
currentApproved: nil,
|
|
announcedRoutes: []netip.Prefix{
|
|
netip.MustParsePrefix("10.1.0.0/24"),
|
|
},
|
|
wantApproved: []netip.Prefix{
|
|
netip.MustParsePrefix("10.1.0.0/24"),
|
|
},
|
|
wantChanged: true,
|
|
},
|
|
{
|
|
name: "nil_announced_routes",
|
|
currentApproved: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.0.0/24"),
|
|
},
|
|
announcedRoutes: nil,
|
|
wantApproved: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.0.0/24"),
|
|
},
|
|
wantChanged: false,
|
|
},
|
|
{
|
|
name: "duplicate_approved_routes",
|
|
currentApproved: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.0.0/24"),
|
|
netip.MustParsePrefix("10.0.0.0/24"), // Duplicate
|
|
},
|
|
announcedRoutes: []netip.Prefix{
|
|
netip.MustParsePrefix("10.1.0.0/24"),
|
|
},
|
|
wantApproved: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.0.0/24"),
|
|
netip.MustParsePrefix("10.1.0.0/24"),
|
|
},
|
|
wantChanged: true,
|
|
},
|
|
{
|
|
name: "empty_slices",
|
|
currentApproved: []netip.Prefix{},
|
|
announcedRoutes: []netip.Prefix{},
|
|
wantApproved: []netip.Prefix{},
|
|
wantChanged: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
for i, pmf := range pmfs {
|
|
t.Run(fmt.Sprintf("%s-policy-index%d", tt.name, i), func(t *testing.T) {
|
|
// Create test user
|
|
user := types.User{
|
|
Model: gorm.Model{ID: 1},
|
|
Name: "test",
|
|
}
|
|
users := []types.User{user}
|
|
|
|
// Create test node
|
|
node := types.Node{
|
|
ID: 1,
|
|
MachineKey: key.NewMachine().Public(),
|
|
NodeKey: key.NewNode().Public(),
|
|
Hostname: "testnode",
|
|
UserID: user.ID,
|
|
User: user,
|
|
RegisterMethod: util.RegisterMethodAuthKey,
|
|
IPv4: ptr.To(netip.MustParseAddr("100.64.0.1")),
|
|
ApprovedRoutes: tt.currentApproved,
|
|
}
|
|
nodes := types.Nodes{&node}
|
|
|
|
// Create policy manager or use nil if specified
|
|
var pm PolicyManager
|
|
var err error
|
|
if tt.name != "nil_policy_manager" {
|
|
pm, err = pmf(users, nodes.ViewSlice())
|
|
assert.NoError(t, err)
|
|
} else {
|
|
pm = nil
|
|
}
|
|
|
|
gotApproved, gotChanged := ApproveRoutesWithPolicy(pm, node.View(), tt.currentApproved, tt.announcedRoutes)
|
|
|
|
assert.Equal(t, tt.wantChanged, gotChanged, "changed flag mismatch")
|
|
|
|
// Handle nil vs empty slice comparison
|
|
if tt.wantApproved == nil {
|
|
assert.Nil(t, gotApproved, "expected nil approved routes")
|
|
} else {
|
|
tsaddr.SortPrefixes(tt.wantApproved)
|
|
assert.Equal(t, tt.wantApproved, gotApproved, "approved routes mismatch")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|