diff --git a/hscontrol/api/v2/acl.go b/hscontrol/api/v2/acl.go index 0bd07a9aa..b43baf201 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" @@ -12,6 +13,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" ) @@ -42,6 +44,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"` @@ -127,6 +144,58 @@ 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, + MaxBodyBytes: 1 << 20, + BodyReadTimeout: 5 * time.Second, + 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) { + refusal := policyRefusal(err) + out.Body.Message, out.Body.Data = refusal.Message, refusal.Data + + return out, nil + } + + if err != nil { + return nil, mapError("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 ad7e3760a..a7333b5bc 100644 --- a/hscontrol/api/v2/errors.go +++ b/hscontrol/api/v2/errors.go @@ -89,7 +89,7 @@ func (e *apiError) GetStatus() int { return e.Status } // policyRefusal answers a refused policy as Tailscale does: the reason in // message and, when tests failed, one data entry per failing test naming its // src. -func policyRefusal(err error) error { +func policyRefusal(err error) *apiError { out := &apiError{Message: err.Error(), Status: http.StatusBadRequest} var failures []policyv2.TestFailure diff --git a/hscontrol/apiv2_acl_test.go b/hscontrol/apiv2_acl_test.go index 82f367d01..bf33f0be5 100644 --- a/hscontrol/apiv2_acl_test.go +++ b/hscontrol/apiv2_acl_test.go @@ -292,6 +292,55 @@ func TestAPIv2ACLRefusalEnvelope(t *testing.T) { 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. +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) + + _, 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/apiv2_compat_test.go b/hscontrol/apiv2_compat_test.go index 8e66b1778..42c5da41c 100644 --- a/hscontrol/apiv2_compat_test.go +++ b/hscontrol/apiv2_compat_test.go @@ -74,18 +74,6 @@ var apiv2CompatSkipReasons = map[string]string{ "oauth/device:before/all/device-oauth-client": deviceOAuthClientMaker, "oauth/device:joiner/all/device-oauth-client": deviceOAuthClientMaker, - "policy-api/P2/0/status": noV2Validate, - "policy-api/P2/0/message": noV2Validate, - "policy-api/P3/0/status": noV2Validate, - "policy-api/P3/0/message": noV2Validate, - "policy-api/P4/0/status": noV2Validate, - "policy-api/P4/0/outcome": noV2Validate, - "policy-api/P5-R1/0/status": noV2Validate, - "policy-api/P5-R1/0/message": noV2Validate, - "policy-api/P5-R2/0/status": noV2Validate, - "policy-api/P5-R2/0/message": noV2Validate, - "policy-api/P5-R2/0/detail": noV2Validate, - "policy-api/P7/0/body": hujsonFormatted, "policy-api/P7/1/body": hujsonFormatted, } @@ -127,7 +115,6 @@ const ( deletedClientGone = "DELETED_CLIENT_GONE: SaaS keeps a deleted OAuth client readable as invalid and revoked; headscale removes it" deletedClientCredentialText = "DELETED_CLIENT_CREDENTIAL_TEXT: SaaS refuses a deleted client's secret and tokens with API token invalid; headscale says invalid client credentials and unauthorized" missingScopeText = "MISSING_SCOPE_TEXT: SaaS refuses a delete outside the token's scopes with calling actor does not have enough permissions; headscale names the missing scope" - noV2Validate = "NO_V2_VALIDATE: headscale's v2 API has no /acl/validate" hujsonFormatted = "HUJSON_FORMATTED: SaaS stores a HuJSON policy formatted, comments kept; headscale stores the bytes as sent" ) @@ -1144,10 +1131,7 @@ func (r *apiReplay) refusalChecks(t *testing.T, a testcapture.Action, endpoint s require.NoError(t, json.Unmarshal(a.Response, &want)) - // Headscale has no v2 validate, so only there may its answer not be JSON. - if err := json.Unmarshal(resp, &got); endpoint != "/tailnet/-/acl/validate" { - require.NoErrorf(t, err, "body: %s", resp) - } + require.NoErrorf(t, json.Unmarshal(resp, &got), "body: %s", resp) users := func(data []struct { User string `json:"user"` diff --git a/hscontrol/servertest/apiv2_devices_test.go b/hscontrol/servertest/apiv2_devices_test.go index 0ac03af34..79c9dc8e6 100644 --- a/hscontrol/servertest/apiv2_devices_test.go +++ b/hscontrol/servertest/apiv2_devices_test.go @@ -364,7 +364,7 @@ terraform { required_providers { tailscale = { source = "tailscale/tailscale" - version = "0.29.2" + version = "0.29.3" } } } diff --git a/hscontrol/servertest/apiv2_oauth_scopes_test.go b/hscontrol/servertest/apiv2_oauth_scopes_test.go index 07ffec964..dcf120697 100644 --- a/hscontrol/servertest/apiv2_oauth_scopes_test.go +++ b/hscontrol/servertest/apiv2_oauth_scopes_test.go @@ -337,7 +337,7 @@ terraform { required_providers { tailscale = { source = "tailscale/tailscale" - version = "0.29.2" + version = "0.29.3" } } } diff --git a/hscontrol/servertest/apiv2_test.go b/hscontrol/servertest/apiv2_test.go index 9b74defd8..bf5525030 100644 --- a/hscontrol/servertest/apiv2_test.go +++ b/hscontrol/servertest/apiv2_test.go @@ -322,7 +322,7 @@ terraform { required_providers { tailscale = { source = "tailscale/tailscale" - version = "0.29.2" + version = "0.29.3" } } } @@ -391,7 +391,7 @@ terraform { required_providers { tailscale = { source = "tailscale/tailscale" - version = "0.29.2" + version = "0.29.3" } } }