api/v1: add code-first Huma implementation of the v1 API

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.
This commit is contained in:
Kristoffer Dalby
2026-06-19 06:12:20 +00:00
parent 97eff90ebe
commit c9bbb5bdec
19 changed files with 2333 additions and 381 deletions
+160
View File
@@ -0,0 +1,160 @@
// Package apiv1 is the code-first Huma implementation of the Headscale v1 API.
// Handlers are a thin adapter over hscontrol/state; Huma emits the OpenAPI 3.1
// spec from the Go definitions (see Spec), and that spec drives the client.
//
// It depends only on the domain layer (hscontrol/state, hscontrol/types) via
// Backend, never on the hscontrol server package, so a future hscontrol/api/v2
// can sit beside it without either importing the other.
package apiv1
import (
"context"
"net/http"
"strings"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humachi"
"github.com/go-chi/chi/v5"
"github.com/juanfont/headscale/hscontrol/state"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/types/change"
)
// Backend is the dependency surface the v1 API needs from the control plane:
// the state layer, the change-notification sink that distributes updates to
// connected nodes, and the config (only Policy.Mode and Policy.Path are read).
type Backend struct {
State *state.State
Change func(...change.Change)
Cfg *types.Config
}
// NewAPI builds the v1 Huma API on the given chi router and registers every
// operation. Auth is enforced by a Huma middleware driven by each operation's
// declared bearer security (see authMiddleware); locally-trusted requests
// bypass it via WithLocalTrust.
func NewAPI(router chi.Router, backend Backend) huma.API {
config := huma.DefaultConfig("Headscale API", "v1")
config.Info.Description = "Headscale control server API."
// Version the OpenAPI/docs routes under /api/v1 so a future v2 owns its own.
// These register as plain mux routes, not operations, so they never appear
// in the emitted spec or client.
config.OpenAPIPath = "/api/v1/openapi"
config.DocsPath = "/api/v1/docs"
// The v1 API does not emit "$schema".
config.SchemasPath = ""
// Drop the default schema-link create hook: it injects a "$schema" property
// and Link header into every response, which the v1 contract omits.
config.CreateHooks = nil
config.Components.SecuritySchemes = map[string]*huma.SecurityScheme{
"bearer": {
Type: "http",
Scheme: "bearer",
},
}
api := humachi.New(router, config)
// Must run before register: Huma snapshots the middleware chain at operation
// registration, so a middleware added afterwards would silently never run.
api.UseMiddleware(authMiddleware(api, backend))
register(api, backend)
return api
}
// bearerAuth is the security requirement applied to every operation: all
// /api/v1 routes require an API key.
var bearerAuth = []map[string][]string{{"bearer": {}}}
// registrations is populated by each resource file's init(), so adding a
// resource group means adding a file rather than editing a shared point. Huma
// sorts the emitted spec, so init order does not affect output.
var registrations []func(huma.API, Backend)
// register wires up every operation contributed by the resource files.
func register(api huma.API, b Backend) {
for _, fn := range registrations {
fn(api, b)
}
}
// Spec emits the OpenAPI 3.1 document. The zero Backend is safe because
// handlers are registered but never invoked during emission.
func Spec() ([]byte, error) {
api := NewAPI(chi.NewMux(), Backend{})
return api.OpenAPI().YAML()
}
// Spec30 emits the document downgraded to OpenAPI 3.0.3, needed because the
// client generator (oapi-codegen v2) cannot yet read the 3.1 spec.
func Spec30() ([]byte, error) {
api := NewAPI(chi.NewMux(), Backend{})
return api.OpenAPI().DowngradeYAML()
}
// Handler builds the v1 API on a fresh mux and returns both. Callers mount the
// mux and may use mux.Match to detect which paths this API serves.
func Handler(backend Backend) (*chi.Mux, huma.API) {
mux := chi.NewMux()
api := NewAPI(mux, backend)
return mux, api
}
// localTrustKey marks a request as arriving over a locally-trusted transport;
// the auth middleware skips authentication for such requests.
type localTrustKey struct{}
// WithLocalTrust wraps a handler so its requests bypass API-key authentication.
// The unix socket uses this — access to the socket is the trust boundary — as
// do in-process tests that exercise the mux directly.
func WithLocalTrust(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
next.ServeHTTP(w, req.WithContext(
context.WithValue(req.Context(), localTrustKey{}, struct{}{}),
))
})
}
// authMiddleware is a pure gate enforcing the bearer API key for any operation
// that declares security; the v1 handlers do not read caller identity.
// Locally-trusted requests and operations without declared security pass
// through. b.State is nil only during spec emission, where no request is
// served, so it is never dereferenced there.
func authMiddleware(api huma.API, b Backend) func(huma.Context, func(huma.Context)) {
return func(ctx huma.Context, next func(huma.Context)) {
if ctx.Context().Value(localTrustKey{}) != nil {
next(ctx)
return
}
if len(ctx.Operation().Security) == 0 {
next(ctx)
return
}
token, ok := strings.CutPrefix(ctx.Header("Authorization"), "Bearer ")
if !ok {
_ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "Unauthorized")
return
}
valid, err := b.State.ValidateAPIKey(token)
if err != nil || !valid {
_ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "Unauthorized")
return
}
next(ctx)
}
}
+245
View File
@@ -0,0 +1,245 @@
package apiv1
import (
"cmp"
"context"
"net/http"
"slices"
"strconv"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/juanfont/headscale/hscontrol/types"
)
func init() {
registrations = append(registrations, registerApiKeys)
}
// ApiKey is the v1 ApiKey message. Timestamps are pointers so a nil source is
// emitted as JSON null, matching protojson's unset Timestamp (e.g. lastSeen on
// a fresh key).
type ApiKey struct {
ID string `format:"uint64" json:"id"`
Prefix string `json:"prefix"`
Expiration *time.Time `json:"expiration" nullable:"true"`
CreatedAt *time.Time `json:"createdAt" nullable:"true"`
LastSeen *time.Time `json:"lastSeen" nullable:"true"`
}
// CreateApiKeyRequestBody is the v1.CreateApiKeyRequest body.
type CreateApiKeyRequestBody struct {
Expiration *time.Time `json:"expiration,omitempty"`
}
// ExpireApiKeyRequestBody is the v1.ExpireApiKeyRequest body.
type ExpireApiKeyRequestBody struct {
Prefix string `json:"prefix,omitempty"`
ID string `format:"uint64" json:"id,omitempty"`
}
type (
createApiKeyInput struct {
Body CreateApiKeyRequestBody
}
createApiKeyOutput struct {
Body struct {
APIKey string `json:"apiKey"`
}
}
)
type (
expireApiKeyInput struct {
Body ExpireApiKeyRequestBody
}
expireApiKeyOutput struct {
Body struct{}
}
)
type (
listApiKeysOutput struct {
Body struct {
APIKeys []ApiKey `json:"apiKeys" nullable:"false"`
}
}
)
type (
deleteApiKeyInput struct {
Prefix string `path:"prefix"`
ID string `format:"uint64" query:"id"`
}
deleteApiKeyOutput struct {
Body struct{}
}
)
func registerApiKeys(api huma.API, b Backend) {
huma.Register(api, huma.Operation{
OperationID: "createApiKey",
Method: http.MethodPost,
Path: "/api/v1/apikey",
Summary: "Create API key",
Tags: []string{"ApiKeys"},
Security: bearerAuth,
}, func(ctx context.Context, in *createApiKeyInput) (*createApiKeyOutput, error) {
// CreateAPIKey requires a non-nil pointer; default a missing expiration
// to the zero time as the gRPC handler does.
var expiration time.Time
if in.Body.Expiration != nil {
expiration = *in.Body.Expiration
}
keyStr, _, err := b.State.CreateAPIKey(&expiration)
if err != nil {
return nil, huma.Error500InternalServerError("creating api key", err)
}
out := &createApiKeyOutput{}
out.Body.APIKey = keyStr
return out, nil
})
huma.Register(api, huma.Operation{
OperationID: "expireApiKey",
Method: http.MethodPost,
Path: "/api/v1/apikey/expire",
Summary: "Expire API key",
Tags: []string{"ApiKeys"},
Security: bearerAuth,
}, func(ctx context.Context, in *expireApiKeyInput) (*expireApiKeyOutput, error) {
key, err := lookupApiKey(b, in.Body.ID, in.Body.Prefix)
if err != nil {
return nil, err
}
err = b.State.ExpireAPIKey(key)
if err != nil {
return nil, huma.Error500InternalServerError("expiring api key", err)
}
return &expireApiKeyOutput{}, nil
})
huma.Register(api, huma.Operation{
OperationID: "listApiKeys",
Method: http.MethodGet,
Path: "/api/v1/apikey",
Summary: "List API keys",
Tags: []string{"ApiKeys"},
Security: bearerAuth,
}, func(ctx context.Context, _ *struct{}) (*listApiKeysOutput, error) {
keys, err := b.State.ListAPIKeys()
if err != nil {
return nil, huma.Error500InternalServerError("listing api keys", err)
}
// Match the gRPC handler's ascending-ID ordering.
slices.SortFunc(keys, func(a, b types.APIKey) int {
return cmp.Compare(a.ID, b.ID)
})
out := &listApiKeysOutput{}
out.Body.APIKeys = make([]ApiKey, len(keys))
for i := range keys {
out.Body.APIKeys[i] = apiKeyFromState(&keys[i])
}
return out, nil
})
huma.Register(api, huma.Operation{
OperationID: "deleteApiKey",
Method: http.MethodDelete,
Path: "/api/v1/apikey/{prefix}",
Summary: "Delete API key",
Tags: []string{"ApiKeys"},
Security: bearerAuth,
}, func(ctx context.Context, in *deleteApiKeyInput) (*deleteApiKeyOutput, error) {
key, err := lookupApiKey(b, in.ID, in.Prefix)
if err != nil {
return nil, err
}
err = b.State.DestroyAPIKey(*key)
if err != nil {
return nil, huma.Error500InternalServerError("deleting api key", err)
}
return &deleteApiKeyOutput{}, nil
})
}
// lookupApiKey resolves an API key by id or prefix; exactly one must be
// supplied. An empty or zero id counts as "no id". Unknown id/prefix maps to
// 404 via mapError.
func lookupApiKey(b Backend, idStr, prefix string) (*types.APIKey, error) {
id, err := parseApiKeyID(idStr)
if err != nil {
return nil, err
}
hasID := id != 0
hasPrefix := prefix != ""
switch {
case hasID && hasPrefix:
return nil, huma.Error400BadRequest("provide either id or prefix, not both")
case hasID:
key, err := b.State.GetAPIKeyByID(id)
if err != nil {
return nil, mapError("getting api key", err)
}
return key, nil
case hasPrefix:
key, err := b.State.GetAPIKey(prefix)
if err != nil {
return nil, mapError("getting api key", err)
}
return key, nil
default:
return nil, huma.Error400BadRequest("must provide id or prefix")
}
}
// parseApiKeyID decodes the optional uint64 id. Empty maps to zero; non-numeric
// is rejected with 400.
func parseApiKeyID(s string) (uint64, error) {
if s == "" {
return 0, nil
}
id, err := strconv.ParseUint(s, 10, 64)
if err != nil {
return 0, huma.Error400BadRequest("invalid api key id", err)
}
return id, nil
}
// apiKeyFromState converts a domain API key into the v1 response shape, masking
// the prefix so the secret is never returned.
func apiKeyFromState(k *types.APIKey) ApiKey {
return ApiKey{
ID: formatID(k.ID),
Prefix: apiKeyMaskedPrefix(k.Prefix),
Expiration: k.Expiration,
CreatedAt: k.CreatedAt,
LastSeen: k.LastSeen,
}
}
// apiKeyMaskedPrefix reproduces the unexported types.APIKey.maskedPrefix.
func apiKeyMaskedPrefix(prefix string) string {
if len(prefix) == types.NewAPIKeyPrefixLength {
return "hskey-api-" + prefix + "-***"
}
return prefix + "***"
}
+163
View File
@@ -0,0 +1,163 @@
package apiv1
import (
"context"
"errors"
"net/http"
"github.com/danielgtaylor/huma/v2"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/util"
)
func init() {
registrations = append(registrations, registerAuth)
}
// errAuthRejected is the verdict handed to the waiting registration flow when
// an auth session is rejected.
var errAuthRejected = errors.New("auth request rejected")
// AuthRegisterRequestBody is the v1.AuthRegisterRequest body.
type AuthRegisterRequestBody struct {
User string `json:"user,omitempty"`
AuthID string `json:"authId,omitempty"`
}
// AuthApproveRequestBody is the v1.AuthApproveRequest body.
type AuthApproveRequestBody struct {
AuthID string `json:"authId,omitempty"`
}
// AuthRejectRequestBody is the v1.AuthRejectRequest body.
type AuthRejectRequestBody struct {
AuthID string `json:"authId,omitempty"`
}
type (
authRegisterInput struct {
Body AuthRegisterRequestBody
}
authRegisterOutput struct {
Body struct {
Node Node `json:"node"`
}
}
)
type (
authApproveInput struct {
Body AuthApproveRequestBody
}
authApproveOutput struct {
Body struct{}
}
)
type (
authRejectInput struct {
Body AuthRejectRequestBody
}
authRejectOutput struct {
Body struct{}
}
)
func registerAuth(api huma.API, b Backend) {
huma.Register(api, huma.Operation{
OperationID: "authRegister",
Method: http.MethodPost,
Path: "/api/v1/auth/register",
Summary: "Register node via auth flow",
Tags: []string{"Auth"},
Security: bearerAuth,
}, func(ctx context.Context, in *authRegisterInput) (*authRegisterOutput, error) {
// Malformed auth_id is 400; unknown user and missing pending session are
// 404 via mapError, matching the Approve/Reject handlers.
registrationID, err := types.AuthIDFromString(in.Body.AuthID)
if err != nil {
return nil, huma.Error400BadRequest("registering node", err)
}
user, err := b.State.GetUserByName(in.Body.User)
if err != nil {
return nil, mapError("looking up user", err)
}
node, nodeChange, err := b.State.HandleNodeFromAuthPath(
registrationID,
types.UserID(user.ID),
nil,
util.RegisterMethodCLI,
)
if err != nil {
return nil, mapError("registering node", err)
}
routeChange, err := b.State.AutoApproveRoutes(node)
if err != nil {
return nil, huma.Error500InternalServerError("auto approving routes", err)
}
b.Change(nodeChange, routeChange)
out := &authRegisterOutput{}
out.Body.Node = nodeFromView(node)
return out, nil
})
huma.Register(api, huma.Operation{
OperationID: "authApprove",
Method: http.MethodPost,
Path: "/api/v1/auth/approve",
Summary: "Approve a pending auth session",
Tags: []string{"Auth"},
Security: bearerAuth,
}, func(ctx context.Context, in *authApproveInput) (*authApproveOutput, error) {
authReq, err := pendingAuthRequest(b, in.Body.AuthID)
if err != nil {
return nil, err
}
authReq.FinishAuth(types.AuthVerdict{})
return &authApproveOutput{}, nil
})
huma.Register(api, huma.Operation{
OperationID: "authReject",
Method: http.MethodPost,
Path: "/api/v1/auth/reject",
Summary: "Reject a pending auth session",
Tags: []string{"Auth"},
Security: bearerAuth,
}, func(ctx context.Context, in *authRejectInput) (*authRejectOutput, error) {
authReq, err := pendingAuthRequest(b, in.Body.AuthID)
if err != nil {
return nil, err
}
authReq.FinishAuth(types.AuthVerdict{
Err: errAuthRejected,
})
return &authRejectOutput{}, nil
})
}
// pendingAuthRequest looks up the pending session for auth_id. Malformed
// auth_id is 400, unknown is 404.
func pendingAuthRequest(b Backend, rawID string) (*types.AuthRequest, error) {
authID, err := types.AuthIDFromString(rawID)
if err != nil {
return nil, huma.Error400BadRequest("invalid auth_id", err)
}
authReq, ok := b.State.GetAuthCacheEntry(authID)
if !ok {
return nil, huma.Error404NotFound("no pending auth session for auth_id " + authID.String())
}
return authReq, nil
}
+49
View File
@@ -0,0 +1,49 @@
package apiv1
import (
"errors"
"github.com/danielgtaylor/huma/v2"
"github.com/juanfont/headscale/hscontrol/db"
"github.com/juanfont/headscale/hscontrol/state"
"gorm.io/gorm"
)
// mapError translates a state/db-layer error into a Huma HTTP error
// (NotFound→404, invalid input→400, conflict→409, everything else→500).
// Handlers use this default mapping and may return a more specific huma.ErrorN
// directly. msg is a human context prefix, e.g. "getting node".
func mapError(msg string, err error) error {
if err == nil {
return nil
}
switch {
case errors.Is(err, gorm.ErrRecordNotFound),
errors.Is(err, state.ErrNodeNotFound),
errors.Is(err, state.ErrNodeNotInNodeStore),
errors.Is(err, db.ErrUserNotFound),
errors.Is(err, db.ErrNodeNotFoundRegistrationCache),
errors.Is(err, state.ErrRegistrationExpired):
return huma.Error404NotFound(msg, err)
case errors.Is(err, state.ErrGivenNameInvalid),
errors.Is(err, state.ErrGivenNameTaken),
errors.Is(err, state.ErrNodeNameNotUnique),
errors.Is(err, state.ErrNodeMarkedTaggedButHasNoTags),
errors.Is(err, state.ErrNodeHasNeitherUserNorTags),
errors.Is(err, state.ErrRequestedTagsInvalidOrNotPermitted),
errors.Is(err, db.ErrUserStillHasNodes),
errors.Is(err, db.ErrCannotChangeOIDCUser),
errors.Is(err, db.ErrPreAuthKeyNotTaggedOrOwned),
errors.Is(err, db.ErrSingleUseAuthKeyHasBeenUsed):
return huma.Error400BadRequest(msg, err)
case errors.Is(err, state.ErrNodeKeyInUse),
errors.Is(err, state.ErrAmbiguousNodeOwnership):
return huma.Error409Conflict(msg, err)
default:
return huma.Error500InternalServerError(msg, err)
}
}
+41
View File
@@ -0,0 +1,41 @@
package apiv1
import (
"context"
"net/http"
"github.com/danielgtaylor/huma/v2"
)
// HealthResponseBody mirrors the v1 HealthResponse message. database_connectivity
// is reported true only when the database responds to a ping.
type HealthResponseBody struct {
DatabaseConnectivity bool `json:"databaseConnectivity"`
}
type healthOutput struct {
Body HealthResponseBody
}
func init() {
registrations = append(registrations, registerHealth)
}
func registerHealth(api huma.API, b Backend) {
huma.Register(api, huma.Operation{
OperationID: "health",
Method: http.MethodGet,
Path: "/api/v1/health",
Summary: "Health check",
Description: "Reports server health, including database connectivity.",
Tags: []string{"Health"},
Security: bearerAuth,
}, func(ctx context.Context, _ *struct{}) (*healthOutput, error) {
err := b.State.PingDB(ctx)
if err != nil {
return nil, mapError("pinging database", err)
}
return &healthOutput{Body: HealthResponseBody{DatabaseConnectivity: true}}, nil
})
}
+703
View File
@@ -0,0 +1,703 @@
package apiv1
import (
"context"
"errors"
"net/http"
"net/netip"
"slices"
"strconv"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/util"
"tailscale.com/net/tsaddr"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
)
func init() {
registrations = append(registrations, registerNodes)
}
// errBackfillNotConfirmed guards BackfillNodeIPs behind explicit confirmed=true.
var errBackfillNotConfirmed = errors.New("not confirmed, aborting")
// registerMethodToV1Enum maps the stored register method onto the
// SCREAMING_SNAKE enum string the v1 contract emits.
var registerMethodToV1Enum = map[string]string{
util.RegisterMethodAuthKey: "REGISTER_METHOD_AUTH_KEY",
util.RegisterMethodOIDC: "REGISTER_METHOD_OIDC",
util.RegisterMethodCLI: "REGISTER_METHOD_CLI",
}
// Node mirrors the v1 Node message. The protojson contract emits unpopulated
// fields: scalars and slices always (no omitempty), nested messages and optional
// timestamps as JSON null when unset.
type Node struct {
ID string `format:"uint64" json:"id"`
MachineKey string `json:"machineKey"`
NodeKey string `json:"nodeKey"`
DiscoKey string `json:"discoKey"`
IPAddresses []string `json:"ipAddresses" nullable:"false"`
Name string `json:"name"`
User *User `json:"user"`
LastSeen *time.Time `json:"lastSeen" nullable:"true"`
Expiry *time.Time `json:"expiry" nullable:"true"`
PreAuthKey *NodePreAuthKey `json:"preAuthKey"`
CreatedAt time.Time `json:"createdAt"`
RegisterMethod string `enum:"REGISTER_METHOD_UNSPECIFIED,REGISTER_METHOD_AUTH_KEY,REGISTER_METHOD_CLI,REGISTER_METHOD_OIDC" json:"registerMethod"`
GivenName string `json:"givenName"`
Online bool `json:"online"`
ApprovedRoutes []string `json:"approvedRoutes" nullable:"false"`
AvailableRoutes []string `json:"availableRoutes" nullable:"false"`
SubnetRoutes []string `json:"subnetRoutes" nullable:"false"`
Tags []string `json:"tags" nullable:"false"`
}
// NodePreAuthKey is the PreAuthKey shape embedded in a Node response. The
// /preauthkey endpoints own the standalone request/response surface.
type NodePreAuthKey struct {
User *User `json:"user"`
ID string `format:"uint64" json:"id"`
Key string `json:"key"`
Reusable bool `json:"reusable"`
Ephemeral bool `json:"ephemeral"`
Used bool `json:"used"`
Expiration *time.Time `json:"expiration" nullable:"true"`
CreatedAt *time.Time `json:"createdAt" nullable:"true"`
AclTags []string `json:"aclTags" nullable:"false"`
}
// SetTagsRequestBody mirrors v1.SetTagsRequest.
type SetTagsRequestBody struct {
Tags []string `json:"tags,omitempty"`
}
// SetApprovedRoutesRequestBody mirrors v1.SetApprovedRoutesRequest.
type SetApprovedRoutesRequestBody struct {
Routes []string `json:"routes,omitempty"`
}
// DebugCreateNodeRequestBody mirrors v1.DebugCreateNodeRequest.
type DebugCreateNodeRequestBody struct {
User string `json:"user,omitempty"`
Key string `json:"key,omitempty"`
Name string `json:"name,omitempty"`
Routes []string `json:"routes,omitempty"`
}
type (
getNodeInput struct {
NodeID string `format:"uint64" path:"nodeId"`
}
nodeOutput struct {
Body struct {
Node Node `json:"node"`
}
}
)
type (
listNodesInput struct {
User string `query:"user"`
}
listNodesOutput struct {
Body struct {
Nodes []Node `json:"nodes" nullable:"false"`
}
}
)
type (
deleteNodeInput struct {
NodeID string `format:"uint64" path:"nodeId"`
}
deleteNodeOutput struct {
Body struct{}
}
)
// ExpireNodeRequestBody mirrors v1.ExpireNodeRequest. Both fields are optional;
// an absent or all-zero body expires the node immediately, as gRPC does.
type ExpireNodeRequestBody struct {
Expiry *time.Time `json:"expiry,omitempty"`
DisableExpiry bool `json:"disableExpiry,omitempty"`
}
type expireNodeInput struct {
NodeID string `format:"uint64" path:"nodeId"`
Body *ExpireNodeRequestBody `required:"false"`
}
type renameNodeInput struct {
NodeID string `format:"uint64" path:"nodeId"`
NewName string `path:"newName"`
}
type setTagsInput struct {
NodeID string `format:"uint64" path:"nodeId"`
Body SetTagsRequestBody
}
type setApprovedRoutesInput struct {
NodeID string `format:"uint64" path:"nodeId"`
Body SetApprovedRoutesRequestBody
}
type registerNodeInput struct {
User string `query:"user"`
Key string `query:"key"`
}
type backfillNodeIPsInput struct {
Confirmed bool `query:"confirmed"`
}
type backfillNodeIPsOutput struct {
Body struct {
Changes []string `json:"changes" nullable:"false"`
}
}
type debugCreateNodeInput struct {
Body DebugCreateNodeRequestBody
}
func registerNodes(api huma.API, b Backend) {
registerNodeReadOps(api, b)
registerNodeWriteOps(api, b)
registerNodeAdminOps(api, b)
}
func registerNodeReadOps(api huma.API, b Backend) {
huma.Register(api, huma.Operation{
OperationID: "getNode",
Method: http.MethodGet,
Path: "/api/v1/node/{nodeId}",
Summary: "Get node",
Tags: []string{"Nodes"},
Security: bearerAuth,
}, func(ctx context.Context, in *getNodeInput) (*nodeOutput, error) {
nodeID, err := parseNodeID(in.NodeID)
if err != nil {
return nil, err
}
node, ok := b.State.GetNodeByID(nodeID)
if !ok {
return nil, huma.Error404NotFound("node not found")
}
out := &nodeOutput{}
out.Body.Node = nodeFromView(node)
return out, nil
})
huma.Register(api, huma.Operation{
OperationID: "listNodes",
Method: http.MethodGet,
Path: "/api/v1/node",
Summary: "List nodes",
Tags: []string{"Nodes"},
Security: bearerAuth,
}, func(ctx context.Context, in *listNodesInput) (*listNodesOutput, error) {
nodes := b.State.ListNodes()
if in.User != "" {
user, err := b.State.GetUserByName(in.User)
if err != nil {
return nil, mapError("listing nodes", err)
}
nodes = b.State.ListNodesByUser(types.UserID(user.ID))
}
out := &listNodesOutput{}
out.Body.Nodes = make([]Node, nodes.Len())
for i, node := range nodes.All() {
n := nodeFromView(node)
// Tags-as-identity: tagged nodes are presented as the special
// TaggedDevices user.
if node.IsTagged() {
user := userFromState(&types.TaggedDevices)
n.User = &user
}
// SubnetRoutes is the routes actively served, exit routes included.
n.SubnetRoutes = util.PrefixesToString(
append(b.State.GetNodePrimaryRoutes(node.ID()), node.ExitRoutes()...),
)
out.Body.Nodes[i] = n
}
// Match the gRPC handler's ascending-ID ordering.
slices.SortFunc(out.Body.Nodes, func(a, b Node) int {
return cmpNodeID(a.ID, b.ID)
})
return out, nil
})
}
func registerNodeWriteOps(api huma.API, b Backend) {
huma.Register(api, huma.Operation{
OperationID: "deleteNode",
Method: http.MethodDelete,
Path: "/api/v1/node/{nodeId}",
Summary: "Delete node",
Tags: []string{"Nodes"},
Security: bearerAuth,
}, func(ctx context.Context, in *deleteNodeInput) (*deleteNodeOutput, error) {
nodeID, err := parseNodeID(in.NodeID)
if err != nil {
return nil, err
}
node, ok := b.State.GetNodeByID(nodeID)
if !ok {
return nil, huma.Error404NotFound("node not found")
}
nodeChange, err := b.State.DeleteNode(node)
if err != nil {
return nil, huma.Error500InternalServerError("deleting node", err)
}
b.Change(nodeChange)
return &deleteNodeOutput{}, nil
})
huma.Register(api, huma.Operation{
OperationID: "expireNode",
Method: http.MethodPost,
Path: "/api/v1/node/{nodeId}/expire",
Summary: "Expire node",
Tags: []string{"Nodes"},
Security: bearerAuth,
}, func(ctx context.Context, in *expireNodeInput) (*nodeOutput, error) {
nodeID, err := parseNodeID(in.NodeID)
if err != nil {
return nil, err
}
// gRPC parity: disableExpiry => nil expiry (never expires); explicit
// expiry honoured; absent/zero body expires now. Both set is a 400.
var (
disableExpiry bool
customExpiry *time.Time
)
if in.Body != nil {
disableExpiry = in.Body.DisableExpiry
customExpiry = in.Body.Expiry
}
if disableExpiry && customExpiry != nil {
return nil, huma.Error400BadRequest("cannot set both disable_expiry and expiry")
}
expiry := time.Now()
switch {
case disableExpiry:
node, nodeChange, expErr := b.State.SetNodeExpiry(nodeID, nil)
if expErr != nil {
return nil, mapError("expiring node", expErr)
}
b.Change(nodeChange)
out := &nodeOutput{}
out.Body.Node = nodeFromView(node)
return out, nil
case customExpiry != nil:
expiry = *customExpiry
}
node, nodeChange, err := b.State.SetNodeExpiry(nodeID, &expiry)
if err != nil {
return nil, mapError("expiring node", err)
}
b.Change(nodeChange)
out := &nodeOutput{}
out.Body.Node = nodeFromView(node)
return out, nil
})
huma.Register(api, huma.Operation{
OperationID: "renameNode",
Method: http.MethodPost,
Path: "/api/v1/node/{nodeId}/rename/{newName}",
Summary: "Rename node",
Tags: []string{"Nodes"},
Security: bearerAuth,
}, func(ctx context.Context, in *renameNodeInput) (*nodeOutput, error) {
nodeID, err := parseNodeID(in.NodeID)
if err != nil {
return nil, err
}
node, nodeChange, err := b.State.RenameNode(nodeID, in.NewName)
if err != nil {
return nil, mapError("renaming node", err)
}
b.Change(nodeChange)
out := &nodeOutput{}
out.Body.Node = nodeFromView(node)
return out, nil
})
huma.Register(api, huma.Operation{
OperationID: "setTags",
Method: http.MethodPost,
Path: "/api/v1/node/{nodeId}/tags",
Summary: "Set tags",
Tags: []string{"Nodes"},
Security: bearerAuth,
}, func(ctx context.Context, in *setTagsInput) (*nodeOutput, error) {
nodeID, err := parseNodeID(in.NodeID)
if err != nil {
return nil, err
}
// Tagged nodes must keep at least one tag, so reject an empty set
// before touching state, as gRPC does.
if len(in.Body.Tags) == 0 {
return nil, huma.Error400BadRequest(
"cannot remove all tags from a node - tagged nodes must have at least one tag",
)
}
for _, tag := range in.Body.Tags {
tagErr := validateTag(tag)
if tagErr != nil {
return nil, huma.Error400BadRequest("setting tags", tagErr)
}
}
_, found := b.State.GetNodeByID(nodeID)
if !found {
return nil, huma.Error404NotFound("node not found")
}
node, nodeChange, err := b.State.SetNodeTags(nodeID, in.Body.Tags)
if err != nil {
return nil, huma.Error400BadRequest("setting tags", err)
}
b.Change(nodeChange)
out := &nodeOutput{}
out.Body.Node = nodeFromView(node)
return out, nil
})
}
func registerNodeAdminOps(api huma.API, b Backend) {
huma.Register(api, huma.Operation{
OperationID: "setApprovedRoutes",
Method: http.MethodPost,
Path: "/api/v1/node/{nodeId}/approve_routes",
Summary: "Set approved routes",
Tags: []string{"Nodes"},
Security: bearerAuth,
}, func(ctx context.Context, in *setApprovedRoutesInput) (*nodeOutput, error) {
nodeID, err := parseNodeID(in.NodeID)
if err != nil {
return nil, err
}
var newApproved []netip.Prefix
for _, route := range in.Body.Routes {
prefix, parseErr := netip.ParsePrefix(route)
if parseErr != nil {
return nil, huma.Error400BadRequest("parsing route", parseErr)
}
// One exit route implies both families, else the client won't
// annotate the node as an exit node.
if prefix == tsaddr.AllIPv4() || prefix == tsaddr.AllIPv6() {
newApproved = append(newApproved, tsaddr.AllIPv4(), tsaddr.AllIPv6())
} else {
newApproved = append(newApproved, prefix)
}
}
slices.SortFunc(newApproved, netip.Prefix.Compare)
newApproved = slices.Compact(newApproved)
node, nodeChange, err := b.State.SetApprovedRoutes(nodeID, newApproved)
if err != nil {
return nil, mapError("setting approved routes", err)
}
b.Change(nodeChange)
out := &nodeOutput{}
out.Body.Node = nodeFromView(node)
// SubnetRoutes here excludes exit routes, unlike the list handler.
out.Body.Node.SubnetRoutes = util.PrefixesToString(
b.State.GetNodePrimaryRoutes(node.ID()),
)
return out, nil
})
huma.Register(api, huma.Operation{
OperationID: "registerNode",
Method: http.MethodPost,
Path: "/api/v1/node/register",
Summary: "Register node",
Tags: []string{"Nodes"},
Security: bearerAuth,
}, func(ctx context.Context, in *registerNodeInput) (*nodeOutput, error) {
registrationID, err := types.AuthIDFromString(in.Key)
if err != nil {
return nil, huma.Error400BadRequest("registering node", err)
}
user, err := b.State.GetUserByName(in.User)
if err != nil {
return nil, mapError("looking up user", err)
}
node, nodeChange, err := b.State.HandleNodeFromAuthPath(
registrationID,
types.UserID(user.ID),
nil,
util.RegisterMethodCLI,
)
if err != nil {
return nil, mapError("registering node", err)
}
routeChange, err := b.State.AutoApproveRoutes(node)
if err != nil {
return nil, huma.Error500InternalServerError("auto approving routes", err)
}
// Empty changes are ignored by the change sink.
b.Change(nodeChange, routeChange)
out := &nodeOutput{}
out.Body.Node = nodeFromView(node)
return out, nil
})
huma.Register(api, huma.Operation{
OperationID: "backfillNodeIPs",
Method: http.MethodPost,
Path: "/api/v1/node/backfillips",
Summary: "Backfill node IPs",
Tags: []string{"Nodes"},
Security: bearerAuth,
}, func(ctx context.Context, in *backfillNodeIPsInput) (*backfillNodeIPsOutput, error) {
if !in.Confirmed {
return nil, huma.Error400BadRequest("backfilling node IPs", errBackfillNotConfirmed)
}
changes, err := b.State.BackfillNodeIPs()
if err != nil {
return nil, huma.Error500InternalServerError("backfilling node IPs", err)
}
out := &backfillNodeIPsOutput{}
out.Body.Changes = changes
if out.Body.Changes == nil {
out.Body.Changes = []string{}
}
return out, nil
})
huma.Register(api, huma.Operation{
OperationID: "debugCreateNode",
Method: http.MethodPost,
Path: "/api/v1/debug/node",
Summary: "Debug create node",
Tags: []string{"Nodes"},
Security: bearerAuth,
}, func(ctx context.Context, in *debugCreateNodeInput) (*nodeOutput, error) {
user, err := b.State.GetUserByName(in.Body.User)
if err != nil {
return nil, mapError("looking up user", err)
}
routes, err := util.StringToIPPrefix(in.Body.Routes)
if err != nil {
return nil, huma.Error400BadRequest("parsing routes", err)
}
registrationID, err := types.AuthIDFromString(in.Body.Key)
if err != nil {
return nil, huma.Error400BadRequest("debug creating node", err)
}
regData := &types.RegistrationData{
NodeKey: key.NewNode().Public(),
MachineKey: key.NewMachine().Public(),
Hostname: in.Body.Name,
Expiry: &time.Time{}, // zero time, not nil, to keep proto JSON round-trip semantics
}
authRegReq := types.NewRegisterAuthRequest(regData)
b.State.SetAuthCacheEntry(registrationID, authRegReq)
// Synthetic echo; the real node is created later via the auth path
// from the cached registration data.
echoNode := types.Node{
NodeKey: regData.NodeKey,
MachineKey: regData.MachineKey,
Hostname: regData.Hostname,
User: user,
Expiry: &time.Time{},
LastSeen: &time.Time{},
Hostinfo: &tailcfg.Hostinfo{
Hostname: in.Body.Name,
OS: "TestOS",
RoutableIPs: routes,
},
}
out := &nodeOutput{}
out.Body.Node = nodeFromView(echoNode.View())
return out, nil
})
}
// nodeFromView builds the Node response from a NodeView. SubnetRoutes is left
// empty; callers that serve routes set it explicitly.
func nodeFromView(view types.NodeView) Node {
node := view.AsStruct()
n := Node{
ID: formatID(uint64(node.ID)),
MachineKey: node.MachineKey.String(),
NodeKey: node.NodeKey.String(),
DiscoKey: node.DiscoKey.String(),
IPAddresses: nonNilStrings(node.IPsAsString()),
Name: node.Hostname,
CreatedAt: node.CreatedAt,
RegisterMethod: registerMethodEnum(node.RegisterMethod),
GivenName: node.GivenName,
Online: node.IsOnline != nil && *node.IsOnline,
ApprovedRoutes: nonNilStrings(util.PrefixesToString(node.ApprovedRoutes)),
AvailableRoutes: nonNilStrings(util.PrefixesToString(node.AnnouncedRoutes())),
SubnetRoutes: []string{},
Tags: nonNilStrings(node.Tags),
}
if node.User != nil {
user := userFromState(node.User)
n.User = &user
}
if node.AuthKey != nil {
n.PreAuthKey = nodePreAuthKeyFromState(node.AuthKey)
}
if node.LastSeen != nil {
ls := *node.LastSeen
n.LastSeen = &ls
}
if node.Expiry != nil {
exp := *node.Expiry
n.Expiry = &exp
}
return n
}
// nodePreAuthKeyFromState builds the embedded NodePreAuthKey, masking the key to
// its prefix (legacy plaintext keys are shown in full).
func nodePreAuthKeyFromState(key *types.PreAuthKey) *NodePreAuthKey {
pak := &NodePreAuthKey{
ID: formatID(key.ID),
Key: maskedPreAuthKey(key),
Reusable: key.Reusable,
Ephemeral: key.Ephemeral,
Used: key.Used,
AclTags: nonNilStrings(key.Tags),
}
if key.User != nil {
user := userFromState(key.User)
pak.User = &user
}
if key.Expiration != nil {
exp := *key.Expiration
pak.Expiration = &exp
}
if key.CreatedAt != nil {
created := *key.CreatedAt
pak.CreatedAt = &created
}
return pak
}
// registerMethodEnum maps the stored register method onto the v1 enum string,
// defaulting to REGISTER_METHOD_UNSPECIFIED for unknown values.
func registerMethodEnum(method string) string {
if enum, ok := registerMethodToV1Enum[method]; ok {
return enum
}
return "REGISTER_METHOD_UNSPECIFIED"
}
func nonNilStrings(s []string) []string {
if s == nil {
return []string{}
}
return s
}
// cmpNodeID orders two decimal node-ID strings numerically, matching the gRPC
// handler's ascending-ID ordering.
func cmpNodeID(a, b string) int {
ai, _ := strconv.ParseUint(a, 10, 64)
bi, _ := strconv.ParseUint(b, 10, 64)
switch {
case ai < bi:
return -1
case ai > bi:
return 1
default:
return 0
}
}
func parseNodeID(s string) (types.NodeID, error) {
id, err := strconv.ParseUint(s, 10, 64)
if err != nil {
return 0, huma.Error400BadRequest(
"type mismatch, parameter: node_id, error: " + err.Error(),
)
}
return types.NodeID(id), nil
}
+191
View File
@@ -0,0 +1,191 @@
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
})
}
+318
View File
@@ -0,0 +1,318 @@
package apiv1
import (
"cmp"
"context"
"net/http"
"slices"
"strconv"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/juanfont/headscale/hscontrol/types"
)
func init() {
registrations = append(registrations, registerPreAuthKeys)
}
// PreAuthKey is the v1 PreAuthKey message. User is a pointer with no omitempty
// so tagged (system-created) keys emit "user":null. Expiration and CreatedAt
// are always emitted, zero-stamped when unset.
type PreAuthKey struct {
User *User `json:"user"`
ID string `format:"uint64" json:"id"`
Key string `json:"key"`
Reusable bool `json:"reusable"`
Ephemeral bool `json:"ephemeral"`
Used bool `json:"used"`
Expiration time.Time `json:"expiration"`
CreatedAt time.Time `json:"createdAt"`
ACLTags []string `json:"aclTags" nullable:"false"`
}
// CreatePreAuthKeyRequestBody is the v1.CreatePreAuthKeyRequest body. Every
// field is optional, hence omitempty throughout.
type CreatePreAuthKeyRequestBody struct {
User string `format:"uint64" json:"user,omitempty"`
Reusable bool `json:"reusable,omitempty"`
Ephemeral bool `json:"ephemeral,omitempty"`
Expiration *time.Time `json:"expiration,omitempty"`
ACLTags []string `json:"aclTags,omitempty"`
}
// ExpirePreAuthKeyRequestBody is the v1.ExpirePreAuthKeyRequest body.
type ExpirePreAuthKeyRequestBody struct {
ID string `format:"uint64" json:"id,omitempty"`
}
type (
createPreAuthKeyInput struct {
Body CreatePreAuthKeyRequestBody
}
preAuthKeyOutput struct {
Body struct {
PreAuthKey PreAuthKey `json:"preAuthKey"`
}
}
)
type (
expirePreAuthKeyInput struct {
Body ExpirePreAuthKeyRequestBody
}
expirePreAuthKeyOutput struct {
Body struct{}
}
)
type (
deletePreAuthKeyInput struct {
ID string `format:"uint64" query:"id"`
}
deletePreAuthKeyOutput struct {
Body struct{}
}
)
type listPreAuthKeysOutput struct {
Body struct {
PreAuthKeys []PreAuthKey `json:"preAuthKeys" nullable:"false"`
}
}
func registerPreAuthKeys(api huma.API, b Backend) {
huma.Register(api, huma.Operation{
OperationID: "createPreAuthKey",
Method: http.MethodPost,
Path: "/api/v1/preauthkey",
Summary: "Create pre-auth key",
Tags: []string{"PreAuthKeys"},
Security: bearerAuth,
}, func(ctx context.Context, in *createPreAuthKeyInput) (*preAuthKeyOutput, error) {
user, err := parsePreAuthKeyUser(in.Body.User)
if err != nil {
return nil, err
}
for _, tag := range in.Body.ACLTags {
tagErr := validateTag(tag)
if tagErr != nil {
return nil, huma.Error400BadRequest("invalid tag", tagErr)
}
}
// CreatePreAuthKey requires a non-nil pointer; zero-stamp when unset.
var expiration time.Time
if in.Body.Expiration != nil {
expiration = *in.Body.Expiration
}
var userID *types.UserID
if user != 0 {
u, getErr := b.State.GetUserByID(user)
if getErr != nil {
return nil, mapError("creating pre-auth key", getErr)
}
userID = u.TypedID()
}
preAuthKey, err := b.State.CreatePreAuthKey(
userID,
in.Body.Reusable,
in.Body.Ephemeral,
&expiration,
in.Body.ACLTags,
)
if err != nil {
// A key that is neither tagged nor user-owned is invalid input (400).
return nil, mapError("creating pre-auth key", err)
}
out := &preAuthKeyOutput{}
out.Body.PreAuthKey = preAuthKeyNewToResponse(preAuthKey)
return out, nil
})
huma.Register(api, huma.Operation{
OperationID: "expirePreAuthKey",
Method: http.MethodPost,
Path: "/api/v1/preauthkey/expire",
Summary: "Expire pre-auth key",
Tags: []string{"PreAuthKeys"},
Security: bearerAuth,
}, func(ctx context.Context, in *expirePreAuthKeyInput) (*expirePreAuthKeyOutput, error) {
id, err := parsePreAuthKeyID(in.Body.ID)
if err != nil {
return nil, err
}
err = b.State.ExpirePreAuthKey(id)
if err != nil {
// An unknown key id maps to 404.
return nil, mapError("expiring pre-auth key", err)
}
return &expirePreAuthKeyOutput{}, nil
})
huma.Register(api, huma.Operation{
OperationID: "deletePreAuthKey",
Method: http.MethodDelete,
Path: "/api/v1/preauthkey",
Summary: "Delete pre-auth key",
Tags: []string{"PreAuthKeys"},
Security: bearerAuth,
}, func(ctx context.Context, in *deletePreAuthKeyInput) (*deletePreAuthKeyOutput, error) {
// DELETE has no body: id is bound from the query string.
id, err := parsePreAuthKeyID(in.ID)
if err != nil {
return nil, err
}
err = b.State.DeletePreAuthKey(id)
if err != nil {
// An unknown key id maps to 404.
return nil, mapError("deleting pre-auth key", err)
}
return &deletePreAuthKeyOutput{}, nil
})
huma.Register(api, huma.Operation{
OperationID: "listPreAuthKeys",
Method: http.MethodGet,
Path: "/api/v1/preauthkey",
Summary: "List pre-auth keys",
Tags: []string{"PreAuthKeys"},
Security: bearerAuth,
}, func(ctx context.Context, _ *struct{}) (*listPreAuthKeysOutput, error) {
preAuthKeys, err := b.State.ListPreAuthKeys()
if err != nil {
return nil, huma.Error500InternalServerError("listing pre-auth keys", err)
}
// Match the gRPC handler's ascending-ID ordering.
slices.SortFunc(preAuthKeys, func(a, b types.PreAuthKey) int {
return cmp.Compare(a.ID, b.ID)
})
out := &listPreAuthKeysOutput{}
out.Body.PreAuthKeys = make([]PreAuthKey, len(preAuthKeys))
for i := range preAuthKeys {
out.Body.PreAuthKeys[i] = preAuthKeyToResponse(&preAuthKeys[i])
}
return out, nil
})
}
// preAuthKeyNewToResponse builds the v1 response for a freshly created key. The
// plaintext key is returned only here; Used is always false.
func preAuthKeyNewToResponse(key *types.PreAuthKeyNew) PreAuthKey {
out := PreAuthKey{
ID: formatID(key.ID),
Key: key.Key,
Reusable: key.Reusable,
Ephemeral: key.Ephemeral,
ACLTags: nonNilTags(key.Tags),
}
if key.User != nil {
u := userFromState(key.User)
out.User = &u
}
if key.Expiration != nil {
out.Expiration = *key.Expiration
}
if key.CreatedAt != nil {
out.CreatedAt = *key.CreatedAt
}
return out
}
// preAuthKeyToResponse builds the v1 response for a stored key, with its key
// field masked (see maskedPreAuthKey).
func preAuthKeyToResponse(key *types.PreAuthKey) PreAuthKey {
out := PreAuthKey{
ID: formatID(key.ID),
Key: maskedPreAuthKey(key),
Reusable: key.Reusable,
Ephemeral: key.Ephemeral,
Used: key.Used,
ACLTags: nonNilTags(key.Tags),
}
if key.User != nil {
u := userFromState(key.User)
out.User = &u
}
if key.Expiration != nil {
out.Expiration = *key.Expiration
}
if key.CreatedAt != nil {
out.CreatedAt = *key.CreatedAt
}
return out
}
// maskedPreAuthKey masks new keys (those with a stored prefix) so the secret is
// never returned; legacy plaintext keys are returned in full for backwards
// compatibility.
func maskedPreAuthKey(key *types.PreAuthKey) string {
if key.Prefix != "" {
return "hskey-auth-" + key.Prefix + "-***"
}
return key.Key
}
// nonNilTags ensures aclTags serializes as [] rather than null, matching
// EmitUnpopulated output.
func nonNilTags(tags []string) []string {
if tags == nil {
return []string{}
}
return tags
}
// parsePreAuthKeyUser parses the optional uint64 user field. Empty means "no
// user" (user 0); non-numeric is rejected with 400.
func parsePreAuthKeyUser(s string) (types.UserID, error) {
if s == "" {
return 0, nil
}
id, err := strconv.ParseUint(s, 10, 64)
if err != nil {
return 0, huma.Error400BadRequest("invalid user id", err)
}
return types.UserID(id), nil
}
// parsePreAuthKeyID parses the uint64 key id. Empty means id 0; non-numeric is
// rejected with 400.
func parsePreAuthKeyID(s string) (uint64, error) {
if s == "" {
return 0, nil
}
id, err := strconv.ParseUint(s, 10, 64)
if err != nil {
return 0, huma.Error400BadRequest("invalid pre-auth key id", err)
}
return id, nil
}
+29
View File
@@ -0,0 +1,29 @@
package apiv1
import (
"errors"
"strings"
)
// ACL tag validation, shared by the node and pre-auth-key resources. These
// reproduce the gRPC validateTag checks and messages.
var (
errTagMissingPrefix = errors.New("tag must start with the string 'tag:'")
errTagNotLowercase = errors.New("tag should be lowercase")
errTagHasSpaces = errors.New("tags must not contain spaces")
)
// validateTag reports whether an ACL tag is well formed: it must start with
// "tag:", be lowercase, and contain no spaces.
func validateTag(tag string) error {
switch {
case !strings.HasPrefix(tag, "tag:"):
return errTagMissingPrefix
case strings.ToLower(tag) != tag:
return errTagNotLowercase
case len(strings.Fields(tag)) > 1:
return errTagHasSpaces
default:
return nil
}
}
+51
View File
@@ -0,0 +1,51 @@
package apiv1
import (
"strconv"
"time"
"github.com/juanfont/headscale/hscontrol/types"
)
// The v1 contract follows protojson: 64-bit integers are JSON strings (avoiding
// precision loss above 2^53), timestamps are RFC 3339, and zero values are
// emitted. Hence response fields carry NO omitempty; request types keep
// omitempty so their fields stay optional in the spec.
// formatID renders a uint64 identifier as the contract's decimal string.
func formatID[T ~uint64 | ~uint](id T) string {
return strconv.FormatUint(uint64(id), 10)
}
// User mirrors the v1 User message.
type User struct {
ID string `format:"uint64" json:"id"`
Name string `json:"name"`
CreatedAt time.Time `json:"createdAt"`
DisplayName string `json:"displayName"`
Email string `json:"email"`
ProviderID string `json:"providerId"`
Provider string `json:"provider"`
ProfilePicURL string `json:"profilePicUrl"`
}
// userFromState converts a domain user into the v1 response shape: Name falls
// back to Username() (email/provider/id) when the stored Name is empty, so OIDC
// users display their email.
func userFromState(u *types.User) User {
name := u.Name
if name == "" {
name = u.Username()
}
return User{
ID: formatID(u.ID),
Name: name,
CreatedAt: u.CreatedAt,
DisplayName: u.DisplayName,
Email: u.Email,
ProviderID: u.ProviderIdentifier.String,
Provider: u.Provider,
ProfilePicURL: u.ProfilePicURL,
}
}
+233
View File
@@ -0,0 +1,233 @@
package apiv1
import (
"cmp"
"context"
"net/http"
"slices"
"strconv"
"github.com/danielgtaylor/huma/v2"
"github.com/juanfont/headscale/hscontrol/types"
"gorm.io/gorm"
)
func init() {
registrations = append(registrations, registerUsers)
}
// CreateUserRequestBody mirrors v1.CreateUserRequest.
type CreateUserRequestBody struct {
Name string `json:"name,omitempty"`
DisplayName string `json:"displayName,omitempty"`
Email string `json:"email,omitempty"`
PictureURL string `json:"pictureUrl,omitempty"`
}
type (
createUserInput struct {
Body CreateUserRequestBody
}
userOutput struct {
Body struct {
User User `json:"user"`
}
}
)
type (
renameUserInput struct {
OldID string `format:"uint64" path:"oldId"`
NewName string `path:"newName"`
}
deleteUserInput struct {
ID string `format:"uint64" path:"id"`
}
deleteUserOutput struct {
Body struct{}
}
)
type (
listUsersInput struct {
ID string `format:"uint64" query:"id"`
Name string `query:"name"`
Email string `query:"email"`
}
listUsersOutput struct {
Body struct {
Users []User `json:"users" nullable:"false"`
}
}
)
func registerUsers(api huma.API, b Backend) {
huma.Register(api, huma.Operation{
OperationID: "createUser",
Method: http.MethodPost,
Path: "/api/v1/user",
Summary: "Create user",
Tags: []string{"Users"},
Security: bearerAuth,
}, func(ctx context.Context, in *createUserInput) (*userOutput, error) {
// Pre-check yields a 409 for the common case; the DB unique constraint
// is the real guard.
if in.Body.Name != "" {
_, err := b.State.GetUserByName(in.Body.Name)
if err == nil {
return nil, huma.Error409Conflict("user already exists")
}
}
user, policyChanged, err := b.State.CreateUser(types.User{
Name: in.Body.Name,
DisplayName: in.Body.DisplayName,
Email: in.Body.Email,
ProfilePicURL: in.Body.PictureURL,
})
if err != nil {
return nil, mapError("creating user", err)
}
b.Change(policyChanged)
out := &userOutput{}
out.Body.User = userFromState(user)
return out, nil
})
huma.Register(api, huma.Operation{
OperationID: "renameUser",
Method: http.MethodPost,
Path: "/api/v1/user/{oldId}/rename/{newName}",
Summary: "Rename user",
Tags: []string{"Users"},
Security: bearerAuth,
}, func(ctx context.Context, in *renameUserInput) (*userOutput, error) {
oldID, err := parseUserID(in.OldID)
if err != nil {
return nil, err
}
oldUser, err := b.State.GetUserByID(oldID)
if err != nil {
return nil, mapError("renaming user", err)
}
_, c, err := b.State.RenameUser(types.UserID(oldUser.ID), in.NewName)
if err != nil {
return nil, mapError("renaming user", err)
}
b.Change(c)
newUser, err := b.State.GetUserByName(in.NewName)
if err != nil {
return nil, huma.Error500InternalServerError("renaming user", err)
}
out := &userOutput{}
out.Body.User = userFromState(newUser)
return out, nil
})
huma.Register(api, huma.Operation{
OperationID: "deleteUser",
Method: http.MethodDelete,
Path: "/api/v1/user/{id}",
Summary: "Delete user",
Tags: []string{"Users"},
Security: bearerAuth,
}, func(ctx context.Context, in *deleteUserInput) (*deleteUserOutput, error) {
id, err := parseUserID(in.ID)
if err != nil {
return nil, err
}
user, err := b.State.GetUserByID(id)
if err != nil {
return nil, mapError("deleting user", err)
}
policyChanged, err := b.State.DeleteUser(types.UserID(user.ID))
if err != nil {
return nil, mapError("deleting user", err)
}
b.Change(policyChanged)
return &deleteUserOutput{}, nil
})
huma.Register(api, huma.Operation{
OperationID: "listUsers",
Method: http.MethodGet,
Path: "/api/v1/user",
Summary: "List users",
Tags: []string{"Users"},
Security: bearerAuth,
}, func(ctx context.Context, in *listUsersInput) (*listUsersOutput, error) {
// Gateway parity: a non-numeric id is a 400 even when other filters win.
if in.ID != "" {
_, err := strconv.ParseUint(in.ID, 10, 64)
if err != nil {
return nil, huma.Error400BadRequest("invalid id", err)
}
}
users, err := listUsersFiltered(b, in)
if err != nil {
return nil, huma.Error500InternalServerError("listing users", err)
}
// Match the gRPC handler's ascending-ID ordering.
slices.SortFunc(users, func(a, b types.User) int {
return cmp.Compare(a.ID, b.ID)
})
out := &listUsersOutput{}
out.Body.Users = make([]User, len(users))
for i := range users {
out.Body.Users[i] = userFromState(&users[i])
}
return out, nil
})
}
// listUsersFiltered reproduces the gRPC ListUsers precedence: name, then email,
// then id, otherwise all users.
func listUsersFiltered(b Backend, in *listUsersInput) ([]types.User, error) {
switch {
case in.Name != "":
return b.State.ListUsersWithFilter(&types.User{Name: in.Name})
case in.Email != "":
return b.State.ListUsersWithFilter(&types.User{Email: in.Email})
case in.ID != "":
id, err := strconv.ParseUint(in.ID, 10, 64)
if err != nil {
return nil, err
}
if id == 0 {
return b.State.ListAllUsers()
}
return b.State.ListUsersWithFilter(&types.User{Model: gorm.Model{ID: uint(id)}})
default:
return b.State.ListAllUsers()
}
}
func parseUserID(s string) (types.UserID, error) {
id, err := strconv.ParseUint(s, 10, 64)
if err != nil {
return 0, huma.Error400BadRequest("invalid user id", err)
}
return types.UserID(id), nil
}