mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-17 16:36:02 +00:00
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.
318 lines
7.9 KiB
Go
318 lines
7.9 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, 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
|
|
}
|