mirror of
https://github.com/juanfont/headscale.git
synced 2026-04-11 11:37:03 +02:00
Build the statsviz Server directly and wrap its Index/Ws handlers in tsweb.Protected instead of calling statsviz.Register on the raw mux which bypasses AllowDebugAccess.
439 lines
12 KiB
Go
439 lines
12 KiB
Go
package hscontrol
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"strings"
|
|
|
|
"github.com/arl/statsviz"
|
|
"github.com/juanfont/headscale/hscontrol/types"
|
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
|
"tailscale.com/tsweb"
|
|
)
|
|
|
|
// protectedDebugHandler wraps an http.Handler with an access check that
|
|
// allows requests from loopback, Tailscale CGNAT IPs, and private
|
|
// (RFC 1918 / RFC 4193) addresses. This extends tsweb.Protected which
|
|
// only allows loopback and Tailscale IPs.
|
|
func protectedDebugHandler(h http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if tsweb.AllowDebugAccess(r) {
|
|
h.ServeHTTP(w, r)
|
|
|
|
return
|
|
}
|
|
|
|
// tsweb.AllowDebugAccess rejects X-Forwarded-For and non-TS IPs.
|
|
// Additionally allow private/LAN addresses so operators can reach
|
|
// debug endpoints from their local network without tailscaled.
|
|
ipStr, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
if err == nil {
|
|
ip, parseErr := netip.ParseAddr(ipStr)
|
|
if parseErr == nil && ip.IsPrivate() {
|
|
h.ServeHTTP(w, r)
|
|
|
|
return
|
|
}
|
|
}
|
|
|
|
http.Error(w, "debug access denied", http.StatusForbidden)
|
|
})
|
|
}
|
|
|
|
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 := 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))
|
|
}
|
|
}))
|
|
|
|
// statsviz.Register would mount handlers directly on the raw mux,
|
|
// bypassing the access gate. Build the server by hand and wrap
|
|
// each handler with protectedDebugHandler.
|
|
statsvizSrv, err := statsviz.NewServer()
|
|
if err == nil {
|
|
debugMux.Handle("/debug/statsviz/", protectedDebugHandler(statsvizSrv.Index()))
|
|
debugMux.Handle("/debug/statsviz/ws", protectedDebugHandler(statsvizSrv.Ws()))
|
|
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
|
|
|
|
debugInfo := h.mapBatcher.Debug()
|
|
for nodeID, info := range debugInfo {
|
|
nodes = append(nodes, nodeStatus{
|
|
id: nodeID,
|
|
connected: info.Connected,
|
|
activeConnections: info.ActiveConnections,
|
|
})
|
|
totalNodes++
|
|
|
|
if info.Connected {
|
|
connectedCount++
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
|
|
debugInfo := h.mapBatcher.Debug()
|
|
for nodeID, debugData := range debugInfo {
|
|
info.ConnectedNodes[fmt.Sprintf("%d", nodeID)] = DebugBatcherNodeInfo{
|
|
Connected: debugData.Connected,
|
|
ActiveConnections: debugData.ActiveConnections,
|
|
}
|
|
info.TotalNodes++
|
|
}
|
|
|
|
return info
|
|
}
|