mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-17 05:20:43 +09:00
c9bbb5bdec
Reimplement the v1 API as a code-first Huma service in hscontrol/api/v1, a thin adapter over the state layer. Huma emits the OpenAPI 3.1 document (openapi/v1/headscale.yaml) from the Go operation and type definitions; cmd/gen-openapi writes it and the 3.0.3 downgrade the client is generated from. Responses reproduce the protojson wire contract (string-encoded 64-bit IDs, all fields emitted, RFC3339/null timestamps); errors map to the correct HTTP status (404/400/409, 500 for server faults) via mapError. Serve it on the chi router at /api/v1 behind the existing API-key middleware, and point /swagger at the emitted spec.
192 lines
4.9 KiB
Go
192 lines
4.9 KiB
Go
package apiv1
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/danielgtaylor/huma/v2"
|
|
policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2"
|
|
"github.com/juanfont/headscale/hscontrol/types"
|
|
"github.com/juanfont/headscale/hscontrol/util"
|
|
)
|
|
|
|
func init() {
|
|
registrations = append(registrations, registerPolicy)
|
|
}
|
|
|
|
// PolicyRequestBody carries the HuJSON policy document as a string, for both
|
|
// v1.SetPolicyRequest and v1.CheckPolicyRequest.
|
|
type PolicyRequestBody struct {
|
|
Policy string `json:"policy,omitempty"`
|
|
}
|
|
|
|
// PolicyResponseBody is the v1.GetPolicyResponse/SetPolicyResponse body. Fields
|
|
// carry no omitempty so zero values are emitted (EmitUnpopulated parity).
|
|
type PolicyResponseBody struct {
|
|
Policy string `json:"policy"`
|
|
UpdatedAt time.Time `json:"updatedAt"`
|
|
}
|
|
|
|
type (
|
|
getPolicyInput struct{}
|
|
getPolicyOutput struct {
|
|
Body PolicyResponseBody
|
|
}
|
|
|
|
setPolicyInput struct {
|
|
Body PolicyRequestBody
|
|
}
|
|
setPolicyOutput struct {
|
|
Body PolicyResponseBody
|
|
}
|
|
|
|
checkPolicyInput struct {
|
|
Body PolicyRequestBody
|
|
}
|
|
checkPolicyOutput struct {
|
|
Body struct{}
|
|
}
|
|
)
|
|
|
|
func registerPolicy(api huma.API, b Backend) {
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "getPolicy",
|
|
Method: http.MethodGet,
|
|
Path: "/api/v1/policy",
|
|
Summary: "Get policy",
|
|
Tags: []string{"Policy"},
|
|
Security: bearerAuth,
|
|
}, func(ctx context.Context, _ *getPolicyInput) (*getPolicyOutput, error) {
|
|
switch b.Cfg.Policy.Mode {
|
|
case types.PolicyModeDB:
|
|
p, err := b.State.GetPolicy()
|
|
if err != nil {
|
|
return nil, huma.Error500InternalServerError("loading ACL from database", err)
|
|
}
|
|
|
|
out := &getPolicyOutput{}
|
|
out.Body.Policy = p.Data
|
|
out.Body.UpdatedAt = p.UpdatedAt
|
|
|
|
return out, nil
|
|
case types.PolicyModeFile:
|
|
absPath := util.AbsolutePathFromConfigPath(b.Cfg.Policy.Path)
|
|
|
|
f, err := os.Open(absPath)
|
|
if err != nil {
|
|
return nil, huma.Error500InternalServerError(
|
|
fmt.Sprintf("reading policy from path %q", absPath), err,
|
|
)
|
|
}
|
|
defer f.Close()
|
|
|
|
data, err := io.ReadAll(f)
|
|
if err != nil {
|
|
return nil, huma.Error500InternalServerError("reading policy from file", err)
|
|
}
|
|
|
|
out := &getPolicyOutput{}
|
|
out.Body.Policy = string(data)
|
|
|
|
return out, nil
|
|
}
|
|
|
|
return nil, huma.Error500InternalServerError(fmt.Sprintf(
|
|
"no supported policy mode found in configuration, policy.mode: %q",
|
|
b.Cfg.Policy.Mode,
|
|
), nil)
|
|
})
|
|
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "setPolicy",
|
|
Method: http.MethodPut,
|
|
Path: "/api/v1/policy",
|
|
Summary: "Set policy",
|
|
Tags: []string{"Policy"},
|
|
Security: bearerAuth,
|
|
}, func(ctx context.Context, in *setPolicyInput) (*setPolicyOutput, error) {
|
|
if b.Cfg.Policy.Mode != types.PolicyModeDB {
|
|
// Policy updates are only valid in DB mode; otherwise 400.
|
|
return nil, huma.Error400BadRequest(
|
|
types.ErrPolicyUpdateIsDisabled.Error(), types.ErrPolicyUpdateIsDisabled,
|
|
)
|
|
}
|
|
|
|
p := in.Body.Policy
|
|
|
|
// Reject policy that would fail when building a map response. SSH rule
|
|
// validation needs a node, so a server with no nodes can't catch every
|
|
// case here.
|
|
nodes := b.State.ListNodes()
|
|
|
|
_, err := b.State.SetPolicy([]byte(p))
|
|
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(p)
|
|
if err != nil {
|
|
return nil, huma.Error500InternalServerError("setting policy", err)
|
|
}
|
|
|
|
// Reload even when content is unchanged: routes manually disabled before
|
|
// may now qualify for auto-approval, so they must be re-evaluated.
|
|
cs, err := b.State.ReloadPolicy()
|
|
if err != nil {
|
|
return nil, huma.Error500InternalServerError("reloading policy", err)
|
|
}
|
|
|
|
if len(cs) > 0 {
|
|
b.Change(cs...)
|
|
}
|
|
|
|
out := &setPolicyOutput{}
|
|
out.Body.Policy = updated.Data
|
|
out.Body.UpdatedAt = updated.UpdatedAt
|
|
|
|
return out, nil
|
|
})
|
|
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "checkPolicy",
|
|
Method: http.MethodPost,
|
|
Path: "/api/v1/policy/check",
|
|
Summary: "Check policy",
|
|
Description: "Validates the given policy against the server's live users and nodes without persisting it.",
|
|
Tags: []string{"Policy"},
|
|
Security: bearerAuth,
|
|
}, func(ctx context.Context, in *checkPolicyInput) (*checkPolicyOutput, error) {
|
|
polB := []byte(in.Body.Policy)
|
|
|
|
users, err := b.State.ListAllUsers()
|
|
if err != nil {
|
|
return nil, huma.Error500InternalServerError("loading users", err)
|
|
}
|
|
|
|
nodes := b.State.ListNodes()
|
|
|
|
pm, err := policyv2.NewPolicyManager(polB, users, nodes)
|
|
if err != nil {
|
|
return nil, huma.Error400BadRequest(err.Error(), err)
|
|
}
|
|
|
|
_, err = pm.SetPolicy(polB)
|
|
if err != nil {
|
|
return nil, huma.Error400BadRequest(err.Error(), err)
|
|
}
|
|
|
|
return &checkPolicyOutput{}, nil
|
|
})
|
|
}
|