mirror of
https://github.com/juanfont/headscale.git
synced 2026-10-30 02:04:07 +09:00
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.
This commit is contained in:
1 parent
2ff342764f
commit
fe08d43978
5 files changed
+862
No files matched your search
@@ -0,0 +1,360 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user