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:
Kristoffer Dalby committed 2026-11-03 17:05:00 +01:00
1 parent be1010c9de
commit 50a6823ffb
4 files changed
+56 -9

No files matched your search

+28 -5
View File
@@ -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
+4 -1
View File
@@ -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
+5 -3
View File
@@ -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"))
+19
View File
@@ -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) {