From 3cddc07026025f727fb780dcf4475343f89b5cef Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 20 Jun 2026 20:16:51 +0000 Subject: [PATCH] api/v2: add Tailscale-compatible ACL endpoint Serve and replace the policy as HuJSON with content-addressed ETags and If-Match preconditions; writes are rejected in file mode. --- hscontrol/api/v2/acl.go | 229 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 hscontrol/api/v2/acl.go diff --git a/hscontrol/api/v2/acl.go b/hscontrol/api/v2/acl.go new file mode 100644 index 00000000..8129e767 --- /dev/null +++ b/hscontrol/api/v2/acl.go @@ -0,0 +1,229 @@ +package apiv2 + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "net/http" + "os" + "strings" + + "github.com/danielgtaylor/huma/v2" + "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 setACLInput struct { + Tailnet string `path:"tailnet"` + IfMatch string `header:"If-Match"` + Accept string `header:"Accept"` + // RawBody captures the HuJSON or JSON policy body verbatim; huma feeds the + // raw request bytes 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}, + }, ScopePolicyFileRead), 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, + }, + }, ScopePolicyFile), 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() + if err != nil { + return nil, huma.Error500InternalServerError("reloading policy", err) + } + + if len(cs) > 0 { + b.Change(cs...) + } + + return streamPolicy([]byte(updated.Data), aclContentType(in.Accept)), nil + }) +} + +// 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 verbatim 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) + } +}