Files
headscale/hscontrol/api/v2/errors.go
T
Kristoffer Dalby a63ee3e032 api/v2: add the acl/validate endpoint
Checks a policy as a write would, without storing it. As on Tailscale,
a refused policy answers 200 with its reason; only an empty body is 400.
Terraform provider 0.29.3 calls it at plan time.
2026-10-29 11:04:05 +01:00

128 lines
3.8 KiB
Go

package apiv2
import (
"errors"
"strconv"
"strings"
"github.com/danielgtaylor/huma/v2"
"github.com/juanfont/headscale/hscontrol/db"
policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2"
"github.com/juanfont/headscale/hscontrol/state"
"gorm.io/gorm"
"tailscale.com/util/multierr"
)
// apiError is the Tailscale API error body. The official Tailscale Go client
// (and therefore the Terraform provider and tscli built on it) decodes 4xx/5xx
// responses into this shape. Huma's default RFC 9457 problem+json would reach
// them with an empty message, so tailscaleErrorTransformer rewrites every v2
// error into this shape. The HTTP status is read from the response code, not
// from the body's status field.
type apiError struct {
Message string `json:"message"`
Data []apiErrorData `json:"data,omitempty"`
Status int `json:"status"`
}
type apiErrorData struct {
User string `json:"user,omitempty"`
Errors []string `json:"errors,omitempty"`
}
// maxErrorDetails caps the failing tests one answer lists.
const maxErrorDetails = 20
// tailscaleErrorTransformer rewrites Huma's RFC 9457 error model into the
// Tailscale error shape. It is registered on the v2 API config only, so the
// headscale-native v1 API keeps emitting problem+json. Non-error bodies pass
// through untouched.
func tailscaleErrorTransformer(_ huma.Context, _ string, v any) (any, error) {
em, ok := v.(*huma.ErrorModel)
if !ok {
return v, nil
}
message := em.Detail
if message == "" {
message = em.Title
}
out := apiError{Message: message, Status: em.Status}
if len(em.Errors) > 0 {
details := make([]string, 0, len(em.Errors))
for _, d := range em.Errors {
details = append(details, d.Error())
}
out.Data = []apiErrorData{{Errors: details}}
}
return out, nil
}
// mapError translates a state/db-layer error into a Huma HTTP error
// (not-found→404, invalid input→400, everything else→500). The transformer
// then reshapes it into the Tailscale body.
func mapError(msg string, err error) error {
if err == nil {
return nil
}
switch {
case errors.Is(err, gorm.ErrRecordNotFound),
errors.Is(err, db.ErrPreAuthKeyNotFound),
errors.Is(err, db.ErrUserNotFound),
errors.Is(err, state.ErrNodeNotFound):
return huma.Error404NotFound(msg, err)
case errors.Is(err, db.ErrPreAuthKeyNotTaggedOrOwned),
errors.Is(err, db.ErrPreAuthKeyACLTagInvalid),
errors.Is(err, db.ErrOAuthClientScopeInvalid),
errors.Is(err, state.ErrGivenNameInvalid),
errors.Is(err, state.ErrGivenNameTaken),
errors.Is(err, state.ErrNodeNameNotUnique),
errors.Is(err, state.ErrRequestedTagsInvalidOrNotPermitted):
return huma.Error400BadRequest(msg, err)
default:
return huma.Error500InternalServerError(msg, err)
}
}
// policyRefusal is a refused policy as Tailscale reports it: the reason as
// message and, when tests failed, one data entry per failing test naming its
// src.
func policyRefusal(err error) (string, []apiErrorData) {
var failures []policyv2.TestFailure
// Range visits every wrapped error, so errors.As would count a failure
// once per wrapper above it.
multierr.Range(err, func(e error) bool {
if tf, ok := e.(*policyv2.TestsFailedError); ok { //nolint:errorlint
failures = append(failures, tf.Failures...)
}
return true
})
if len(failures) == 0 {
return err.Error(), nil
}
listed := failures[:min(len(failures), maxErrorDetails)]
data := make([]apiErrorData, 0, len(listed)+1)
for _, f := range listed {
// A policy writes a user known only by name as name@.
data = append(data, apiErrorData{User: strings.TrimSuffix(f.Src, "@"), Errors: f.Errors})
}
if more := len(failures) - len(listed); more > 0 {
data = append(data, apiErrorData{Errors: []string{"and " + strconv.Itoa(more) + " more"}})
}
return "test(s) failed", data
}