mirror of
https://github.com/juanfont/headscale.git
synced 2026-11-04 12:39:25 +09:00
api/v2: add the acl/validate endpoint
It 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 a 400.
This commit is contained in:
1 parent
a3d9e01eff
commit
52c4f6b2c4
4 files changed
+120
-18
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in new issue
Block a user