From 50a6823ffb5ca18334e927e10389437d6815812a Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 31 Oct 2026 14:26:06 +0000 Subject: [PATCH] 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. --- hscontrol/api/v2/acl.go | 33 ++++++++++++++++++++++++++++----- hscontrol/api/v2/errors.go | 5 ++++- hscontrol/apiv2_acl_test.go | 8 +++++--- hscontrol/apiv2_compat_test.go | 19 +++++++++++++++++++ 4 files changed, 56 insertions(+), 9 deletions(-) diff --git a/hscontrol/api/v2/acl.go b/hscontrol/api/v2/acl.go index 96bacaca0..31e28de6f 100644 --- a/hscontrol/api/v2/acl.go +++ b/hscontrol/api/v2/acl.go @@ -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 diff --git a/hscontrol/api/v2/errors.go b/hscontrol/api/v2/errors.go index 3bfec76ce..db3abf303 100644 --- a/hscontrol/api/v2/errors.go +++ b/hscontrol/api/v2/errors.go @@ -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 diff --git a/hscontrol/apiv2_acl_test.go b/hscontrol/apiv2_acl_test.go index b7956ff54..97845b984 100644 --- a/hscontrol/apiv2_acl_test.go +++ b/hscontrol/apiv2_acl_test.go @@ -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")) diff --git a/hscontrol/apiv2_compat_test.go b/hscontrol/apiv2_compat_test.go index 706f3bd8c..a9af13ad5 100644 --- a/hscontrol/apiv2_compat_test.go +++ b/hscontrol/apiv2_compat_test.go @@ -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) {