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
@ -42,6 +42,28 @@ type PolicyManager interface {
|
|||||||
// both fields are empty and the caller falls back to existing behavior.
|
// both fields are empty and the caller falls back to existing behavior.
|
||||||
ViaRoutesForPeer(viewer, peer types.NodeView) types.ViaRouteResult
|
ViaRoutesForPeer(viewer, peer types.NodeView) types.ViaRouteResult
|
||||||
|
|
||||||
|
// NodeCapMap returns the policy-derived CapMap for the given node,
|
||||||
|
// or nil when no nodeAttrs entry targets it. The returned map is
|
||||||
|
// owned by the manager; treat it as read-only and copy before
|
||||||
|
// merging into a [tailcfg.Node]. It describes the node's own
|
||||||
|
// capabilities, not a per-viewer view.
|
||||||
|
NodeCapMap(id types.NodeID) tailcfg.NodeCapMap
|
||||||
|
|
||||||
|
// NodeCapMaps returns a snapshot of the per-node policy CapMap so
|
||||||
|
// callers can amortise lock acquisitions over a peer loop. The
|
||||||
|
// outer map is a fresh container; the inner [tailcfg.NodeCapMap]
|
||||||
|
// values are shared with the manager and read-only.
|
||||||
|
NodeCapMaps() map[types.NodeID]tailcfg.NodeCapMap
|
||||||
|
|
||||||
|
// NodesWithChangedCapMap returns the IDs of nodes whose nodeAttrs
|
||||||
|
// CapMap shifted during recent updateLocked calls. The buffer
|
||||||
|
// drains on read; callers consume it once per update cycle to
|
||||||
|
// decide which nodes need a self-targeted MapResponse.
|
||||||
|
// refreshNodeAttrsLocked appends to the buffer rather than
|
||||||
|
// overwriting, so a SetUsers/SetNodes between SetPolicy and the
|
||||||
|
// drain cannot lose the policy-reload diff.
|
||||||
|
NodesWithChangedCapMap() []types.NodeID
|
||||||
|
|
||||||
Version() int
|
Version() int
|
||||||
DebugString() string
|
DebugString() string
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package v2
|
package v2
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"slices"
|
"slices"
|
||||||
|
|
||||||
@ -94,6 +95,92 @@ func buildUserNodeIndex(
|
|||||||
return idx
|
return idx
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// compileNodeAttrs returns the per-node CapMap derived from policy
|
||||||
|
// nodeAttrs plus the tailnet-wide RandomizeClientPort flag.
|
||||||
|
//
|
||||||
|
// Returns an error when a target alias fails to resolve so the caller
|
||||||
|
// surfaces a corrupt policy instead of silently granting a partial set
|
||||||
|
// of attrs.
|
||||||
|
func (pol *Policy) compileNodeAttrs(
|
||||||
|
users types.Users,
|
||||||
|
nodes views.Slice[types.NodeView],
|
||||||
|
) (map[types.NodeID]tailcfg.NodeCapMap, error) {
|
||||||
|
empty := map[types.NodeID]tailcfg.NodeCapMap{}
|
||||||
|
|
||||||
|
if pol == nil {
|
||||||
|
return empty, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(pol.NodeAttrs) == 0 && !pol.RandomizeClientPort {
|
||||||
|
return empty, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make(map[types.NodeID]tailcfg.NodeCapMap)
|
||||||
|
stamp := func(id types.NodeID, attr tailcfg.NodeCapability) {
|
||||||
|
capMap, ok := result[id]
|
||||||
|
if !ok {
|
||||||
|
capMap = tailcfg.NodeCapMap{}
|
||||||
|
result[id] = capMap
|
||||||
|
}
|
||||||
|
|
||||||
|
// nil RawMessage matches the wire format from a Tailscale-hosted
|
||||||
|
// control plane: capabilities without companion data marshal as
|
||||||
|
// `null` rather than `[]`. Storing nil keeps the merge stable
|
||||||
|
// and lets the compat test diff cleanly against captured
|
||||||
|
// netmaps.
|
||||||
|
if _, exists := capMap[attr]; !exists {
|
||||||
|
capMap[attr] = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache each node's IPs once per call. Without the cache, the
|
||||||
|
// node-attr inner loop would call NodeView.IPs() once per attr
|
||||||
|
// per node — O(grants × nodes) allocations of a 2-element slice
|
||||||
|
// for what is invariant per node within a single policy compile.
|
||||||
|
type nodeIPs struct {
|
||||||
|
id types.NodeID
|
||||||
|
ips []netip.Addr
|
||||||
|
}
|
||||||
|
|
||||||
|
nodeList := make([]nodeIPs, 0, nodes.Len())
|
||||||
|
for _, n := range nodes.All() {
|
||||||
|
nodeList = append(nodeList, nodeIPs{id: n.ID(), ips: n.IPs()})
|
||||||
|
}
|
||||||
|
|
||||||
|
if pol.RandomizeClientPort {
|
||||||
|
for _, ni := range nodeList {
|
||||||
|
stamp(ni.id, tailcfg.NodeAttrRandomizeClientPort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, na := range pol.NodeAttrs {
|
||||||
|
if len(na.Attrs) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := na.Targets.Resolve(pol, users, nodes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("nodeAttrs target %s: %w", na.Targets, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resolved == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ni := range nodeList {
|
||||||
|
if !slices.ContainsFunc(ni.ips, resolved.Contains) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, attr := range na.Attrs {
|
||||||
|
stamp(ni.id, attr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
// compileGrants resolves all policy grants into compiledGrant structs.
|
// compileGrants resolves all policy grants into compiledGrant structs.
|
||||||
// Source resolution and non-self destination resolution happens once
|
// Source resolution and non-self destination resolution happens once
|
||||||
// here. This is the single resolution path that replaces the
|
// here. This is the single resolution path that replaces the
|
||||||
|
|||||||
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")
|
||||||
|
}
|
||||||
@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
@ -64,6 +65,16 @@ type PolicyManager struct {
|
|||||||
// needsPerNodeFilter is true when any compiled grant requires
|
// needsPerNodeFilter is true when any compiled grant requires
|
||||||
// per-node work (autogroup:self or via grants).
|
// per-node work (autogroup:self or via grants).
|
||||||
needsPerNodeFilter bool
|
needsPerNodeFilter bool
|
||||||
|
|
||||||
|
// nodeAttrsMap is the per-node CapMap compiled from policy.NodeAttrs.
|
||||||
|
// nodeAttrsHashes shadow it for change detection between updateLocked
|
||||||
|
// runs. nodeAttrsChanged accumulates the union of all per-call diffs
|
||||||
|
// since the last drain — refresh APPENDS, never overwrites, so a
|
||||||
|
// concurrent SetUsers/SetNodes between SetPolicy and the drain
|
||||||
|
// cannot silently lose the policy-reload diff.
|
||||||
|
nodeAttrsMap map[types.NodeID]tailcfg.NodeCapMap
|
||||||
|
nodeAttrsHashes map[types.NodeID]deephash.Sum
|
||||||
|
nodeAttrsChanged []types.NodeID
|
||||||
}
|
}
|
||||||
|
|
||||||
// filterAndPolicy combines the compiled filter rules with policy content for hashing.
|
// filterAndPolicy combines the compiled filter rules with policy content for hashing.
|
||||||
@ -298,6 +309,16 @@ func (pm *PolicyManager) updateLocked() (bool, error) {
|
|||||||
pm.exitSet = exitSet
|
pm.exitSet = exitSet
|
||||||
pm.exitSetHash = exitSetHash
|
pm.exitSetHash = exitSetHash
|
||||||
|
|
||||||
|
// Recompile per-node nodeAttrs CapMap and append the diff to
|
||||||
|
// pm.nodeAttrsChanged. The drain (NodesWithChangedCapMap) returns
|
||||||
|
// the accumulated union of every change since the last drain;
|
||||||
|
// SetUsers/SetNodes appending between SetPolicy and the drain
|
||||||
|
// cannot lose the policy-reload diff.
|
||||||
|
err = pm.refreshNodeAttrsLocked()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
// Determine if we need to send updates to nodes
|
// Determine if we need to send updates to nodes
|
||||||
// filterChanged now includes policy content changes (via combined hash),
|
// filterChanged now includes policy content changes (via combined hash),
|
||||||
// so it will detect changes even for autogroup:self where compiled filter is empty
|
// so it will detect changes even for autogroup:self where compiled filter is empty
|
||||||
@ -473,6 +494,7 @@ func (pm *PolicyManager) SetPolicy(polB []byte) (bool, error) {
|
|||||||
Int("groups.count", len(pol.Groups)).
|
Int("groups.count", len(pol.Groups)).
|
||||||
Int("hosts.count", len(pol.Hosts)).
|
Int("hosts.count", len(pol.Hosts)).
|
||||||
Int("tagOwners.count", len(pol.TagOwners)).
|
Int("tagOwners.count", len(pol.TagOwners)).
|
||||||
|
Int("nodeAttrs.count", len(pol.NodeAttrs)).
|
||||||
Int("autoApprovers.routes.count", len(pol.AutoApprovers.Routes)).
|
Int("autoApprovers.routes.count", len(pol.AutoApprovers.Routes)).
|
||||||
Int("tests.count", len(pol.Tests)).
|
Int("tests.count", len(pol.Tests)).
|
||||||
Msg("Policy parsed successfully")
|
Msg("Policy parsed successfully")
|
||||||
@ -1571,3 +1593,125 @@ func resolveTagOwners(p *Policy, users types.Users, nodes views.Slice[types.Node
|
|||||||
|
|
||||||
return ret, nil
|
return ret, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// refreshNodeAttrsLocked recompiles the per-node nodeAttrs CapMap and
|
||||||
|
// appends the IDs whose CapMap differs from the previous snapshot
|
||||||
|
// (including newly-targeted nodes and nodes that lost all attrs) to
|
||||||
|
// pm.nodeAttrsChanged. Append, not overwrite: a concurrent
|
||||||
|
// SetUsers/SetNodes between SetPolicy and a NodesWithChangedCapMap
|
||||||
|
// drain cannot clobber the policy-reload diff.
|
||||||
|
//
|
||||||
|
// Caller must hold pm.mu.
|
||||||
|
func (pm *PolicyManager) refreshNodeAttrsLocked() error {
|
||||||
|
// Fast path for the common steady-state shape: tailnet has no
|
||||||
|
// nodeAttrs entries and never had any. Skip the compile + per-node
|
||||||
|
// hash walk entirely. As soon as the operator adds a nodeAttrs
|
||||||
|
// entry pm.nodeAttrsHashes becomes non-empty and the gate opens.
|
||||||
|
if pm.pol != nil &&
|
||||||
|
len(pm.pol.NodeAttrs) == 0 &&
|
||||||
|
!pm.pol.RandomizeClientPort &&
|
||||||
|
len(pm.nodeAttrsHashes) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
newMap, err := pm.pol.compileNodeAttrs(pm.users, pm.nodes)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("compiling nodeAttrs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
newHashes := make(map[types.NodeID]deephash.Sum, len(newMap))
|
||||||
|
for id, capMap := range newMap {
|
||||||
|
newHashes[id] = deephash.Hash(&capMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk the union of old and new node IDs and emit the delta.
|
||||||
|
seen := make(map[types.NodeID]struct{}, len(newHashes)+len(pm.nodeAttrsHashes))
|
||||||
|
|
||||||
|
var changed []types.NodeID
|
||||||
|
|
||||||
|
for id, h := range newHashes {
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
if pm.nodeAttrsHashes[id] != h {
|
||||||
|
changed = append(changed, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for id := range pm.nodeAttrsHashes {
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Node lost all nodeAttrs since the last update.
|
||||||
|
changed = append(changed, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pm.nodeAttrsMap = newMap
|
||||||
|
pm.nodeAttrsHashes = newHashes
|
||||||
|
pm.nodeAttrsChanged = append(pm.nodeAttrsChanged, changed...)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NodeCapMap returns the policy-derived CapMap for the given node, or
|
||||||
|
// nil when the node has no nodeAttrs entries that target it. The
|
||||||
|
// returned map is a defensive clone — caller mutations cannot reach
|
||||||
|
// the manager-owned cache.
|
||||||
|
func (pm *PolicyManager) NodeCapMap(id types.NodeID) tailcfg.NodeCapMap {
|
||||||
|
if pm == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
pm.mu.Lock()
|
||||||
|
defer pm.mu.Unlock()
|
||||||
|
|
||||||
|
src := pm.nodeAttrsMap[id]
|
||||||
|
if len(src) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make(tailcfg.NodeCapMap, len(src))
|
||||||
|
maps.Copy(out, src)
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// NodeCapMaps returns a snapshot of the per-node policy CapMap. The
|
||||||
|
// mapper calls this once per request to amortise lock acquisitions
|
||||||
|
// over a peer-loop instead of taking the lock per peer. The returned
|
||||||
|
// map is a fresh container; the inner [tailcfg.NodeCapMap] values are
|
||||||
|
// shared with the manager and must be treated as read-only.
|
||||||
|
func (pm *PolicyManager) NodeCapMaps() map[types.NodeID]tailcfg.NodeCapMap {
|
||||||
|
if pm == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
pm.mu.Lock()
|
||||||
|
defer pm.mu.Unlock()
|
||||||
|
|
||||||
|
out := make(map[types.NodeID]tailcfg.NodeCapMap, len(pm.nodeAttrsMap))
|
||||||
|
maps.Copy(out, pm.nodeAttrsMap)
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// NodesWithChangedCapMap returns the IDs of nodes whose nodeAttrs
|
||||||
|
// CapMap shifted across one or more updateLocked calls since the
|
||||||
|
// last drain. The buffer drains on return. The mapper calls this
|
||||||
|
// once per ReloadPolicy to decide which nodes need a SelfUpdate.
|
||||||
|
//
|
||||||
|
// refreshNodeAttrsLocked APPENDS to the buffer; the drain returns
|
||||||
|
// the union of every change since the previous read. A concurrent
|
||||||
|
// SetUsers/SetNodes between SetPolicy and a drain cannot silently
|
||||||
|
// lose the policy-reload diff.
|
||||||
|
func (pm *PolicyManager) NodesWithChangedCapMap() []types.NodeID {
|
||||||
|
if pm == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
pm.mu.Lock()
|
||||||
|
defer pm.mu.Unlock()
|
||||||
|
|
||||||
|
out := pm.nodeAttrsChanged
|
||||||
|
pm.nodeAttrsChanged = nil
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@ -77,6 +77,26 @@ var (
|
|||||||
ErrGrantDefaultRouteCIDR = errors.New("to allow all IP addresses, use \"*\" or \"autogroup:internet\"")
|
ErrGrantDefaultRouteCIDR = errors.New("to allow all IP addresses, use \"*\" or \"autogroup:internet\"")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// NodeAttrs validation errors.
|
||||||
|
var (
|
||||||
|
ErrNodeAttrsIPPoolReserved = errors.New("nodeAttrs ipPool must not overlap reserved Tailscale ranges")
|
||||||
|
ErrNodeAttrsIPPoolOutOfRange = errors.New("nodeAttrs ipPool must be within 100.64.0.0/10")
|
||||||
|
ErrNodeAttrsAutogroupNotAllowed = errors.New("nodeAttrs target does not support this autogroup")
|
||||||
|
ErrNodeAttrUnsupported = errors.New("nodeAttrs uses a feature headscale does not yet support")
|
||||||
|
ErrNodeAttrIPPoolUnsupported = errors.New("nodeAttrs ipPool requires the IP allocator (https://github.com/juanfont/headscale/issues/2912)")
|
||||||
|
ErrNodeAttrTargetUnsupported = errors.New("nodeAttrs target alias type is not supported")
|
||||||
|
)
|
||||||
|
|
||||||
|
// nodeAttrUnsupportedCaps lists caps that headscale parses but cannot act on
|
||||||
|
// today. Each entry maps to the tracking issue an operator can follow. The
|
||||||
|
// caps are accepted by Tailscale SaaS, but delivering them via headscale
|
||||||
|
// without the matching server-side machinery would be misleading -- nodes
|
||||||
|
// would advertise a feature that does not work. Reject at policy load and
|
||||||
|
// point operators at the issue.
|
||||||
|
var nodeAttrUnsupportedCaps = map[tailcfg.NodeCapability]string{
|
||||||
|
tailcfg.NodeAttrFunnel: "https://github.com/juanfont/headscale/issues/2527",
|
||||||
|
}
|
||||||
|
|
||||||
// Policy validation errors.
|
// Policy validation errors.
|
||||||
var (
|
var (
|
||||||
ErrUnknownAliasType = errors.New("unknown alias type")
|
ErrUnknownAliasType = errors.New("unknown alias type")
|
||||||
@ -1917,6 +1937,19 @@ type Grant struct {
|
|||||||
Via []Tag `json:"via,omitzero"`
|
Via []Tag `json:"via,omitzero"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NodeAttrGrant attaches Tailscale node capabilities (and/or an IP-pool
|
||||||
|
// preference) to every node selected by Targets. The Targets aliases are
|
||||||
|
// resolved exactly like ACL/grant sources, so users, groups, tags, hosts,
|
||||||
|
// prefixes, autogroup:member, autogroup:tagged, and "*" are all valid.
|
||||||
|
//
|
||||||
|
// IPPool is parsed and validated for forward compatibility with the IP
|
||||||
|
// allocator; the policy compiler does not consume it yet.
|
||||||
|
type NodeAttrGrant struct {
|
||||||
|
Targets Aliases `json:"target"`
|
||||||
|
Attrs []tailcfg.NodeCapability `json:"attr,omitempty"`
|
||||||
|
IPPool []netip.Prefix `json:"ipPool,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// aclToGrants converts an ACL rule to one or more equivalent Grant rules.
|
// aclToGrants converts an ACL rule to one or more equivalent Grant rules.
|
||||||
func aclToGrants(acl ACL) []Grant {
|
func aclToGrants(acl ACL) []Grant {
|
||||||
ret := make([]Grant, 0, len(acl.Destinations))
|
ret := make([]Grant, 0, len(acl.Destinations))
|
||||||
@ -1998,14 +2031,16 @@ type Policy struct {
|
|||||||
// callers using it should panic if not
|
// callers using it should panic if not
|
||||||
validated bool `json:"-"`
|
validated bool `json:"-"`
|
||||||
|
|
||||||
Groups Groups `json:"groups,omitempty"`
|
Groups Groups `json:"groups,omitempty"`
|
||||||
Hosts Hosts `json:"hosts,omitempty"`
|
Hosts Hosts `json:"hosts,omitempty"`
|
||||||
TagOwners TagOwners `json:"tagOwners,omitempty"`
|
TagOwners TagOwners `json:"tagOwners,omitempty"`
|
||||||
ACLs []ACL `json:"acls,omitempty"`
|
ACLs []ACL `json:"acls,omitempty"`
|
||||||
Grants []Grant `json:"grants,omitempty"`
|
Grants []Grant `json:"grants,omitempty"`
|
||||||
AutoApprovers AutoApproverPolicy `json:"autoApprovers"`
|
NodeAttrs []NodeAttrGrant `json:"nodeAttrs,omitempty"`
|
||||||
SSHs []SSH `json:"ssh,omitempty"`
|
AutoApprovers AutoApproverPolicy `json:"autoApprovers"`
|
||||||
Tests []PolicyTest `json:"tests,omitempty"`
|
SSHs []SSH `json:"ssh,omitempty"`
|
||||||
|
Tests []PolicyTest `json:"tests,omitempty"`
|
||||||
|
RandomizeClientPort bool `json:"randomizeClientPort,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarshalJSON is deliberately not implemented for Policy.
|
// MarshalJSON is deliberately not implemented for Policy.
|
||||||
@ -2018,11 +2053,24 @@ var (
|
|||||||
autogroupForSSHSrc = []AutoGroup{AutoGroupMember, AutoGroupTagged}
|
autogroupForSSHSrc = []AutoGroup{AutoGroupMember, AutoGroupTagged}
|
||||||
autogroupForSSHDst = []AutoGroup{AutoGroupMember, AutoGroupTagged, AutoGroupSelf}
|
autogroupForSSHDst = []AutoGroup{AutoGroupMember, AutoGroupTagged, AutoGroupSelf}
|
||||||
autogroupForSSHUser = []AutoGroup{AutoGroupNonRoot}
|
autogroupForSSHUser = []AutoGroup{AutoGroupNonRoot}
|
||||||
|
autogroupForNodeAttrs = []AutoGroup{AutoGroupMember, AutoGroupTagged}
|
||||||
autogroupNotSupported = []AutoGroup{}
|
autogroupNotSupported = []AutoGroup{}
|
||||||
|
|
||||||
errUnknownProtocolWildcard = errors.New("proto name \"*\" not known; use protocol number 0-255 or protocol name (icmp, tcp, udp, etc.)")
|
errUnknownProtocolWildcard = errors.New("proto name \"*\" not known; use protocol number 0-255 or protocol name (icmp, tcp, udp, etc.)")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// reservedTSRanges are CGNAT subranges that Tailscale uses internally and that
|
||||||
|
// nodeAttrs ipPool entries must not overlap.
|
||||||
|
//
|
||||||
|
// - 100.100.100.0/24 is MagicDNS / TSMP
|
||||||
|
// - 100.115.92.0/23 is the Quad100 / IPN service range
|
||||||
|
//
|
||||||
|
// (See https://tailscale.com/kb/1304/ip-pool for the operator-facing list.)
|
||||||
|
var reservedTSRanges = []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("100.100.100.0/24"),
|
||||||
|
netip.MustParsePrefix("100.115.92.0/23"),
|
||||||
|
}
|
||||||
|
|
||||||
func validateAutogroupSupported(ag *AutoGroup) error {
|
func validateAutogroupSupported(ag *AutoGroup) error {
|
||||||
if ag == nil {
|
if ag == nil {
|
||||||
return nil
|
return nil
|
||||||
@ -2071,6 +2119,47 @@ func validateAutogroupForDst(dst *AutoGroup) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validateAutogroupForNodeAttrs accepts only the autogroups that make sense
|
||||||
|
// as a nodeAttrs target: autogroup:member and autogroup:tagged.
|
||||||
|
// autogroup:self / autogroup:internet / autogroup:danger-all are rejected —
|
||||||
|
// none of them describes a stable identity set that a node-level attribute
|
||||||
|
// can attach to. autogroup:admin / autogroup:owner are rejected one layer
|
||||||
|
// up: AutoGroup.UnmarshalJSON returns ErrInvalidAutogroup at parse time
|
||||||
|
// because those values aren't in the allowed set, so the policy never
|
||||||
|
// reaches this validator.
|
||||||
|
func validateAutogroupForNodeAttrs(ag *AutoGroup) error {
|
||||||
|
if ag == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Contains(autogroupForNodeAttrs, *ag) {
|
||||||
|
return fmt.Errorf("%w: %q, can be %v", ErrNodeAttrsAutogroupNotAllowed, *ag, autogroupForNodeAttrs)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateNodeAttrIPPool rejects ipPool entries outside the CGNAT range or
|
||||||
|
// overlapping the Tailscale-reserved subranges (MagicDNS, Quad100/IPN). A
|
||||||
|
// prefix is considered "within" CGNAT when it is at least as specific as
|
||||||
|
// 100.64.0.0/10 and its first address lies inside it.
|
||||||
|
func validateNodeAttrIPPool(prefix netip.Prefix) error {
|
||||||
|
cgnat := tsaddr.CGNATRange()
|
||||||
|
masked := prefix.Masked()
|
||||||
|
|
||||||
|
if masked.Bits() < cgnat.Bits() || !cgnat.Contains(masked.Addr()) {
|
||||||
|
return fmt.Errorf("%w: %q", ErrNodeAttrsIPPoolOutOfRange, prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, reserved := range reservedTSRanges {
|
||||||
|
if masked.Overlaps(reserved) {
|
||||||
|
return fmt.Errorf("%w: %q overlaps %q", ErrNodeAttrsIPPoolReserved, prefix, reserved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func validateAutogroupForSSHSrc(src *AutoGroup) error {
|
func validateAutogroupForSSHSrc(src *AutoGroup) error {
|
||||||
if src == nil {
|
if src == nil {
|
||||||
return nil
|
return nil
|
||||||
@ -2628,6 +2717,68 @@ func (p *Policy) validate() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, na := range p.NodeAttrs {
|
||||||
|
// SaaS accepts entries with neither attr nor ipPool (they
|
||||||
|
// compile to a no-op); headscale follows suit so policies
|
||||||
|
// captured against SaaS round-trip cleanly.
|
||||||
|
for _, target := range na.Targets {
|
||||||
|
switch t := target.(type) {
|
||||||
|
case *Host:
|
||||||
|
if !p.Hosts.exist(*t) {
|
||||||
|
errs = append(errs, fmt.Errorf("%w: %q", ErrHostNotDefined, *t))
|
||||||
|
}
|
||||||
|
case *AutoGroup:
|
||||||
|
err := validateAutogroupSupported(t)
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
err = validateAutogroupForNodeAttrs(t)
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
case *Group:
|
||||||
|
err := p.Groups.Contains(t)
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
case *Tag:
|
||||||
|
err := p.TagOwners.Contains(t)
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
case *Username, *Prefix, Asterix:
|
||||||
|
// User / prefix / wildcard targets are accepted at
|
||||||
|
// parse time and resolved at compile time (where a
|
||||||
|
// typo'd username surfaces as a propagated Resolve
|
||||||
|
// error from compileNodeAttrs). Mirrors the grant
|
||||||
|
// source-side validation shape.
|
||||||
|
default:
|
||||||
|
errs = append(errs, fmt.Errorf("%w: %q (%T)", ErrNodeAttrTargetUnsupported, target, target))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, attr := range na.Attrs {
|
||||||
|
issue, ok := nodeAttrUnsupportedCaps[attr]
|
||||||
|
if ok {
|
||||||
|
errs = append(errs, fmt.Errorf("%w: %q tracked in %s", ErrNodeAttrUnsupported, attr, issue))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(na.IPPool) > 0 {
|
||||||
|
errs = append(errs, ErrNodeAttrIPPoolUnsupported)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, prefix := range na.IPPool {
|
||||||
|
err := validateNodeAttrIPPool(prefix)
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for _, tagOwners := range p.TagOwners {
|
for _, tagOwners := range p.TagOwners {
|
||||||
for _, tagOwner := range tagOwners {
|
for _, tagOwner := range tagOwners {
|
||||||
switch tagOwner := tagOwner.(type) {
|
switch tagOwner := tagOwner.(type) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user