Files
headscale/hscontrol/api/v2/keys.go
Kristoffer Dalby fe08d43978 api/v2: add Tailscale-compatible keys API
Add the v2 foundation — Basic/Bearer auth, scope scaffolding, the
tailnet check, and the Tailscale error shape — plus the auth-key
endpoints mapped onto Headscale pre-auth keys.
2026-06-21 04:17:03 +02:00

361 lines
9.7 KiB
Go

package apiv2
import (
"context"
"net/http"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/juanfont/headscale/hscontrol/types"
)
func init() {
registrations = append(registrations, registerKeys)
}
// defaultExpiry is the auth-key lifetime when the request omits expirySeconds:
// 90 days, matching Tailscale and the Terraform provider default.
const defaultExpiry = 90 * 24 * time.Hour
// keyTypeAuth is the only key kind Headscale issues; Tailscale's OAuth-client
// and federated-identity kinds are out of scope.
const keyTypeAuth = "auth"
// KeyCapabilities maps a resource to the actions a key permits. Headscale
// populates only devices.create (auth keys); the named types (vs Tailscale's
// anonymous nesting) give Huma stable schema names.
type KeyCapabilities struct {
Devices KeyDeviceCapabilities `json:"devices"`
}
type KeyDeviceCapabilities struct {
Create KeyDeviceCreateCapabilities `json:"create"`
}
type KeyDeviceCreateCapabilities struct {
Reusable bool `json:"reusable"`
Ephemeral bool `json:"ephemeral"`
Preauthorized bool `json:"preauthorized"`
// Tags is not nullable:"false": the Tailscale clients send "tags":null for
// an untagged key, which must be accepted. The response always emits [] via
// emptyIfNil.
Tags []string `json:"tags"`
}
// CreateKeyRequest is the POST body. expirySeconds is plain seconds (unlike the
// response, see Key).
type CreateKeyRequest struct {
Capabilities KeyCapabilities `json:"capabilities"`
ExpirySeconds int64 `doc:"Lifetime in seconds; defaults to 90 days." json:"expirySeconds,omitempty"`
Description string `json:"description,omitempty" maxLength:"50"`
}
// Key is the Tailscale auth-key response. expirySeconds is emitted in seconds
// to match the Tailscale spec; the secret key is present only at creation.
type Key struct {
ID string `json:"id"`
KeyType string `json:"keyType"`
Key string `json:"key,omitempty"`
Description string `json:"description,omitempty"`
ExpirySeconds int64 `json:"expirySeconds,omitempty"`
Created time.Time `json:"created"`
Expires *time.Time `json:"expires,omitempty"`
Revoked *time.Time `json:"revoked,omitempty"`
Invalid bool `json:"invalid"`
Capabilities KeyCapabilities `json:"capabilities"`
Tags []string `json:"tags,omitempty"`
UserID string `json:"userId,omitempty"`
}
type (
createKeyInput struct {
Tailnet string `doc:"Tailnet; must be \"-\" (the single Headscale tailnet)." path:"tailnet"`
Body CreateKeyRequest
}
listKeysInput struct {
Tailnet string `path:"tailnet"`
All bool `doc:"Accepted for compatibility; Headscale returns all keys." query:"all"`
}
keyByIDInput struct {
Tailnet string `path:"tailnet"`
KeyID string `path:"keyId"`
}
keyOutput struct {
Body Key
}
listKeysOutput struct {
Body struct {
Keys []Key `json:"keys" nullable:"false"`
}
}
deleteKeyOutput struct {
Body struct{}
}
)
func registerKeys(api huma.API, b Backend) {
keysTags := []string{"Keys", "Tailscale compat"}
huma.Register(api, requireScope(huma.Operation{
OperationID: "createKey",
Method: http.MethodPost,
Path: "/api/v2/tailnet/{tailnet}/keys",
Summary: "Create an auth key",
Tags: keysTags,
Security: security,
Errors: []int{
http.StatusBadRequest,
http.StatusUnauthorized,
http.StatusForbidden,
http.StatusNotFound,
},
}, ScopeAuthKeys), func(ctx context.Context, in *createKeyInput) (*keyOutput, error) {
err := requireDefaultTailnet(in.Tailnet)
if err != nil {
return nil, err
}
create := in.Body.Capabilities.Devices.Create
// Ownership: tags -> tagged key; no tags -> owned by the API key's user;
// no tags and an ownerless (legacy/admin) key -> 400.
var userID *types.UserID
if len(create.Tags) == 0 {
uid, ok := ownerUser(ctx)
if !ok {
return nil, huma.Error400BadRequest(
"an auth key without tags must be created with a user-owned API key",
)
}
userID = &uid
}
expiration := time.Now().Add(expiryDuration(in.Body.ExpirySeconds))
pak, err := b.State.CreatePreAuthKey(
userID,
create.Reusable,
create.Ephemeral,
&expiration,
create.Tags,
)
if err != nil {
return nil, mapError("creating auth key", err)
}
if in.Body.Description != "" {
err := b.State.SetPreAuthKeyDescription(
pak.ID,
in.Body.Description,
)
if err != nil {
return nil, huma.Error500InternalServerError(
"setting auth key description",
err,
)
}
}
return &keyOutput{Body: keyFromNew(pak, in.Body)}, nil
})
huma.Register(api, requireScope(huma.Operation{
OperationID: "listKeys",
Method: http.MethodGet,
Path: "/api/v2/tailnet/{tailnet}/keys",
Summary: "List auth keys",
Tags: keysTags,
Security: security,
Errors: []int{
http.StatusUnauthorized,
http.StatusForbidden,
http.StatusNotFound,
},
}, ScopeAuthKeysRead), func(ctx context.Context, in *listKeysInput) (*listKeysOutput, error) {
err := requireDefaultTailnet(in.Tailnet)
if err != nil {
return nil, err
}
keys, err := b.State.ListPreAuthKeys()
if err != nil {
return nil, huma.Error500InternalServerError("listing auth keys", err)
}
out := &listKeysOutput{}
out.Body.Keys = make([]Key, 0, len(keys))
for i := range keys {
out.Body.Keys = append(out.Body.Keys, keyFromStored(&keys[i]))
}
return out, nil
})
huma.Register(api, requireScope(huma.Operation{
OperationID: "getKey",
Method: http.MethodGet,
Path: "/api/v2/tailnet/{tailnet}/keys/{keyId}",
Summary: "Get an auth key",
Tags: keysTags,
Security: security,
Errors: []int{
http.StatusUnauthorized,
http.StatusForbidden,
http.StatusNotFound,
},
}, ScopeAuthKeysRead), func(ctx context.Context, in *keyByIDInput) (*keyOutput, error) {
err := requireDefaultTailnet(in.Tailnet)
if err != nil {
return nil, err
}
key, err := findKeyByID(b, in.KeyID)
if err != nil {
return nil, err
}
return &keyOutput{Body: keyFromStored(key)}, nil
})
huma.Register(api, requireScope(huma.Operation{
OperationID: "deleteKey",
Method: http.MethodDelete,
Path: "/api/v2/tailnet/{tailnet}/keys/{keyId}",
Summary: "Delete an auth key",
Tags: keysTags,
Security: security,
DefaultStatus: http.StatusOK,
Errors: []int{
http.StatusUnauthorized,
http.StatusForbidden,
http.StatusNotFound,
},
}, ScopeAuthKeys), func(ctx context.Context, in *keyByIDInput) (*deleteKeyOutput, error) {
err := requireDefaultTailnet(in.Tailnet)
if err != nil {
return nil, err
}
id, err := parseID(in.KeyID, "auth key")
if err != nil {
return nil, err
}
err = b.State.DeletePreAuthKey(id)
if err != nil {
return nil, mapError("deleting auth key", err)
}
return &deleteKeyOutput{}, nil
})
}
// findKeyByID looks up a stored pre-auth key by its (stringified) id with a
// direct by-id query; an unknown id surfaces as gorm.ErrRecordNotFound, which
// mapError turns into a 404.
func findKeyByID(b Backend, rawID string) (*types.PreAuthKey, error) {
id, err := parseID(rawID, "auth key")
if err != nil {
return nil, err
}
pak, err := b.State.GetPreAuthKeyByID(id)
if err != nil {
return nil, mapError("looking up auth key", err)
}
return pak, nil
}
// keyFromNew builds the create response from the freshly created key. The
// plaintext secret is returned only here. preauthorized is reported true to
// match keyFromStored: Headscale always authorizes pre-auth-key nodes, so the
// create and read paths must agree or the Terraform provider sees a diff.
func keyFromNew(pak *types.PreAuthKeyNew, req CreateKeyRequest) Key {
create := req.Capabilities.Devices.Create
key := Key{
ID: pak.StringID(),
KeyType: keyTypeAuth,
Key: pak.Key,
Description: req.Description,
Created: timeOrZero(pak.CreatedAt),
Capabilities: capabilities(
create.Reusable,
create.Ephemeral,
true,
pak.Tags,
),
Tags: pak.Tags,
}
if pak.Expiration != nil {
key.Expires = pak.Expiration
key.ExpirySeconds = expirySeconds(pak.CreatedAt, pak.Expiration)
}
// A tagged key presents no owner; a user-owned key reports its user id.
if len(pak.Tags) == 0 && pak.User != nil {
key.UserID = pak.User.StringID()
}
return key
}
// keyFromStored builds the get/list response from a stored key. The secret is
// never returned here. preauthorized is reported true: Headscale has no separate
// device-approval step, so every pre-auth key authorizes its nodes. Reporting it
// stably keeps the Terraform provider from seeing a forced-replacement diff.
func keyFromStored(pak *types.PreAuthKey) Key {
key := Key{
ID: pak.StringID(),
KeyType: keyTypeAuth,
Description: pak.Description,
Created: timeOrZero(pak.CreatedAt),
Invalid: pak.Validate() != nil,
Capabilities: capabilities(pak.Reusable, pak.Ephemeral, true, pak.Tags),
Tags: pak.Tags,
}
if pak.Expiration != nil {
key.Expires = pak.Expiration
key.ExpirySeconds = expirySeconds(pak.CreatedAt, pak.Expiration)
}
if len(pak.Tags) == 0 && pak.User != nil {
key.UserID = pak.User.StringID()
}
return key
}
func capabilities(
reusable, ephemeral, preauthorized bool,
tags []string,
) KeyCapabilities {
return KeyCapabilities{
Devices: KeyDeviceCapabilities{Create: KeyDeviceCreateCapabilities{
Reusable: reusable,
Ephemeral: ephemeral,
Preauthorized: preauthorized,
Tags: emptyIfNil(tags),
}},
}
}
func expiryDuration(seconds int64) time.Duration {
if seconds <= 0 {
return defaultExpiry
}
return time.Duration(seconds) * time.Second
}