From 8caa04ba93ab6f633bc04aea9da7cb383f7bbdd7 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 17 Oct 2026 15:43:46 +0000 Subject: [PATCH] api/v2: limit request bodies and redact schema-validation details Bound schema, raw, and token-form bodies before parsing. Omit submitted values from schema errors while preserving policy reasons and test labels. --- hscontrol/api/v2/acl.go | 8 ++ hscontrol/api/v2/api.go | 5 ++ hscontrol/api/v2/devices.go | 5 ++ hscontrol/api/v2/errors.go | 98 ++++++++++++++--------- hscontrol/api/v2/guard_test.go | 21 +++++ hscontrol/api/v2/keys.go | 15 ++-- hscontrol/api/v2/oauth.go | 6 ++ hscontrol/apiv2_acl_test.go | 71 ++++++++++++++++ hscontrol/apiv2_compat_test.go | 8 -- hscontrol/apiv2_oauth_test.go | 43 ++++++++++ hscontrol/servertest/names_compat_test.go | 6 -- 11 files changed, 229 insertions(+), 57 deletions(-) diff --git a/hscontrol/api/v2/acl.go b/hscontrol/api/v2/acl.go index a4f7d637d..96bacaca0 100644 --- a/hscontrol/api/v2/acl.go +++ b/hscontrol/api/v2/acl.go @@ -9,6 +9,7 @@ import ( "net/http" "os" "strings" + "time" "github.com/danielgtaylor/huma/v2" "github.com/juanfont/headscale/hscontrol/scope" @@ -103,6 +104,10 @@ func registerACL(api huma.API, b Backend) { // The body is an opaque HuJSON document captured raw; skip huma's // schema validation (which would expect a base64 string). SkipValidateBody: true, + // huma's own Body defaults, which it never applies to RawBody; also + // what v1 setPolicy accepts. + MaxBodyBytes: 1 << 20, + BodyReadTimeout: 5 * time.Second, Errors: []int{ http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusPreconditionFailed, http.StatusInternalServerError, @@ -154,6 +159,9 @@ func registerValidateACL(api huma.API, b Backend, aclTags []string) { Security: security, DefaultStatus: http.StatusOK, SkipValidateBody: true, + // As setACL: huma applies no body limit to RawBody. + MaxBodyBytes: 1 << 20, + BodyReadTimeout: 5 * time.Second, Errors: []int{ http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusInternalServerError, diff --git a/hscontrol/api/v2/api.go b/hscontrol/api/v2/api.go index e42c51b83..122bcf7bc 100644 --- a/hscontrol/api/v2/api.go +++ b/hscontrol/api/v2/api.go @@ -45,6 +45,11 @@ type Backend struct { // or as a Bearer token. var security = []map[string][]string{{"basicAuth": {}}, {"bearerAuth": {}}} +// maxBodyBytes replaces huma's 1 MiB default on operations whose body huma +// validates: validation records an error per bad element before the handler +// runs, and a real body is a few names, tags or routes. +const maxBodyBytes = 64 << 10 + // registrations is populated by each resource file's init(), so adding a // resource means adding a file rather than editing a shared list. var registrations []func(huma.API, Backend) diff --git a/hscontrol/api/v2/devices.go b/hscontrol/api/v2/devices.go index ce803401a..febff5b72 100644 --- a/hscontrol/api/v2/devices.go +++ b/hscontrol/api/v2/devices.go @@ -195,6 +195,7 @@ func registerDevices(api huma.API, b Backend) { Tags: deviceTags, Security: security, DefaultStatus: http.StatusOK, + MaxBodyBytes: maxBodyBytes, Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, }, scope.DevicesCore), func(ctx context.Context, in *setAuthorizedInput) (*emptyOutput, error) { _, err := lookupNode(b, in.DeviceID) @@ -222,6 +223,7 @@ func registerDevices(api huma.API, b Backend) { Tags: deviceTags, Security: security, DefaultStatus: http.StatusOK, + MaxBodyBytes: maxBodyBytes, Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, }, scope.DevicesCore), func(ctx context.Context, in *setNameInput) (*emptyOutput, error) { node, err := lookupNode(b, in.DeviceID) @@ -247,6 +249,7 @@ func registerDevices(api huma.API, b Backend) { Tags: deviceTags, Security: security, DefaultStatus: http.StatusOK, + MaxBodyBytes: maxBodyBytes, Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, }, scope.DevicesCore), func(ctx context.Context, in *setTagsInput) (*emptyOutput, error) { node, err := lookupNode(b, in.DeviceID) @@ -293,6 +296,7 @@ func registerDevices(api huma.API, b Backend) { Tags: deviceTags, Security: security, DefaultStatus: http.StatusOK, + MaxBodyBytes: maxBodyBytes, Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, }, scope.DevicesCore), func(ctx context.Context, in *setKeyInput) (*emptyOutput, error) { node, err := lookupNode(b, in.DeviceID) @@ -326,6 +330,7 @@ func registerDevices(api huma.API, b Backend) { Tags: deviceTags, Security: security, DefaultStatus: http.StatusOK, + MaxBodyBytes: maxBodyBytes, Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, }, scope.DevicesRoutes), func(ctx context.Context, in *setSubnetRoutesInput) (*deviceRoutesOutput, error) { node, err := lookupNode(b, in.DeviceID) diff --git a/hscontrol/api/v2/errors.go b/hscontrol/api/v2/errors.go index 1ddfc532d..00e28f71a 100644 --- a/hscontrol/api/v2/errors.go +++ b/hscontrol/api/v2/errors.go @@ -2,6 +2,7 @@ package apiv2 import ( "errors" + "net/http" "strconv" "strings" @@ -30,7 +31,8 @@ type apiErrorData struct { Errors []string `json:"errors,omitempty"` } -// maxErrorDetails caps the failing tests one answer lists. +// maxErrorDetails caps the errors, or failing tests, one response lists. huma +// reports one error per bad element of the body, so the caller picks how many. const maxErrorDetails = 20 // tailscaleErrorTransformer rewrites Huma's RFC 9457 error model into the @@ -50,50 +52,38 @@ func tailscaleErrorTransformer(_ huma.Context, _ string, v any) (any, error) { 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()) + 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 } -// 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 - } +func (e *apiError) Error() string { return e.Message } - 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) - - // A refused policy is answered with its own reason as the message. - case errors.Is(err, state.ErrInvalidPolicy): - return huma.Error400BadRequest(err.Error(), err) - - default: - return huma.Error500InternalServerError(msg, err) - } -} +func (e *apiError) GetStatus() int { return e.Status } // 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 @@ -129,3 +119,39 @@ func policyRefusal(err error) (string, []apiErrorData) { return "test(s) failed", data } + +// 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): + message, data := policyRefusal(err) + + return &apiError{Message: message, Status: http.StatusBadRequest, Data: data} + + default: + return huma.Error500InternalServerError(msg, err) + } +} diff --git a/hscontrol/api/v2/guard_test.go b/hscontrol/api/v2/guard_test.go index 7d52e0c79..cfefbe606 100644 --- a/hscontrol/api/v2/guard_test.go +++ b/hscontrol/api/v2/guard_test.go @@ -47,6 +47,27 @@ func TestEveryAuthenticatedOperationDeclaresScope(t *testing.T) { } } +// TestEveryRequestBodyIsBounded fails an operation whose body has no size or +// read-time limit, or a validated body above maxBodyBytes. huma sets its +// defaults only for a Body input, never for RawBody. +func TestEveryRequestBodyIsBounded(t *testing.T) { + api := NewAPI(chi.NewMux(), Backend{}) + + for path, item := range api.OpenAPI().Paths { + for method, op := range humaOperations(item) { + if op == nil || op.RequestBody == nil { + continue + } + + if op.MaxBodyBytes <= 0 || op.BodyReadTimeout <= 0 || + (!op.SkipValidateBody && op.MaxBodyBytes > maxBodyBytes) { + t.Errorf("%s %s: MaxBodyBytes=%d BodyReadTimeout=%s", + method, path, op.MaxBodyBytes, op.BodyReadTimeout) + } + } + } +} + func humaOperations(item *huma.PathItem) map[string]*huma.Operation { return map[string]*huma.Operation{ "GET": item.Get, diff --git a/hscontrol/api/v2/keys.go b/hscontrol/api/v2/keys.go index 64b53ab18..84a8ce972 100644 --- a/hscontrol/api/v2/keys.go +++ b/hscontrol/api/v2/keys.go @@ -126,13 +126,14 @@ func registerKeys(api huma.API, b Backend) { keysTags := []string{"Keys", "Tailscale compat"} huma.Register(api, huma.Operation{ - OperationID: "createKey", - Method: http.MethodPost, - Path: "/api/v2/tailnet/{tailnet}/keys", - Summary: "Create an auth key or OAuth client", - Description: "Requires the `auth_keys` scope for an auth key, or `oauth_keys` for an OAuth client (an admin API key is all-access).", - Tags: keysTags, - Security: security, + OperationID: "createKey", + Method: http.MethodPost, + Path: "/api/v2/tailnet/{tailnet}/keys", + Summary: "Create an auth key or OAuth client", + Description: "Requires the `auth_keys` scope for an auth key, or `oauth_keys` for an OAuth client (an admin API key is all-access).", + Tags: keysTags, + Security: security, + MaxBodyBytes: maxBodyBytes, Errors: []int{ http.StatusBadRequest, http.StatusUnauthorized, diff --git a/hscontrol/api/v2/oauth.go b/hscontrol/api/v2/oauth.go index 897e53dad..452fed482 100644 --- a/hscontrol/api/v2/oauth.go +++ b/hscontrol/api/v2/oauth.go @@ -16,6 +16,10 @@ import ( // Tailscale's non-configurable one hour. const accessTokenTTL = time.Hour +// maxTokenRequestBytes caps the token request form, which is read before the +// secret is checked and whose scope and tag lists are stored on the token. +const maxTokenRequestBytes = 16 << 10 + // registerOAuthToken mounts POST /api/v2/oauth/token on the router. It is a plain // handler, not a Huma operation: it consumes application/x-www-form-urlencoded // and emits RFC 6749 OAuth2 error bodies ({"error","error_description"}), neither @@ -38,6 +42,8 @@ type tokenResponse struct { // short-lived bearer token. func oauthTokenHandler(b Backend) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxTokenRequestBytes) + err := r.ParseForm() if err != nil { writeOAuthError(w, http.StatusBadRequest, "invalid_request", "could not parse request body") diff --git a/hscontrol/apiv2_acl_test.go b/hscontrol/apiv2_acl_test.go index 10c14a063..b7956ff54 100644 --- a/hscontrol/apiv2_acl_test.go +++ b/hscontrol/apiv2_acl_test.go @@ -10,6 +10,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "testing" "github.com/danielgtaylor/huma/v2/humatest" @@ -128,6 +129,17 @@ func TestAPIv2ACLSetHuJSONWithComments(t *testing.T) { assert.Equal(t, policyETagOf(policy), raw.Header().Get("Etag")) } +// TestAPIv2ACLSetBodyLimited: huma gives a RawBody input no size limit of its +// own, so setACL must declare one. +func TestAPIv2ACLSetBodyLimited(t *testing.T) { + app := createTestApp(t) + api := registerAPIV2(t, app) + + set := setACL(t, api, allowAllPolicy+strings.Repeat(" ", 1<<20), "application/json") + assert.Equal(t, http.StatusRequestEntityTooLarge, set.Code) + assert.Empty(t, storedPolicy(t, app)) +} + func TestAPIv2ACLETagChangesOnChangeStableOnNoop(t *testing.T) { app := createTestApp(t) api := registerAPIV2(t, app) @@ -233,6 +245,53 @@ func TestAPIv2ACLPersistFailureKeepsLivePolicy(t *testing.T) { assert.Equal(t, before, after, "an unstored policy must not be enforced") } +// TestAPIv2ACLRefusalEnvelope: a refused policy answers with the reason in +// message and, when tests fail, one data entry per failing test naming its +// src. +func TestAPIv2ACLRefusalEnvelope(t *testing.T) { + app := createTestApp(t) + app.state.CreateUserForTest("alice") + api := registerAPIV2(t, app) + + type refusal struct { + Message string `json:"message"` + Data []struct { + User string `json:"user"` + Errors []string `json:"errors"` + } `json:"data"` + } + + decode := func(resp *httptest.ResponseRecorder) refusal { + t.Helper() + require.Equalf(t, http.StatusBadRequest, resp.Code, "body: %s", resp.Body) + + var r refusal + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &r)) + + return r + } + + parse := decode(setACL(t, api, `{ this is not valid`, "application/json")) + assert.NotEmpty(t, parse.Message) + assert.NotEqual(t, "setting policy", parse.Message, "the reason is the message") + assert.Empty(t, parse.Data) + + tests := decode(setACL(t, api, `{ + "acls": [{"action": "accept", "src": ["*"], "dst": ["*:*"]}], + "tests": [ + {"src": "alice@", "deny": ["100.64.0.2:22"]}, + {"src": "100.64.0.1", "accept": ["100.64.0.2:22"]}, + {"src": "100.64.0.3", "deny": ["100.64.0.2:22", "100.64.0.4:22"]} + ] + }`, "application/json")) + assert.Equal(t, "test(s) failed", tests.Message) + require.Len(t, tests.Data, 2) + assert.Equal(t, "alice", tests.Data[0].User) + assert.Len(t, tests.Data[0].Errors, 1) + assert.Equal(t, "100.64.0.3", tests.Data[1].User) + assert.Len(t, tests.Data[1].Errors, 2) +} + // TestAPIv2ACLValidate: validate stores nothing and answers a refused policy // with 200, its reason as message and one data entry per failing test; only // a body with no policy at all is a 400. @@ -291,6 +350,18 @@ func TestAPIv2ACLNonDefaultTailnet404(t *testing.T) { api.Post("/api/v2/tailnet/example.com/acl", bytes.NewReader([]byte(allowAllPolicy))).Code) } +// TestAPIv2ACLUnknownPolicyMode: the handler's 500 carries a nil error detail, +// which the error transformer must not dereference. +func TestAPIv2ACLUnknownPolicyMode(t *testing.T) { + cfg := &types.Config{Policy: types.PolicyConfig{Mode: "unknown"}} + _, api := humatest.New(t, apiv2.Config()) + apiv2.Register(api, apiv2.Backend{Cfg: cfg}) + + get := api.Get("/api/v2/tailnet/-/acl") + assert.Equal(t, http.StatusInternalServerError, get.Code) + assert.Contains(t, get.Body.String(), "unsupported policy mode") +} + func TestAPIv2ACLFileModeReadOnly(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "acl.hujson") diff --git a/hscontrol/apiv2_compat_test.go b/hscontrol/apiv2_compat_test.go index d6c9eaa84..b3e2915e1 100644 --- a/hscontrol/apiv2_compat_test.go +++ b/hscontrol/apiv2_compat_test.go @@ -80,9 +80,6 @@ var apiv2CompatSkipReasons = map[string]string{ "oauth/K2/0/outcome": unknownKeyType, "oauth/K5/0/status": unknownKeyType, - "oauth/J3/1/detail": refusalDetail, - "oauth/J4/0/detail": refusalDetail, - "oauth/E4/0/status": tagRefusalStatus, "oauth/E5/0/status": tagRefusalStatus, "oauth/T2/1/status": tagRefusalStatus, @@ -168,10 +165,6 @@ var apiv2CompatSkipReasons = map[string]string{ "oauth/device:mixed/empty-route-lists": emptyRouteListsOmitted, "oauth/device:prod-db/empty-route-lists": emptyRouteListsOmitted, - "policy-api/P3/1/detail": refusalDetail, - "policy-api/P5-R1/1/detail": refusalDetail, - "policy-api/P5-R2/1/detail": refusalDetail, - "policy-api/P7/0/body": hujsonFormatted, "policy-api/P7/1/body": hujsonFormatted, } @@ -227,7 +220,6 @@ const ( deviceOAuthClientID = "DEVICE_OAUTH_CLIENT_ID: SaaS device bodies carry oauthClientId, naming the join key or the OAuth client that made it; headscale's have no such field" taggedDeviceUser = "TAGGED_DEVICE_USER: SaaS shows a tagged device's user as the login that made its key, empty for a key a token made; headscale shows tagged-devices" emptyRouteListsOmitted = "EMPTY_ROUTE_LISTS_OMITTED: SaaS sends advertisedRoutes and enabledRoutes as [] for a device without routes under fields=all; headscale leaves both out" - refusalDetail = "REFUSAL_DETAIL: SaaS puts a refused policy's reason in message, with one detail per failing test naming the user; headscale answers setting policy with the reason as one detail without a user" hujsonFormatted = "HUJSON_FORMATTED: SaaS stores a HuJSON policy formatted, comments kept; headscale stores the bytes as sent" ) diff --git a/hscontrol/apiv2_oauth_test.go b/hscontrol/apiv2_oauth_test.go index 82063c8fe..39db71454 100644 --- a/hscontrol/apiv2_oauth_test.go +++ b/hscontrol/apiv2_oauth_test.go @@ -13,6 +13,7 @@ import ( "testing" apiv2 "github.com/juanfont/headscale/hscontrol/api/v2" + "github.com/juanfont/headscale/hscontrol/scope" "github.com/juanfont/headscale/hscontrol/types/testcapture" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -222,6 +223,48 @@ func TestAPIv2OAuth_TokenEndpoint(t *testing.T) { assert.Equal(t, "no-store", resp.Header.Get("Cache-Control")) } +// TestAPIv2OAuth_RequestBodiesBounded: the token form is parsed before the +// secret is checked, and huma validates a whole body before the handler runs. +func TestAPIv2OAuth_RequestBodiesBounded(t *testing.T) { + _, baseURL, admin := newOAuthTestServer(t) + _, secret := createClient(t, baseURL, admin, []string{"users:read"}, nil) + + scopes := strings.TrimSpace(strings.Repeat("users:read ", 2000)) + status, m := mintToken(t, baseURL, "", secret, scopes, false) + assert.Equalf(t, http.StatusBadRequest, status, "%v", m) + assert.Equal(t, "invalid_request", m["error"]) + + tok := mintScopedToken(t, baseURL, admin, scope.AuthKeys) + status, _ = apiReq(t, http.MethodPost, baseURL+"/api/v2/tailnet/-/keys", tok, + map[string]any{"tags": make([]int, 40000)}) + assert.Equal(t, http.StatusRequestEntityTooLarge, status) +} + +// TestAPIv2OAuth_ValidationErrorsBounded: huma reports one error per unexpected +// property, and each carries the whole request object as its value. +func TestAPIv2OAuth_ValidationErrorsBounded(t *testing.T) { + _, baseURL, admin := newOAuthTestServer(t) + tok := mintScopedToken(t, baseURL, admin, scope.AuthKeys) + + props := map[string]int{} + for i := range 1000 { + props[fmt.Sprintf("%040d", i)] = 0 + } + + status, body := apiReq(t, http.MethodPost, baseURL+"/api/v2/tailnet/-/keys", tok, props) + require.Equal(t, http.StatusUnprocessableEntity, status) + require.Less(t, len(body), 16<<10, "the error answer grows with the request") + assert.NotContains(t, string(body), "map[") + + var resp struct { + Data []struct{ Errors []string } + } + require.NoError(t, json.Unmarshal(body, &resp)) + require.Len(t, resp.Data, 1) + require.Len(t, resp.Data[0].Errors, 21) + assert.Equal(t, "and 980 more", resp.Data[0].Errors[20]) +} + func TestAPIv2OAuth_ScopeEnforcement(t *testing.T) { _, baseURL, admin := newOAuthTestServer(t) diff --git a/hscontrol/servertest/names_compat_test.go b/hscontrol/servertest/names_compat_test.go index da3bf98b8..d7846a504 100644 --- a/hscontrol/servertest/names_compat_test.go +++ b/hscontrol/servertest/names_compat_test.go @@ -60,16 +60,10 @@ var nameSkipReasons = map[string]string{ "names/N12a/pdb/label": adminNameFollowsHostname, "names/N12b/pdb/label": adminNameFollowsHostname, "names/N13/pdb/label": adminNameFollowsHostname, - - "names/N9a/0/message": nameTakenText, - "names/N9b/0/message": nameTakenText, - - "names-svc/S5/0/message": nameTakenText, } const ( adminNameFollowsHostname = "ADMIN_NAME_FOLLOWS_HOSTNAME: an admin name equal to the hostname's label counts as derived from it, so a hostname change replaces it; SaaS keeps an admin name across hostname changes" - nameTakenText = "NAME_TAKEN_TEXT: headscale's v2 message for a rename refused as taken is the operation, with the reason only in data; SaaS's message says the name is already taken" v2NameBare = "V2_NAME_BARE: headscale's v2 device name is the bare label; SaaS's is the FQDN under the tailnet domain" )