mirror of
https://github.com/juanfont/headscale.git
synced 2026-11-04 04:34:07 +09:00
api/v2: serve policies and errors with SaaS's content types
A JSON read of a commented policy held comments, so JSON clients failed on it. Errors say application/json, and a policy write answers as HuJSON.
This commit is contained in:
1 parent
be1010c9de
commit
50a6823ffb
4 files changed
+56
-9
No files matched your search
+28
-5
@@ -8,6 +8,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
"github.com/juanfont/headscale/hscontrol/state"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
"github.com/tailscale/hujson"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -142,7 +144,7 @@ func registerACL(api huma.API, b Backend) {
|
||||
return nil, mapError("setting policy", err)
|
||||
}
|
||||
|
||||
return streamPolicy([]byte(updated.Data), aclContentType(in.Accept)), nil
|
||||
return streamPolicy([]byte(updated.Data), aclWriteContentType(in.Accept)), nil
|
||||
})
|
||||
|
||||
registerValidateACL(api, b, aclTags)
|
||||
@@ -235,19 +237,40 @@ func currentPolicy(b Backend) ([]byte, error) {
|
||||
return nil, huma.Error500InternalServerError("unsupported policy mode", nil)
|
||||
}
|
||||
|
||||
// streamPolicy writes the policy bytes as-is with the chosen content type and
|
||||
// a content-addressed ETag, bypassing huma's JSON marshaler so HuJSON survives.
|
||||
// streamPolicy writes the policy with the chosen content type and a
|
||||
// content-addressed ETag, bypassing huma's JSON marshaler so HuJSON survives. As
|
||||
// JSON, the policy loses its comments and trailing commas: a JSON reader cannot
|
||||
// parse them. The ETag stays over the stored bytes, so it holds for either read.
|
||||
func streamPolicy(data []byte, contentType string) *huma.StreamResponse {
|
||||
return &huma.StreamResponse{Body: func(ctx huma.Context) {
|
||||
body := data
|
||||
|
||||
if contentType == contentTypeJSON {
|
||||
standard, err := hujson.Standardize(slices.Clone(data))
|
||||
if err == nil {
|
||||
body = standard
|
||||
}
|
||||
}
|
||||
|
||||
ctx.SetHeader("Content-Type", contentType)
|
||||
ctx.SetHeader("ETag", policyETag(data))
|
||||
ctx.SetStatus(http.StatusOK)
|
||||
_, _ = ctx.BodyWriter().Write(data)
|
||||
_, _ = ctx.BodyWriter().Write(body)
|
||||
}}
|
||||
}
|
||||
|
||||
// aclWriteContentType answers a policy write as HuJSON, as SaaS does, unless the
|
||||
// caller asks for JSON.
|
||||
func aclWriteContentType(accept string) string {
|
||||
if strings.Contains(accept, contentTypeJSON) {
|
||||
return contentTypeJSON
|
||||
}
|
||||
|
||||
return contentTypeHuJSON
|
||||
}
|
||||
|
||||
// aclContentType serves HuJSON only when explicitly asked; everything else
|
||||
// (including an empty Accept) gets application/json. The bytes are identical.
|
||||
// (including an empty Accept) gets application/json.
|
||||
func aclContentType(accept string) string {
|
||||
if strings.Contains(accept, contentTypeHuJSON) {
|
||||
return contentTypeHuJSON
|
||||
|
||||
@@ -40,12 +40,15 @@ const maxErrorDetails = 20
|
||||
// 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) {
|
||||
func tailscaleErrorTransformer(ctx huma.Context, _ string, v any) (any, error) {
|
||||
em, ok := v.(*huma.ErrorModel)
|
||||
if !ok {
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// As SaaS, whatever the caller accepts. Huma chose this for its own model.
|
||||
ctx.SetHeader("Content-Type", contentTypeJSON)
|
||||
|
||||
message := em.Detail
|
||||
if message == "" {
|
||||
message = em.Title
|
||||
|
||||
@@ -84,8 +84,10 @@ func TestAPIv2ACLContentNegotiation(t *testing.T) {
|
||||
|
||||
assert.Equal(t, "application/json", jsonResp.Header().Get("Content-Type"))
|
||||
assert.Equal(t, "application/hujson", huResp.Header().Get("Content-Type"))
|
||||
// Same bytes, same ETag — the ETag is over content, not type.
|
||||
assert.Equal(t, jsonResp.Body.Bytes(), huResp.Body.Bytes())
|
||||
// The default policy has comments: a JSON read must still parse. The ETag is
|
||||
// over the stored bytes, not the type.
|
||||
assert.True(t, json.Valid(jsonResp.Body.Bytes()), jsonResp.Body.String())
|
||||
assert.Contains(t, huResp.Body.String(), "//")
|
||||
assert.Equal(t, jsonResp.Header().Get("Etag"), huResp.Header().Get("Etag"))
|
||||
}
|
||||
|
||||
@@ -373,7 +375,7 @@ func TestAPIv2ACLFileModeReadOnly(t *testing.T) {
|
||||
apiv2.Register(api, apiv2.Backend{Cfg: cfg})
|
||||
|
||||
// GET serves the file bytes + their ETag.
|
||||
get := api.Get("/api/v2/tailnet/-/acl")
|
||||
get := api.Get("/api/v2/tailnet/-/acl", "Accept: application/hujson")
|
||||
require.Equalf(t, http.StatusOK, get.Code, "body: %s", get.Body)
|
||||
assert.Equal(t, fileBytes, get.Body.String())
|
||||
assert.Equal(t, policyETagOf(fileBytes), get.Header().Get("Etag"))
|
||||
|
||||
@@ -560,6 +560,10 @@ func (r *apiReplay) api(t *testing.T, step testcapture.Step, i int, a testcaptur
|
||||
checks = append(checks, r.etagCheck(step, i, label, respHeader.Get("ETag")))
|
||||
}
|
||||
|
||||
if a.Status == status {
|
||||
checks = append(checks, contentTypeChecks(a.RespHeader["Content-Type"], respHeader.Get("Content-Type"))...)
|
||||
}
|
||||
|
||||
if a.Kind == "api" && saasRefused && (isWrite || isValidate) {
|
||||
checks = append(checks, r.refusalChecks(t, a, endpoint, body, resp)...)
|
||||
}
|
||||
@@ -573,6 +577,21 @@ func (r *apiReplay) api(t *testing.T, step testcapture.Step, i int, a testcaptur
|
||||
return checks
|
||||
}
|
||||
|
||||
// contentTypeChecks compares the media types, when SaaS recorded one.
|
||||
func contentTypeChecks(want, got string) []apiCheck {
|
||||
if want == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return []apiCheck{{"content-type", mediaType(want), mediaType(got)}}
|
||||
}
|
||||
|
||||
func mediaType(contentType string) string {
|
||||
media, _, _ := strings.Cut(contentType, ";")
|
||||
|
||||
return media
|
||||
}
|
||||
|
||||
// request is the call a sent, with SaaS's labels, emails and addresses
|
||||
// replaced by headscale's and its principal's credential.
|
||||
func (r *apiReplay) request(t *testing.T, a testcapture.Action, isPolicy bool) (string, map[string]string, []byte) {
|
||||
|
||||
Reference in new issue
Block a user