mirror of
https://github.com/juanfont/headscale.git
synced 2026-04-23 09:08:44 +02:00
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
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: ptr.To(user.ID),
|
|
User: ptr.To(user),
|
|
RegisterMethod: util.RegisterMethodAuthKey,
|
|
Hostinfo: &tailcfg.Hostinfo{
|
|
RoutableIPs: tt.announcedRoutes,
|
|
},
|
|
IPv4: ptr.To(netip.MustParseAddr("100.64.0.1")),
|
|
ApprovedRoutes: tt.currentApproved,
|
|
Tags: 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: ptr.To(user.ID),
|
|
User: ptr.To(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: ptr.To(user.ID),
|
|
User: ptr.To(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)
|
|
}
|