headscale/hscontrol/api/v1/apikeys.go
Kristoffer Dalby c9bbb5bdec 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.
2026-06-19 15:21:00 +02:00

245 lines
6 KiB
Go

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 + "***"
}