Files
headscale/hscontrol/api/v2/acl.go
T
Kristoffer Dalby a63ee3e032 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.
2026-10-29 11:04:05 +01:00

294 lines
8.5 KiB
Go

package apiv2
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"net/http"
"os"
"strings"
"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"
)
func init() {
registrations = append(registrations, registerACL)
}
const (
contentTypeJSON = "application/json"
contentTypeHuJSON = "application/hujson"
)
// defaultPolicy is served by GET when no policy is set, and is what an If-Match
// of "ts-default" matches against. Allow-all, matching the cluster's behaviour
// with no configured policy.
const defaultPolicy = `{
// Headscale default policy. Allows all communication.
"acls": [
{"action": "accept", "src": ["*"], "dst": ["*:*"]},
],
}
`
type getACLInput struct {
Tailnet string `path:"tailnet"`
Accept string `header:"Accept"`
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"`
Accept string `header:"Accept"`
// RawBody captures the raw HuJSON or JSON policy bytes; huma feeds them here
// regardless of Content-Type. The declared type only shapes the OpenAPI schema.
RawBody []byte `contentType:"application/json"`
}
func registerACL(api huma.API, b Backend) {
aclTags := []string{"Policy", "Tailscale compat"}
huma.Register(api, requireScope(huma.Operation{
OperationID: "getACL",
Method: http.MethodGet,
Path: "/api/v2/tailnet/{tailnet}/acl",
Summary: "Get the policy file",
Tags: aclTags,
Security: security,
Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusInternalServerError},
}, scope.PolicyFileRead), func(ctx context.Context, in *getACLInput) (*huma.StreamResponse, error) {
err := requireDefaultTailnet(in.Tailnet)
if err != nil {
return nil, err
}
data, err := currentPolicy(b)
if err != nil {
return nil, err
}
return streamPolicy(data, aclContentType(in.Accept)), nil
})
huma.Register(api, requireScope(huma.Operation{
OperationID: "setACL",
Method: http.MethodPost,
Path: "/api/v2/tailnet/{tailnet}/acl",
Summary: "Set the policy file",
Tags: aclTags,
Security: security,
DefaultStatus: http.StatusOK,
// The body is an opaque HuJSON document captured raw; skip huma's
// schema validation (which would expect a base64 string).
SkipValidateBody: true,
Errors: []int{
http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden,
http.StatusNotFound, http.StatusPreconditionFailed, http.StatusInternalServerError,
},
}, scope.PolicyFile), func(ctx context.Context, in *setACLInput) (*huma.StreamResponse, error) {
err := requireDefaultTailnet(in.Tailnet)
if err != nil {
return nil, err
}
if b.Cfg.Policy.Mode != types.PolicyModeDB {
return nil, huma.Error400BadRequest(
types.ErrPolicyUpdateIsDisabled.Error(), types.ErrPolicyUpdateIsDisabled,
)
}
if in.IfMatch != "" {
current, err := currentPolicy(b)
if err != nil {
return nil, err
}
if !etagMatches(in.IfMatch, current) {
return nil, huma.Error412PreconditionFailed("precondition failed, invalid old hash")
}
}
// Mirror the v1 setPolicy flow: validate, SSH-check, persist, reload.
nodes := b.State.ListNodes()
_, err = b.State.SetPolicy(in.RawBody)
if err != nil {
return nil, huma.Error400BadRequest("setting policy", err)
}
if nodes.Len() > 0 {
_, err = b.State.SSHPolicy(nodes.At(0))
if err != nil {
return nil, huma.Error400BadRequest("verifying SSH rules", err)
}
}
updated, err := b.State.SetPolicyInDB(string(in.RawBody))
if err != nil {
return nil, huma.Error500InternalServerError("setting policy", err)
}
cs, err := b.State.ReloadPolicy()
b.Change(cs...)
if err != nil {
return nil, huma.Error500InternalServerError("reloading policy", err)
}
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
// none is set. File mode reads the configured path; DB mode reads the database.
func currentPolicy(b Backend) ([]byte, error) {
switch b.Cfg.Policy.Mode {
case types.PolicyModeDB:
p, err := b.State.GetPolicy()
if err != nil {
if errors.Is(err, types.ErrPolicyNotFound) {
return []byte(defaultPolicy), nil
}
return nil, huma.Error500InternalServerError("loading policy", err)
}
if p.Data == "" {
return []byte(defaultPolicy), nil
}
return []byte(p.Data), nil
case types.PolicyModeFile:
path := util.AbsolutePathFromConfigPath(b.Cfg.Policy.Path)
if path == "" {
return []byte(defaultPolicy), nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil, huma.Error500InternalServerError("reading policy file", err)
}
return data, nil
}
return nil, huma.Error500InternalServerError("unsupported policy mode", nil)
}
// streamPolicy writes the policy bytes as-is with the chosen content type and
// a content-addressed ETag, bypassing huma's JSON marshaler so HuJSON survives.
func streamPolicy(data []byte, contentType string) *huma.StreamResponse {
return &huma.StreamResponse{Body: func(ctx huma.Context) {
ctx.SetHeader("Content-Type", contentType)
ctx.SetHeader("ETag", policyETag(data))
ctx.SetStatus(http.StatusOK)
_, _ = ctx.BodyWriter().Write(data)
}}
}
// aclContentType serves HuJSON only when explicitly asked; everything else
// (including an empty Accept) gets application/json. The bytes are identical.
func aclContentType(accept string) string {
if strings.Contains(accept, contentTypeHuJSON) {
return contentTypeHuJSON
}
return contentTypeJSON
}
// policyETag is the quoted hex SHA-256 of the policy bytes: stable across reads,
// changes iff the policy changes.
func policyETag(data []byte) string {
sum := sha256.Sum256(data)
return `"` + hex.EncodeToString(sum[:]) + `"`
}
// etagMatches reports whether an If-Match header satisfies the current policy.
// "*" always matches; "ts-default" matches only when no policy is set (an
// approximation of Tailscale's untouched-default semantics).
func etagMatches(ifMatch string, current []byte) bool {
ifMatch = strings.TrimSpace(ifMatch)
switch ifMatch {
case "*":
return true
case `"ts-default"`, "ts-default":
return string(current) == defaultPolicy
default:
return ifMatch == policyETag(current)
}
}