mirror of
https://github.com/juanfont/headscale.git
synced 2025-08-14 13:51:01 +02:00
debug: add json and improve
Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
This commit is contained in:
parent
075547f19d
commit
2e8128b1f4
@ -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)
|
||||
|
@ -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
|
||||
}
|
||||
|
378
hscontrol/state/debug.go
Normal file
378
hscontrol/state/debug.go
Normal file
@ -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(),
|
||||
}
|
||||
}
|
78
hscontrol/state/debug_test.go
Normal file
78
hscontrol/state/debug_test.go
Normal file
@ -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")
|
||||
}
|
@ -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.
|
||||
|
Loading…
Reference in New Issue
Block a user