mirror of
https://github.com/juanfont/headscale.git
synced 2026-11-04 12:39:25 +09:00
It 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 a 400.
178 lines
5.1 KiB
Go
178 lines
5.1 KiB
Go
package apiv2
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"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 errors one response lists. huma reports one per bad
|
|
// element of the body, so the caller picks how many there are.
|
|
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}
|
|
|
|
listed := em.Errors[:min(len(em.Errors), maxErrorDetails)]
|
|
|
|
details := make([]string, 0, len(listed)+1)
|
|
for _, d := range listed {
|
|
// huma stores a nil detail for a nil error argument.
|
|
if d == nil {
|
|
continue
|
|
}
|
|
|
|
// The value is not echoed: for an unexpected property it is the whole
|
|
// request object, once per property.
|
|
if d.Location == "" {
|
|
details = append(details, d.Message)
|
|
} else {
|
|
details = append(details, d.Message+" ("+d.Location+")")
|
|
}
|
|
}
|
|
|
|
if more := len(em.Errors) - len(listed); more > 0 {
|
|
details = append(details, "and "+strconv.Itoa(more)+" more")
|
|
}
|
|
|
|
if len(details) > 0 {
|
|
out.Data = []apiErrorData{{Errors: details}}
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
func (e *apiError) Error() string { return e.Message }
|
|
|
|
func (e *apiError) GetStatus() int { return e.Status }
|
|
|
|
// policyRefusal answers a refused policy as Tailscale does: the reason in
|
|
// message and, when tests failed, one data entry per failing test naming its
|
|
// src.
|
|
func policyRefusal(err error) *apiError {
|
|
out := &apiError{Message: err.Error(), Status: http.StatusBadRequest}
|
|
|
|
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 out
|
|
}
|
|
|
|
out.Message = "test(s) failed"
|
|
|
|
for _, f := range failures[:min(len(failures), maxErrorDetails)] {
|
|
// A policy writes a user known only by name as name@.
|
|
out.Data = append(out.Data, apiErrorData{User: strings.TrimSuffix(f.Src, "@"), Errors: f.Errors})
|
|
}
|
|
|
|
if more := len(failures) - maxErrorDetails; more > 0 {
|
|
out.Data = append(out.Data, apiErrorData{Errors: []string{"and " + strconv.Itoa(more) + " more"}})
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
// unownedTags returns the tags a token holding tokenTags may not apply.
|
|
func unownedTags(b Backend, tags, tokenTags []string) []string {
|
|
var out []string
|
|
|
|
for _, tag := range tags {
|
|
if !b.State.TagOwnedByTags(tag, tokenTags) {
|
|
out = append(out, tag)
|
|
}
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
// tagsRefused is Tailscale's 400 for tags a token may not apply.
|
|
func tagsRefused(prefix string, tags []string) error {
|
|
return huma.Error400BadRequest(
|
|
fmt.Sprintf("%srequested tags %v are invalid or not permitted", prefix, tags),
|
|
)
|
|
}
|
|
|
|
// 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):
|
|
// As on Tailscale SaaS, the reason is the message.
|
|
return huma.Error400BadRequest(err.Error(), err)
|
|
|
|
// A refused policy is answered with its own reason as the message.
|
|
case errors.Is(err, state.ErrInvalidPolicy):
|
|
return policyRefusal(err)
|
|
|
|
default:
|
|
return huma.Error500InternalServerError(msg, err)
|
|
}
|
|
}
|