mirror of
https://github.com/juanfont/headscale.git
synced 2025-09-25 17:51:11 +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>
362 lines
10 KiB
Go
362 lines
10 KiB
Go
package policy
|
|
|
|
import (
|
|
"fmt"
|
|
"net/netip"
|
|
"testing"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
"github.com/juanfont/headscale/hscontrol/types"
|
|
"github.com/juanfont/headscale/hscontrol/util"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/gorm"
|
|
"tailscale.com/tailcfg"
|
|
"tailscale.com/types/key"
|
|
"tailscale.com/types/ptr"
|
|
)
|
|
|
|
func TestApproveRoutesWithPolicy_NeverRemovesRoutes(t *testing.T) {
|
|
// Test policy that allows specific routes to be auto-approved
|
|
aclPolicy := `
|
|
{
|
|
"groups": {
|
|
"group:admins": ["test@"],
|
|
},
|
|
"acls": [
|
|
{"action": "accept", "src": ["*"], "dst": ["*:*"]},
|
|
],
|
|
"autoApprovers": {
|
|
"routes": {
|
|
"10.0.0.0/24": ["test@"],
|
|
"192.168.0.0/24": ["group:admins"],
|
|
"172.16.0.0/16": ["tag:approved"],
|
|
},
|
|
},
|
|
"tagOwners": {
|
|
"tag:approved": ["test@"],
|
|
},
|
|
}`
|
|
|
|
tests := []struct {
|
|
name string
|
|
currentApproved []netip.Prefix
|
|
announcedRoutes []netip.Prefix
|
|
nodeHostname string
|
|
nodeUser string
|
|
nodeTags []string
|
|
wantApproved []netip.Prefix
|
|
wantChanged bool
|
|
wantRemovedRoutes []netip.Prefix // Routes that should NOT be in the result
|
|
}{
|
|
{
|
|
name: "previously_approved_route_no_longer_advertised_remains",
|
|
currentApproved: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.0.0/24"),
|
|
netip.MustParsePrefix("192.168.0.0/24"),
|
|
},
|
|
announcedRoutes: []netip.Prefix{
|
|
netip.MustParsePrefix("192.168.0.0/24"), // Only this one still advertised
|
|
},
|
|
nodeUser: "test",
|
|
wantApproved: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.0.0/24"), // Should remain!
|
|
netip.MustParsePrefix("192.168.0.0/24"),
|
|
},
|
|
wantChanged: false,
|
|
wantRemovedRoutes: []netip.Prefix{}, // Nothing should be removed
|
|
},
|
|
{
|
|
name: "add_new_auto_approved_route_keeps_existing",
|
|
currentApproved: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.0.0/24"),
|
|
},
|
|
announcedRoutes: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.0.0/24"), // Still advertised
|
|
netip.MustParsePrefix("192.168.0.0/24"), // New route
|
|
},
|
|
nodeUser: "test",
|
|
wantApproved: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.0.0/24"),
|
|
netip.MustParsePrefix("192.168.0.0/24"), // Auto-approved via group
|
|
},
|
|
wantChanged: true,
|
|
},
|
|
{
|
|
name: "no_announced_routes_keeps_all_approved",
|
|
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 anymore
|
|
nodeUser: "test",
|
|
wantApproved: []netip.Prefix{
|
|
netip.MustParsePrefix("172.16.0.0/16"),
|
|
netip.MustParsePrefix("10.0.0.0/24"),
|
|
netip.MustParsePrefix("192.168.0.0/24"),
|
|
},
|
|
wantChanged: false,
|
|
},
|
|
{
|
|
name: "manually_approved_route_not_in_policy_remains",
|
|
currentApproved: []netip.Prefix{
|
|
netip.MustParsePrefix("203.0.113.0/24"), // Not in auto-approvers
|
|
},
|
|
announcedRoutes: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.0.0/24"), // Can be auto-approved
|
|
},
|
|
nodeUser: "test",
|
|
wantApproved: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.0.0/24"), // New auto-approved
|
|
netip.MustParsePrefix("203.0.113.0/24"), // Manual approval preserved
|
|
},
|
|
wantChanged: true,
|
|
},
|
|
{
|
|
name: "tagged_node_gets_tag_approved_routes",
|
|
currentApproved: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.0.0/24"),
|
|
},
|
|
announcedRoutes: []netip.Prefix{
|
|
netip.MustParsePrefix("172.16.0.0/16"), // Tag-approved route
|
|
},
|
|
nodeUser: "test",
|
|
nodeTags: []string{"tag:approved"},
|
|
wantApproved: []netip.Prefix{
|
|
netip.MustParsePrefix("172.16.0.0/16"), // New tag-approved
|
|
netip.MustParsePrefix("10.0.0.0/24"), // Previous approval preserved
|
|
},
|
|
wantChanged: true,
|
|
},
|
|
{
|
|
name: "complex_scenario_multiple_changes",
|
|
currentApproved: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.0.0/24"), // Will not be advertised
|
|
netip.MustParsePrefix("203.0.113.0/24"), // Manual, not advertised
|
|
},
|
|
announcedRoutes: []netip.Prefix{
|
|
netip.MustParsePrefix("192.168.0.0/24"), // New, auto-approvable
|
|
netip.MustParsePrefix("172.16.0.0/16"), // New, not approvable (no tag)
|
|
netip.MustParsePrefix("198.51.100.0/24"), // New, not in policy
|
|
},
|
|
nodeUser: "test",
|
|
wantApproved: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.0.0/24"), // Kept despite not advertised
|
|
netip.MustParsePrefix("192.168.0.0/24"), // New auto-approved
|
|
netip.MustParsePrefix("203.0.113.0/24"), // Kept despite not advertised
|
|
},
|
|
wantChanged: true,
|
|
},
|
|
}
|
|
|
|
pmfs := PolicyManagerFuncsForTest([]byte(aclPolicy))
|
|
|
|
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: tt.nodeUser,
|
|
}
|
|
users := []types.User{user}
|
|
|
|
// Create test node
|
|
node := types.Node{
|
|
ID: 1,
|
|
MachineKey: key.NewMachine().Public(),
|
|
NodeKey: key.NewNode().Public(),
|
|
Hostname: tt.nodeHostname,
|
|
UserID: user.ID,
|
|
User: user,
|
|
RegisterMethod: util.RegisterMethodAuthKey,
|
|
Hostinfo: &tailcfg.Hostinfo{
|
|
RoutableIPs: tt.announcedRoutes,
|
|
},
|
|
IPv4: ptr.To(netip.MustParseAddr("100.64.0.1")),
|
|
ApprovedRoutes: tt.currentApproved,
|
|
ForcedTags: tt.nodeTags,
|
|
}
|
|
nodes := types.Nodes{&node}
|
|
|
|
// Create policy manager
|
|
pm, err := pmf(users, nodes.ViewSlice())
|
|
require.NoError(t, err)
|
|
require.NotNil(t, pm)
|
|
|
|
// Test ApproveRoutesWithPolicy
|
|
gotApproved, gotChanged := ApproveRoutesWithPolicy(
|
|
pm,
|
|
node.View(),
|
|
tt.currentApproved,
|
|
tt.announcedRoutes,
|
|
)
|
|
|
|
// Check change flag
|
|
assert.Equal(t, tt.wantChanged, gotChanged, "change flag mismatch")
|
|
|
|
// Check approved routes match expected
|
|
if diff := cmp.Diff(tt.wantApproved, gotApproved, util.Comparers...); diff != "" {
|
|
t.Logf("Want: %v", tt.wantApproved)
|
|
t.Logf("Got: %v", gotApproved)
|
|
t.Errorf("unexpected approved routes (-want +got):\n%s", diff)
|
|
}
|
|
|
|
// Verify 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)
|
|
}
|
|
|
|
// Verify no routes were incorrectly removed
|
|
for _, removedRoute := range tt.wantRemovedRoutes {
|
|
assert.NotContains(t, gotApproved, removedRoute,
|
|
"route %s should have been removed but wasn't", removedRoute)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestApproveRoutesWithPolicy_EdgeCases(t *testing.T) {
|
|
aclPolicy := `
|
|
{
|
|
"acls": [
|
|
{"action": "accept", "src": ["*"], "dst": ["*:*"]},
|
|
],
|
|
"autoApprovers": {
|
|
"routes": {
|
|
"10.0.0.0/8": ["test@"],
|
|
},
|
|
},
|
|
}`
|
|
|
|
tests := []struct {
|
|
name string
|
|
currentApproved []netip.Prefix
|
|
announcedRoutes []netip.Prefix
|
|
wantApproved []netip.Prefix
|
|
wantChanged bool
|
|
}{
|
|
{
|
|
name: "nil_current_approved",
|
|
currentApproved: nil,
|
|
announcedRoutes: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.0.0/24"),
|
|
},
|
|
wantApproved: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.0.0/24"),
|
|
},
|
|
wantChanged: true,
|
|
},
|
|
{
|
|
name: "empty_current_approved",
|
|
currentApproved: []netip.Prefix{},
|
|
announcedRoutes: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.0.0/24"),
|
|
},
|
|
wantApproved: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.0.0/24"),
|
|
},
|
|
wantChanged: true,
|
|
},
|
|
{
|
|
name: "duplicate_routes_handled",
|
|
currentApproved: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.0.0/24"),
|
|
netip.MustParsePrefix("10.0.0.0/24"), // Duplicate
|
|
},
|
|
announcedRoutes: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.0.0/24"),
|
|
},
|
|
wantApproved: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.0.0/24"),
|
|
},
|
|
wantChanged: true, // Duplicates are removed, so it's a change
|
|
},
|
|
}
|
|
|
|
pmfs := PolicyManagerFuncsForTest([]byte(aclPolicy))
|
|
|
|
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}
|
|
|
|
node := types.Node{
|
|
ID: 1,
|
|
MachineKey: key.NewMachine().Public(),
|
|
NodeKey: key.NewNode().Public(),
|
|
Hostname: "testnode",
|
|
UserID: user.ID,
|
|
User: user,
|
|
RegisterMethod: util.RegisterMethodAuthKey,
|
|
Hostinfo: &tailcfg.Hostinfo{
|
|
RoutableIPs: tt.announcedRoutes,
|
|
},
|
|
IPv4: ptr.To(netip.MustParseAddr("100.64.0.1")),
|
|
ApprovedRoutes: tt.currentApproved,
|
|
}
|
|
nodes := types.Nodes{&node}
|
|
|
|
pm, err := pmf(users, nodes.ViewSlice())
|
|
require.NoError(t, err)
|
|
|
|
gotApproved, gotChanged := ApproveRoutesWithPolicy(
|
|
pm,
|
|
node.View(),
|
|
tt.currentApproved,
|
|
tt.announcedRoutes,
|
|
)
|
|
|
|
assert.Equal(t, tt.wantChanged, gotChanged)
|
|
|
|
if diff := cmp.Diff(tt.wantApproved, gotApproved, util.Comparers...); diff != "" {
|
|
t.Errorf("unexpected approved routes (-want +got):\n%s", diff)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestApproveRoutesWithPolicy_NilPolicyManagerCase(t *testing.T) {
|
|
user := types.User{
|
|
Model: gorm.Model{ID: 1},
|
|
Name: "test",
|
|
}
|
|
|
|
currentApproved := []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.0.0/24"),
|
|
}
|
|
announcedRoutes := []netip.Prefix{
|
|
netip.MustParsePrefix("192.168.0.0/24"),
|
|
}
|
|
|
|
node := types.Node{
|
|
ID: 1,
|
|
MachineKey: key.NewMachine().Public(),
|
|
NodeKey: key.NewNode().Public(),
|
|
Hostname: "testnode",
|
|
UserID: user.ID,
|
|
User: user,
|
|
RegisterMethod: util.RegisterMethodAuthKey,
|
|
Hostinfo: &tailcfg.Hostinfo{
|
|
RoutableIPs: announcedRoutes,
|
|
},
|
|
IPv4: ptr.To(netip.MustParseAddr("100.64.0.1")),
|
|
ApprovedRoutes: currentApproved,
|
|
}
|
|
|
|
// With nil policy manager, should return current approved unchanged
|
|
gotApproved, gotChanged := ApproveRoutesWithPolicy(nil, node.View(), currentApproved, announcedRoutes)
|
|
|
|
assert.False(t, gotChanged)
|
|
assert.Equal(t, currentApproved, gotApproved)
|
|
}
|