Files
headscale/hscontrol/api/v1/errors.go
T
Kristoffer Dalby 18f7048c58 hscontrol/api: map missing-node errors to 404
RenameNode and ExpireNode surface ErrNodeNotInNodeStore for an unknown
node; mapStateError only knew ErrNodeNotFound, so they 500'd. Treat both
as not found.
2026-06-19 05:58:10 +00:00

156 lines
4.6 KiB
Go

package apiv1
import (
"context"
"encoding/json"
"errors"
"net/http"
oas "github.com/juanfont/headscale/gen/api/v1"
"github.com/juanfont/headscale/hscontrol/db"
"github.com/juanfont/headscale/hscontrol/state"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/ogen-go/ogen/ogenerrors"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
)
const problemContentType = "application/problem+json"
// apiError builds an RFC 7807 problem response with the given HTTP status and
// detail. Handlers return it so expected errors render as problem documents
// with the correct status code (ogen encodes [oas.ErrorStatusCode] directly,
// without going through [errorHandler]).
func apiError(status int, detail string) *oas.ErrorStatusCode {
return &oas.ErrorStatusCode{
StatusCode: status,
Response: oas.Problem{
Title: oas.NewOptString(http.StatusText(status)),
//nolint:gosec // G115: status is an HTTP status code, always within int32.
Status: oas.NewOptInt32(int32(status)),
Detail: oas.NewOptString(detail),
},
}
}
func notFound(
detail string,
) *oas.ErrorStatusCode {
return apiError(http.StatusNotFound, detail)
}
func badRequest(
detail string,
) *oas.ErrorStatusCode {
return apiError(http.StatusBadRequest, detail)
}
func internalError(detail string) *oas.ErrorStatusCode {
return apiError(http.StatusInternalServerError, detail)
}
// mapStateError classifies an error returned by the state layer into an HTTP
// problem response. Not-found sentinels become 404; everything else is a 500.
// Handlers that need a different status (e.g. validation 400) build the problem
// explicitly with [badRequest] rather than routing through here.
func mapStateError(err error) *oas.ErrorStatusCode {
switch {
case errors.Is(err, gorm.ErrRecordNotFound),
errors.Is(err, state.ErrNodeNotFound),
errors.Is(err, state.ErrNodeNotInNodeStore),
errors.Is(err, db.ErrUserNotFound):
return notFound(err.Error())
case errors.Is(err, types.ErrPolicyUpdateIsDisabled):
return badRequest(err.Error())
default:
return internalError(err.Error())
}
}
// NewError converts an error that ogen raises outside a handler return value —
// failed authentication, request decoding — into a typed problem response. ogen
// calls it from the generated security/decoding paths; the resulting
// [oas.ErrorStatusCode] is then encoded as application/problem+json.
func (s *Server) NewError(_ context.Context, err error) *oas.ErrorStatusCode {
return classify(err)
}
// errorHandler renders problems for the remaining ogen error path: a plain
// error returned from a handler. Expected errors are returned as
// [oas.ErrorStatusCode] (encoded directly by ogen), so this is the safety net
// for anything else.
func errorHandler(
_ context.Context,
w http.ResponseWriter,
_ *http.Request,
err error,
) {
writeProblem(w, classify(err))
}
// classify maps an arbitrary error to a problem response. An already-typed
// [oas.ErrorStatusCode] passes through; ogen's framework errors carry their own
// HTTP status via Code() (security -> 401, decode -> 400); everything else is
// classified by [mapStateError].
func classify(err error) *oas.ErrorStatusCode {
var esc *oas.ErrorStatusCode
if errors.As(err, &esc) {
return esc
}
// A failed security requirement (missing/malformed bearer) must not echo
// ogen's internal "operation X: security ...: not satisfied" message, which
// leaks the operation name. Return a minimal 401.
var secErr *ogenerrors.SecurityError
if errors.As(err, &secErr) {
return apiError(http.StatusUnauthorized, "valid API key required")
}
var coder interface{ Code() int }
if errors.As(err, &coder) {
return apiError(coder.Code(), err.Error())
}
return mapStateError(err)
}
func writeProblem(w http.ResponseWriter, esc *oas.ErrorStatusCode) {
w.Header().Set("Content-Type", problemContentType)
w.WriteHeader(esc.StatusCode)
p := esc.Response
status := esc.StatusCode
if v, ok := p.Status.Get(); ok {
status = int(v)
}
body := problemJSON{
Title: p.Title.Or(""),
Status: status,
Detail: p.Detail.Or(""),
}
if v, ok := p.Type.Get(); ok {
body.Type = v.String()
}
if v, ok := p.Instance.Get(); ok {
body.Instance = v.String()
}
err := json.NewEncoder(w).Encode(body)
if err != nil {
log.Error().Err(err).Msg("writing problem response failed")
}
}
// problemJSON mirrors [oas.Problem] for hand-written encoding in
// [errorHandler]; ogen's own encoder is package-private.
type problemJSON struct {
Type string `json:"type,omitempty"`
Title string `json:"title,omitempty"`
Status int `json:"status,omitempty"`
Detail string `json:"detail,omitempty"`
Instance string `json:"instance,omitempty"`
}