mirror of
https://github.com/juanfont/headscale.git
synced 2026-10-31 02:34:07 +09:00
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.
This commit is contained in:
1 parent
a336e1e7e6
commit
a63ee3e032
5 files changed
+204
No files matched your search
@@ -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)
|
- 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)
|
- 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)
|
- 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)
|
## 0.29.6 (2026-10-24)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package apiv2
|
package apiv2
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
@@ -11,6 +12,7 @@ import (
|
|||||||
|
|
||||||
"github.com/danielgtaylor/huma/v2"
|
"github.com/danielgtaylor/huma/v2"
|
||||||
"github.com/juanfont/headscale/hscontrol/scope"
|
"github.com/juanfont/headscale/hscontrol/scope"
|
||||||
|
"github.com/juanfont/headscale/hscontrol/state"
|
||||||
"github.com/juanfont/headscale/hscontrol/types"
|
"github.com/juanfont/headscale/hscontrol/types"
|
||||||
"github.com/juanfont/headscale/hscontrol/util"
|
"github.com/juanfont/headscale/hscontrol/util"
|
||||||
)
|
)
|
||||||
@@ -41,6 +43,21 @@ type getACLInput struct {
|
|||||||
Details bool `doc:"Accepted for compatibility; ignored." query:"details"`
|
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 {
|
type setACLInput struct {
|
||||||
Tailnet string `path:"tailnet"`
|
Tailnet string `path:"tailnet"`
|
||||||
IfMatch string `header:"If-Match"`
|
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
|
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
|
// currentPolicy returns the stored policy bytes, or the allow-all default when
|
||||||
|
|||||||
@@ -2,11 +2,15 @@ package apiv2
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/danielgtaylor/huma/v2"
|
"github.com/danielgtaylor/huma/v2"
|
||||||
"github.com/juanfont/headscale/hscontrol/db"
|
"github.com/juanfont/headscale/hscontrol/db"
|
||||||
|
policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2"
|
||||||
"github.com/juanfont/headscale/hscontrol/state"
|
"github.com/juanfont/headscale/hscontrol/state"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"tailscale.com/util/multierr"
|
||||||
)
|
)
|
||||||
|
|
||||||
// apiError is the Tailscale API error body. The official Tailscale Go client
|
// apiError is the Tailscale API error body. The official Tailscale Go client
|
||||||
@@ -26,6 +30,9 @@ type apiErrorData struct {
|
|||||||
Errors []string `json:"errors,omitempty"`
|
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
|
// tailscaleErrorTransformer rewrites Huma's RFC 9457 error model into the
|
||||||
// Tailscale error shape. It is registered on the v2 API config only, so 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
|
// 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)
|
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
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -201,6 +202,56 @@ func TestAPIv2ACLInvalidPolicyAtomicity(t *testing.T) {
|
|||||||
assert.JSONEq(t, allowAllPolicy, storedPolicy(t, app))
|
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) {
|
func TestAPIv2ACLNonDefaultTailnet404(t *testing.T) {
|
||||||
api := registerAPIV2(t, createTestApp(t))
|
api := registerAPIV2(t, createTestApp(t))
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,17 @@ const (
|
|||||||
// ErrUnsupportedPolicyMode is returned for invalid policy modes. Valid modes are "file" and "db".
|
// ErrUnsupportedPolicyMode is returned for invalid policy modes. Valid modes are "file" and "db".
|
||||||
var ErrUnsupportedPolicyMode = errors.New("unsupported policy mode")
|
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.
|
// ErrNodeNotFound is returned when a node cannot be found by its ID.
|
||||||
var ErrNodeNotFound = errors.New("node not found")
|
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)
|
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.
|
// GetNodePrimaryRoutes returns the primary routes for a node.
|
||||||
func (s *State) GetNodePrimaryRoutes(nodeID types.NodeID) []netip.Prefix {
|
func (s *State) GetNodePrimaryRoutes(nodeID types.NodeID) []netip.Prefix {
|
||||||
return s.nodeStore.PrimaryRoutesForNode(nodeID)
|
return s.nodeStore.PrimaryRoutesForNode(nodeID)
|
||||||
|
|||||||
Reference in new issue
Block a user