From d5a4e6e36a8db6d2e15223d97023cc7a0034f812 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Thu, 9 Apr 2026 17:59:46 +0000 Subject: [PATCH] debug: route statsviz through tsweb.Protected 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. --- hscontrol/debug.go | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/hscontrol/debug.go b/hscontrol/debug.go index 52189af8..3257e494 100644 --- a/hscontrol/debug.go +++ b/hscontrol/debug.go @@ -3,7 +3,9 @@ package hscontrol import ( "encoding/json" "fmt" + "net" "net/http" + "net/netip" "strings" "github.com/arl/statsviz" @@ -12,6 +14,35 @@ import ( "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) @@ -293,8 +324,13 @@ func (h *Headscale) debugHTTPServer() *http.Server { } })) - err := statsviz.Register(debugMux) + // 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)") }