From bceac495f97f364e0f5a6c2ed67dfa44f52a68c8 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 3 Jun 2026 10:25:20 +0000 Subject: [PATCH] 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 --- hscontrol/policy/pm.go | 8 ++ hscontrol/policy/v2/compiled.go | 52 ++++++++ hscontrol/policy/v2/node_recompute_test.go | 137 +++++++++++++++++++++ hscontrol/policy/v2/policy.go | 56 +++++++++ 4 files changed, 253 insertions(+) create mode 100644 hscontrol/policy/v2/node_recompute_test.go diff --git a/hscontrol/policy/pm.go b/hscontrol/policy/pm.go index 5709a3f5..4c9dc397 100644 --- a/hscontrol/policy/pm.go +++ b/hscontrol/policy/pm.go @@ -36,6 +36,14 @@ type PolicyManager interface { // NodeCanApproveRoute reports whether the given node can approve the given route. 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. // 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, diff --git a/hscontrol/policy/v2/compiled.go b/hscontrol/policy/v2/compiled.go index 6019baa6..1f8c9ad4 100644 --- a/hscontrol/policy/v2/compiled.go +++ b/hscontrol/policy/v2/compiled.go @@ -594,6 +594,58 @@ func hasPerNodeGrants(grants []compiledGrant) bool { 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. // Via grants produce no global rules (they are per-node only); regular // grants contribute their full pre-compiled ruleset; self grants diff --git a/hscontrol/policy/v2/node_recompute_test.go b/hscontrol/policy/v2/node_recompute_test.go new file mode 100644 index 00000000..ed8c9db1 --- /dev/null +++ b/hscontrol/policy/v2/node_recompute_test.go @@ -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) + }) + } +} diff --git a/hscontrol/policy/v2/policy.go b/hscontrol/policy/v2/policy.go index 299a2e2f..d1c570ad 100644 --- a/hscontrol/policy/v2/policy.go +++ b/hscontrol/policy/v2/policy.go @@ -45,6 +45,15 @@ type PolicyManager struct { autoApproveMapHash deephash.Sum 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 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.userNodeIdx = buildUserNodeIndex(pm.nodes) 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 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 } +// 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 // caching on first access. Rules use SessionDuration = 0 (no // auto-approval) and emit check URLs of the form