From 2e8128b1f45e54bad3ba7c5c87740e969728d2ac Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 6 Aug 2025 08:44:16 +0200 Subject: [PATCH] debug: add json and improve Signed-off-by: Kristoffer Dalby --- hscontrol/debug.go | 234 ++++++++++++++------- hscontrol/routes/primary.go | 112 +++++++++- hscontrol/state/debug.go | 378 ++++++++++++++++++++++++++++++++++ hscontrol/state/debug_test.go | 78 +++++++ hscontrol/state/node_store.go | 64 +++++- 5 files changed, 791 insertions(+), 75 deletions(-) create mode 100644 hscontrol/state/debug.go create mode 100644 hscontrol/state/debug_test.go diff --git a/hscontrol/debug.go b/hscontrol/debug.go index 7583c5ad..20366f2b 100644 --- a/hscontrol/debug.go +++ b/hscontrol/debug.go @@ -2,60 +2,83 @@ package hscontrol import ( "encoding/json" - "fmt" "net/http" - "os" + "strings" "github.com/arl/statsviz" "github.com/juanfont/headscale/hscontrol/types" - "github.com/juanfont/headscale/hscontrol/util" "github.com/prometheus/client_golang/prometheus/promhttp" - "tailscale.com/tailcfg" "tailscale.com/tsweb" ) func (h *Headscale) debugHTTPServer() *http.Server { debugMux := http.NewServeMux() debug := tsweb.Debugger(debugMux) + + // State overview endpoint + debug.Handle("overview", "State overview", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check Accept header to determine response format + acceptHeader := r.Header.Get("Accept") + wantsJSON := strings.Contains(acceptHeader, "application/json") + + if wantsJSON { + overview := h.state.DebugOverviewJSON() + overviewJSON, err := json.MarshalIndent(overview, "", " ") + if err != nil { + httpError(w, err) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(overviewJSON) + } else { + // Default to text/plain for backward compatibility + overview := h.state.DebugOverview() + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + w.Write([]byte(overview)) + } + })) + + // Configuration endpoint debug.Handle("config", "Current configuration", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - config, err := json.MarshalIndent(h.cfg, "", " ") + config := h.state.DebugConfig() + configJSON, err := json.MarshalIndent(config, "", " ") if err != nil { httpError(w, err) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - w.Write(config) + w.Write(configJSON) })) - debug.Handle("policy", "Current policy", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch h.cfg.Policy.Mode { - case types.PolicyModeDB: - p, err := h.state.GetPolicy() - if err != nil { - httpError(w, err) - return - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - w.Write([]byte(p.Data)) - case types.PolicyModeFile: - // Read the file directly for debug purposes - absPath := util.AbsolutePathFromConfigPath(h.cfg.Policy.Path) - pol, err := os.ReadFile(absPath) - if err != nil { - httpError(w, err) - return - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - w.Write(pol) - default: - httpError(w, fmt.Errorf("unsupported policy mode: %s", h.cfg.Policy.Mode)) - } - })) - debug.Handle("filter", "Current filter", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - filter, _ := h.state.Filter() + // Policy endpoint + debug.Handle("policy", "Current policy", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + policy, err := h.state.DebugPolicy() + if err != nil { + httpError(w, err) + return + } + // Policy data is HuJSON, which is a superset of JSON + // Set content type based on Accept header preference + acceptHeader := r.Header.Get("Accept") + if strings.Contains(acceptHeader, "application/json") { + w.Header().Set("Content-Type", "application/json") + } else { + w.Header().Set("Content-Type", "text/plain") + } + w.WriteHeader(http.StatusOK) + w.Write([]byte(policy)) + })) + + // Filter rules endpoint + debug.Handle("filter", "Current filter rules", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + filter, err := h.state.DebugFilter() + if err != nil { + httpError(w, err) + return + } filterJSON, err := json.MarshalIndent(filter, "", " ") if err != nil { httpError(w, err) @@ -65,25 +88,11 @@ func (h *Headscale) debugHTTPServer() *http.Server { w.WriteHeader(http.StatusOK) w.Write(filterJSON) })) - debug.Handle("ssh", "SSH Policy per node", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - nodes, err := h.state.ListNodes() - if err != nil { - httpError(w, err) - return - } - sshPol := make(map[string]*tailcfg.SSHPolicy) - for _, node := range nodes.All() { - pol, err := h.state.SSHPolicy(node) - if err != nil { - httpError(w, err) - return - } - - sshPol[fmt.Sprintf("id:%d hostname:%s givenname:%s", node.ID(), node.Hostname(), node.GivenName())] = pol - } - - sshJSON, err := json.MarshalIndent(sshPol, "", " ") + // SSH policies endpoint + debug.Handle("ssh", "SSH policies per node", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sshPolicies := h.state.DebugSSHPolicies() + sshJSON, err := json.MarshalIndent(sshPolicies, "", " ") if err != nil { httpError(w, err) return @@ -92,33 +101,118 @@ func (h *Headscale) debugHTTPServer() *http.Server { w.WriteHeader(http.StatusOK) w.Write(sshJSON) })) - debug.Handle("derpmap", "Current DERPMap", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - dm := h.state.DERPMap() - dmJSON, err := json.MarshalIndent(dm, "", " ") + // DERP map endpoint + debug.Handle("derp", "DERP map configuration", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check Accept header to determine response format + acceptHeader := r.Header.Get("Accept") + wantsJSON := strings.Contains(acceptHeader, "application/json") + + if wantsJSON { + derpInfo := h.state.DebugDERPJSON() + derpJSON, err := json.MarshalIndent(derpInfo, "", " ") + if err != nil { + httpError(w, err) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(derpJSON) + } else { + // Default to text/plain for backward compatibility + derpInfo := h.state.DebugDERPMap() + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + w.Write([]byte(derpInfo)) + } + })) + + // NodeStore endpoint + debug.Handle("nodestore", "NodeStore information", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check Accept header to determine response format + acceptHeader := r.Header.Get("Accept") + wantsJSON := strings.Contains(acceptHeader, "application/json") + + if wantsJSON { + nodeStoreInfo := h.state.DebugNodeStoreJSON() + nodeStoreJSON, err := json.MarshalIndent(nodeStoreInfo, "", " ") + if err != nil { + httpError(w, err) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(nodeStoreJSON) + } else { + // Default to text/plain for backward compatibility + nodeStoreInfo := h.state.DebugNodeStore() + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + w.Write([]byte(nodeStoreInfo)) + } + })) + + // Registration cache endpoint + debug.Handle("registration-cache", "Registration cache information", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cacheInfo := h.state.DebugRegistrationCache() + cacheJSON, err := json.MarshalIndent(cacheInfo, "", " ") if err != nil { httpError(w, err) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - w.Write(dmJSON) + w.Write(cacheJSON) })) - debug.Handle("registration-cache", "Pending registrations", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // TODO(kradalby): This should be replaced with a proper state method that returns registration info - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - w.Write([]byte("{}")) // For now, return empty object + + // Routes endpoint + debug.Handle("routes", "Primary routes", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check Accept header to determine response format + acceptHeader := r.Header.Get("Accept") + wantsJSON := strings.Contains(acceptHeader, "application/json") + + if wantsJSON { + routes := h.state.DebugRoutes() + routesJSON, err := json.MarshalIndent(routes, "", " ") + if err != nil { + httpError(w, err) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(routesJSON) + } else { + // Default to text/plain for backward compatibility + routes := h.state.DebugRoutesString() + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + w.Write([]byte(routes)) + } })) - debug.Handle("routes", "Routes", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/plain") - w.WriteHeader(http.StatusOK) - w.Write([]byte(h.state.PrimaryRoutesString())) - })) - debug.Handle("policy-manager", "Policy Manager", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/plain") - w.WriteHeader(http.StatusOK) - w.Write([]byte(h.state.PolicyDebugString())) + + // Policy manager endpoint + debug.Handle("policy-manager", "Policy manager state", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check Accept header to determine response format + acceptHeader := r.Header.Get("Accept") + wantsJSON := strings.Contains(acceptHeader, "application/json") + + if wantsJSON { + policyManagerInfo := h.state.DebugPolicyManagerJSON() + policyManagerJSON, err := json.MarshalIndent(policyManagerInfo, "", " ") + if err != nil { + httpError(w, err) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(policyManagerJSON) + } else { + // Default to text/plain for backward compatibility + policyManagerInfo := h.state.DebugPolicyManager() + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + w.Write([]byte(policyManagerInfo)) + } })) err := statsviz.Register(debugMux) diff --git a/hscontrol/routes/primary.go b/hscontrol/routes/primary.go index 55547ccb..ddcacf76 100644 --- a/hscontrol/routes/primary.go +++ b/hscontrol/routes/primary.go @@ -10,6 +10,7 @@ import ( "github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/util" + "github.com/rs/zerolog/log" xmaps "golang.org/x/exp/maps" "tailscale.com/net/tsaddr" "tailscale.com/util/set" @@ -45,6 +46,8 @@ func New() *PrimaryRoutes { // 4. If the primary routes have changed, update the internal state and return true. // 5. Otherwise, return false. func (pr *PrimaryRoutes) updatePrimaryLocked() bool { + log.Debug().Msg("updatePrimaryLocked starting") + // reset the primaries map, as we are going to recalculate it. allPrimaries := make(map[netip.Prefix][]types.NodeID) pr.isPrimary = make(map[types.NodeID]bool) @@ -74,21 +77,50 @@ func (pr *PrimaryRoutes) updatePrimaryLocked() bool { // If the current primary is still available, continue. // If the current primary is not available, select a new one. for prefix, nodes := range allPrimaries { + log.Debug(). + Str("prefix", prefix.String()). + Uints64("availableNodes", func() []uint64 { + ids := make([]uint64, len(nodes)) + for i, id := range nodes { + ids[i] = id.Uint64() + } + + return ids + }()). + Msg("Processing prefix for primary route selection") + if node, ok := pr.primaries[prefix]; ok { // If the current primary is still available, continue. if slices.Contains(nodes, node) { + log.Debug(). + Str("prefix", prefix.String()). + Uint64("currentPrimary", node.Uint64()). + Msg("Current primary still available, keeping it") + continue + } else { + log.Debug(). + Str("prefix", prefix.String()). + Uint64("oldPrimary", node.Uint64()). + Msg("Current primary no longer available") } } if len(nodes) >= 1 { pr.primaries[prefix] = nodes[0] changed = true + log.Debug(). + Str("prefix", prefix.String()). + Uint64("newPrimary", nodes[0].Uint64()). + Msg("Selected new primary for prefix") } } // Clean up any remaining primaries that are no longer valid. for prefix := range pr.primaries { if _, ok := allPrimaries[prefix]; !ok { + log.Debug(). + Str("prefix", prefix.String()). + Msg("Cleaning up primary route that no longer has available nodes") delete(pr.primaries, prefix) changed = true } @@ -99,6 +131,11 @@ func (pr *PrimaryRoutes) updatePrimaryLocked() bool { pr.isPrimary[nodeID] = true } + log.Debug(). + Bool("changed", changed). + Str("finalState", pr.stringLocked()). + Msg("updatePrimaryLocked completed") + return changed } @@ -110,14 +147,31 @@ func (pr *PrimaryRoutes) SetRoutes(node types.NodeID, prefixes ...netip.Prefix) pr.mu.Lock() defer pr.mu.Unlock() + log.Debug(). + Uint64("node.id", node.Uint64()). + Strs("prefixes", util.PrefixesToString(prefixes)). + Msg("PrimaryRoutes.SetRoutes called") + + // If no routes are being set, remove the node from the routes map. if len(prefixes) == 0 { + wasPresent := false if _, ok := pr.routes[node]; ok { delete(pr.routes, node) - return pr.updatePrimaryLocked() + wasPresent = true + log.Debug(). + Uint64("node.id", node.Uint64()). + Msg("Removed node from primary routes (no prefixes)") } + changed := pr.updatePrimaryLocked() + log.Debug(). + Uint64("node.id", node.Uint64()). + Bool("wasPresent", wasPresent). + Bool("changed", changed). + Str("newState", pr.stringLocked()). + Msg("SetRoutes completed (remove)") - return false + return changed } rs := make(set.Set[netip.Prefix], len(prefixes)) @@ -129,11 +183,25 @@ func (pr *PrimaryRoutes) SetRoutes(node types.NodeID, prefixes ...netip.Prefix) if rs.Len() != 0 { pr.routes[node] = rs + log.Debug(). + Uint64("node.id", node.Uint64()). + Strs("routes", util.PrefixesToString(rs.Slice())). + Msg("Updated node routes in primary route manager") } else { delete(pr.routes, node) + log.Debug(). + Uint64("node.id", node.Uint64()). + Msg("Removed node from primary routes (only exit routes)") } - return pr.updatePrimaryLocked() + changed := pr.updatePrimaryLocked() + log.Debug(). + Uint64("node.id", node.Uint64()). + Bool("changed", changed). + Str("newState", pr.stringLocked()). + Msg("SetRoutes completed (update)") + + return changed } func (pr *PrimaryRoutes) PrimaryRoutes(id types.NodeID) []netip.Prefix { @@ -188,3 +256,41 @@ func (pr *PrimaryRoutes) stringLocked() string { return sb.String() } + +// DebugRoutes represents the primary routes state in a structured format for JSON serialization. +type DebugRoutes struct { + // AvailableRoutes maps node IDs to their advertised routes + // In the context of primary routes, this represents the routes that are available + // for each node. A route will only be available if it is advertised by the node + // AND approved. + // Only routes by nodes currently connected to the headscale server are included. + AvailableRoutes map[types.NodeID][]netip.Prefix `json:"available_routes"` + + // PrimaryRoutes maps route prefixes to the primary node serving them + PrimaryRoutes map[string]types.NodeID `json:"primary_routes"` +} + +// DebugJSON returns a structured representation of the primary routes state suitable for JSON serialization. +func (pr *PrimaryRoutes) DebugJSON() DebugRoutes { + pr.mu.Lock() + defer pr.mu.Unlock() + + debug := DebugRoutes{ + AvailableRoutes: make(map[types.NodeID][]netip.Prefix), + PrimaryRoutes: make(map[string]types.NodeID), + } + + // Populate available routes + for nodeID, routes := range pr.routes { + prefixes := routes.Slice() + tsaddr.SortPrefixes(prefixes) + debug.AvailableRoutes[nodeID] = prefixes + } + + // Populate primary routes + for prefix, nodeID := range pr.primaries { + debug.PrimaryRoutes[prefix.String()] = nodeID + } + + return debug +} diff --git a/hscontrol/state/debug.go b/hscontrol/state/debug.go new file mode 100644 index 00000000..b03e53a2 --- /dev/null +++ b/hscontrol/state/debug.go @@ -0,0 +1,378 @@ +package state + +import ( + "fmt" + "strings" + "time" + + "github.com/juanfont/headscale/hscontrol/routes" + "github.com/juanfont/headscale/hscontrol/types" + "tailscale.com/tailcfg" +) + +// DebugOverviewInfo represents the state overview information in a structured format. +type DebugOverviewInfo struct { + Nodes struct { + Total int `json:"total"` + Online int `json:"online"` + Expired int `json:"expired"` + Ephemeral int `json:"ephemeral"` + } `json:"nodes"` + Users map[string]int `json:"users"` // username -> node count + TotalUsers int `json:"total_users"` + Policy struct { + Mode string `json:"mode"` + Path string `json:"path,omitempty"` + } `json:"policy"` + DERP struct { + Configured bool `json:"configured"` + Regions int `json:"regions"` + } `json:"derp"` + PrimaryRoutes int `json:"primary_routes"` +} + +// DebugDERPInfo represents DERP map information in a structured format. +type DebugDERPInfo struct { + Configured bool `json:"configured"` + TotalRegions int `json:"total_regions"` + Regions map[int]*DebugDERPRegion `json:"regions,omitempty"` +} + +// DebugDERPRegion represents a single DERP region. +type DebugDERPRegion struct { + RegionID int `json:"region_id"` + RegionName string `json:"region_name"` + Nodes []*DebugDERPNode `json:"nodes"` +} + +// DebugDERPNode represents a single DERP node. +type DebugDERPNode struct { + Name string `json:"name"` + HostName string `json:"hostname"` + DERPPort int `json:"derp_port"` + STUNPort int `json:"stun_port,omitempty"` +} + +// DebugStringInfo wraps a debug string for JSON serialization. +type DebugStringInfo struct { + Content string `json:"content"` +} + +// DebugOverview returns a comprehensive overview of the current state for debugging. +func (s *State) DebugOverview() string { + s.mu.RLock() + defer s.mu.RUnlock() + + allNodes := s.nodeStore.ListNodes() + users, _ := s.ListAllUsers() + + var sb strings.Builder + + sb.WriteString("=== Headscale State Overview ===\n\n") + + // Node statistics + sb.WriteString(fmt.Sprintf("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.User().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++ + } + } + } + + sb.WriteString(fmt.Sprintf(" - Online: %d\n", onlineCount)) + sb.WriteString(fmt.Sprintf(" - Expired: %d\n", expiredCount)) + sb.WriteString(fmt.Sprintf(" - Ephemeral: %d\n", ephemeralCount)) + sb.WriteString("\n") + + // User statistics + sb.WriteString(fmt.Sprintf("Users: %d total\n", len(users))) + for userName, nodeCount := range userNodeCounts { + sb.WriteString(fmt.Sprintf(" - %s: %d nodes\n", userName, nodeCount)) + } + sb.WriteString("\n") + + // Policy information + sb.WriteString("Policy:\n") + sb.WriteString(fmt.Sprintf(" - Mode: %s\n", s.cfg.Policy.Mode)) + if s.cfg.Policy.Mode == types.PolicyModeFile { + sb.WriteString(fmt.Sprintf(" - Path: %s\n", s.cfg.Policy.Path)) + } + sb.WriteString("\n") + + // DERP information + if s.derpMap != nil { + sb.WriteString(fmt.Sprintf("DERP: %d regions configured\n", len(s.derpMap.Regions))) + } else { + sb.WriteString("DERP: not configured\n") + } + sb.WriteString("\n") + + // Route information + routeCount := len(strings.Split(strings.TrimSpace(s.primaryRoutes.String()), "\n")) + if s.primaryRoutes.String() == "" { + routeCount = 0 + } + sb.WriteString(fmt.Sprintf("Primary Routes: %d active\n", routeCount)) + sb.WriteString("\n") + + // Registration cache + sb.WriteString("Registration Cache: active\n") + sb.WriteString("\n") + + return sb.String() +} + +// DebugNodeStore returns debug information about the NodeStore. +func (s *State) DebugNodeStore() string { + return s.nodeStore.DebugString() +} + +// DebugDERPMap returns debug information about the DERP map configuration. +func (s *State) DebugDERPMap() string { + if s.derpMap == nil { + return "DERP Map: not configured\n" + } + + var sb strings.Builder + + sb.WriteString("=== DERP Map Configuration ===\n\n") + + sb.WriteString(fmt.Sprintf("Total Regions: %d\n\n", len(s.derpMap.Regions))) + + for regionID, region := range s.derpMap.Regions { + sb.WriteString(fmt.Sprintf("Region %d: %s\n", regionID, region.RegionName)) + sb.WriteString(fmt.Sprintf(" - Nodes: %d\n", len(region.Nodes))) + + for _, node := range region.Nodes { + sb.WriteString(fmt.Sprintf(" - %s (%s:%d)\n", + node.Name, node.HostName, node.DERPPort)) + if node.STUNPort != 0 { + sb.WriteString(fmt.Sprintf(" STUN: %d\n", node.STUNPort)) + } + } + sb.WriteString("\n") + } + + return sb.String() +} + +// DebugSSHPolicies returns debug information about SSH policies for all nodes. +func (s *State) DebugSSHPolicies() map[string]*tailcfg.SSHPolicy { + nodes := s.nodeStore.ListNodes() + + sshPolicies := make(map[string]*tailcfg.SSHPolicy) + + for _, node := range nodes.All() { + if !node.Valid() { + continue + } + + pol, err := s.SSHPolicy(node) + if err != nil { + // Store the error information + continue + } + + key := fmt.Sprintf("id:%d hostname:%s givenname:%s", + node.ID(), node.Hostname(), node.GivenName()) + sshPolicies[key] = pol + } + + return sshPolicies +} + +// DebugRegistrationCache returns debug information about the registration cache. +func (s *State) DebugRegistrationCache() map[string]interface{} { + // The cache doesn't expose internal statistics, so we provide basic info + result := map[string]interface{}{ + "type": "zcache", + "expiration": registerCacheExpiration.String(), + "cleanup": registerCacheCleanup.String(), + "status": "active", + } + + return result +} + +// DebugConfig returns debug information about the current configuration. +func (s *State) DebugConfig() *types.Config { + return s.cfg +} + +// DebugPolicy returns the current policy data as a string. +func (s *State) DebugPolicy() (string, error) { + switch s.cfg.Policy.Mode { + case types.PolicyModeDB: + p, err := s.GetPolicy() + if err != nil { + return "", err + } + + return p.Data, nil + case types.PolicyModeFile: + pol, err := policyBytes(s.db, s.cfg) + if err != nil { + return "", err + } + + return string(pol), nil + default: + return "", fmt.Errorf("unsupported policy mode: %s", s.cfg.Policy.Mode) + } +} + +// DebugFilter returns the current filter rules and matchers. +func (s *State) DebugFilter() ([]tailcfg.FilterRule, error) { + filter, _ := s.Filter() + return filter, nil +} + +// DebugRoutes returns the current primary routes information as a structured object. +func (s *State) DebugRoutes() routes.DebugRoutes { + return s.primaryRoutes.DebugJSON() +} + +// DebugRoutesString returns the current primary routes information as a string. +func (s *State) DebugRoutesString() string { + return s.PrimaryRoutesString() +} + +// DebugPolicyManager returns the policy manager debug string. +func (s *State) DebugPolicyManager() string { + return s.PolicyDebugString() +} + +// PolicyDebugString returns a debug representation of the current policy. +func (s *State) PolicyDebugString() string { + return s.polMan.DebugString() +} + +// DebugOverviewJSON returns a structured overview of the current state for debugging. +func (s *State) DebugOverviewJSON() DebugOverviewInfo { + s.mu.RLock() + defer s.mu.RUnlock() + + allNodes := s.nodeStore.ListNodes() + users, _ := s.ListAllUsers() + + info := DebugOverviewInfo{ + Users: make(map[string]int), + TotalUsers: len(users), + } + + // Node statistics + info.Nodes.Total = allNodes.Len() + now := time.Now() + + for _, node := range allNodes.All() { + if node.Valid() { + userName := node.User().Name + info.Users[userName]++ + + if node.IsOnline().Valid() && node.IsOnline().Get() { + info.Nodes.Online++ + } + + if node.Expiry().Valid() && node.Expiry().Get().Before(now) { + info.Nodes.Expired++ + } + + if node.AuthKey().Valid() && node.AuthKey().Ephemeral() { + info.Nodes.Ephemeral++ + } + } + } + + // Policy information + info.Policy.Mode = string(s.cfg.Policy.Mode) + if s.cfg.Policy.Mode == types.PolicyModeFile { + info.Policy.Path = s.cfg.Policy.Path + } + + // DERP information + if s.derpMap != nil { + info.DERP.Configured = true + info.DERP.Regions = len(s.derpMap.Regions) + } else { + info.DERP.Configured = false + info.DERP.Regions = 0 + } + + // Route information + routeCount := len(strings.Split(strings.TrimSpace(s.primaryRoutes.String()), "\n")) + if s.primaryRoutes.String() == "" { + routeCount = 0 + } + info.PrimaryRoutes = routeCount + + return info +} + +// DebugDERPJSON returns structured debug information about the DERP map configuration. +func (s *State) DebugDERPJSON() DebugDERPInfo { + info := DebugDERPInfo{ + Configured: s.derpMap != nil, + Regions: make(map[int]*DebugDERPRegion), + } + + if s.derpMap == nil { + return info + } + + info.TotalRegions = len(s.derpMap.Regions) + + for regionID, region := range s.derpMap.Regions { + debugRegion := &DebugDERPRegion{ + RegionID: regionID, + RegionName: region.RegionName, + Nodes: make([]*DebugDERPNode, 0, len(region.Nodes)), + } + + for _, node := range region.Nodes { + debugNode := &DebugDERPNode{ + Name: node.Name, + HostName: node.HostName, + DERPPort: node.DERPPort, + STUNPort: node.STUNPort, + } + debugRegion.Nodes = append(debugRegion.Nodes, debugNode) + } + + info.Regions[regionID] = debugRegion + } + + return info +} + +// DebugNodeStoreJSON returns structured debug information about the NodeStore. +func (s *State) DebugNodeStoreJSON() DebugStringInfo { + return DebugStringInfo{ + Content: s.nodeStore.DebugString(), + } +} + +// DebugPolicyManagerJSON returns structured debug information about the policy manager. +func (s *State) DebugPolicyManagerJSON() DebugStringInfo { + return DebugStringInfo{ + Content: s.polMan.DebugString(), + } +} diff --git a/hscontrol/state/debug_test.go b/hscontrol/state/debug_test.go new file mode 100644 index 00000000..ae6c340b --- /dev/null +++ b/hscontrol/state/debug_test.go @@ -0,0 +1,78 @@ +package state + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNodeStoreDebugString(t *testing.T) { + tests := []struct { + name string + setupFn func() *NodeStore + contains []string + }{ + { + name: "empty nodestore", + setupFn: func() *NodeStore { + return NewNodeStore(nil, allowAllPeersFunc) + }, + contains: []string{ + "=== NodeStore Debug Information ===", + "Total Nodes: 0", + "Users with Nodes: 0", + "NodeKey Index: 0 entries", + }, + }, + { + name: "nodestore with data", + setupFn: func() *NodeStore { + node1 := createTestNode(1, 1, "user1", "node1") + node2 := createTestNode(2, 2, "user2", "node2") + + store := NewNodeStore(nil, allowAllPeersFunc) + store.Start() + + store.PutNode(node1) + store.PutNode(node2) + + return store + }, + contains: []string{ + "Total Nodes: 2", + "Users with Nodes: 2", + "Peer Relationships:", + "NodeKey Index: 2 entries", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store := tt.setupFn() + if store.writeQueue != nil { + defer store.Stop() + } + + debugStr := store.DebugString() + + for _, expected := range tt.contains { + assert.Contains(t, debugStr, expected, + "Debug string should contain: %s\nActual debug:\n%s", expected, debugStr) + } + }) + } +} + +func TestDebugRegistrationCache(t *testing.T) { + // Create a minimal NodeStore for testing debug methods + store := NewNodeStore(nil, allowAllPeersFunc) + + debugStr := store.DebugString() + + // Should contain basic debug information + assert.Contains(t, debugStr, "=== NodeStore Debug Information ===") + assert.Contains(t, debugStr, "Total Nodes: 0") + assert.Contains(t, debugStr, "Users with Nodes: 0") + assert.Contains(t, debugStr, "NodeKey Index: 0 entries") +} diff --git a/hscontrol/state/node_store.go b/hscontrol/state/node_store.go index ea0f3b61..902d72ba 100644 --- a/hscontrol/state/node_store.go +++ b/hscontrol/state/node_store.go @@ -240,8 +240,68 @@ func (s *NodeStore) GetNode(id types.NodeID) types.NodeView { } // GetNodeByNodeKey retrieves a node by its NodeKey. -func (s *NodeStore) GetNodeByNodeKey(nodeKey key.NodePublic) types.NodeView { - return s.data.Load().nodesByNodeKey[nodeKey] +// The bool indicates if the node exists or is available (like "err not found"). +// The NodeView might be invalid, so it must be checked with .Valid(), which must be used to ensure +// it isn't an invalid node (this is more of a node error or node is broken). +func (s *NodeStore) GetNodeByNodeKey(nodeKey key.NodePublic) (types.NodeView, bool) { + timer := prometheus.NewTimer(nodeStoreOperationDuration.WithLabelValues("get_by_key")) + defer timer.ObserveDuration() + + nodeStoreOperations.WithLabelValues("get_by_key").Inc() + + nodeView, exists := s.data.Load().nodesByNodeKey[nodeKey] + + return nodeView, exists +} + +// DebugString returns debug information about the NodeStore. +func (s *NodeStore) DebugString() string { + snapshot := s.data.Load() + + var sb strings.Builder + + sb.WriteString("=== NodeStore Debug Information ===\n\n") + + // Basic counts + sb.WriteString(fmt.Sprintf("Total Nodes: %d\n", len(snapshot.nodesByID))) + sb.WriteString(fmt.Sprintf("Users with Nodes: %d\n", len(snapshot.nodesByUser))) + sb.WriteString("\n") + + // User distribution + sb.WriteString("Nodes by User:\n") + for userID, nodes := range snapshot.nodesByUser { + if len(nodes) > 0 { + userName := "unknown" + if len(nodes) > 0 && nodes[0].Valid() { + userName = nodes[0].User().Name + } + sb.WriteString(fmt.Sprintf(" - User %d (%s): %d nodes\n", userID, userName, len(nodes))) + } + } + sb.WriteString("\n") + + // Peer relationships summary + sb.WriteString("Peer Relationships:\n") + totalPeers := 0 + for nodeID, peers := range snapshot.peersByNode { + peerCount := len(peers) + totalPeers += peerCount + if node, exists := snapshot.nodesByID[nodeID]; exists { + sb.WriteString(fmt.Sprintf(" - Node %d (%s): %d peers\n", + nodeID, node.Hostname, peerCount)) + } + } + if len(snapshot.peersByNode) > 0 { + avgPeers := float64(totalPeers) / float64(len(snapshot.peersByNode)) + sb.WriteString(fmt.Sprintf(" - Average peers per node: %.1f\n", avgPeers)) + } + sb.WriteString("\n") + + // Node key index + sb.WriteString(fmt.Sprintf("NodeKey Index: %d entries\n", len(snapshot.nodesByNodeKey))) + sb.WriteString("\n") + + return sb.String() } // ListNodes returns a slice of all nodes in the store.