mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-16 04:50:45 +09:00
96d736ec23
Serialize node, user and pre-auth-key responses through the NodeView, UserView and PreAuthKeyView accessors instead of AsStruct(), which clones the whole record on every read. Document the convention in AGENTS.md.
319 lines
7.9 KiB
Go
319 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 := userFromView(key.User.View())
|
|
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.View()),
|
|
Reusable: key.Reusable,
|
|
Ephemeral: key.Ephemeral,
|
|
Used: key.Used,
|
|
ACLTags: nonNilTags(key.Tags),
|
|
}
|
|
|
|
if key.User != nil {
|
|
u := userFromView(key.User.View())
|
|
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.PreAuthKeyView) 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
|
|
}
|