policy/v2: parse, validate, and compile nodeAttrs
ACL policies now accept a top-level nodeAttrs block. Each entry hands a list of tailcfg node capabilities to every node matching target. Accepted target forms are the same as acls.src and grants.src: users, groups, tags, hosts, prefixes, autogroup:member, autogroup:tagged, and *. autogroup:self, autogroup:internet, and autogroup:danger-all are rejected at validate time because none describes a stable identity set a node-level attribute can attach to. NodeAttrGrant carries Targets, Attrs, and IPPool. IPPool is parsed but rejected at validate time -- the allocator that consumes it is not yet implemented. nodeAttrUnsupportedCaps lists caps SaaS accepts that headscale cannot act on (funnel today) and rejects them with a tracking-issue link in the error. compileNodeAttrs resolves each entry's targets, then maps every targeted node to a tailcfg.NodeCapMap of the entry's attrs. Per-node IPs are cached once per call so the inner attr loop is O(grants) instead of O(grants * nodes) IP allocations. PolicyManager grows NodeCapMap (per-node), NodeCapMaps (snapshot for batched callers), and NodesWithChangedCapMap (drain buffer for the self-broadcast diff). refreshNodeAttrsLocked appends to the drain rather than overwriting so a SetUsers/SetNodes between SetPolicy and the drain cannot lose the policy-reload diff.
This commit is contained in:
parent
c4ab267c36
commit
a4f05b0962
5 changed files with 769 additions and 8 deletions
357
hscontrol/policy/v2/nodeattrs_test.go
Normal file
357
hscontrol/policy/v2/nodeattrs_test.go
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
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"
|
||||
"gorm.io/gorm"
|
||||
"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{
|
||||
{Model: gorm.Model{ID: 1}, Name: "alice", Email: "alice@example.com"},
|
||||
{Model: gorm.Model{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")
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue