1
0
mirror of https://github.com/juanfont/headscale.git synced 2025-11-10 01:20:58 +01:00

debug: add /debug/http-routes endpoint

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
This commit is contained in:
Kristoffer Dalby 2025-10-27 11:20:55 +01:00
parent e68e2288f7
commit 733fc40c3d
No known key found for this signature in database
3 changed files with 187 additions and 2 deletions

View File

@ -733,7 +733,7 @@ func (h *Headscale) Serve() error {
return fmt.Errorf("failed to bind to TCP address: %w", err)
}
debugHTTPServer := h.debugHTTPServer()
debugHTTPServer := h.debugHTTPServer(router)
errorGroup.Go(func() error { return debugHTTPServer.Serve(debugHTTPListener) })
log.Info().

View File

@ -4,16 +4,18 @@ 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() *http.Server {
func (h *Headscale) debugHTTPServer(mainRouter *mux.Router) *http.Server {
debugMux := http.NewServeMux()
debug := tsweb.Debugger(debugMux)
@ -268,6 +270,34 @@ func (h *Headscale) debugHTTPServer() *http.Server {
}
}))
// 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)")
@ -406,3 +436,96 @@ func (h *Headscale) debugBatcherJSON() DebugBatcherInfo {
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
}

62
hscontrol/debug_test.go Normal file
View File

@ -0,0 +1,62 @@
package hscontrol
import (
"net/http"
"strings"
"testing"
"github.com/gorilla/mux"
)
func TestDebugHTTPRoutes(t *testing.T) {
// Create a test router with some sample routes
router := mux.NewRouter()
router.HandleFunc("/test1", testHandler1).Methods(http.MethodGet).Name("test1-route")
router.HandleFunc("/test2", testHandler2).Methods(http.MethodPost, http.MethodPut)
router.HandleFunc("/test3/{id}", testHandler3)
// Create a test Headscale instance
h := &Headscale{}
// Test text format
textOutput := h.debugHTTPRoutes(router)
if !strings.Contains(textOutput, "/test1") {
t.Errorf("Expected output to contain /test1, got: %s", textOutput)
}
if !strings.Contains(textOutput, "Total routes:") {
t.Errorf("Expected output to contain total routes count, got: %s", textOutput)
}
// Test JSON format
jsonOutput := h.debugHTTPRoutesJSON(router)
if jsonOutput.TotalCount != 3 {
t.Errorf("Expected 3 routes, got: %d", jsonOutput.TotalCount)
}
// Verify first route has the name we set
foundNamedRoute := false
for _, route := range jsonOutput.Routes {
if route.Name == "test1-route" {
foundNamedRoute = true
break
}
}
if !foundNamedRoute {
t.Error("Expected to find route with name 'test1-route'")
}
}
func testHandler1(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}
func testHandler2(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}
func testHandler3(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}