mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-09 01:20:21 +09:00
db: add API-key owner and pre-auth-key description for v2
Give API keys an optional owning user and pre-auth keys a free-text description, plus the state accessors the v2 API needs to act as a key's owner and to persist descriptions.
This commit is contained in:
@@ -25,6 +25,7 @@ const (
|
||||
var (
|
||||
ErrAPIKeyFailedToParse = errors.New("failed to parse ApiKey")
|
||||
ErrAPIKeyGenerationFailed = errors.New("failed to generate API key")
|
||||
ErrAPIKeyExpired = errors.New("API key expired")
|
||||
)
|
||||
|
||||
// CreateAPIKey creates a new [types.APIKey] in a user, and returns it.
|
||||
@@ -127,6 +128,31 @@ func (hsdb *HSDatabase) ValidateAPIKey(keyStr string) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// AuthenticateAPIKey validates keyStr and returns the matching, unexpired
|
||||
// [types.APIKey] (with its owning UserID populated). Unlike ValidateAPIKey it
|
||||
// returns the key itself, so the v2 API can act as the key's owning user. A
|
||||
// non-nil error means the key is missing, malformed, or expired.
|
||||
func (hsdb *HSDatabase) AuthenticateAPIKey(keyStr string) (*types.APIKey, error) {
|
||||
key, err := validateAPIKey(hsdb.DB, keyStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if key.Expiration != nil && key.Expiration.Before(time.Now()) {
|
||||
return nil, ErrAPIKeyExpired
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// SetAPIKeyUser sets the owning user of an API key. Used when an admin mints a
|
||||
// key on behalf of a user (headscale apikeys create --user).
|
||||
func (hsdb *HSDatabase) SetAPIKeyUser(keyID uint64, userID types.UserID) error {
|
||||
return hsdb.DB.Model(&types.APIKey{}).
|
||||
Where("id = ?", keyID).
|
||||
Update("user_id", uint(userID)).Error
|
||||
}
|
||||
|
||||
// ParseAPIKeyPrefix extracts the database prefix from a display prefix.
|
||||
// Handles formats: "hskey-api-{12chars}-***", "hskey-api-{12chars}", or just "{12chars}".
|
||||
// Returns the 12-character prefix suitable for database lookup.
|
||||
|
||||
+37
-4
@@ -38,9 +38,9 @@ var errDatabaseNotSupported = errors.New("database type not supported")
|
||||
var errForeignKeyConstraintsViolated = errors.New("foreign key constraints violated")
|
||||
|
||||
const (
|
||||
maxIdleConns = 100
|
||||
maxOpenConns = 100
|
||||
contextTimeoutSecs = 10
|
||||
maxIdleConns = 100
|
||||
maxOpenConns = 100
|
||||
contextTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
type HSDatabase struct {
|
||||
@@ -781,6 +781,39 @@ WHERE user_id IS NULL
|
||||
},
|
||||
Rollback: func(db *gorm.DB) error { return nil },
|
||||
},
|
||||
{
|
||||
// Add an optional owning user to API keys so the v2 API can
|
||||
// create user-owned (untagged) auth keys, mirroring Tailscale's
|
||||
// "key owned by the creating identity".
|
||||
ID: "202606191500-api-key-user-id",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
if !tx.Migrator().HasColumn(&types.APIKey{}, "user_id") {
|
||||
err := tx.Migrator().AddColumn(&types.APIKey{}, "user_id")
|
||||
if err != nil {
|
||||
return fmt.Errorf("adding user_id to api_keys: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
Rollback: func(db *gorm.DB) error { return nil },
|
||||
},
|
||||
{
|
||||
// Add a free-text description to pre-auth keys, set via the
|
||||
// v2 keys API.
|
||||
ID: "202606191501-pre-auth-key-description",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
if !tx.Migrator().HasColumn(&types.PreAuthKey{}, "description") {
|
||||
err := tx.Migrator().AddColumn(&types.PreAuthKey{}, "description")
|
||||
if err != nil {
|
||||
return fmt.Errorf("adding description to pre_auth_keys: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
Rollback: func(db *gorm.DB) error { return nil },
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -873,7 +906,7 @@ WHERE user_id IS NULL
|
||||
defer sqlConn.SetMaxIdleConns(1)
|
||||
defer sqlConn.SetMaxOpenConns(1)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), contextTimeoutSecs*time.Second)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), contextTimeout)
|
||||
defer cancel()
|
||||
|
||||
opts := squibble.DigestOptions{
|
||||
|
||||
@@ -133,6 +133,15 @@ func CreatePreAuthKey(
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetPreAuthKeyDescription sets the free-text description on a pre-auth key.
|
||||
// The v2 keys API sets it after creation rather than threading it through the
|
||||
// many-armed CreatePreAuthKey signature shared by every other caller.
|
||||
func (hsdb *HSDatabase) SetPreAuthKeyDescription(id uint64, description string) error {
|
||||
return hsdb.DB.Model(&types.PreAuthKey{}).
|
||||
Where("id = ?", id).
|
||||
Update("description", description).Error
|
||||
}
|
||||
|
||||
func (hsdb *HSDatabase) ListPreAuthKeys() ([]types.PreAuthKey, error) {
|
||||
return Read(hsdb.DB, ListPreAuthKeys)
|
||||
}
|
||||
@@ -295,6 +304,20 @@ func GetPreAuthKey(tx *gorm.DB, key string) (*types.PreAuthKey, error) {
|
||||
return findAuthKey(tx, key)
|
||||
}
|
||||
|
||||
// GetPreAuthKeyByID returns a [types.PreAuthKey] by its primary key, with the
|
||||
// owning user preloaded.
|
||||
func (hsdb *HSDatabase) GetPreAuthKeyByID(id uint64) (*types.PreAuthKey, error) {
|
||||
pak := types.PreAuthKey{}
|
||||
// Explicit primary-key clause: a struct condition would drop a zero-valued
|
||||
// ID, making the lookup unconditional and returning the first row instead
|
||||
// of not-found.
|
||||
if result := hsdb.DB.Preload("User").First(&pak, "id = ?", id); result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
|
||||
return &pak, nil
|
||||
}
|
||||
|
||||
// DestroyPreAuthKey destroys a preauthkey. Returns error if the [types.PreAuthKey]
|
||||
// does not exist. This also clears the auth_key_id on any nodes that reference
|
||||
// this key.
|
||||
|
||||
@@ -44,6 +44,7 @@ CREATE TABLE pre_auth_keys(
|
||||
prefix text,
|
||||
hash blob,
|
||||
user_id integer,
|
||||
description text,
|
||||
reusable numeric,
|
||||
ephemeral numeric DEFAULT false,
|
||||
used numeric DEFAULT false,
|
||||
@@ -60,6 +61,7 @@ CREATE TABLE api_keys(
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
prefix text,
|
||||
hash blob,
|
||||
user_id integer,
|
||||
expiration datetime,
|
||||
last_seen datetime,
|
||||
|
||||
|
||||
@@ -1373,6 +1373,17 @@ func (s *State) ValidateAPIKey(keyStr string) (bool, error) {
|
||||
return s.db.ValidateAPIKey(keyStr)
|
||||
}
|
||||
|
||||
// AuthenticateAPIKey validates an API key and returns it (with its owning
|
||||
// user), so callers like the v2 API can act as the key's owner.
|
||||
func (s *State) AuthenticateAPIKey(keyStr string) (*types.APIKey, error) {
|
||||
return s.db.AuthenticateAPIKey(keyStr)
|
||||
}
|
||||
|
||||
// SetAPIKeyUser sets the owning user of an API key by its database ID.
|
||||
func (s *State) SetAPIKeyUser(keyID uint64, userID types.UserID) error {
|
||||
return s.db.SetAPIKeyUser(keyID, userID)
|
||||
}
|
||||
|
||||
// CreateAPIKey generates a new API key with optional expiration.
|
||||
func (s *State) CreateAPIKey(expiration *time.Time) (string, *types.APIKey, error) {
|
||||
return s.db.CreateAPIKey(expiration)
|
||||
@@ -1457,9 +1468,15 @@ func (s *State) DB() *hsdb.HSDatabase {
|
||||
return s.db
|
||||
}
|
||||
|
||||
// GetPreAuthKey retrieves a pre-authentication key by ID.
|
||||
func (s *State) GetPreAuthKey(id string) (*types.PreAuthKey, error) {
|
||||
return s.db.GetPreAuthKey(id)
|
||||
// GetPreAuthKey retrieves a pre-authentication key by its secret. The caller is
|
||||
// responsible for checking whether the key is usable (expired or used).
|
||||
func (s *State) GetPreAuthKey(keyStr string) (*types.PreAuthKey, error) {
|
||||
return s.db.GetPreAuthKey(keyStr)
|
||||
}
|
||||
|
||||
// GetPreAuthKeyByID retrieves a pre-authentication key by its database id.
|
||||
func (s *State) GetPreAuthKeyByID(id uint64) (*types.PreAuthKey, error) {
|
||||
return s.db.GetPreAuthKeyByID(id)
|
||||
}
|
||||
|
||||
// ListPreAuthKeys returns all pre-authentication keys for a user.
|
||||
@@ -1467,6 +1484,11 @@ func (s *State) ListPreAuthKeys() ([]types.PreAuthKey, error) {
|
||||
return s.db.ListPreAuthKeys()
|
||||
}
|
||||
|
||||
// SetPreAuthKeyDescription sets the free-text description on a pre-auth key.
|
||||
func (s *State) SetPreAuthKeyDescription(id uint64, description string) error {
|
||||
return s.db.SetPreAuthKeyDescription(id, description)
|
||||
}
|
||||
|
||||
// ExpirePreAuthKey marks a pre-authentication key as expired.
|
||||
func (s *State) ExpirePreAuthKey(id uint64) error {
|
||||
return s.db.ExpirePreAuthKey(id)
|
||||
|
||||
@@ -17,6 +17,13 @@ type APIKey struct {
|
||||
Prefix string `gorm:"uniqueIndex"`
|
||||
Hash []byte
|
||||
|
||||
// Optional owning user id. When set, an auth key created through the v2 API
|
||||
// with no tags is owned by this user — mirroring Tailscale, where a key is
|
||||
// owned by the identity that created it. Nil for legacy/admin keys, which
|
||||
// can only create tagged keys. Kept as a plain column (no foreign key) so an
|
||||
// upgraded database matches a freshly-migrated one.
|
||||
UserID *uint
|
||||
|
||||
CreatedAt *time.Time
|
||||
Expiration *time.Time
|
||||
LastSeen *time.Time
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
"github.com/juanfont/headscale/hscontrol/util/zlog/zf"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
@@ -12,6 +14,26 @@ type PAKError string
|
||||
|
||||
func (e PAKError) Error() string { return string(e) }
|
||||
|
||||
// StringID returns the key's id as a decimal string, the form the HTTP APIs
|
||||
// render it as.
|
||||
func (pak *PreAuthKey) StringID() string {
|
||||
if pak == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return strconv.FormatUint(pak.ID, util.Base10)
|
||||
}
|
||||
|
||||
// StringID returns the key's id as a decimal string, the form the HTTP APIs
|
||||
// render it as.
|
||||
func (pak *PreAuthKeyNew) StringID() string {
|
||||
if pak == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return strconv.FormatUint(pak.ID, util.Base10)
|
||||
}
|
||||
|
||||
// PreAuthKey describes a pre-authorization key usable in a particular user.
|
||||
type PreAuthKey struct {
|
||||
ID uint64 `gorm:"primary_key"`
|
||||
@@ -29,6 +51,10 @@ type PreAuthKey struct {
|
||||
UserID *uint
|
||||
User *User `gorm:"constraint:OnDelete:SET NULL;"`
|
||||
|
||||
// Free-text description, set via the v2 API. Empty for keys created through
|
||||
// the v1 API or CLI.
|
||||
Description string
|
||||
|
||||
Reusable bool
|
||||
Ephemeral bool `gorm:"default:false"`
|
||||
Used bool `gorm:"default:false"`
|
||||
|
||||
@@ -137,16 +137,17 @@ func (src *PreAuthKey) Clone() *PreAuthKey {
|
||||
|
||||
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
|
||||
var _PreAuthKeyCloneNeedsRegeneration = PreAuthKey(struct {
|
||||
ID uint64
|
||||
Key string
|
||||
Prefix string
|
||||
Hash []byte
|
||||
UserID *uint
|
||||
User *User
|
||||
Reusable bool
|
||||
Ephemeral bool
|
||||
Used bool
|
||||
Tags []string
|
||||
CreatedAt *time.Time
|
||||
Expiration *time.Time
|
||||
ID uint64
|
||||
Key string
|
||||
Prefix string
|
||||
Hash []byte
|
||||
UserID *uint
|
||||
User *User
|
||||
Description string
|
||||
Reusable bool
|
||||
Ephemeral bool
|
||||
Used bool
|
||||
Tags []string
|
||||
CreatedAt *time.Time
|
||||
Expiration *time.Time
|
||||
}{})
|
||||
|
||||
@@ -395,10 +395,14 @@ func (v PreAuthKeyView) Hash() views.ByteSlice[[]byte] { return views.ByteSliceO
|
||||
// Can be nil for system-created tagged keys
|
||||
func (v PreAuthKeyView) UserID() views.ValuePointer[uint] { return views.ValuePointerOf(v.ж.UserID) }
|
||||
|
||||
func (v PreAuthKeyView) User() UserView { return v.ж.User.View() }
|
||||
func (v PreAuthKeyView) Reusable() bool { return v.ж.Reusable }
|
||||
func (v PreAuthKeyView) Ephemeral() bool { return v.ж.Ephemeral }
|
||||
func (v PreAuthKeyView) Used() bool { return v.ж.Used }
|
||||
func (v PreAuthKeyView) User() UserView { return v.ж.User.View() }
|
||||
|
||||
// Free-text description, set via the v2 API. Empty for keys created through
|
||||
// the v1 API or CLI.
|
||||
func (v PreAuthKeyView) Description() string { return v.ж.Description }
|
||||
func (v PreAuthKeyView) Reusable() bool { return v.ж.Reusable }
|
||||
func (v PreAuthKeyView) Ephemeral() bool { return v.ж.Ephemeral }
|
||||
func (v PreAuthKeyView) Used() bool { return v.ж.Used }
|
||||
|
||||
// Tags to assign to nodes registered with this key.
|
||||
// Tags are copied to the node during registration.
|
||||
@@ -414,16 +418,17 @@ func (v PreAuthKeyView) Expiration() views.ValuePointer[time.Time] {
|
||||
|
||||
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
|
||||
var _PreAuthKeyViewNeedsRegeneration = PreAuthKey(struct {
|
||||
ID uint64
|
||||
Key string
|
||||
Prefix string
|
||||
Hash []byte
|
||||
UserID *uint
|
||||
User *User
|
||||
Reusable bool
|
||||
Ephemeral bool
|
||||
Used bool
|
||||
Tags []string
|
||||
CreatedAt *time.Time
|
||||
Expiration *time.Time
|
||||
ID uint64
|
||||
Key string
|
||||
Prefix string
|
||||
Hash []byte
|
||||
UserID *uint
|
||||
User *User
|
||||
Description string
|
||||
Reusable bool
|
||||
Ephemeral bool
|
||||
Used bool
|
||||
Tags []string
|
||||
CreatedAt *time.Time
|
||||
Expiration *time.Time
|
||||
}{})
|
||||
|
||||
Reference in New Issue
Block a user