From 145672f0b69cb8cdff68b44f5aac907f7485cb73 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Tue, 16 Jun 2026 07:00:35 +0000 Subject: [PATCH] state: consolidate route-election and node helpers --- hscontrol/state/debug.go | 71 +++++-------------------- hscontrol/state/ha_health.go | 26 ++++------ hscontrol/state/node_store.go | 98 +++++++++++++++-------------------- hscontrol/state/state.go | 22 ++++---- 4 files changed, 78 insertions(+), 139 deletions(-) diff --git a/hscontrol/state/debug.go b/hscontrol/state/debug.go index 3fbd0902..91a9c449 100644 --- a/hscontrol/state/debug.go +++ b/hscontrol/state/debug.go @@ -62,51 +62,23 @@ type DebugStringInfo struct { // DebugOverview returns a comprehensive overview of the current state for debugging. func (s *State) DebugOverview() string { - allNodes := s.nodeStore.ListNodes() - users, _ := s.ListAllUsers() + info := s.DebugOverviewJSON() var sb strings.Builder sb.WriteString("=== Headscale State Overview ===\n\n") // Node statistics - fmt.Fprintf(&sb, "Nodes: %d total\n", allNodes.Len()) - - userNodeCounts := make(map[string]int) - onlineCount := 0 - expiredCount := 0 - ephemeralCount := 0 - - now := time.Now() - - for _, node := range allNodes.All() { - if node.Valid() { - userName := node.Owner().Name() - userNodeCounts[userName]++ - - if node.IsOnline().Valid() && node.IsOnline().Get() { - onlineCount++ - } - - if node.Expiry().Valid() && node.Expiry().Get().Before(now) { - expiredCount++ - } - - if node.AuthKey().Valid() && node.AuthKey().Ephemeral() { - ephemeralCount++ - } - } - } - - fmt.Fprintf(&sb, " - Online: %d\n", onlineCount) - fmt.Fprintf(&sb, " - Expired: %d\n", expiredCount) - fmt.Fprintf(&sb, " - Ephemeral: %d\n", ephemeralCount) + fmt.Fprintf(&sb, "Nodes: %d total\n", info.Nodes.Total) + fmt.Fprintf(&sb, " - Online: %d\n", info.Nodes.Online) + fmt.Fprintf(&sb, " - Expired: %d\n", info.Nodes.Expired) + fmt.Fprintf(&sb, " - Ephemeral: %d\n", info.Nodes.Ephemeral) sb.WriteString("\n") // User statistics - fmt.Fprintf(&sb, "Users: %d total\n", len(users)) + fmt.Fprintf(&sb, "Users: %d total\n", info.TotalUsers) - for userName, nodeCount := range userNodeCounts { + for userName, nodeCount := range info.Users { fmt.Fprintf(&sb, " - %s: %d nodes\n", userName, nodeCount) } @@ -114,18 +86,17 @@ func (s *State) DebugOverview() string { // Policy information sb.WriteString("Policy:\n") - fmt.Fprintf(&sb, " - Mode: %s\n", s.cfg.Policy.Mode) + fmt.Fprintf(&sb, " - Mode: %s\n", info.Policy.Mode) - if s.cfg.Policy.Mode == types.PolicyModeFile { - fmt.Fprintf(&sb, " - Path: %s\n", s.cfg.Policy.Path) + if info.Policy.Mode == string(types.PolicyModeFile) { + fmt.Fprintf(&sb, " - Path: %s\n", info.Policy.Path) } sb.WriteString("\n") // DERP information - derpMap := s.derpMap.Load() - if derpMap != nil { - fmt.Fprintf(&sb, "DERP: %d regions configured\n", len(derpMap.Regions)) + if info.DERP.Configured { + fmt.Fprintf(&sb, "DERP: %d regions configured\n", info.DERP.Regions) } else { sb.WriteString("DERP: not configured\n") } @@ -133,14 +104,7 @@ func (s *State) DebugOverview() string { sb.WriteString("\n") // Route information - primaryStr := s.PrimaryRoutesString() - - routeCount := len(strings.Split(strings.TrimSpace(primaryStr), "\n")) - if primaryStr == "" { - routeCount = 0 - } - - fmt.Fprintf(&sb, "Primary Routes: %d active\n", routeCount) + fmt.Fprintf(&sb, "Primary Routes: %d active\n", info.PrimaryRoutes) sb.WriteString("\n") // Registration cache @@ -371,14 +335,7 @@ func (s *State) DebugOverviewJSON() DebugOverviewInfo { } // Route information - primaryStr := s.PrimaryRoutesString() - - routeCount := len(strings.Split(strings.TrimSpace(primaryStr), "\n")) - if primaryStr == "" { - routeCount = 0 - } - - info.PrimaryRoutes = routeCount + info.PrimaryRoutes = len(s.nodeStore.PrimaryRoutes()) return info } diff --git a/hscontrol/state/ha_health.go b/hscontrol/state/ha_health.go index e6482b52..4e76e9a3 100644 --- a/hscontrol/state/ha_health.go +++ b/hscontrol/state/ha_health.go @@ -74,16 +74,24 @@ func (p *HAHealthProber) ProbeOnce( ) { haNodes := p.state.nodeStore.HANodes() - // Drop stable-session entries for nodes that are no longer HA - // candidates so a future reappearance starts fresh. + // Build the deduplicated node-ID set and slice in one pass. + var nodeIDs []types.NodeID + seen := make(set.Set[types.NodeID]) for _, nodes := range haNodes { for _, id := range nodes { + if seen.Contains(id) { + continue + } + seen.Add(id) + nodeIDs = append(nodeIDs, id) } } + // Drop stable-session entries for nodes that are no longer HA + // candidates so a future reappearance starts fresh. p.lastStableSession.Range(func(id types.NodeID, _ uint64) bool { if !seen.Contains(id) { p.lastStableSession.Delete(id) @@ -96,20 +104,6 @@ func (p *HAHealthProber) ProbeOnce( return } - // Deduplicate node IDs across prefixes. - var nodeIDs []types.NodeID - - dedup := make(set.Set[types.NodeID]) - - for _, nodes := range haNodes { - for _, id := range nodes { - if !dedup.Contains(id) { - dedup.Add(id) - nodeIDs = append(nodeIDs, id) - } - } - } - log.Debug(). Int("haNodes", len(nodeIDs)). Msg("HA health prober starting probe cycle") diff --git a/hscontrol/state/node_store.go b/hscontrol/state/node_store.go index e1e17a89..cece54ed 100644 --- a/hscontrol/state/node_store.go +++ b/hscontrol/state/node_store.go @@ -527,33 +527,24 @@ func (s *NodeStore) applyBatch(batch []work) { // Update node count gauge nodeStoreNodesCount.Set(float64(len(nodes))) - // Send the resulting nodes to all work items that requested them + // Send the resulting nodes to all work items that requested them. + // A zero-value NodeView{} reports Valid()==false, matching node.View() + // for a node that was deleted or never existed. for nodeID, workItems := range nodeResultRequests { + var nodeView types.NodeView if node, exists := nodes[nodeID]; exists { - nodeView := node.View() - for _, w := range workItems { - w.nodeResult <- nodeView + nodeView = node.View() + } - close(w.nodeResult) + for _, w := range workItems { + w.nodeResult <- nodeView - if w.errResult != nil { - w.errResult <- setErrResults[w] + close(w.nodeResult) - close(w.errResult) - } - } - } else { - // Node was deleted or doesn't exist - for _, w := range workItems { - w.nodeResult <- types.NodeView{} // Send invalid view + if w.errResult != nil { + w.errResult <- setErrResults[w] - close(w.nodeResult) - - if w.errResult != nil { - w.errResult <- setErrResults[w] - - close(w.errResult) - } + close(w.errResult) } } } @@ -669,26 +660,13 @@ func snapshotFromNodes( return newSnap } -// electPrimaryRoutes picks the primary advertiser for each non-exit -// prefix. Inputs are restricted to online nodes that advertise the -// prefix. The previous primary is preserved when it is still online -// and healthy (anti-flap); otherwise the lowest-NodeID healthy -// advertiser wins. When every advertiser is unhealthy the previous -// primary is preserved only if still a candidate — falling back to -// any other candidate would point peers at a node the prober has -// already declared unreachable, so leaving the prefix unmapped is -// preferred until a probe cycle finds one that responds. -func electPrimaryRoutes( +// onlineAdvertisers maps each non-exit prefix to the IDs of online nodes +// that approve it. Nodes are visited in the order of ids, so the per-prefix +// slices preserve that order. +func onlineAdvertisers( nodes map[types.NodeID]types.Node, - prev map[netip.Prefix]types.NodeID, -) (map[netip.Prefix]types.NodeID, map[types.NodeID]bool) { - ids := make([]types.NodeID, 0, len(nodes)) - for id := range nodes { - ids = append(ids, id) - } - - slices.Sort(ids) - + ids []types.NodeID, +) map[netip.Prefix][]types.NodeID { advertisers := make(map[netip.Prefix][]types.NodeID) for _, id := range ids { @@ -706,6 +684,24 @@ func electPrimaryRoutes( } } + return advertisers +} + +// electPrimaryRoutes picks the primary advertiser for each non-exit +// prefix. Inputs are restricted to online nodes that advertise the +// prefix. The previous primary is preserved when it is still online +// and healthy (anti-flap); otherwise the lowest-NodeID healthy +// advertiser wins. When every advertiser is unhealthy the previous +// primary is preserved only if still a candidate — falling back to +// any other candidate would point peers at a node the prober has +// already declared unreachable, so leaving the prefix unmapped is +// preferred until a probe cycle finds one that responds. +func electPrimaryRoutes( + nodes map[types.NodeID]types.Node, + prev map[netip.Prefix]types.NodeID, +) (map[netip.Prefix]types.NodeID, map[types.NodeID]bool) { + advertisers := onlineAdvertisers(nodes, slices.Sorted(maps.Keys(nodes))) + routes := make(map[netip.Prefix]types.NodeID, len(advertisers)) for prefix, candidates := range advertisers { if cur, ok := prev[prefix]; ok && @@ -734,7 +730,7 @@ func electPrimaryRoutes( // would point peers at a node the prober has already declared // unreachable; leaving the prefix unmapped is honest until a // probe cycle picks one that responds. - if !found && len(candidates) >= 1 { + if !found { if cur, ok := prev[prefix]; ok && slices.Contains(candidates, cur) { selected = cur found = true @@ -919,21 +915,10 @@ func (s *NodeStore) PrimaryRoutesForNode(id types.NodeID) []netip.Prefix { func (s *NodeStore) HANodes() map[netip.Prefix][]types.NodeID { snap := s.data.Load() - advertisers := make(map[netip.Prefix][]types.NodeID) - - for id, n := range snap.nodesByID { - if n.IsOnline == nil || !*n.IsOnline { - continue - } - - for _, p := range n.AllApprovedRoutes() { - if tsaddr.IsExitRoute(p) { - continue - } - - advertisers[p] = append(advertisers[p], id) - } - } + advertisers := onlineAdvertisers( + snap.nodesByID, + slices.Sorted(maps.Keys(snap.nodesByID)), + ) out := make(map[netip.Prefix][]types.NodeID) @@ -942,7 +927,6 @@ func (s *NodeStore) HANodes() map[netip.Prefix][]types.NodeID { continue } - slices.Sort(ids) out[p] = ids } diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go index a56ffff1..b67d2df1 100644 --- a/hscontrol/state/state.go +++ b/hscontrol/state/state.go @@ -771,6 +771,16 @@ func (s *State) ResolveNode(query string) (types.NodeView, bool) { return s.GetNodeByID(id) } + // keepLowest returns whichever node has the lower ID, so repeated + // calls resolve deterministically across snapshot iterations. + keepLowest := func(cur, cand types.NodeView) types.NodeView { + if !cur.Valid() || cand.ID() < cur.ID() { + return cand + } + + return cur + } + // Try IP address. addr, addrErr := netip.ParseAddr(query) if addrErr == nil { @@ -781,9 +791,7 @@ func (s *State) ResolveNode(query string) (types.NodeView, bool) { continue } - if !match.Valid() || n.ID() < match.ID() { - match = n - } + match = keepLowest(match, n) } return match, match.Valid() @@ -795,13 +803,9 @@ func (s *State) ResolveNode(query string) (types.NodeView, bool) { for _, n := range s.ListNodes().All() { if n.GivenName() == query { - if !givenMatch.Valid() || n.ID() < givenMatch.ID() { - givenMatch = n - } + givenMatch = keepLowest(givenMatch, n) } else if n.Hostname() == query { - if !hostMatch.Valid() || n.ID() < hostMatch.ID() { - hostMatch = n - } + hostMatch = keepLowest(hostMatch, n) } }