policy: add NodeNeedsPeerRecompute predicate
Reports whether a node's online/offline transition forces peers to recompute their netmap. True for subnet routers, relay targets (tailscale.com/cap/relay), and via targets; false otherwise. The relay-target IP set and via-target tag set are precompiled from the grants in updateLocked, alongside the existing filter, so the per-node check is a cheap set lookup. Keyed on the node itself, so an ordinary node in a tailnet that uses relay or via for other nodes is still classified as not needing a recompute. Updates #3293
This commit is contained in:
parent
171fd7a3c5
commit
bceac495f9
@ -36,6 +36,14 @@ type PolicyManager interface {
|
|||||||
// NodeCanApproveRoute reports whether the given node can approve the given route.
|
// NodeCanApproveRoute reports whether the given node can approve the given route.
|
||||||
NodeCanApproveRoute(node types.NodeView, route netip.Prefix) bool
|
NodeCanApproveRoute(node types.NodeView, route netip.Prefix) bool
|
||||||
|
|
||||||
|
// NodeNeedsPeerRecompute reports whether peers must recompute their
|
||||||
|
// netmap when the node's online state changes. True for subnet
|
||||||
|
// routers, relay targets (tailscale.com/cap/relay), and via targets;
|
||||||
|
// false for ordinary nodes, which only need a lightweight online or
|
||||||
|
// offline peer patch. [State.Connect] and [State.Disconnect] use it to
|
||||||
|
// avoid a tailnet-wide recompute on every ordinary reconnect.
|
||||||
|
NodeNeedsPeerRecompute(node types.NodeView) bool
|
||||||
|
|
||||||
// ViaRoutesForPeer computes via grant effects for a viewer-peer pair.
|
// ViaRoutesForPeer computes via grant effects for a viewer-peer pair.
|
||||||
// It returns which routes should be included (peer is via-designated for viewer)
|
// It returns which routes should be included (peer is via-designated for viewer)
|
||||||
// and excluded (steered to a different peer). When no via grants apply,
|
// and excluded (steered to a different peer). When no via grants apply,
|
||||||
|
|||||||
@ -594,6 +594,58 @@ func hasPerNodeGrants(grants []compiledGrant) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// collectRelayTargetIPs returns the set of IPs that are destinations of a
|
||||||
|
// tailscale.com/cap/relay grant. A node whose IP is in this set is a relay
|
||||||
|
// target: when it goes offline, peers holding a PeerRelay allocation through
|
||||||
|
// it must recompute their netmap to drop the now-dead allocation. The relay
|
||||||
|
// cap is carried on each grant's [tailcfg.CapGrant] with Dsts set to the
|
||||||
|
// resolved relay destinations (see [Policy.compileOtherDests]); the reversed
|
||||||
|
// companion rule carries [tailcfg.PeerCapabilityRelayTarget] instead and is
|
||||||
|
// intentionally skipped.
|
||||||
|
func collectRelayTargetIPs(grants []compiledGrant) (*netipx.IPSet, error) {
|
||||||
|
var b netipx.IPSetBuilder
|
||||||
|
|
||||||
|
for i := range grants {
|
||||||
|
for _, rule := range grants[i].rules {
|
||||||
|
for _, cg := range rule.CapGrant {
|
||||||
|
if _, ok := cg.CapMap[tailcfg.PeerCapabilityRelay]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, dst := range cg.Dsts {
|
||||||
|
b.AddPrefix(dst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.IPSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectViaTargetTags returns the set of tags used as via targets across all
|
||||||
|
// grants. A node carrying any of these tags is a via target: peers steering
|
||||||
|
// traffic through it must recompute when it goes offline. Returns nil when no
|
||||||
|
// via grants exist.
|
||||||
|
func collectViaTargetTags(grants []compiledGrant) map[Tag]struct{} {
|
||||||
|
var tags map[Tag]struct{}
|
||||||
|
|
||||||
|
for i := range grants {
|
||||||
|
if grants[i].via == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, t := range grants[i].via.viaTags {
|
||||||
|
if tags == nil {
|
||||||
|
tags = make(map[Tag]struct{})
|
||||||
|
}
|
||||||
|
|
||||||
|
tags[t] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
// globalFilterRules extracts global filter rules from [compiledGrant]s.
|
// globalFilterRules extracts global filter rules from [compiledGrant]s.
|
||||||
// Via grants produce no global rules (they are per-node only); regular
|
// Via grants produce no global rules (they are per-node only); regular
|
||||||
// grants contribute their full pre-compiled ruleset; self grants
|
// grants contribute their full pre-compiled ruleset; self grants
|
||||||
|
|||||||
137
hscontrol/policy/v2/node_recompute_test.go
Normal file
137
hscontrol/policy/v2/node_recompute_test.go
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
package v2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/juanfont/headscale/hscontrol/types"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"tailscale.com/tailcfg"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestNodeNeedsPeerRecompute pins which node roles force peers to recompute
|
||||||
|
// their netmap when the node's online state changes. An ordinary node only
|
||||||
|
// needs the lightweight online/offline peer patch; subnet routers, relay
|
||||||
|
// targets, and via targets change what peers compute and therefore need a
|
||||||
|
// full recompute. The predicate is keyed on the flipping node, so an ordinary
|
||||||
|
// node in a tailnet that uses relay or via elsewhere must still be classified
|
||||||
|
// as not needing a recompute.
|
||||||
|
func TestNodeNeedsPeerRecompute(t *testing.T) {
|
||||||
|
users := types.Users{
|
||||||
|
{Model: gorm.Model{ID: 1}, Name: "user1", Email: "user1@headscale.net"},
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowAll = `{"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}`
|
||||||
|
|
||||||
|
relayPol := `{
|
||||||
|
"tagOwners": {"tag:relay": ["user1@"]},
|
||||||
|
"grants": [
|
||||||
|
{"src": ["*"], "dst": ["tag:relay"], "app": {"tailscale.com/cap/relay": [{}]}}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
viaPol := `{
|
||||||
|
"tagOwners": {"tag:via": ["user1@"]},
|
||||||
|
"grants": [
|
||||||
|
{"src": ["*"], "dst": ["10.0.0.0/24"], "ip": ["*"], "via": ["tag:via"]}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
taildrivePol := `{
|
||||||
|
"tagOwners": {"tag:drive": ["user1@"]},
|
||||||
|
"grants": [
|
||||||
|
{"src": ["*"], "dst": ["tag:drive"], "app": {"tailscale.com/cap/drive": [{}]}}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
ordinary := node("ordinary", "100.64.0.1", "fd7a:115c:a1e0::1", users[0])
|
||||||
|
ordinary.ID = 1
|
||||||
|
|
||||||
|
subnetRouter := node("subnet", "100.64.0.2", "fd7a:115c:a1e0::2", users[0])
|
||||||
|
subnetRouter.ID = 2
|
||||||
|
subnetRouter.Hostinfo = &tailcfg.Hostinfo{
|
||||||
|
RoutableIPs: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/24")},
|
||||||
|
}
|
||||||
|
subnetRouter.ApprovedRoutes = []netip.Prefix{netip.MustParsePrefix("10.0.0.0/24")}
|
||||||
|
|
||||||
|
relayTarget := node("relay", "100.64.0.3", "fd7a:115c:a1e0::3", users[0])
|
||||||
|
relayTarget.ID = 3
|
||||||
|
relayTarget.Tags = []string{"tag:relay"}
|
||||||
|
|
||||||
|
viaTarget := node("via", "100.64.0.4", "fd7a:115c:a1e0::4", users[0])
|
||||||
|
viaTarget.ID = 4
|
||||||
|
viaTarget.Tags = []string{"tag:via"}
|
||||||
|
|
||||||
|
driveTarget := node("drive", "100.64.0.5", "fd7a:115c:a1e0::5", users[0])
|
||||||
|
driveTarget.ID = 5
|
||||||
|
driveTarget.Tags = []string{"tag:drive"}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
pol string
|
||||||
|
nodes types.Nodes
|
||||||
|
subject *types.Node
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "ordinary node under allow-all does not need recompute",
|
||||||
|
pol: allowAll,
|
||||||
|
nodes: types.Nodes{ordinary},
|
||||||
|
subject: ordinary,
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "subnet router needs recompute",
|
||||||
|
pol: allowAll,
|
||||||
|
nodes: types.Nodes{subnetRouter},
|
||||||
|
subject: subnetRouter,
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "relay target needs recompute",
|
||||||
|
pol: relayPol,
|
||||||
|
nodes: types.Nodes{relayTarget, ordinary},
|
||||||
|
subject: relayTarget,
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ordinary node in a relay-using tailnet does not need recompute",
|
||||||
|
pol: relayPol,
|
||||||
|
nodes: types.Nodes{relayTarget, ordinary},
|
||||||
|
subject: ordinary,
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "via target needs recompute",
|
||||||
|
pol: viaPol,
|
||||||
|
nodes: types.Nodes{viaTarget, ordinary},
|
||||||
|
subject: viaTarget,
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ordinary node in a via-using tailnet does not need recompute",
|
||||||
|
pol: viaPol,
|
||||||
|
nodes: types.Nodes{viaTarget, ordinary},
|
||||||
|
subject: ordinary,
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "taildrive target does not need recompute",
|
||||||
|
pol: taildrivePol,
|
||||||
|
nodes: types.Nodes{driveTarget, ordinary},
|
||||||
|
subject: driveTarget,
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
pm, err := NewPolicyManager([]byte(tt.pol), users, tt.nodes.ViewSlice())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got := pm.NodeNeedsPeerRecompute(tt.subject.View())
|
||||||
|
require.Equal(t, tt.want, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -45,6 +45,15 @@ type PolicyManager struct {
|
|||||||
autoApproveMapHash deephash.Sum
|
autoApproveMapHash deephash.Sum
|
||||||
autoApproveMap map[netip.Prefix]*netipx.IPSet
|
autoApproveMap map[netip.Prefix]*netipx.IPSet
|
||||||
|
|
||||||
|
// relayTargetIPs holds the IPs of nodes that are destinations of a
|
||||||
|
// tailscale.com/cap/relay grant; viaTargetTags holds the tags used as
|
||||||
|
// via targets. A node matching either, or that is a subnet router,
|
||||||
|
// forces peers to recompute their netmap when its online state changes
|
||||||
|
// (see [PolicyManager.NodeNeedsPeerRecompute]). Recomputed from the
|
||||||
|
// compiled grants on every policy/user/node change.
|
||||||
|
relayTargetIPs *netipx.IPSet
|
||||||
|
viaTargetTags map[Tag]struct{}
|
||||||
|
|
||||||
// Lazy map of SSH policies
|
// Lazy map of SSH policies
|
||||||
sshPolicyMap map[types.NodeID]*tailcfg.SSHPolicy
|
sshPolicyMap map[types.NodeID]*tailcfg.SSHPolicy
|
||||||
|
|
||||||
@ -223,6 +232,14 @@ func (pm *PolicyManager) updateLocked() (bool, error) {
|
|||||||
pm.compiledGrants = pm.pol.compileGrants(pm.users, pm.nodes)
|
pm.compiledGrants = pm.pol.compileGrants(pm.users, pm.nodes)
|
||||||
pm.userNodeIdx = buildUserNodeIndex(pm.nodes)
|
pm.userNodeIdx = buildUserNodeIndex(pm.nodes)
|
||||||
pm.needsPerNodeFilter = hasPerNodeGrants(pm.compiledGrants)
|
pm.needsPerNodeFilter = hasPerNodeGrants(pm.compiledGrants)
|
||||||
|
pm.viaTargetTags = collectViaTargetTags(pm.compiledGrants)
|
||||||
|
|
||||||
|
relayTargetIPs, err := collectRelayTargetIPs(pm.compiledGrants)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("collecting relay target IPs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pm.relayTargetIPs = relayTargetIPs
|
||||||
|
|
||||||
var filter []tailcfg.FilterRule
|
var filter []tailcfg.FilterRule
|
||||||
if pm.pol == nil || (pm.pol.ACLs == nil && pm.pol.Grants == nil) {
|
if pm.pol == nil || (pm.pol.ACLs == nil && pm.pol.Grants == nil) {
|
||||||
@ -360,6 +377,45 @@ func (pm *PolicyManager) updateLocked() (bool, error) {
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NodeNeedsPeerRecompute reports whether peers must recompute their netmap
|
||||||
|
// when node's online state changes. A plain node only needs the lightweight
|
||||||
|
// online/offline peer patch; these roles change what peers compute when the
|
||||||
|
// node goes up or down, so they require a full recompute:
|
||||||
|
// - subnet router: primary-route failover changes peers' AllowedIPs
|
||||||
|
// - relay target (tailscale.com/cap/relay): peers must drop a stale
|
||||||
|
// PeerRelay allocation
|
||||||
|
// - via target: peers steer traffic through this node
|
||||||
|
//
|
||||||
|
// The check is keyed on the node itself, so an ordinary node in a tailnet
|
||||||
|
// that uses relay or via for other nodes is correctly classified as not
|
||||||
|
// needing a recompute.
|
||||||
|
func (pm *PolicyManager) NodeNeedsPeerRecompute(node types.NodeView) bool {
|
||||||
|
if !node.Valid() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subnet-router status is intrinsic to the node, so it needs no policy
|
||||||
|
// state and is checked without the lock.
|
||||||
|
if node.IsSubnetRouter() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
pm.mu.Lock()
|
||||||
|
defer pm.mu.Unlock()
|
||||||
|
|
||||||
|
if pm.relayTargetIPs != nil && node.InIPSet(pm.relayTargetIPs) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
for tag := range pm.viaTargetTags {
|
||||||
|
if node.HasTag(string(tag)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// SSHPolicy returns the [tailcfg.SSHPolicy] for node, compiling and
|
// SSHPolicy returns the [tailcfg.SSHPolicy] for node, compiling and
|
||||||
// caching on first access. Rules use SessionDuration = 0 (no
|
// caching on first access. Rules use SessionDuration = 0 (no
|
||||||
// auto-approval) and emit check URLs of the form
|
// auto-approval) and emit check URLs of the form
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user