hscontrol: consolidate debug-endpoint and template helpers

This commit is contained in:
Kristoffer Dalby
2026-06-16 09:00:19 +00:00
parent 3979887d8d
commit 8c10a96921
4 changed files with 76 additions and 272 deletions
+38 -195
View File
@@ -48,51 +48,45 @@ func protectedDebugHandler(h http.Handler) http.Handler {
})
}
// writeJSON marshals v with indentation and writes it as a 200 JSON response.
func writeJSON(w http.ResponseWriter, v any) {
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
httpError(w, err)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(b)
}
// writeDebug renders a debug endpoint as JSON or text/plain depending on the
// request's Accept header. JSON is produced only when explicitly requested;
// text/plain is the default for backward compatibility.
func writeDebug(w http.ResponseWriter, r *http.Request, jsonVal func() any, textVal func() string) {
if strings.Contains(r.Header.Get("Accept"), "application/json") {
writeJSON(w, jsonVal())
return
}
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(textVal()))
}
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))
}
writeDebug(w, r, func() any { return h.state.DebugOverviewJSON() }, h.state.DebugOverview)
}))
// 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)
writeJSON(w, h.state.DebugConfig())
}))
// Policy endpoint
@@ -123,157 +117,37 @@ func (h *Headscale) debugHTTPServer() *http.Server {
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)
writeJSON(w, filter)
}))
// 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)
writeJSON(w, h.state.DebugSSHPolicies())
}))
// 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))
}
writeDebug(w, r, func() any { return h.state.DebugDERPJSON() }, h.state.DebugDERPMap)
}))
// [state.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))
}
writeDebug(w, r, func() any { return h.state.DebugNodeStoreJSON() }, h.state.DebugNodeStore)
}))
// 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)
writeJSON(w, h.state.DebugRegistrationCache())
}))
// 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))
}
writeDebug(w, r, func() any { return h.state.DebugRoutes() }, h.state.DebugRoutesString)
}))
// 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))
}
writeDebug(w, r, func() any { return h.state.DebugPolicyManagerJSON() }, h.state.DebugPolicyManager)
}))
debug.Handle("mapresponses", "Map responses for all nodes", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -290,43 +164,12 @@ func (h *Headscale) debugHTTPServer() *http.Server {
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)
writeJSON(w, res)
}))
// [mapper.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))
}
writeDebug(w, r, func() any { return h.debugBatcherJSON() }, h.debugBatcher)
}))
// Ping endpoint: sends a [tailcfg.PingRequest] to a node and waits for it to respond.
+13 -8
View File
@@ -300,18 +300,23 @@ func NewAuthProviderWeb(serverURL string) *AuthProviderWeb {
}
}
func (a *AuthProviderWeb) RegisterURL(authID types.AuthID) string {
// authPathURL builds an auth-flow URL of the form
// "<serverURL>/<kind>/<id>", trimming a trailing slash from serverURL.
func authPathURL(serverURL, kind string, authID types.AuthID) string {
return fmt.Sprintf(
"%s/register/%s",
strings.TrimSuffix(a.serverURL, "/"),
authID.String())
"%s/%s/%s",
strings.TrimSuffix(serverURL, "/"),
kind,
authID.String(),
)
}
func (a *AuthProviderWeb) RegisterURL(authID types.AuthID) string {
return authPathURL(a.serverURL, "register", authID)
}
func (a *AuthProviderWeb) AuthURL(authID types.AuthID) string {
return fmt.Sprintf(
"%s/auth/%s",
strings.TrimSuffix(a.serverURL, "/"),
authID.String())
return authPathURL(a.serverURL, "auth", authID)
}
func (a *AuthProviderWeb) AuthHandler(
+2 -8
View File
@@ -116,10 +116,7 @@ func NewAuthProviderOIDC(
}
func (a *AuthProviderOIDC) AuthURL(authID types.AuthID) string {
return fmt.Sprintf(
"%s/auth/%s",
strings.TrimSuffix(a.serverURL, "/"),
authID.String())
return authPathURL(a.serverURL, "auth", authID)
}
func (a *AuthProviderOIDC) AuthHandler(
@@ -130,10 +127,7 @@ func (a *AuthProviderOIDC) AuthHandler(
}
func (a *AuthProviderOIDC) RegisterURL(authID types.AuthID) string {
return fmt.Sprintf(
"%s/register/%s",
strings.TrimSuffix(a.serverURL, "/"),
authID.String())
return authPathURL(a.serverURL, "register", authID)
}
// RegisterHandler registers the OIDC callback handler with the given router.
+23 -61
View File
@@ -3,7 +3,6 @@ package hscontrol
import (
"bytes"
_ "embed"
"html/template"
"net/http"
textTemplate "text/template"
@@ -59,32 +58,20 @@ func (h *Headscale) ApplePlatformConfig(
URL: h.cfg.ServerURL,
}
var payload bytes.Buffer
switch platform {
case "macos-standalone":
err := macosStandaloneTemplate.Execute(&payload, platformConfig)
if err != nil {
httpError(writer, err)
return
}
case "macos-app-store":
err := macosAppStoreTemplate.Execute(&payload, platformConfig)
if err != nil {
httpError(writer, err)
return
}
case "ios":
err := iosTemplate.Execute(&payload, platformConfig)
if err != nil {
httpError(writer, err)
return
}
default:
payloadType, ok := applePayloadType[platform]
if !ok {
httpError(writer, NewHTTPError(http.StatusBadRequest, "platform must be ios, macos-app-store or macos-standalone", nil))
return
}
platformConfig.PayloadType = payloadType
var payload bytes.Buffer
if err := payloadTemplate.Execute(&payload, platformConfig); err != nil { //nolint:noinlineerr
httpError(writer, err)
return
}
config := AppleMobileConfig{
UUID: id,
URL: h.cfg.ServerURL,
@@ -110,8 +97,17 @@ type AppleMobileConfig struct {
}
type AppleMobilePlatformConfig struct {
UUID uuid.UUID
URL string
UUID uuid.UUID
URL string
PayloadType string
}
// applePayloadType maps a platform request path to the Tailscale IPN
// PayloadType emitted in the rendered Apple profile.
var applePayloadType = map[string]string{
"ios": "io.tailscale.ipn.ios",
"macos-app-store": "io.tailscale.ipn.macos",
"macos-standalone": "io.tailscale.ipn.macsys",
}
var commonTemplate = textTemplate.Must(
@@ -141,10 +137,10 @@ var commonTemplate = textTemplate.Must(
</plist>`),
)
var iosTemplate = textTemplate.Must(textTemplate.New("iosTemplate").Parse(`
var payloadTemplate = textTemplate.Must(textTemplate.New("payloadTemplate").Parse(`
<dict>
<key>PayloadType</key>
<string>io.tailscale.ipn.ios</string>
<string>{{.PayloadType}}</string>
<key>PayloadUUID</key>
<string>{{.UUID}}</string>
<key>PayloadIdentifier</key>
@@ -158,37 +154,3 @@ var iosTemplate = textTemplate.Must(textTemplate.New("iosTemplate").Parse(`
<string>{{.URL}}</string>
</dict>
`))
var macosAppStoreTemplate = template.Must(template.New("macosTemplate").Parse(`
<dict>
<key>PayloadType</key>
<string>io.tailscale.ipn.macos</string>
<key>PayloadUUID</key>
<string>{{.UUID}}</string>
<key>PayloadIdentifier</key>
<string>com.github.juanfont.headscale</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadEnabled</key>
<true/>
<key>ControlURL</key>
<string>{{.URL}}</string>
</dict>
`))
var macosStandaloneTemplate = template.Must(template.New("macosStandaloneTemplate").Parse(`
<dict>
<key>PayloadType</key>
<string>io.tailscale.ipn.macsys</string>
<key>PayloadUUID</key>
<string>{{.UUID}}</string>
<key>PayloadIdentifier</key>
<string>com.github.juanfont.headscale</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadEnabled</key>
<true/>
<key>ControlURL</key>
<string>{{.URL}}</string>
</dict>
`))