mirror of
https://github.com/juanfont/headscale.git
synced 2025-11-10 01:20:58 +01:00
Add new debug endpoint to list all registered HTTP routes with their
methods and optional names. This helps with debugging and understanding
the API surface area.
The endpoint supports both text and JSON formats:
Text format (default):
```
=== Registered HTTP Routes ===
/api/v1/ [ALL]
/apple [GET]
/health [GET]
/register/{registration_id} [GET]
/ts2021 [POST, GET]
Total routes: 14
```
JSON format:
```json
{
"routes": [
{
"path": "/health",
"methods": ["GET"]
},
{
"path": "/ts2021",
"methods": ["POST", "GET"]
}
],
"total_count": 14
}
```
Usage:
- Text: curl http://localhost:9090/debug/http-routes
- JSON: curl -H "Accept: application/json" http://localhost:9090/debug/http-routes
Changes:
- Modified debugHTTPServer() to accept router parameter
- Added collectRoutes() to walk router and collect route information
- Added debugHTTPRoutes() for text output
- Added debugHTTPRoutesJSON() for structured output
- Added HTTPRouteInfo and DebugHTTPRoutesInfo types
- Added unit tests for route listing functionality
532 lines
15 KiB
Go
532 lines
15 KiB
Go
package hscontrol
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/arl/statsviz"
|
|
"github.com/gorilla/mux"
|
|
"github.com/juanfont/headscale/hscontrol/mapper"
|
|
"github.com/juanfont/headscale/hscontrol/types"
|
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
|
"tailscale.com/tsweb"
|
|
)
|
|
|
|
func (h *Headscale) debugHTTPServer(mainRouter *mux.Router) *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 := 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(configJSON)
|
|
}))
|
|
|
|
// 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)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write(filterJSON)
|
|
}))
|
|
|
|
// 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
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write(sshJSON)
|
|
}))
|
|
|
|
// 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 {
|
|
nodeStoreNodes := h.state.DebugNodeStoreJSON()
|
|
nodeStoreJSON, err := json.MarshalIndent(nodeStoreNodes, "", " ")
|
|
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(cacheJSON)
|
|
}))
|
|
|
|
// 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))
|
|
}
|
|
}))
|
|
|
|
// 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))
|
|
}
|
|
}))
|
|
|
|
debug.Handle("mapresponses", "Map responses for all nodes", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
res, err := h.mapBatcher.DebugMapResponses()
|
|
if err != nil {
|
|
httpError(w, err)
|
|
return
|
|
}
|
|
|
|
if res == nil {
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte("HEADSCALE_DEBUG_DUMP_MAPRESPONSE_PATH not set"))
|
|
return
|
|
}
|
|
|
|
resJSON, err := json.MarshalIndent(res, "", " ")
|
|
if err != nil {
|
|
httpError(w, err)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write(resJSON)
|
|
}))
|
|
|
|
// Batcher endpoint
|
|
debug.Handle("batcher", "Batcher connected nodes", 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 {
|
|
batcherInfo := h.debugBatcherJSON()
|
|
|
|
batcherJSON, err := json.MarshalIndent(batcherInfo, "", " ")
|
|
if err != nil {
|
|
httpError(w, err)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write(batcherJSON)
|
|
} else {
|
|
// Default to text/plain for backward compatibility
|
|
batcherInfo := h.debugBatcher()
|
|
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(batcherInfo))
|
|
}
|
|
}))
|
|
|
|
// HTTP routes endpoint
|
|
debug.Handle("http-routes", "Registered HTTP 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 {
|
|
routesInfo := h.debugHTTPRoutesJSON(mainRouter)
|
|
|
|
routesJSON, err := json.MarshalIndent(routesInfo, "", " ")
|
|
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
|
|
routesInfo := h.debugHTTPRoutes(mainRouter)
|
|
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(routesInfo))
|
|
}
|
|
}))
|
|
|
|
err := statsviz.Register(debugMux)
|
|
if err == nil {
|
|
debug.URL("/debug/statsviz", "Statsviz (visualise go metrics)")
|
|
}
|
|
|
|
debug.URL("/metrics", "Prometheus metrics")
|
|
debugMux.Handle("/metrics", promhttp.Handler())
|
|
|
|
debugHTTPServer := &http.Server{
|
|
Addr: h.cfg.MetricsAddr,
|
|
Handler: debugMux,
|
|
ReadTimeout: types.HTTPTimeout,
|
|
WriteTimeout: 0,
|
|
}
|
|
|
|
return debugHTTPServer
|
|
}
|
|
|
|
// debugBatcher returns debug information about the batcher's connected nodes.
|
|
func (h *Headscale) debugBatcher() string {
|
|
var sb strings.Builder
|
|
sb.WriteString("=== Batcher Connected Nodes ===\n\n")
|
|
|
|
totalNodes := 0
|
|
connectedCount := 0
|
|
|
|
// Collect nodes and sort them by ID
|
|
type nodeStatus struct {
|
|
id types.NodeID
|
|
connected bool
|
|
activeConnections int
|
|
}
|
|
|
|
var nodes []nodeStatus
|
|
|
|
// Try to get detailed debug info if we have a LockFreeBatcher
|
|
if batcher, ok := h.mapBatcher.(*mapper.LockFreeBatcher); ok {
|
|
debugInfo := batcher.Debug()
|
|
for nodeID, info := range debugInfo {
|
|
nodes = append(nodes, nodeStatus{
|
|
id: nodeID,
|
|
connected: info.Connected,
|
|
activeConnections: info.ActiveConnections,
|
|
})
|
|
totalNodes++
|
|
if info.Connected {
|
|
connectedCount++
|
|
}
|
|
}
|
|
} else {
|
|
// Fallback to basic connection info
|
|
connectedMap := h.mapBatcher.ConnectedMap()
|
|
connectedMap.Range(func(nodeID types.NodeID, connected bool) bool {
|
|
nodes = append(nodes, nodeStatus{
|
|
id: nodeID,
|
|
connected: connected,
|
|
activeConnections: 0,
|
|
})
|
|
totalNodes++
|
|
if connected {
|
|
connectedCount++
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
|
|
// Sort by node ID
|
|
for i := 0; i < len(nodes); i++ {
|
|
for j := i + 1; j < len(nodes); j++ {
|
|
if nodes[i].id > nodes[j].id {
|
|
nodes[i], nodes[j] = nodes[j], nodes[i]
|
|
}
|
|
}
|
|
}
|
|
|
|
// Output sorted nodes
|
|
for _, node := range nodes {
|
|
status := "disconnected"
|
|
if node.connected {
|
|
status = "connected"
|
|
}
|
|
|
|
if node.activeConnections > 0 {
|
|
sb.WriteString(fmt.Sprintf("Node %d:\t%s (%d connections)\n", node.id, status, node.activeConnections))
|
|
} else {
|
|
sb.WriteString(fmt.Sprintf("Node %d:\t%s\n", node.id, status))
|
|
}
|
|
}
|
|
|
|
sb.WriteString(fmt.Sprintf("\nSummary: %d connected, %d total\n", connectedCount, totalNodes))
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
// DebugBatcherInfo represents batcher connection information in a structured format.
|
|
type DebugBatcherInfo struct {
|
|
ConnectedNodes map[string]DebugBatcherNodeInfo `json:"connected_nodes"` // NodeID -> node connection info
|
|
TotalNodes int `json:"total_nodes"`
|
|
}
|
|
|
|
// DebugBatcherNodeInfo represents connection information for a single node.
|
|
type DebugBatcherNodeInfo struct {
|
|
Connected bool `json:"connected"`
|
|
ActiveConnections int `json:"active_connections"`
|
|
}
|
|
|
|
// debugBatcherJSON returns structured debug information about the batcher's connected nodes.
|
|
func (h *Headscale) debugBatcherJSON() DebugBatcherInfo {
|
|
info := DebugBatcherInfo{
|
|
ConnectedNodes: make(map[string]DebugBatcherNodeInfo),
|
|
TotalNodes: 0,
|
|
}
|
|
|
|
// Try to get detailed debug info if we have a LockFreeBatcher
|
|
if batcher, ok := h.mapBatcher.(*mapper.LockFreeBatcher); ok {
|
|
debugInfo := batcher.Debug()
|
|
for nodeID, debugData := range debugInfo {
|
|
info.ConnectedNodes[fmt.Sprintf("%d", nodeID)] = DebugBatcherNodeInfo{
|
|
Connected: debugData.Connected,
|
|
ActiveConnections: debugData.ActiveConnections,
|
|
}
|
|
info.TotalNodes++
|
|
}
|
|
} else {
|
|
// Fallback to basic connection info
|
|
connectedMap := h.mapBatcher.ConnectedMap()
|
|
connectedMap.Range(func(nodeID types.NodeID, connected bool) bool {
|
|
info.ConnectedNodes[fmt.Sprintf("%d", nodeID)] = DebugBatcherNodeInfo{
|
|
Connected: connected,
|
|
ActiveConnections: 0,
|
|
}
|
|
info.TotalNodes++
|
|
return true
|
|
})
|
|
}
|
|
|
|
return info
|
|
}
|
|
|
|
// HTTPRouteInfo represents information about a registered HTTP route.
|
|
type HTTPRouteInfo struct {
|
|
Path string `json:"path"`
|
|
Methods []string `json:"methods"`
|
|
Name string `json:"name,omitempty"`
|
|
}
|
|
|
|
// DebugHTTPRoutesInfo represents all HTTP routes in a structured format.
|
|
type DebugHTTPRoutesInfo struct {
|
|
Routes []HTTPRouteInfo `json:"routes"`
|
|
TotalCount int `json:"total_count"`
|
|
}
|
|
|
|
// debugHTTPRoutes returns a text representation of all registered HTTP routes.
|
|
func (h *Headscale) debugHTTPRoutes(router *mux.Router) string {
|
|
var sb strings.Builder
|
|
sb.WriteString("=== Registered HTTP Routes ===\n\n")
|
|
|
|
routes := collectRoutes(router)
|
|
|
|
for _, route := range routes {
|
|
methods := strings.Join(route.Methods, ", ")
|
|
if methods == "" {
|
|
methods = "ALL"
|
|
}
|
|
|
|
if route.Name != "" {
|
|
sb.WriteString(fmt.Sprintf("%-50s [%-20s] %s\n", route.Path, methods, route.Name))
|
|
} else {
|
|
sb.WriteString(fmt.Sprintf("%-50s [%-20s]\n", route.Path, methods))
|
|
}
|
|
}
|
|
|
|
sb.WriteString(fmt.Sprintf("\nTotal routes: %d\n", len(routes)))
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
// debugHTTPRoutesJSON returns a structured representation of all registered HTTP routes.
|
|
func (h *Headscale) debugHTTPRoutesJSON(router *mux.Router) DebugHTTPRoutesInfo {
|
|
routes := collectRoutes(router)
|
|
|
|
return DebugHTTPRoutesInfo{
|
|
Routes: routes,
|
|
TotalCount: len(routes),
|
|
}
|
|
}
|
|
|
|
// collectRoutes walks the router and collects all registered routes.
|
|
// Routes are returned sorted by path for consistent output.
|
|
func collectRoutes(router *mux.Router) []HTTPRouteInfo {
|
|
var routes []HTTPRouteInfo
|
|
|
|
_ = router.Walk(func(route *mux.Route, router *mux.Router, ancestors []*mux.Route) error {
|
|
pathTemplate, err := route.GetPathTemplate()
|
|
if err != nil {
|
|
// If we can't get a path template, try GetPathRegexp
|
|
var pathRegexp string
|
|
|
|
pathRegexp, err = route.GetPathRegexp()
|
|
if err != nil {
|
|
// Skip routes without a path (both template and regexp failed)
|
|
return nil //nolint:nilerr // intentionally skip routes without paths
|
|
}
|
|
|
|
pathTemplate = pathRegexp
|
|
}
|
|
|
|
methods, err := route.GetMethods()
|
|
if err != nil {
|
|
// No methods means it accepts all methods
|
|
methods = []string{}
|
|
}
|
|
|
|
name := route.GetName()
|
|
|
|
routes = append(routes, HTTPRouteInfo{
|
|
Path: pathTemplate,
|
|
Methods: methods,
|
|
Name: name,
|
|
})
|
|
|
|
return nil
|
|
})
|
|
|
|
// Sort routes by path for consistent output
|
|
sort.Slice(routes, func(i, j int) bool {
|
|
return routes[i].Path < routes[j].Path
|
|
})
|
|
|
|
return routes
|
|
}
|