mapper, policy/v2: stamp suggest-exit-node on Peer.CapMap when exit routes approved

The Tailscale client surfaces 'use this peer as your exit node' when
the peer's CapMap carries the tailcfg.NodeAttrSuggestExitNode cap.
SaaS emits it only on peers whose advertised exit routes are
approved -- not every peer that just has the cap in its own
nodeAttrs slot.

policyv2.PeerCapMap encodes that emission rule: it walks the
peer's own self-CapMap (built from compileNodeAttrs) and surfaces
the gated entries (today just suggest-exit-node when the peer
IsExitNode). Mapper.buildTailPeers calls it for each peer instead
of merging the peer's full nodeAttrs CapMap onto its peer view.

allCapMaps snapshots the full per-node CapMap once per peer-list
build so pm.mu is acquired once rather than per peer.
This commit is contained in:
Kristoffer Dalby 2026-05-11 14:52:04 +00:00
parent 078b9e308f
commit b3f795f0b4

View File

@ -8,6 +8,7 @@ import (
"time" "time"
"github.com/juanfont/headscale/hscontrol/policy" "github.com/juanfont/headscale/hscontrol/policy"
policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2"
"github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/types"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
"tailscale.com/types/views" "tailscale.com/types/views"
@ -264,6 +265,11 @@ func (b *MapResponseBuilder) buildTailPeers(peers views.Slice[types.NodeView]) (
changedViews = peers changedViews = peers
} }
// Snapshot the per-node policy CapMap once per peer-list build
// instead of locking the policy manager per peer. The per-call
// path used to take pm.mu N times for an N-peer response.
allCapMaps := b.mapper.state.NodeCapMaps()
// Build tail nodes with per-peer via-aware route function. // Build tail nodes with per-peer via-aware route function.
tailPeers := make([]*tailcfg.Node, 0, changedViews.Len()) tailPeers := make([]*tailcfg.Node, 0, changedViews.Len())
@ -275,21 +281,15 @@ func (b *MapResponseBuilder) buildTailPeers(peers views.Slice[types.NodeView]) (
return nil, err return nil, err
} }
// Each peer's CapMap travels alongside the peer entry -- // [tailcfg.Node.CapMap] on a peer carries the small set of
// Tailscale's client reads it for `NodeAttrSuggestExitNode`, // caps the Tailscale client reads from the peer view rather
// `NodeAttrDNSSubdomainResolve`, and other peer-self attrs // than the self view (suggest-exit-node, dns-subdomain-resolve
// (see tstest/integration/testcontrol/testcontrol.go:1350, // — see ipn/ipnlocal/local.go:7534 and node_backend.go:745).
// ipn/ipnlocal/local.go:7562, ipn/ipnlocal/node_backend.go:745). // The Tailscale-hosted control plane stamps these only when
// TailNode already stamped the baseline; merge the // the peer satisfies the cap's emission condition; every other
// peer's own policy nodeAttrs delta on top so peer-side // cap stays off the peer view, leaving CapMap empty for most
// consumers see the same value the peer sees on its self entry. // peers. [policyv2.PeerCapMap] encodes those conditions.
if policyCaps := b.mapper.state.NodeCapMap(peer.ID()); len(policyCaps) > 0 { tn.CapMap = policyv2.PeerCapMap(peer, allCapMaps[peer.ID()])
if tn.CapMap == nil {
tn.CapMap = make(tailcfg.NodeCapMap, len(policyCaps))
}
maps.Copy(tn.CapMap, policyCaps)
}
tailPeers = append(tailPeers, tn) tailPeers = append(tailPeers, tn)
} }