From a63ee3e0326835df2f5ac76fd1f1bb630689f07f Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 28 Oct 2026 20:05:54 +0000 Subject: [PATCH] api/v2: add the acl/validate endpoint 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 400. Terraform provider 0.29.3 calls it at plan time. --- CHANGELOG.md | 1 + hscontrol/api/v2/acl.go | 66 +++++++++++++++++++++++++++++++++++++ hscontrol/api/v2/errors.go | 42 +++++++++++++++++++++++ hscontrol/apiv2_acl_test.go | 51 ++++++++++++++++++++++++++++ hscontrol/state/state.go | 44 +++++++++++++++++++++++++ 5 files changed, 204 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ff48f321..aeaee9a01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -148,6 +148,7 @@ the ACME DNS-01 challenge through a built-in DNS provider: `acmedns`, - Node joins and deletions under churn, especially in tailnets with many tags, no longer stall registrations, map updates and API requests; ephemeral nodes are cleaned up within seconds instead of minutes [#3575](https://github.com/juanfont/headscale/pull/3575) - Shutdown keeps the LastSeen of connected nodes: it waits for their map sessions to close before closing the database [#3575](https://github.com/juanfont/headscale/pull/3575) - Deleting an ephemeral node no longer logs a spurious `ephemeral node deletion failed` error later [#3575](https://github.com/juanfont/headscale/pull/3575) +- Add the v2 `acl/validate` endpoint, which Terraform provider 0.29.3 and newer call to check `tailscale_acl` at plan time; before it, those releases failed with `invalid character 'p' after top-level value` ## 0.29.6 (2026-10-24) diff --git a/hscontrol/api/v2/acl.go b/hscontrol/api/v2/acl.go index 3d6329fe6..07bf53e30 100644 --- a/hscontrol/api/v2/acl.go +++ b/hscontrol/api/v2/acl.go @@ -1,6 +1,7 @@ package apiv2 import ( + "bytes" "context" "crypto/sha256" "encoding/hex" @@ -11,6 +12,7 @@ import ( "github.com/danielgtaylor/huma/v2" "github.com/juanfont/headscale/hscontrol/scope" + "github.com/juanfont/headscale/hscontrol/state" "github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/util" ) @@ -41,6 +43,21 @@ type getACLInput struct { Details bool `doc:"Accepted for compatibility; ignored." query:"details"` } +type validateACLInput struct { + Tailnet string `path:"tailnet"` + RawBody []byte `contentType:"application/json"` +} + +// validateACLOutput is the verdict on a policy. Tailscale answers a refused +// policy with 200, its reason as message and, for failed tests, one data +// entry per failing test. +type validateACLOutput struct { + Body struct { + Message string `json:"message,omitempty"` + Data []apiErrorData `json:"data,omitempty"` + } +} + type setACLInput struct { Tailnet string `path:"tailnet"` IfMatch string `header:"If-Match"` @@ -142,6 +159,55 @@ func registerACL(api huma.API, b Backend) { return streamPolicy([]byte(updated.Data), aclContentType(in.Accept)), nil }) + + registerValidateACL(api, b, aclTags) +} + +func registerValidateACL(api huma.API, b Backend, aclTags []string) { + huma.Register(api, requireScope(huma.Operation{ + OperationID: "validateACL", + Method: http.MethodPost, + Path: "/api/v2/tailnet/{tailnet}/acl/validate", + Summary: "Validate a policy file", + Description: "Checks a policy, its tests and its SSH rules without storing it. A refused policy answers 200 with the reason as message.", + Tags: aclTags, + Security: security, + DefaultStatus: http.StatusOK, + SkipValidateBody: true, + Errors: []int{ + http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, + http.StatusNotFound, http.StatusInternalServerError, + }, + }, scope.PolicyFileRead), func(ctx context.Context, in *validateACLInput) (*validateACLOutput, error) { + err := requireDefaultTailnet(in.Tailnet) + if err != nil { + return nil, err + } + + // As on Tailscale SaaS, only a body with no policy at all is a 400. + if len(bytes.TrimSpace(in.RawBody)) == 0 { + return nil, huma.Error400BadRequest("unexpected end of JSON input") + } + + out := &validateACLOutput{} + + err = b.State.ValidatePolicy(in.RawBody) + if errors.Is(err, state.ErrInvalidPolicy) { + out.Body.Message, out.Body.Data = policyRefusal(err) + + return out, nil + } + + if err != nil { + return nil, huma.Error500InternalServerError("validating policy", err) + } + + return out, nil + }) + + // huma requires a RawBody; the handler answers an empty one as Tailscale + // does. + api.OpenAPI().Paths["/api/v2/tailnet/{tailnet}/acl/validate"].Post.RequestBody.Required = false } // currentPolicy returns the stored policy bytes, or the allow-all default when diff --git a/hscontrol/api/v2/errors.go b/hscontrol/api/v2/errors.go index 3a918eda4..93d3e4a48 100644 --- a/hscontrol/api/v2/errors.go +++ b/hscontrol/api/v2/errors.go @@ -2,11 +2,15 @@ package apiv2 import ( "errors" + "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 @@ -26,6 +30,9 @@ type apiErrorData struct { Errors []string `json:"errors,omitempty"` } +// maxErrorDetails caps the failing tests one answer lists. +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 @@ -83,3 +90,38 @@ func mapError(msg string, err error) error { return huma.Error500InternalServerError(msg, err) } } + +// 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 +// src. +func policyRefusal(err error) (string, []apiErrorData) { + 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 err.Error(), nil + } + + listed := failures[:min(len(failures), maxErrorDetails)] + data := make([]apiErrorData, 0, len(listed)+1) + + for _, f := range listed { + // A policy writes a user known only by name as name@. + data = append(data, apiErrorData{User: strings.TrimSuffix(f.Src, "@"), Errors: f.Errors}) + } + + if more := len(failures) - len(listed); more > 0 { + data = append(data, apiErrorData{Errors: []string{"and " + strconv.Itoa(more) + " more"}}) + } + + return "test(s) failed", data +} diff --git a/hscontrol/apiv2_acl_test.go b/hscontrol/apiv2_acl_test.go index 8f21ed525..6116d2316 100644 --- a/hscontrol/apiv2_acl_test.go +++ b/hscontrol/apiv2_acl_test.go @@ -4,6 +4,7 @@ import ( "bytes" "crypto/sha256" "encoding/hex" + "encoding/json" "errors" "net/http" "net/http/httptest" @@ -201,6 +202,56 @@ func TestAPIv2ACLInvalidPolicyAtomicity(t *testing.T) { assert.JSONEq(t, allowAllPolicy, storedPolicy(t, app)) } +// 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. +func TestAPIv2ACLValidate(t *testing.T) { + app := createTestApp(t) + app.state.CreateUserForTest("alice") + api := registerAPIV2(t, app) + + type verdict struct { + Message string `json:"message"` + Data []struct { + User string `json:"user"` + Errors []string `json:"errors"` + } `json:"data"` + } + + validate := func(body string, wantStatus int) verdict { + t.Helper() + + resp := api.Post("/api/v2/tailnet/-/acl/validate", + bytes.NewReader([]byte(body)), "Content-Type: application/hujson") + require.Equalf(t, wantStatus, resp.Code, "body: %s", resp.Body) + + var v verdict + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &v)) + + return v + } + + for _, empty := range []string{"", " \n\t"} { + assert.Equal(t, "unexpected end of JSON input", + validate(empty, http.StatusBadRequest).Message) + } + + assert.Equal(t, verdict{}, validate(allowAllPolicy, http.StatusOK)) + assert.NotEmpty(t, validate(`{"acls": [`, http.StatusOK).Message) + + failed := validate(`{ + "acls": [{"action": "accept", "src": ["*"], "dst": ["*:*"]}], + "tests": [{"src": "alice@", "deny": ["100.64.0.2:22"]}] + }`, http.StatusOK) + assert.Equal(t, "test(s) failed", failed.Message) + require.Len(t, failed.Data, 1) + assert.Equal(t, "alice", failed.Data[0].User) + assert.NotEmpty(t, failed.Data[0].Errors) + + _, err := app.state.GetPolicy() + require.ErrorIs(t, err, types.ErrPolicyNotFound, "validate stores nothing") +} + func TestAPIv2ACLNonDefaultTailnet404(t *testing.T) { api := registerAPIV2(t, createTestApp(t)) diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go index bb86ca8a6..449a7129f 100644 --- a/hscontrol/state/state.go +++ b/hscontrol/state/state.go @@ -68,6 +68,17 @@ const ( // ErrUnsupportedPolicyMode is returned for invalid policy modes. Valid modes are "file" and "db". var ErrUnsupportedPolicyMode = errors.New("unsupported policy mode") +// ErrInvalidPolicy is returned by [State.ValidatePolicy] for a policy it +// refuses. +var ErrInvalidPolicy = errors.New("invalid policy") + +// invalidPolicyError marks a refusal as [ErrInvalidPolicy] and keeps the +// reason's own text, which API answers show as is. +type invalidPolicyError struct{ reason error } + +func (e invalidPolicyError) Error() string { return e.reason.Error() } +func (e invalidPolicyError) Unwrap() []error { return []error{ErrInvalidPolicy, e.reason} } + // ErrNodeNotFound is returned when a node cannot be found by its ID. var ErrNodeNotFound = errors.New("node not found") @@ -1396,6 +1407,39 @@ func (s *State) SetPolicyInDB(data string) (*types.Policy, error) { return s.db.SetPolicy(data) } +// ValidatePolicy reports whether a policy write would accept pol. It +// compiles pol against the current users and nodes on a throwaway policy +// manager; the live policy is not touched. +func (s *State) ValidatePolicy(pol []byte) error { + users, err := s.ListAllUsers() + if err != nil { + return fmt.Errorf("loading users: %w", err) + } + + nodes := s.ListNodes() + + pm, err := policy.NewPolicyManager(nil, users, nodes) + if err != nil { + return fmt.Errorf("creating policy manager: %w", err) + } + + _, err = pm.SetPolicy(pol) + if err != nil { + return invalidPolicyError{err} + } + + // SSH rules compile per node, so a server without nodes cannot catch + // every case here. + if nodes.Len() > 0 { + _, err = pm.SSHPolicy(s.cfg.ServerURL, nodes.At(0)) + if err != nil { + return invalidPolicyError{fmt.Errorf("verifying SSH rules: %w", err)} + } + } + + return nil +} + // GetNodePrimaryRoutes returns the primary routes for a node. func (s *State) GetNodePrimaryRoutes(nodeID types.NodeID) []netip.Prefix { return s.nodeStore.PrimaryRoutesForNode(nodeID)