Files
headscale/hscontrol/handlers.go
Kristoffer Dalby 78990491da oidc: render HTML error pages for browser-facing failures
Add httpUserError() alongside httpError() for browser-facing error
paths. It renders a styled HTML page using the AuthError template
instead of returning plain text. Technical error details stay in
server logs; the HTML page shows actionable messages derived from
the HTTP status code:

  401/403 → "You are not authorized. Please contact your administrator."
  410     → "Your session has expired. Please try again."
  400-499 → "The request could not be processed. Please try again."
  500+    → "Something went wrong. Please try again later."

Convert all httpError calls in oidc.go (OIDC callback, SSH check,
registration confirm) to httpUserError. Machine-facing endpoints
(noise, verify, key, health, debug) are unchanged.

Fixes juanfont/headscale#3182
2026-04-13 17:23:47 +01:00

403 lines
11 KiB
Go

package hscontrol
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/juanfont/headscale/hscontrol/assets"
"github.com/juanfont/headscale/hscontrol/templates"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/rs/zerolog/log"
"tailscale.com/tailcfg"
)
const (
// NoiseCapabilityVersion is used by Tailscale clients to indicate
// their codebase version. Tailscale clients can communicate over TS2021
// from CapabilityVersion 28, but we only have good support for it
// since https://github.com/tailscale/tailscale/pull/4323 (Noise in any HTTPS port).
//
// Related to this change, there is https://github.com/tailscale/tailscale/pull/5379,
// where CapabilityVersion 39 is introduced to indicate #4323 was merged.
//
// See also https://github.com/tailscale/tailscale/blob/main/tailcfg/tailcfg.go
NoiseCapabilityVersion = 39
reservedResponseHeaderSize = 4
)
// httpError logs an error and sends an HTTP error response with the given.
func httpError(w http.ResponseWriter, err error) {
if herr, ok := errors.AsType[HTTPError](err); ok {
http.Error(w, herr.Msg, herr.Code)
log.Error().Err(herr.Err).Int("code", herr.Code).Msgf("user msg: %s", herr.Msg)
} else {
http.Error(w, "internal server error", http.StatusInternalServerError)
log.Error().Err(err).Int("code", http.StatusInternalServerError).Msg("http internal server error")
}
}
// httpUserError logs an error and sends a styled HTML error page.
// Use this for browser-facing error paths (OIDC, registration confirm)
// where the user should see a branded page instead of plain text.
// Technical details go to the server log; the HTML page only shows
// an actionable message derived from the HTTP status code.
func httpUserError(w http.ResponseWriter, err error) {
code := http.StatusInternalServerError
if herr, ok := errors.AsType[HTTPError](err); ok {
if herr.Code != 0 {
code = herr.Code
}
log.Error().Err(herr.Err).Int("code", code).Msgf("user msg: %s", herr.Msg)
} else {
log.Error().Err(err).Int("code", code).Msg("http internal server error")
}
userMsg := userMessageForStatusCode(code)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(code)
page := templates.AuthError(templates.AuthErrorResult{
Title: "Headscale - Error",
Heading: http.StatusText(code),
Message: userMsg,
})
_, werr := w.Write([]byte(page.Render()))
if werr != nil {
log.Error().Err(werr).Msg("failed to write HTML error response")
}
}
func userMessageForStatusCode(code int) string {
switch {
case code == http.StatusUnauthorized || code == http.StatusForbidden:
return "You are not authorized. Please contact your administrator."
case code == http.StatusGone:
return "Your session has expired. Please try again."
case code >= 400 && code < 500:
return "The request could not be processed. Please try again."
default:
return "Something went wrong. Please try again later."
}
}
// HTTPError represents an error that is surfaced to the user via web.
type HTTPError struct {
Code int // HTTP response code to send to client; 0 means 500
Msg string // Response body to send to client
Err error // Detailed error to log on the server
}
func (e HTTPError) Error() string { return fmt.Sprintf("http error[%d]: %s, %s", e.Code, e.Msg, e.Err) }
func (e HTTPError) Unwrap() error { return e.Err }
// NewHTTPError returns an HTTPError containing the given information.
func NewHTTPError(code int, msg string, err error) HTTPError {
return HTTPError{Code: code, Msg: msg, Err: err}
}
var errMethodNotAllowed = NewHTTPError(http.StatusMethodNotAllowed, "method not allowed", nil)
var ErrRegisterMethodCLIDoesNotSupportExpire = errors.New(
"machines registered with CLI do not support expiry",
)
func parseCapabilityVersion(req *http.Request) (tailcfg.CapabilityVersion, error) {
clientCapabilityStr := req.URL.Query().Get("v")
if clientCapabilityStr == "" {
return 0, NewHTTPError(http.StatusBadRequest, "capability version must be set", nil)
}
clientCapabilityVersion, err := strconv.Atoi(clientCapabilityStr)
if err != nil {
return 0, NewHTTPError(http.StatusBadRequest, "invalid capability version", fmt.Errorf("parsing capability version: %w", err))
}
return tailcfg.CapabilityVersion(clientCapabilityVersion), nil
}
// verifyBodyLimit caps the request body for /verify. The DERP verify
// protocol payload (tailcfg.DERPAdmitClientRequest) is a few hundred
// bytes; 4 KiB is generous and prevents an unauthenticated client from
// OOMing the public router with arbitrarily large POSTs.
const verifyBodyLimit int64 = 4 * 1024
func (h *Headscale) handleVerifyRequest(
req *http.Request,
writer io.Writer,
) error {
body, err := io.ReadAll(req.Body)
if err != nil {
return NewHTTPError(http.StatusRequestEntityTooLarge, "request body too large", fmt.Errorf("reading request body: %w", err))
}
var derpAdmitClientRequest tailcfg.DERPAdmitClientRequest
if err := json.Unmarshal(body, &derpAdmitClientRequest); err != nil { //nolint:noinlineerr
return NewHTTPError(http.StatusBadRequest, "Bad Request: invalid JSON", fmt.Errorf("parsing DERP client request: %w", err))
}
nodes := h.state.ListNodes()
// Check if any node has the requested NodeKey
var nodeKeyFound bool
for _, node := range nodes.All() {
if node.NodeKey() == derpAdmitClientRequest.NodePublic {
nodeKeyFound = true
break
}
}
resp := &tailcfg.DERPAdmitClientResponse{
Allow: nodeKeyFound,
}
return json.NewEncoder(writer).Encode(resp)
}
// VerifyHandler see https://github.com/tailscale/tailscale/blob/964282d34f06ecc06ce644769c66b0b31d118340/derp/derp_server.go#L1159
// DERP use verifyClientsURL to verify whether a client is allowed to connect to the DERP server.
func (h *Headscale) VerifyHandler(
writer http.ResponseWriter,
req *http.Request,
) {
if req.Method != http.MethodPost {
httpError(writer, errMethodNotAllowed)
return
}
req.Body = http.MaxBytesReader(writer, req.Body, verifyBodyLimit)
err := h.handleVerifyRequest(req, writer)
if err != nil {
httpError(writer, err)
return
}
writer.Header().Set("Content-Type", "application/json")
}
// KeyHandler provides the Headscale pub key
// Listens in /key.
func (h *Headscale) KeyHandler(
writer http.ResponseWriter,
req *http.Request,
) {
// New Tailscale clients send a 'v' parameter to indicate the CurrentCapabilityVersion
capVer, err := parseCapabilityVersion(req)
if err != nil {
httpError(writer, err)
return
}
// TS2021 (Tailscale v2 protocol) requires to have a different key
if capVer >= NoiseCapabilityVersion {
resp := tailcfg.OverTLSPublicKeyResponse{
PublicKey: h.noisePrivateKey.Public(),
}
writer.Header().Set("Content-Type", "application/json")
err := json.NewEncoder(writer).Encode(resp)
if err != nil {
log.Error().Err(err).Msg("failed to encode public key response")
}
return
}
}
func (h *Headscale) HealthHandler(
writer http.ResponseWriter,
req *http.Request,
) {
respond := func(err error) {
writer.Header().Set("Content-Type", "application/health+json; charset=utf-8")
res := struct {
Status string `json:"status"`
}{
Status: "pass",
}
if err != nil {
writer.WriteHeader(http.StatusInternalServerError)
res.Status = "fail"
}
encErr := json.NewEncoder(writer).Encode(res)
if encErr != nil {
log.Error().Err(encErr).Msg("failed to encode health response")
}
}
err := h.state.PingDB(req.Context())
if err != nil {
respond(err)
return
}
respond(nil)
}
func (h *Headscale) RobotsHandler(
writer http.ResponseWriter,
req *http.Request,
) {
writer.Header().Set("Content-Type", "text/plain")
writer.WriteHeader(http.StatusOK)
_, err := writer.Write([]byte("User-agent: *\nDisallow: /"))
if err != nil {
log.Error().
Caller().
Err(err).
Msg("Failed to write HTTP response")
}
}
// VersionHandler returns version information about the Headscale server
// Listens in /version.
func (h *Headscale) VersionHandler(
writer http.ResponseWriter,
req *http.Request,
) {
writer.Header().Set("Content-Type", "application/json")
writer.WriteHeader(http.StatusOK)
versionInfo := types.GetVersionInfo()
err := json.NewEncoder(writer).Encode(versionInfo)
if err != nil {
log.Error().
Caller().
Err(err).
Msg("Failed to write version response")
}
}
type AuthProviderWeb struct {
serverURL string
}
func NewAuthProviderWeb(serverURL string) *AuthProviderWeb {
return &AuthProviderWeb{
serverURL: serverURL,
}
}
func (a *AuthProviderWeb) RegisterURL(authID types.AuthID) string {
return fmt.Sprintf(
"%s/register/%s",
strings.TrimSuffix(a.serverURL, "/"),
authID.String())
}
func (a *AuthProviderWeb) AuthURL(authID types.AuthID) string {
return fmt.Sprintf(
"%s/auth/%s",
strings.TrimSuffix(a.serverURL, "/"),
authID.String())
}
func (a *AuthProviderWeb) AuthHandler(
writer http.ResponseWriter,
req *http.Request,
) {
authID, err := authIDFromRequest(req)
if err != nil {
httpError(writer, err)
return
}
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
writer.WriteHeader(http.StatusOK)
_, err = writer.Write([]byte(templates.AuthWeb(
"Authentication check",
"Run the command below in the headscale server to approve this authentication request:",
"headscale auth approve --auth-id "+authID.String(),
).Render()))
if err != nil {
log.Error().Err(err).Msg("failed to write auth response")
}
}
func authIDFromRequest(req *http.Request) (types.AuthID, error) {
raw, err := urlParam[string](req, "auth_id")
if err != nil {
return "", NewHTTPError(http.StatusBadRequest, "invalid auth id", fmt.Errorf("parsing auth_id from URL: %w", err))
}
// We need to make sure we dont open for XSS style injections, if the parameter that
// is passed as a key is not parsable/validated as a NodePublic key, then fail to render
// the template and log an error.
authId, err := types.AuthIDFromString(raw)
if err != nil {
return "", NewHTTPError(http.StatusBadRequest, "invalid auth id", fmt.Errorf("parsing auth_id from URL: %w", err))
}
return authId, nil
}
// RegisterHandler shows a simple message in the browser to point to the CLI
// Listens in /register/:registration_id.
//
// This is not part of the Tailscale control API, as we could send whatever URL
// in the RegisterResponse.AuthURL field.
func (a *AuthProviderWeb) RegisterHandler(
writer http.ResponseWriter,
req *http.Request,
) {
authId, err := authIDFromRequest(req)
if err != nil {
httpError(writer, err)
return
}
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
writer.WriteHeader(http.StatusOK)
_, err = writer.Write([]byte(templates.AuthWeb(
"Node registration",
"Run the command below in the headscale server to add this node to your network:",
fmt.Sprintf("headscale auth register --auth-id %s --user USERNAME", authId.String()),
).Render()))
if err != nil {
log.Error().Err(err).Msg("failed to write register response")
}
}
func FaviconHandler(writer http.ResponseWriter, req *http.Request) {
writer.Header().Set("Content-Type", "image/png")
http.ServeContent(writer, req, "favicon.ico", time.Unix(0, 0), bytes.NewReader(assets.Favicon))
}
// BlankHandler returns a blank page with favicon linked.
func BlankHandler(writer http.ResponseWriter, res *http.Request) {
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
writer.WriteHeader(http.StatusOK)
_, err := writer.Write([]byte(templates.BlankPage().Render()))
if err != nil {
log.Error().
Caller().
Err(err).
Msg("Failed to write HTTP response")
}
}