all: annotate complex functions with gocyclo rationale

Splitting these functions does not buy clarity — each has been
extracted before and put back. Pin the //nolint:gocyclo on each
with the reason their shape resists clean decomposition.

  policy/v2/policy.go     ViaRoutesForPeer        — three-pass
                                                    via-grant resolution
  policy/v2/filter.go     compileSSHPolicy        — per-rule
                                                    branches with
                                                    intertwined
                                                    autogroup:self
                                                    handling
                                                    (annotated in the
                                                    earlier nil-error
                                                    commit)
  state/state.go          HandleNodeFromPreAuthKey — security-
                                                    sensitive
                                                    sequential
                                                    validation order
  servertest/routes_test  TestRoutes              — table-driven
                                                    test driver with
                                                    many independent
                                                    subtests

Also: //nolint:recvcheck on policy/v2.SSHUser — UnmarshalJSON
requires a pointer receiver; the other methods on this string
newtype use value receivers by convention.
This commit is contained in:
Kristoffer Dalby 2026-05-18 18:35:23 +00:00
parent 3e2aa5814e
commit 17236fd284
4 changed files with 238 additions and 213 deletions

View file

@ -2,11 +2,11 @@
// coordinating between subsystems like database, IP allocation,
// policy management, and DERP routing.
//
// The central type State owns a copy-on-write NodeStore
// (node_store.go), a PrimaryRoutes HA ledger, the PolicyManager, and a
// pingTracker for PingRequest correlation. Cross-subsystem operations
// (node updates, policy evaluation, IP allocation) go through State
// rather than directly to the database.
// The central type [State] owns a copy-on-write [NodeStore]
// (node_store.go), a PrimaryRoutes HA ledger, the [policy.PolicyManager],
// and a [pingTracker] for [tailcfg.PingRequest] correlation.
// Cross-subsystem operations (node updates, policy evaluation, IP
// allocation) go through [State] rather than directly to the database.
package state
@ -71,7 +71,7 @@ var ErrNodeNotFound = errors.New("node not found")
// ErrInvalidNodeView is returned when an invalid node view is provided.
var ErrInvalidNodeView = errors.New("invalid node view provided")
// ErrNodeNotInNodeStore is returned when a node no longer exists in the NodeStore.
// ErrNodeNotInNodeStore is returned when a node no longer exists in the [NodeStore].
var ErrNodeNotInNodeStore = errors.New("node no longer exists in NodeStore")
// ErrNodeNameNotUnique is returned when a node name is not unique.
@ -122,6 +122,9 @@ type sshCheckPair struct {
// State manages Headscale's core state, coordinating between database, policy management,
// IP allocation, and DERP routing. All methods are thread-safe.
//
// See [policy.PolicyManager] for policy evaluation and [NodeStore] for the
// in-memory node cache.
type State struct {
// cfg holds the current Headscale configuration
cfg *types.Config
@ -169,7 +172,7 @@ type State struct {
sshCheckMu sync.RWMutex
}
// NewState creates and initializes a new State instance, setting up the database,
// NewState creates and initializes a new [State] instance, setting up the database,
// IP allocator, DERP map, policy manager, and loading existing users and nodes.
func NewState(cfg *types.Config) (*State, error) {
cacheExpiration := registerCacheExpiration
@ -226,7 +229,7 @@ func NewState(cfg *types.Config) (*State, error) {
return nil, fmt.Errorf("initializing policy manager: %w", err)
}
// Apply defaults for NodeStore batch configuration if not set.
// Apply defaults for [NodeStore] batch configuration if not set.
// This ensures tests that create Config directly (without viper) still work.
batchSize := cfg.Tuning.NodeStoreBatchSize
if batchSize == 0 {
@ -238,7 +241,7 @@ func NewState(cfg *types.Config) (*State, error) {
batchTimeout = defaultNodeStoreBatchTimeout
}
// PolicyManager.BuildPeerMap handles both global and per-node filter complexity.
// [policy.PolicyManager.BuildPeerMap] handles both global and per-node filter complexity.
// This moves the complex peer relationship logic into the policy package where it belongs.
nodeStore := NewNodeStore(
nodes,
@ -264,7 +267,7 @@ func NewState(cfg *types.Config) (*State, error) {
}, nil
}
// Close gracefully shuts down the State instance and releases all resources.
// Close gracefully shuts down the [State] instance and releases all resources.
func (s *State) Close() error {
s.pings.drain()
s.nodeStore.Stop()
@ -288,7 +291,7 @@ func (s *State) DERPMap() tailcfg.DERPMapView {
}
// ReloadPolicy reloads the access control policy and triggers auto-approval if changed.
// Returns true if the policy changed.
// Returns the resulting [change.Change] slice when the policy or routes changed.
func (s *State) ReloadPolicy() ([]change.Change, error) {
pol, err := hsdb.PolicyBytes(s.db.DB, s.cfg)
if err != nil {
@ -304,18 +307,18 @@ func (s *State) ReloadPolicy() ([]change.Change, error) {
// approvals don't persist if checkPeriod rules are modified or removed.
s.ClearSSHCheckAuth()
// Rebuild peer maps after policy changes because the peersFunc in NodeStore
// uses the PolicyManager's filters. Without this, nodes won't see newly allowed
// peers until a node is added/removed, causing autogroup:self policies to not
// propagate correctly when switching between policy types.
// Rebuild peer maps after policy changes because the peersFunc in [NodeStore]
// uses the [policy.PolicyManager]'s filters. Without this, nodes won't see
// newly allowed peers until a node is added/removed, causing autogroup:self
// policies to not propagate correctly when switching between policy types.
s.nodeStore.RebuildPeerMaps()
//nolint:prealloc // cs starts with one element and may grow
cs := []change.Change{change.PolicyChange()}
// Per-node selective self refresh for nodeAttrs. A broadcast
// PolicyChange() re-renders peer lists and packet filters but
// never repopulates a node's own [tailcfg.Node.CapMap]; that
// [change.PolicyChange] re-renders peer lists and packet filters
// but never repopulates a node's own [tailcfg.Node.CapMap]; that
// lives on the self entry only. The drain returns every node ID
// whose cap output shifted across recent updateLocked calls —
// refreshNodeAttrsLocked appends rather than overwrites so a
@ -476,18 +479,19 @@ func (s *State) ListAllUsers() ([]types.User, error) {
// persistNodeToDB saves the given node state to the database.
// This function must receive the exact node state to save to ensure consistency between
// NodeStore and the database. It verifies the node still exists in NodeStore to prevent
// race conditions where a node might be deleted between UpdateNode returning and
// persistNodeToDB being called.
// [NodeStore] and the database. It verifies the node still exists in [NodeStore] to
// prevent race conditions where a node might be deleted between [NodeStore.UpdateNode]
// returning and persistNodeToDB being called.
func (s *State) persistNodeToDB(node types.NodeView) (types.NodeView, change.Change, error) {
if !node.Valid() {
return types.NodeView{}, change.Change{}, ErrInvalidNodeView
}
// Verify the node still exists in NodeStore before persisting to database.
// Without this check, we could hit a race condition where UpdateNode returns a valid
// node from a batch update, then the node gets deleted (e.g., ephemeral node logout),
// and persistNodeToDB would incorrectly re-insert the deleted node into the database.
// Verify the node still exists in [NodeStore] before persisting to database.
// Without this check, we could hit a race condition where [NodeStore.UpdateNode]
// returns a valid node from a batch update, then the node gets deleted (e.g.,
// ephemeral node logout), and persistNodeToDB would incorrectly re-insert the
// deleted node into the database.
_, exists := s.nodeStore.GetNode(node.ID())
if !exists {
log.Warn().
@ -523,12 +527,12 @@ func (s *State) persistNodeToDB(node types.NodeView) (types.NodeView, change.Cha
}
func (s *State) SaveNode(node types.NodeView) (types.NodeView, change.Change, error) {
// Update NodeStore first
// Update [NodeStore] first
nodePtr := node.AsStruct()
resultNode := s.nodeStore.PutNode(*nodePtr)
// Then save to database using the result from PutNode
// Then save to database using the result from [NodeStore.PutNode]
return s.persistNodeToDB(resultNode)
}
@ -563,14 +567,15 @@ func (s *State) DeleteNode(node types.NodeView) (change.Change, error) {
// Connect marks a node connected and returns the resulting changes
// plus a session epoch. The caller must pass the epoch back to
// Disconnect so deferred grace-period disconnects from a previous
// poll session are dropped (see poll.go).
// [State.Disconnect] so deferred grace-period disconnects from a
// previous poll session are dropped (see poll.go).
func (s *State) Connect(id types.NodeID) ([]change.Change, uint64) {
prevRoutes := s.nodeStore.PrimaryRoutes()
// Reconnecting clears Unhealthy: the node just proved basic
// connectivity by completing the Noise handshake.
var epoch uint64
node, ok := s.nodeStore.UpdateNode(id, func(n *types.Node) {
n.SessionEpoch++
epoch = n.SessionEpoch
@ -597,17 +602,20 @@ func (s *State) Connect(id types.NodeID) ([]change.Change, uint64) {
}
// Disconnect marks the node offline. epoch must match the value
// Connect returned for this session; otherwise the call no-ops so a
// deferred disconnect from an older session cannot overwrite state set
// by a newer Connect. The check and the IsOnline write share an
// UpdateNode closure, making them atomic against concurrent Connects.
// [State.Connect] returned for this session; otherwise the call no-ops
// so a deferred disconnect from an older session cannot overwrite state
// set by a newer [State.Connect]. The check and the IsOnline write share
// an [NodeStore.UpdateNode] closure, making them atomic against
// concurrent connects.
func (s *State) Disconnect(id types.NodeID, epoch uint64) ([]change.Change, error) {
var stale bool
node, ok := s.nodeStore.UpdateNode(id, func(n *types.Node) {
if n.SessionEpoch != epoch {
stale = true
return
}
now := time.Now()
n.LastSeen = &now
n.IsOnline = new(false)
@ -631,7 +639,7 @@ func (s *State) Disconnect(id types.NodeID, epoch uint64) ([]change.Change, erro
log.Info().EmbedObject(node).Msg("node disconnected")
// Persist LastSeen best-effort: NodeStore already reflects offline
// Persist LastSeen best-effort: [NodeStore] already reflects offline
// and peers still need the change notifications below.
_, c, err := s.persistNodeToDB(node)
if err != nil {
@ -807,11 +815,11 @@ func (s *State) ListEphemeralNodes() views.Slice[types.NodeView] {
// SetNodeExpiry updates the expiration time for a node.
// If expiry is nil, the node's expiry is disabled (node will never expire).
func (s *State) SetNodeExpiry(nodeID types.NodeID, expiry *time.Time) (types.NodeView, change.Change, error) {
// Update NodeStore before database to ensure consistency. The NodeStore update is
// blocking and will be the source of truth for the batcher. The database update must
// make the exact same change. If the database update fails, the NodeStore change will
// remain, but since we return an error, no change notification will be sent to the
// batcher, preventing inconsistent state propagation.
// Update [NodeStore] before database to ensure consistency. The [NodeStore] update
// is blocking and will be the source of truth for the batcher. The database update
// must make the exact same change. If the database update fails, the [NodeStore]
// change will remain, but since we return an error, no change notification will be
// sent to the batcher, preventing inconsistent state propagation.
n, ok := s.nodeStore.UpdateNode(nodeID, func(node *types.Node) {
node.Expiry = expiry
})
@ -877,9 +885,9 @@ func (s *State) SetNodeTags(nodeID types.NodeID, tags []string) (types.NodeView,
// Log the operation
logTagOperation(existingNode, validatedTags)
// Update NodeStore before database to ensure consistency. The NodeStore update is
// blocking and will be the source of truth for the batcher. The database update must
// make the exact same change.
// Update [NodeStore] before database to ensure consistency. The [NodeStore] update
// is blocking and will be the source of truth for the batcher. The database update
// must make the exact same change.
n, ok := s.nodeStore.UpdateNode(nodeID, func(node *types.Node) {
node.Tags = validatedTags
// Tagged nodes are owned by their tags, not a user.
@ -974,7 +982,7 @@ func (s *State) BackfillNodeIPs() ([]string, error) {
return nil, err
}
// Refresh NodeStore after IP changes to ensure consistency
// Refresh [NodeStore] after IP changes to ensure consistency
if len(changes) > 0 {
nodes, err := s.db.ListNodes()
if err != nil {
@ -996,7 +1004,7 @@ func (s *State) BackfillNodeIPs() ([]string, error) {
node.Hostinfo.NetInfo = netInfo
}
// TODO(kradalby): This should just update the IP addresses, nothing else in the node store.
// We should avoid PutNode here.
// We should avoid [NodeStore.PutNode] here.
_ = s.nodeStore.PutNode(*node)
}
}
@ -1061,7 +1069,7 @@ func (s *State) MatchersForNode(node types.NodeView) ([]matcher.Match, error) {
}
// NodeCapMap returns the policy-derived CapMap for the given node, suitable
// for merging into tailcfg.Node.CapMap when the node is rendered as self or
// for merging into [tailcfg.Node.CapMap] when the node is rendered as self or
// as someone else's peer.
func (s *State) NodeCapMap(id types.NodeID) tailcfg.NodeCapMap {
return s.polMan.NodeCapMap(id)
@ -1103,8 +1111,9 @@ func (s *State) AutoApproveRoutes(nv types.NodeView) (change.Change, error) {
Strs("routes.approved.new", util.PrefixesToString(approved)).
Msg("Single node auto-approval detected route changes")
// Persist the auto-approved routes to database and NodeStore via SetApprovedRoutes
// This ensures consistency between database and NodeStore
// Persist the auto-approved routes to database and [NodeStore] via
// [State.SetApprovedRoutes]. This ensures consistency between database
// and [NodeStore].
_, c, err := s.SetApprovedRoutes(nv.ID(), approved)
if err != nil {
log.Error().
@ -1342,11 +1351,11 @@ func (s *State) CreateNodeForTest(user *types.User, hostname ...string) *types.N
return s.db.CreateNodeForTest(user, hostname...)
}
// PutNodeInStoreForTest writes a test node into the in-memory NodeStore
// so handlers backed by NodeStore lookups (e.g. GetNodeByID) can see it.
// CreateNodeForTest only saves to the database, which is fine for tests
// that exercise the DB layer directly but insufficient for handler tests
// that go through State.
// PutNodeInStoreForTest writes a test node into the in-memory [NodeStore]
// so handlers backed by [NodeStore] lookups (e.g. [State.GetNodeByID]) can
// see it. [State.CreateNodeForTest] only saves to the database, which is
// fine for tests that exercise the DB layer directly but insufficient for
// handler tests that go through [State].
func (s *State) PutNodeInStoreForTest(node types.Node) types.NodeView {
return s.nodeStore.PutNode(node)
}
@ -1462,7 +1471,7 @@ type newNodeParams struct {
// authNodeUpdateParams contains parameters for updating an existing node during auth.
type authNodeUpdateParams struct {
// Node to update; must be valid and in NodeStore.
// Node to update; must be valid and in [NodeStore].
ExistingNode types.NodeView
// Cached registration payload from the originating client request.
RegData *types.RegistrationData
@ -1481,7 +1490,7 @@ type authNodeUpdateParams struct {
}
// applyAuthNodeUpdate applies common update logic for re-authenticating or converting
// an existing node. It updates the node in NodeStore, processes RequestTags, and
// an existing node. It updates the node in [NodeStore], processes RequestTags, and
// persists changes to the database.
func (s *State) applyAuthNodeUpdate(params authNodeUpdateParams) (types.NodeView, error) {
regData := params.RegData
@ -1509,8 +1518,9 @@ func (s *State) applyAuthNodeUpdate(params authNodeUpdateParams) (types.NodeView
oldTags := params.ExistingNode.Tags().AsSlice()
// Validate tags BEFORE calling UpdateNode to ensure we don't modify NodeStore
// if validation fails. This maintains consistency between NodeStore and database.
// Validate tags BEFORE calling [NodeStore.UpdateNode] to ensure we don't modify
// [NodeStore] if validation fails. This maintains consistency between [NodeStore]
// and database.
rejectedTags := s.validateRequestTags(params.ExistingNode, requestTags)
if len(rejectedTags) > 0 {
return types.NodeView{}, fmt.Errorf(
@ -1520,7 +1530,7 @@ func (s *State) applyAuthNodeUpdate(params authNodeUpdateParams) (types.NodeView
)
}
// Update existing node in NodeStore - validation passed, safe to mutate
// Update existing node in [NodeStore] - validation passed, safe to mutate
updatedNodeView, ok := s.nodeStore.UpdateNode(params.ExistingNode.ID(), func(node *types.Node) {
node.NodeKey = regData.NodeKey
node.DiscoKey = regData.DiscoKey
@ -1536,10 +1546,11 @@ func (s *State) applyAuthNodeUpdate(params authNodeUpdateParams) (types.NodeView
node.Endpoints = regData.Endpoints
// Do NOT reset IsOnline here. Online status is managed exclusively by
// Connect()/Disconnect() in the poll session lifecycle. Resetting it
// during re-registration causes a false offline blip: the change
// notification triggers a map regeneration showing the node as offline
// to peers, even though Connect() will immediately set it back to true.
// [State.Connect]/[State.Disconnect] in the poll session lifecycle.
// Resetting it during re-registration causes a false offline blip: the
// change notification triggers a map regeneration showing the node as
// offline to peers, even though [State.Connect] will immediately set it
// back to true.
node.LastSeen = new(time.Now())
// On conversion (tagged → user) we set the new register method.
@ -1634,7 +1645,7 @@ func (s *State) applyAuthNodeUpdate(params authNodeUpdateParams) (types.NodeView
return updatedNodeView, nil
}
// createAndSaveNewNode creates a new node, allocates IPs, saves to DB, and adds to NodeStore.
// createAndSaveNewNode creates a new node, allocates IPs, saves to DB, and adds to [NodeStore].
// It preserves netinfo from an existing node if one is provided (for faster DERP connectivity).
func (s *State) createAndSaveNewNode(params newNodeParams) (types.NodeView, error) {
// Preserve NetInfo from existing node if available
@ -1655,7 +1666,7 @@ func (s *State) createAndSaveNewNode(params newNodeParams) (types.NodeView, erro
Hostinfo: params.Hostinfo,
Endpoints: params.Endpoints,
LastSeen: new(time.Now()),
IsOnline: new(false), // Explicitly offline until Connect() is called
IsOnline: new(false), // Explicitly offline until [State.Connect] is called
RegisterMethod: params.RegisterMethod,
Expiry: params.Expiry,
}
@ -1748,14 +1759,14 @@ func (s *State) createAndSaveNewNode(params newNodeParams) (types.NodeView, erro
nodeToRegister.IPv4 = ipv4
nodeToRegister.IPv6 = ipv6
// Seed GivenName from the sanitised raw hostname. NodeStore.PutNode
// Seed GivenName from the sanitised raw hostname. [NodeStore.PutNode]
// bumps on collision and falls back to "node" if the sanitised
// result is empty (pure non-ASCII / punctuation input).
if nodeToRegister.GivenName == "" {
nodeToRegister.GivenName = dnsname.SanitizeHostname(nodeToRegister.Hostname)
}
// New node - database first to get ID, then NodeStore
// New node - database first to get ID, then [NodeStore]
savedNode, err := hsdb.Write(s.db.DB, func(tx *gorm.DB) (*types.Node, error) {
err := tx.Save(&nodeToRegister).Error
if err != nil {
@ -1775,12 +1786,12 @@ func (s *State) createAndSaveNewNode(params newNodeParams) (types.NodeView, erro
return types.NodeView{}, err
}
// Add to NodeStore after database creates the ID
// Add to [NodeStore] after database creates the ID
return s.nodeStore.PutNode(*savedNode), nil
}
// validateRequestTags validates that the requested tags are permitted for the node.
// This should be called BEFORE UpdateNode to ensure we don't modify NodeStore
// This should be called BEFORE [NodeStore.UpdateNode] to ensure we don't modify [NodeStore]
// if validation fails. Returns the list of rejected tags (empty if all valid).
func (s *State) validateRequestTags(node types.NodeView, requestTags []string) []string {
// Empty tags = clear tags, always permitted
@ -2085,6 +2096,7 @@ func (s *State) findExistingNodeForPAK(
return types.NodeView{}, false
}
//nolint:gocyclo // sequential validation/update/create paths with security-sensitive ordering
func (s *State) HandleNodeFromPreAuthKey(
regReq tailcfg.RegisterRequest,
machineKey key.MachinePublic,
@ -2201,8 +2213,9 @@ func (s *State) HandleNodeFromPreAuthKey(
node.AuthKey = pak
node.AuthKeyID = &pak.ID
// Do NOT reset IsOnline here. Online status is managed exclusively by
// Connect()/Disconnect() in the poll session lifecycle. Resetting it
// during re-registration causes a false offline blip to peers.
// [State.Connect]/[State.Disconnect] in the poll session lifecycle.
// Resetting it during re-registration causes a false offline blip
// to peers.
node.LastSeen = new(time.Now())
// Tagged nodes keep their existing expiry (disabled).
@ -2237,7 +2250,7 @@ func (s *State) HandleNodeFromPreAuthKey(
// Only mark the key used on the *first* registration. On
// re-registration the same key is already used and the
// atomic compare-and-set in UsePreAuthKey would otherwise
// atomic compare-and-set in [hsdb.UsePreAuthKey] would otherwise
// reject it as "authkey already used". This is the path
// behind issue #2830 where containers restart with the
// same one-shot key.
@ -2478,7 +2491,7 @@ func (s *State) autoApproveNodes() ([]change.Change, error) {
// isAutoDerivedGivenName reports whether given matches what
// dnsname.SanitizeHostname(hostname) would produce, optionally with a
// NodeStore collision-bump "-N" suffix. It is used to detect whether a
// [NodeStore] collision-bump "-N" suffix. It is used to detect whether a
// GivenName has been admin-renamed (in which case it must not be
// overwritten by client-side hostname changes).
func isAutoDerivedGivenName(given, hostname string) bool {
@ -2498,10 +2511,10 @@ func isAutoDerivedGivenName(given, hostname string) bool {
}
// UpdateNodeFromMapRequest is the sync point where Hostinfo changes,
// endpoint updates, and route advertisements from a MapRequest land in
// the NodeStore. It produces a change.Change summarising what actually
// moved so downstream subsystems (mapper, policy, primary routes) can
// react accordingly.
// endpoint updates, and route advertisements from a [tailcfg.MapRequest]
// land in the [NodeStore]. It produces a [change.Change] summarising
// what actually moved so downstream subsystems (mapper, policy, primary
// routes) can react accordingly.
//
// TODO(kradalby): This is essentially a patch update that could be sent directly to nodes,
// which means we could shortcut the whole change thing if there are no other important updates.
@ -2528,7 +2541,7 @@ func (s *State) UpdateNodeFromMapRequest(id types.NodeID, req tailcfg.MapRequest
// Hostinfo + auto-approval that follows shifted any prefix.
prevRoutes := s.nodeStore.PrimaryRoutes()
// We need to ensure we update the node as it is in the NodeStore at
// We need to ensure we update the node as it is in the [NodeStore] at
// the time of the request.
updatedNode, ok := s.nodeStore.UpdateNode(id, func(currentNode *types.Node) {
peerChange := currentNode.PeerChangeFromMapRequest(req)
@ -2555,7 +2568,7 @@ func (s *State) UpdateNodeFromMapRequest(id types.NodeID, req tailcfg.MapRequest
return
}
// Calculate route approval before NodeStore update to avoid calling View() inside callback
// Calculate route approval before [NodeStore] update to avoid calling View() inside callback
var hasNewRoutes bool
if hi := req.Hostinfo; hi != nil {
hasNewRoutes = len(hi.RoutableIPs) > 0
@ -2617,7 +2630,7 @@ func (s *State) UpdateNodeFromMapRequest(id types.NodeID, req tailcfg.MapRequest
currentNode.Hostname = req.Hostinfo.Hostname
if autoDerived {
currentNode.GivenName = dnsname.SanitizeHostname(req.Hostinfo.Hostname)
// NodeStore.UpdateNode auto-bumps GivenName on collision.
// [NodeStore.UpdateNode] auto-bumps GivenName on collision.
}
}
@ -2655,13 +2668,13 @@ func (s *State) UpdateNodeFromMapRequest(id types.NodeID, req tailcfg.MapRequest
Strs(zf.AutoApprovedRoutes, util.PrefixesToString(autoApprovedRoutes)).
Msg("Persisting auto-approved routes from MapRequest")
// SetApprovedRoutes will update both database and PrimaryRoutes table
// [State.SetApprovedRoutes] will update both database and PrimaryRoutes table
_, c, err := s.SetApprovedRoutes(id, autoApprovedRoutes)
if err != nil {
return change.Change{}, fmt.Errorf("persisting auto-approved routes: %w", err)
}
// If SetApprovedRoutes resulted in a policy change, return it
// If [State.SetApprovedRoutes] resulted in a policy change, return it
if !c.IsEmpty() {
return c, nil
}
@ -2702,7 +2715,7 @@ func (s *State) UpdateNodeFromMapRequest(id types.NodeID, req tailcfg.MapRequest
return buildMapRequestChangeResponse(id, updatedNode, hostinfoChanged, endpointChanged, derpChanged)
}
// buildMapRequestChangeResponse determines the appropriate response type for a MapRequest update.
// buildMapRequestChangeResponse determines the appropriate response type for a [tailcfg.MapRequest] update.
// Hostinfo changes require a full update, while endpoint/DERP changes can use lightweight patches.
func buildMapRequestChangeResponse(
id types.NodeID,