mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-11 18:41:13 +09:00
594 lines
18 KiB
Go
594 lines
18 KiB
Go
package apiv2
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/danielgtaylor/huma/v2"
|
|
"github.com/juanfont/headscale/hscontrol/scope"
|
|
"github.com/juanfont/headscale/hscontrol/types"
|
|
"github.com/juanfont/headscale/hscontrol/util"
|
|
)
|
|
|
|
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
|
|
|
|
const (
|
|
// keyTypeAuth is a machine auth key (Headscale pre-auth key); the default.
|
|
keyTypeAuth = "auth"
|
|
// keyTypeClient is an OAuth client (client-credentials). Multiplexed onto the
|
|
// keys resource exactly as Tailscale does.
|
|
keyTypeClient = "client"
|
|
)
|
|
|
|
// 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. It is multiplexed by keyType: "auth"
|
|
// (default) creates a machine auth key from Capabilities; "client" creates an
|
|
// OAuth client from the top-level Scopes and Tags. expirySeconds is plain
|
|
// seconds (unlike the response, see Key).
|
|
type CreateKeyRequest struct {
|
|
KeyType string `doc:"Key kind: \"auth\" (default) or \"client\" (OAuth client)." json:"keyType,omitempty"`
|
|
// Capabilities is optional: an auth key carries device-create capabilities,
|
|
// but an OAuth client (keyType:"client") has none and the Tailscale clients
|
|
// omit the field entirely, so it must not be required.
|
|
Capabilities *KeyCapabilities `json:"capabilities,omitempty"`
|
|
ExpirySeconds int64 `doc:"Lifetime in seconds; defaults to 90 days. Auth keys only." json:"expirySeconds,omitempty"`
|
|
Description string `json:"description,omitempty" maxLength:"50"`
|
|
// Scopes and Tags are top-level and apply only to keyType "client" (an OAuth
|
|
// client). Auth-key tags live under Capabilities.Devices.Create.Tags.
|
|
Scopes []string `doc:"OAuth scopes granted to the client. keyType=client only." json:"scopes,omitempty"`
|
|
Tags []string `doc:"Tags the client may assign. keyType=client only." json:"tags,omitempty"`
|
|
}
|
|
|
|
// Key is the Tailscale key response, shared by auth keys and OAuth clients.
|
|
// 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"`
|
|
Scopes []string `json:"scopes,omitempty"`
|
|
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{}
|
|
}
|
|
)
|
|
|
|
// The keys resource is multiplexed by keyType (auth key vs OAuth client), so the
|
|
// scope an operation requires depends on the request rather than being fixed.
|
|
// requireScope (which the middleware enforces statically) is therefore omitted
|
|
// here; each handler authorizes via requireKeyScope once the kind is known.
|
|
func registerKeys(api huma.API, b Backend) {
|
|
keysTags := []string{"Keys", "Tailscale compat"}
|
|
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "createKey",
|
|
Method: http.MethodPost,
|
|
Path: "/api/v2/tailnet/{tailnet}/keys",
|
|
Summary: "Create an auth key or OAuth client",
|
|
Description: "Requires the `auth_keys` scope for an auth key, or `oauth_keys` for an OAuth client (an admin API key is all-access).",
|
|
Tags: keysTags,
|
|
Security: security,
|
|
Errors: []int{
|
|
http.StatusBadRequest,
|
|
http.StatusUnauthorized,
|
|
http.StatusForbidden,
|
|
http.StatusNotFound,
|
|
},
|
|
}, func(ctx context.Context, in *createKeyInput) (*keyOutput, error) {
|
|
err := requireDefaultTailnet(in.Tailnet)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if in.Body.KeyType == keyTypeClient {
|
|
return createOAuthClient(ctx, b, in.Body)
|
|
}
|
|
|
|
return createAuthKey(ctx, b, in.Body)
|
|
})
|
|
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "listKeys",
|
|
Method: http.MethodGet,
|
|
Path: "/api/v2/tailnet/{tailnet}/keys",
|
|
Summary: "List auth keys and OAuth clients",
|
|
Description: "A token sees the kinds it can read: `auth_keys:read` for auth keys, `oauth_keys:read` for OAuth clients (an admin API key sees all).",
|
|
Tags: keysTags,
|
|
Security: security,
|
|
Errors: []int{
|
|
http.StatusUnauthorized,
|
|
http.StatusForbidden,
|
|
http.StatusNotFound,
|
|
},
|
|
}, func(ctx context.Context, in *listKeysInput) (*listKeysOutput, error) {
|
|
err := requireDefaultTailnet(in.Tailnet)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
scopes, isOAuth := principalScopes(ctx)
|
|
|
|
out := &listKeysOutput{}
|
|
out.Body.Keys = []Key{}
|
|
|
|
// A token sees the key kinds it has read scope for; an admin key sees all.
|
|
if !isOAuth || scope.Grants(scope.Parse(scopes), scope.AuthKeysRead) {
|
|
keys, err := b.State.ListPreAuthKeys()
|
|
if err != nil {
|
|
return nil, huma.Error500InternalServerError("listing auth keys", err)
|
|
}
|
|
|
|
for i := range keys {
|
|
out.Body.Keys = append(out.Body.Keys, keyFromStored(&keys[i]))
|
|
}
|
|
}
|
|
|
|
if !isOAuth || scope.Grants(scope.Parse(scopes), scope.OAuthKeysRead) {
|
|
clients, err := b.State.ListOAuthClients()
|
|
if err != nil {
|
|
return nil, huma.Error500InternalServerError("listing oauth clients", err)
|
|
}
|
|
|
|
for i := range clients {
|
|
out.Body.Keys = append(out.Body.Keys, oauthClientToKey(&clients[i], ""))
|
|
}
|
|
}
|
|
|
|
return out, nil
|
|
})
|
|
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "getKey",
|
|
Method: http.MethodGet,
|
|
Path: "/api/v2/tailnet/{tailnet}/keys/{keyId}",
|
|
Summary: "Get an auth key or OAuth client",
|
|
Description: "Requires `auth_keys:read` for an auth key, or `oauth_keys:read` for an OAuth client (an admin API key is all-access).",
|
|
Tags: keysTags,
|
|
Security: security,
|
|
Errors: []int{
|
|
http.StatusUnauthorized,
|
|
http.StatusForbidden,
|
|
http.StatusNotFound,
|
|
},
|
|
}, func(ctx context.Context, in *keyByIDInput) (*keyOutput, error) {
|
|
err := requireDefaultTailnet(in.Tailnet)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// An OAuth client id is a hex string distinct from a numeric auth-key id,
|
|
// so a client lookup that hits is authoritative; otherwise fall through to
|
|
// the auth-key path. The lookup is gated on the caller actually holding
|
|
// oauth_keys:read so a token without it cannot tell a real client id (403)
|
|
// from an unknown key (404) — i.e. no client-existence oracle.
|
|
if requireKeyScope(ctx, scope.OAuthKeysRead) == nil {
|
|
client, err := b.State.GetOAuthClientByClientID(in.KeyID)
|
|
if err == nil {
|
|
return &keyOutput{Body: oauthClientToKey(client, "")}, nil
|
|
}
|
|
}
|
|
|
|
err = requireKeyScope(ctx, scope.AuthKeysRead)
|
|
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, huma.Operation{
|
|
OperationID: "deleteKey",
|
|
Method: http.MethodDelete,
|
|
Path: "/api/v2/tailnet/{tailnet}/keys/{keyId}",
|
|
Summary: "Delete an auth key or OAuth client",
|
|
Description: "Requires the `auth_keys` scope for an auth key, or `oauth_keys` for an OAuth client (an admin API key is all-access).",
|
|
Tags: keysTags,
|
|
Security: security,
|
|
DefaultStatus: http.StatusOK,
|
|
Errors: []int{
|
|
http.StatusUnauthorized,
|
|
http.StatusForbidden,
|
|
http.StatusNotFound,
|
|
},
|
|
}, func(ctx context.Context, in *keyByIDInput) (*deleteKeyOutput, error) {
|
|
err := requireDefaultTailnet(in.Tailnet)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Gated on oauth_keys (write) for the same no-existence-oracle reason as
|
|
// getKey: a token without it must not learn that an id is an OAuth client.
|
|
if requireKeyScope(ctx, scope.OAuthKeys) == nil {
|
|
_, err = b.State.GetOAuthClientByClientID(in.KeyID)
|
|
if err == nil {
|
|
err = b.State.RevokeOAuthClient(in.KeyID)
|
|
if err != nil {
|
|
return nil, mapError("deleting oauth client", err)
|
|
}
|
|
|
|
return &deleteKeyOutput{}, nil
|
|
}
|
|
}
|
|
|
|
err = requireKeyScope(ctx, scope.AuthKeys)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
id, err := parseID(in.KeyID, "auth key")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Tailscale's DELETE revokes the key but keeps it retrievable (invalid)
|
|
// rather than destroying it; the collector reaps it after the retention
|
|
// window.
|
|
err = b.State.RevokePreAuthKey(id)
|
|
if err != nil {
|
|
return nil, mapError("revoking auth key", err)
|
|
}
|
|
|
|
return &deleteKeyOutput{}, nil
|
|
})
|
|
}
|
|
|
|
// requireKeyScope authorizes a keys operation for an OAuth access token. An admin
|
|
// API key carries no OAuth scopes and is all-access, so it always passes.
|
|
func requireKeyScope(ctx context.Context, need scope.Scope) error {
|
|
scopes, isOAuth := principalScopes(ctx)
|
|
if !isOAuth {
|
|
return nil
|
|
}
|
|
|
|
if !scope.Grants(scope.Parse(scopes), need) {
|
|
return huma.Error403Forbidden("token is missing the required scope " + string(need))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// createAuthKey creates a machine auth key (pre-auth key). Ownership: tags -> a
|
|
// tagged key; no tags -> owned by the API key's user. An OAuth access token must
|
|
// mint tagged keys, and each tag must be within the token's grant.
|
|
func createAuthKey(ctx context.Context, b Backend, body CreateKeyRequest) (*keyOutput, error) {
|
|
err := requireKeyScope(ctx, scope.AuthKeys)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var create KeyDeviceCreateCapabilities
|
|
if body.Capabilities != nil {
|
|
create = body.Capabilities.Devices.Create
|
|
}
|
|
|
|
tokenTags, isOAuth := principalTags(ctx)
|
|
|
|
var userID *types.UserID
|
|
|
|
switch {
|
|
case len(create.Tags) > 0:
|
|
// A key minted by an OAuth token may only carry tags within the token's
|
|
// grant (held directly, or owned by a held tag) and defined in policy,
|
|
// matching SetNodeTags. An admin key keeps the historical behaviour of
|
|
// validating only tag syntax (db.validateACLTags).
|
|
if isOAuth {
|
|
for _, tag := range create.Tags {
|
|
if !b.State.TagExists(tag) {
|
|
return nil, huma.Error400BadRequest("tag " + tag + " is not defined in policy")
|
|
}
|
|
|
|
if !b.State.TagOwnedByTags(tag, tokenTags) {
|
|
return nil, huma.Error403Forbidden(
|
|
"token may not assign tag " + tag,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
case isOAuth:
|
|
// OAuth-minted keys are tailnet/tag-owned; an untagged (user-owned) key
|
|
// cannot be created from a token.
|
|
return nil, huma.Error403Forbidden("an OAuth client must create tagged auth keys")
|
|
|
|
default:
|
|
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(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 body.Description != "" {
|
|
err := b.State.SetPreAuthKeyDescription(pak.ID, body.Description)
|
|
if err != nil {
|
|
return nil, huma.Error500InternalServerError("setting auth key description", err)
|
|
}
|
|
}
|
|
|
|
return &keyOutput{Body: keyFromNew(pak, body)}, nil
|
|
}
|
|
|
|
// createOAuthClient creates an OAuth client (keyType:"client"). The client secret
|
|
// is returned once, here.
|
|
func createOAuthClient(ctx context.Context, b Backend, body CreateKeyRequest) (*keyOutput, error) {
|
|
err := requireKeyScope(ctx, scope.OAuthKeys)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(body.Scopes) == 0 {
|
|
return nil, huma.Error400BadRequest("an OAuth client must declare at least one scope")
|
|
}
|
|
|
|
// Tailscale: tags are mandatory when the scopes include devices:core or
|
|
// auth_keys, because such a client mints tagged, tailnet-owned credentials.
|
|
if scope.RequiresTags(scope.Parse(body.Scopes)) && len(body.Tags) == 0 {
|
|
return nil, huma.Error400BadRequest(
|
|
"tags are required when scopes include devices:core or auth_keys",
|
|
)
|
|
}
|
|
|
|
// A client created by an OAuth token may not be granted authority the token
|
|
// lacks: its scopes must each be within the token's grant, and its tags within
|
|
// the token's tags and defined in policy (matching SetNodeTags). Otherwise an
|
|
// oauth_keys token could mint an all-access client and escalate. An admin API
|
|
// key (not an OAuth token) is unrestricted and keeps the historical tag
|
|
// behaviour (syntax-only validation).
|
|
if tokenScopes, isOAuth := principalScopes(ctx); isOAuth {
|
|
for _, s := range body.Scopes {
|
|
if !scope.Grants(scope.Parse(tokenScopes), scope.Scope(s)) {
|
|
return nil, huma.Error403Forbidden(
|
|
"client may not be granted scope " + s + " beyond the creating token",
|
|
)
|
|
}
|
|
}
|
|
|
|
tokenTags, _ := principalTags(ctx)
|
|
|
|
for _, tag := range body.Tags {
|
|
if !b.State.TagExists(tag) {
|
|
return nil, huma.Error400BadRequest("tag " + tag + " is not defined in policy")
|
|
}
|
|
|
|
if !b.State.TagOwnedByTags(tag, tokenTags) {
|
|
return nil, huma.Error403Forbidden(
|
|
"client may not be granted tag " + tag + " beyond the creating token",
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
var creator *uint
|
|
|
|
if uid, ok := ownerUser(ctx); ok {
|
|
u := uint(uid)
|
|
creator = &u
|
|
}
|
|
|
|
secret, client, err := b.State.CreateOAuthClient(body.Scopes, body.Tags, body.Description, creator)
|
|
if err != nil {
|
|
return nil, mapError("creating oauth client", err)
|
|
}
|
|
|
|
return &keyOutput{Body: oauthClientToKey(client, secret)}, 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 {
|
|
var create KeyDeviceCreateCapabilities
|
|
if req.Capabilities != nil {
|
|
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 pak.Revoked != nil {
|
|
key.Revoked = pak.Revoked
|
|
}
|
|
|
|
if len(pak.Tags) == 0 && pak.User != nil {
|
|
key.UserID = pak.User.StringID()
|
|
}
|
|
|
|
return key
|
|
}
|
|
|
|
// oauthClientToKey builds the keys response for an OAuth client. secret is the
|
|
// plaintext client secret, set only on the create response and empty on get/list
|
|
// (the secret is never re-exposed).
|
|
func oauthClientToKey(client *types.OAuthClient, secret string) Key {
|
|
key := Key{
|
|
ID: client.ClientID,
|
|
KeyType: keyTypeClient,
|
|
Key: secret,
|
|
Description: client.Description,
|
|
Created: timeOrZero(client.CreatedAt),
|
|
Scopes: emptyIfNil(client.Scopes),
|
|
Tags: emptyIfNil(client.Tags),
|
|
}
|
|
|
|
if client.Revoked != nil {
|
|
key.Revoked = client.Revoked
|
|
key.Invalid = true
|
|
}
|
|
|
|
if client.UserID != nil {
|
|
key.UserID = strconv.FormatUint(uint64(*client.UserID), util.Base10)
|
|
}
|
|
|
|
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
|
|
}
|