From 3d811f29a6659589f4dc55dd329bcab5eade6c20 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 20 Jun 2026 20:16:51 +0000 Subject: [PATCH] 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. --- hscontrol/db/api_key.go | 26 +++++++++++++++++++++ hscontrol/db/db.go | 41 ++++++++++++++++++++++++++++++---- hscontrol/db/preauth_keys.go | 23 +++++++++++++++++++ hscontrol/db/schema.sql | 2 ++ hscontrol/state/state.go | 28 ++++++++++++++++++++--- hscontrol/types/api_key.go | 7 ++++++ hscontrol/types/preauth_key.go | 26 +++++++++++++++++++++ hscontrol/types/types_clone.go | 25 +++++++++++---------- hscontrol/types/types_view.go | 37 +++++++++++++++++------------- 9 files changed, 180 insertions(+), 35 deletions(-) diff --git a/hscontrol/db/api_key.go b/hscontrol/db/api_key.go index 41f16915..b56f1c18 100644 --- a/hscontrol/db/api_key.go +++ b/hscontrol/db/api_key.go @@ -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. diff --git a/hscontrol/db/db.go b/hscontrol/db/db.go index 431ae510..ea9b8085 100644 --- a/hscontrol/db/db.go +++ b/hscontrol/db/db.go @@ -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{ diff --git a/hscontrol/db/preauth_keys.go b/hscontrol/db/preauth_keys.go index 2f4f5de0..ff2c7a30 100644 --- a/hscontrol/db/preauth_keys.go +++ b/hscontrol/db/preauth_keys.go @@ -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. diff --git a/hscontrol/db/schema.sql b/hscontrol/db/schema.sql index 781446c0..d7172c56 100644 --- a/hscontrol/db/schema.sql +++ b/hscontrol/db/schema.sql @@ -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, diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go index ab4fe997..9c003378 100644 --- a/hscontrol/state/state.go +++ b/hscontrol/state/state.go @@ -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) diff --git a/hscontrol/types/api_key.go b/hscontrol/types/api_key.go index 17854b44..845f88ca 100644 --- a/hscontrol/types/api_key.go +++ b/hscontrol/types/api_key.go @@ -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 diff --git a/hscontrol/types/preauth_key.go b/hscontrol/types/preauth_key.go index cf8d178a..30e4a627 100644 --- a/hscontrol/types/preauth_key.go +++ b/hscontrol/types/preauth_key.go @@ -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"` diff --git a/hscontrol/types/types_clone.go b/hscontrol/types/types_clone.go index 315cabf8..f3587ba0 100644 --- a/hscontrol/types/types_clone.go +++ b/hscontrol/types/types_clone.go @@ -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 }{}) diff --git a/hscontrol/types/types_view.go b/hscontrol/types/types_view.go index 4bdaa778..312059c3 100644 --- a/hscontrol/types/types_view.go +++ b/hscontrol/types/types_view.go @@ -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 }{})