mirror of
https://github.com/juanfont/headscale.git
synced 2026-09-14 04:22:01 +09:00
2c76d5c5b7
Go 1.27 allows setting promoted fields of embedded structs directly in composite literals, so gorm.Model wrappers go away. Plus strings.Cut, errors.AsType, reflect.TypeAssert and one gofumpt nit.
357 lines
10 KiB
Go
357 lines
10 KiB
Go
package v2
|
|
|
|
import (
|
|
"net/netip"
|
|
"slices"
|
|
"testing"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
"github.com/google/go-cmp/cmp/cmpopts"
|
|
"github.com/juanfont/headscale/hscontrol/types"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"tailscale.com/tailcfg"
|
|
)
|
|
|
|
// nodeAttrsTestUsers returns a minimal user set: two passkey-style users on
|
|
// different domains, mirroring the production multi-domain shape so user-target
|
|
// resolution is exercised across both.
|
|
func nodeAttrsTestUsers() types.Users {
|
|
return types.Users{
|
|
{ID: 1, Name: "alice", Email: "alice@example.com"},
|
|
{ID: 2, Name: "bob", Email: "bob@example.org"},
|
|
}
|
|
}
|
|
|
|
// nodeAttrsTestNodes returns a fixed mix of user-owned and tagged nodes used
|
|
// by every nodeAttrs unit test. Two user-owned nodes (one per user) and three
|
|
// tagged nodes (server, client, prod) so target resolution can be exercised
|
|
// across user, group, tag, autogroup, and wildcard alias forms.
|
|
func nodeAttrsTestNodes(users types.Users) types.Nodes {
|
|
return types.Nodes{
|
|
{
|
|
ID: 1,
|
|
GivenName: "alice-laptop",
|
|
User: &users[0],
|
|
UserID: &users[0].ID,
|
|
IPv4: ptrAddr("100.64.0.1"),
|
|
IPv6: ptrAddr("fd7a:115c:a1e0::1"),
|
|
Hostinfo: &tailcfg.Hostinfo{},
|
|
},
|
|
{
|
|
ID: 2,
|
|
GivenName: "bob-laptop",
|
|
User: &users[1],
|
|
UserID: &users[1].ID,
|
|
IPv4: ptrAddr("100.64.0.2"),
|
|
IPv6: ptrAddr("fd7a:115c:a1e0::2"),
|
|
Hostinfo: &tailcfg.Hostinfo{},
|
|
},
|
|
{
|
|
ID: 3,
|
|
GivenName: "server",
|
|
Tags: []string{"tag:server"},
|
|
IPv4: ptrAddr("100.64.0.3"),
|
|
IPv6: ptrAddr("fd7a:115c:a1e0::3"),
|
|
Hostinfo: &tailcfg.Hostinfo{},
|
|
},
|
|
{
|
|
ID: 4,
|
|
GivenName: "client",
|
|
Tags: []string{"tag:client"},
|
|
IPv4: ptrAddr("100.64.0.4"),
|
|
IPv6: ptrAddr("fd7a:115c:a1e0::4"),
|
|
Hostinfo: &tailcfg.Hostinfo{},
|
|
},
|
|
{
|
|
ID: 5,
|
|
GivenName: "prod",
|
|
Tags: []string{"tag:prod"},
|
|
IPv4: ptrAddr("100.64.0.5"),
|
|
IPv6: ptrAddr("fd7a:115c:a1e0::5"),
|
|
Hostinfo: &tailcfg.Hostinfo{},
|
|
},
|
|
}
|
|
}
|
|
|
|
const nodeAttrsTagOwners = `"tag:server": ["alice@example.com"],
|
|
"tag:client": ["alice@example.com"],
|
|
"tag:prod": ["alice@example.com"]`
|
|
|
|
func TestNodeAttrsCompile(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
capMap := func(c tailcfg.NodeCapability) tailcfg.NodeCapMap {
|
|
return tailcfg.NodeCapMap{c: nil}
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
// extra is appended inside the policy block alongside tagOwners.
|
|
extra string
|
|
want map[types.NodeID]tailcfg.NodeCapMap
|
|
}{
|
|
{
|
|
name: "wildcard target hits every node",
|
|
extra: `"nodeAttrs": [{"target": ["*"], "attr": ["randomize-client-port"]}]`,
|
|
want: map[types.NodeID]tailcfg.NodeCapMap{
|
|
1: capMap(tailcfg.NodeAttrRandomizeClientPort),
|
|
2: capMap(tailcfg.NodeAttrRandomizeClientPort),
|
|
3: capMap(tailcfg.NodeAttrRandomizeClientPort),
|
|
4: capMap(tailcfg.NodeAttrRandomizeClientPort),
|
|
5: capMap(tailcfg.NodeAttrRandomizeClientPort),
|
|
},
|
|
},
|
|
{
|
|
name: "user target hits only that user's untagged nodes",
|
|
extra: `"nodeAttrs": [{"target": ["alice@example.com"], "attr": ["randomize-client-port"]}]`,
|
|
want: map[types.NodeID]tailcfg.NodeCapMap{
|
|
1: capMap(tailcfg.NodeAttrRandomizeClientPort),
|
|
},
|
|
},
|
|
{
|
|
name: "tag target hits only matching tagged nodes",
|
|
extra: `"nodeAttrs": [{"target": ["tag:server"], "attr": ["drive:share", "drive:access"]}]`,
|
|
want: map[types.NodeID]tailcfg.NodeCapMap{
|
|
3: {
|
|
tailcfg.NodeAttrsTaildriveShare: nil,
|
|
tailcfg.NodeAttrsTaildriveAccess: nil,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "autogroup:member hits untagged nodes only",
|
|
extra: `"nodeAttrs": [{"target": ["autogroup:member"], "attr": ["randomize-client-port"]}]`,
|
|
want: map[types.NodeID]tailcfg.NodeCapMap{
|
|
1: capMap(tailcfg.NodeAttrRandomizeClientPort),
|
|
2: capMap(tailcfg.NodeAttrRandomizeClientPort),
|
|
},
|
|
},
|
|
{
|
|
name: "autogroup:tagged hits tagged nodes only",
|
|
extra: `"nodeAttrs": [{"target": ["autogroup:tagged"], "attr": ["disable-captive-portal-detection"]}]`,
|
|
want: map[types.NodeID]tailcfg.NodeCapMap{
|
|
3: capMap(tailcfg.NodeAttrDisableCaptivePortalDetection),
|
|
4: capMap(tailcfg.NodeAttrDisableCaptivePortalDetection),
|
|
5: capMap(tailcfg.NodeAttrDisableCaptivePortalDetection),
|
|
},
|
|
},
|
|
{
|
|
name: "merging two grants on overlapping targets unions attrs",
|
|
extra: `"nodeAttrs": [
|
|
{"target": ["*"], "attr": ["drive:access"]},
|
|
{"target": ["tag:server"], "attr": ["drive:share"]}
|
|
]`,
|
|
want: map[types.NodeID]tailcfg.NodeCapMap{
|
|
1: capMap(tailcfg.NodeAttrsTaildriveAccess),
|
|
2: capMap(tailcfg.NodeAttrsTaildriveAccess),
|
|
3: {
|
|
tailcfg.NodeAttrsTaildriveAccess: nil,
|
|
tailcfg.NodeAttrsTaildriveShare: nil,
|
|
},
|
|
4: capMap(tailcfg.NodeAttrsTaildriveAccess),
|
|
5: capMap(tailcfg.NodeAttrsTaildriveAccess),
|
|
},
|
|
},
|
|
{
|
|
name: "empty entry compiles to nothing",
|
|
extra: `"nodeAttrs": [{"target": ["*"]}]`,
|
|
want: nil,
|
|
},
|
|
{
|
|
name: "top-level randomizeClientPort stamps every node",
|
|
extra: `"randomizeClientPort": true`,
|
|
want: map[types.NodeID]tailcfg.NodeCapMap{
|
|
1: capMap(tailcfg.NodeAttrRandomizeClientPort),
|
|
2: capMap(tailcfg.NodeAttrRandomizeClientPort),
|
|
3: capMap(tailcfg.NodeAttrRandomizeClientPort),
|
|
4: capMap(tailcfg.NodeAttrRandomizeClientPort),
|
|
5: capMap(tailcfg.NodeAttrRandomizeClientPort),
|
|
},
|
|
},
|
|
{
|
|
name: "global randomize plus per-tag entry merges",
|
|
extra: `"randomizeClientPort": true,
|
|
"nodeAttrs": [{"target": ["tag:server"], "attr": ["disable-captive-portal-detection"]}]`,
|
|
want: map[types.NodeID]tailcfg.NodeCapMap{
|
|
1: capMap(tailcfg.NodeAttrRandomizeClientPort),
|
|
2: capMap(tailcfg.NodeAttrRandomizeClientPort),
|
|
3: {
|
|
tailcfg.NodeAttrRandomizeClientPort: nil,
|
|
tailcfg.NodeAttrDisableCaptivePortalDetection: nil,
|
|
},
|
|
4: capMap(tailcfg.NodeAttrRandomizeClientPort),
|
|
5: capMap(tailcfg.NodeAttrRandomizeClientPort),
|
|
},
|
|
},
|
|
}
|
|
|
|
users := nodeAttrsTestUsers()
|
|
nodes := nodeAttrsTestNodes(users)
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
policy := `{
|
|
"tagOwners": {` + nodeAttrsTagOwners + `},
|
|
` + tt.extra + `
|
|
}`
|
|
|
|
pm, err := NewPolicyManager([]byte(policy), users, nodes.ViewSlice())
|
|
require.NoErrorf(t, err, "policy must parse and validate:\n%s", policy)
|
|
|
|
got, err := pm.pol.compileNodeAttrs(users, pm.nodes)
|
|
require.NoError(t, err)
|
|
|
|
if diff := cmp.Diff(tt.want, got, cmpopts.EquateEmpty()); diff != "" {
|
|
t.Errorf("compileNodeAttrs (-want +got):\n%s", diff)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNodeAttrsValidate(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
extra string
|
|
wantErr error
|
|
}{
|
|
{
|
|
name: "autogroup:self target rejected",
|
|
extra: `"nodeAttrs": [{"target": ["autogroup:self"], "attr": ["randomize-client-port"]}]`,
|
|
wantErr: ErrNodeAttrsAutogroupNotAllowed,
|
|
},
|
|
{
|
|
name: "autogroup:admin target rejected with user-role hint",
|
|
extra: `"nodeAttrs": [{"target": ["autogroup:admin"], "attr": ["randomize-client-port"]}]`,
|
|
wantErr: ErrNodeAttrsAutogroupNotAllowed,
|
|
},
|
|
{
|
|
name: "autogroup:owner target rejected with user-role hint",
|
|
extra: `"nodeAttrs": [{"target": ["autogroup:owner"], "attr": ["randomize-client-port"]}]`,
|
|
wantErr: ErrNodeAttrsAutogroupNotAllowed,
|
|
},
|
|
{
|
|
name: "funnel attr rejected as unsupported",
|
|
extra: `"nodeAttrs": [{"target": ["*"], "attr": ["funnel"]}]`,
|
|
wantErr: ErrNodeAttrUnsupported,
|
|
},
|
|
{
|
|
name: "ipPool set rejected as unsupported",
|
|
extra: `"nodeAttrs": [{"target": ["autogroup:member"], "ipPool": ["100.81.0.0/16"]}]`,
|
|
wantErr: ErrNodeAttrIPPoolUnsupported,
|
|
},
|
|
{
|
|
name: "ipPool overlapping reserved range rejected at validate",
|
|
extra: `"nodeAttrs": [{"target": ["autogroup:member"], "ipPool": ["100.100.100.0/24"]}]`,
|
|
wantErr: ErrNodeAttrsIPPoolReserved,
|
|
},
|
|
}
|
|
|
|
users := nodeAttrsTestUsers()
|
|
nodes := nodeAttrsTestNodes(users)
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
policy := `{
|
|
"tagOwners": {` + nodeAttrsTagOwners + `},
|
|
` + tt.extra + `
|
|
}`
|
|
|
|
_, err := NewPolicyManager([]byte(policy), users, nodes.ViewSlice())
|
|
require.Error(t, err)
|
|
assert.ErrorIs(t, err, tt.wantErr)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNodeAttrsIPPoolValidator(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
prefix string
|
|
wantErr error
|
|
}{
|
|
{name: "in cgnat", prefix: "100.81.0.0/16"},
|
|
{name: "outside cgnat", prefix: "10.0.0.0/8", wantErr: ErrNodeAttrsIPPoolOutOfRange},
|
|
{name: "less specific than cgnat", prefix: "100.0.0.0/8", wantErr: ErrNodeAttrsIPPoolOutOfRange},
|
|
{name: "whole cgnat overlaps reserved", prefix: "100.64.0.0/10", wantErr: ErrNodeAttrsIPPoolReserved},
|
|
{name: "overlaps quad100", prefix: "100.100.100.0/24", wantErr: ErrNodeAttrsIPPoolReserved},
|
|
{name: "overlaps ipn", prefix: "100.115.92.0/24", wantErr: ErrNodeAttrsIPPoolReserved},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
err := validateNodeAttrIPPool(netip.MustParsePrefix(tt.prefix))
|
|
if tt.wantErr != nil {
|
|
require.Error(t, err)
|
|
assert.ErrorIs(t, err, tt.wantErr)
|
|
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNodesWithChangedCapMap(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
users := nodeAttrsTestUsers()
|
|
nodes := nodeAttrsTestNodes(users)
|
|
|
|
policyA := `{
|
|
"tagOwners": {` + nodeAttrsTagOwners + `},
|
|
"nodeAttrs": [{
|
|
"target": ["tag:server"],
|
|
"attr": ["randomize-client-port"]
|
|
}]
|
|
}`
|
|
|
|
pm, err := NewPolicyManager([]byte(policyA), users, nodes.ViewSlice())
|
|
require.NoError(t, err)
|
|
|
|
initial := pm.NodesWithChangedCapMap()
|
|
slices.Sort(initial)
|
|
assert.Equal(t, []types.NodeID{3}, initial,
|
|
"first build reports every node with a non-empty CapMap")
|
|
|
|
// Swap targets: server loses the attr, client and prod gain it.
|
|
policyB := `{
|
|
"tagOwners": {` + nodeAttrsTagOwners + `},
|
|
"nodeAttrs": [{
|
|
"target": ["tag:client", "tag:prod"],
|
|
"attr": ["randomize-client-port"]
|
|
}]
|
|
}`
|
|
|
|
changed, err := pm.SetPolicy([]byte(policyB))
|
|
require.NoError(t, err)
|
|
require.True(t, changed)
|
|
|
|
delta := pm.NodesWithChangedCapMap()
|
|
slices.Sort(delta)
|
|
assert.Equal(t, []types.NodeID{3, 4, 5}, delta,
|
|
"server lost the cap, client and prod gained it -- diff is the symmetric difference")
|
|
|
|
assert.Empty(t, pm.NodesWithChangedCapMap(),
|
|
"NodesWithChangedCapMap drains its buffer on read")
|
|
|
|
// Reload the same bytes. updateLocked still runs, but no node's
|
|
// CapMap hash should change.
|
|
_, err = pm.SetPolicy([]byte(policyB))
|
|
require.NoError(t, err)
|
|
|
|
assert.Empty(t, pm.NodesWithChangedCapMap(),
|
|
"reloading the same policy must not produce CapMap diffs")
|
|
}
|