From 6b413fe23729458df76c517aca1ec23a8931fe3b Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 20 Jun 2026 20:16:51 +0000 Subject: [PATCH] api/v2: soft-revoke auth keys with a configurable collector Tailscale's keys API has no separate expire verb: DELETE is the revoke. Map it to a soft revoke so the key stays retrievable as invalid afterwards instead of vanishing, matching the SDK and Terraform's expectations. Add a revoked timestamp to pre-auth keys (migration plus schema), mark a key invalid once revoked, and have the keys DELETE handler stamp it rather than destroy the row. A background collector reaps revoked keys after a configurable retention window (preauth_keys.revoked_retention, default 168h), so the table does not grow without bound. --- hscontrol/api/v2/keys.go | 11 +++++-- hscontrol/app.go | 19 ++++++++++++ hscontrol/db/db.go | 17 ++++++++++ hscontrol/db/preauth_keys.go | 57 ++++++++++++++++++++++++++++++++++ hscontrol/db/schema.sql | 1 + hscontrol/state/state.go | 12 +++++++ hscontrol/types/config.go | 14 +++++++++ hscontrol/types/preauth_key.go | 9 ++++++ hscontrol/types/types_clone.go | 4 +++ hscontrol/types/types_view.go | 8 +++++ 10 files changed, 150 insertions(+), 2 deletions(-) diff --git a/hscontrol/api/v2/keys.go b/hscontrol/api/v2/keys.go index 9ffb0a99..9ce5f26f 100644 --- a/hscontrol/api/v2/keys.go +++ b/hscontrol/api/v2/keys.go @@ -249,9 +249,12 @@ func registerKeys(api huma.API, b Backend) { return nil, err } - err = b.State.DeletePreAuthKey(id) + // 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("deleting auth key", err) + return nil, mapError("revoking auth key", err) } return &deleteKeyOutput{}, nil @@ -330,6 +333,10 @@ func keyFromStored(pak *types.PreAuthKey) Key { 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() } diff --git a/hscontrol/app.go b/hscontrol/app.go index af19af9b..7564c909 100644 --- a/hscontrol/app.go +++ b/hscontrol/app.go @@ -310,12 +310,31 @@ func (h *Headscale) scheduledTasks(ctx context.Context) { Msg("HA subnet router health probing enabled") } + var revokedKeyGCChan <-chan time.Time + + if h.cfg.PreAuthKeys.RevokedRetention > 0 { + revokedKeyTicker := time.NewTicker(time.Hour) + defer revokedKeyTicker.Stop() + + revokedKeyGCChan = revokedKeyTicker.C + } + for { select { case <-ctx.Done(): log.Info().Caller().Msg("scheduled task worker is shutting down.") return + case <-revokedKeyGCChan: + cutoff := time.Now().Add(-h.cfg.PreAuthKeys.RevokedRetention) + + reaped, err := h.state.DestroyRevokedPreAuthKeysBefore(cutoff) + if err != nil { + log.Error().Err(err).Msg("reaping revoked pre-auth keys") + } else if reaped > 0 { + log.Info().Int("count", reaped).Msg("reaped revoked pre-auth keys") + } + case <-expireTicker.C: var ( expiredNodeChanges []change.Change diff --git a/hscontrol/db/db.go b/hscontrol/db/db.go index ea9b8085..af2a6d34 100644 --- a/hscontrol/db/db.go +++ b/hscontrol/db/db.go @@ -814,6 +814,23 @@ WHERE user_id IS NULL }, Rollback: func(db *gorm.DB) error { return nil }, }, + { + // Add a revoked timestamp to pre-auth keys. The v2 API's DELETE + // soft-revokes a key (set revoked = now) rather than destroying + // it; the row is reaped later by the background collector. + ID: "202606201200-pre-auth-key-revoked", + Migrate: func(tx *gorm.DB) error { + if !tx.Migrator().HasColumn(&types.PreAuthKey{}, "revoked") { + err := tx.Migrator().AddColumn(&types.PreAuthKey{}, "revoked") + if err != nil { + return fmt.Errorf("adding revoked to pre_auth_keys: %w", err) + } + } + + return nil + }, + Rollback: func(db *gorm.DB) error { return nil }, + }, }, ) diff --git a/hscontrol/db/preauth_keys.go b/hscontrol/db/preauth_keys.go index ff2c7a30..1da35ac3 100644 --- a/hscontrol/db/preauth_keys.go +++ b/hscontrol/db/preauth_keys.go @@ -357,6 +357,63 @@ func (hsdb *HSDatabase) DeletePreAuthKey(id uint64) error { }) } +func (hsdb *HSDatabase) RevokePreAuthKey(id uint64) error { + return hsdb.Write(func(tx *gorm.DB) error { + return RevokePreAuthKey(tx, id) + }) +} + +// RevokePreAuthKey soft-revokes a key (the v2 API's DELETE): the row is kept and +// stays retrievable with its invalid flag set, but the key can no longer +// authorize nodes. The background collector hard-deletes it after the retention +// window. An already-revoked or unknown id returns [ErrPreAuthKeyNotFound], so a +// repeated DELETE is a clean 404. +func RevokePreAuthKey(tx *gorm.DB, id uint64) error { + res := tx.Model(&types.PreAuthKey{}). + Where("id = ? AND revoked IS NULL", id). + Update("revoked", time.Now()) + if res.Error != nil { + return res.Error + } + + if res.RowsAffected == 0 { + return ErrPreAuthKeyNotFound + } + + return nil +} + +// DestroyRevokedPreAuthKeysBefore hard-deletes every key revoked before cutoff, +// returning how many were removed. The background collector calls this to reap +// soft-revoked keys after the retention window. +func (hsdb *HSDatabase) DestroyRevokedPreAuthKeysBefore(cutoff time.Time) (int, error) { + var count int + + err := hsdb.Write(func(tx *gorm.DB) error { + var ids []uint64 + + err := tx.Model(&types.PreAuthKey{}). + Where("revoked IS NOT NULL AND revoked < ?", cutoff). + Pluck("id", &ids).Error + if err != nil { + return err + } + + for _, id := range ids { + err := DestroyPreAuthKey(tx, id) + if err != nil { + return err + } + } + + count = len(ids) + + return nil + }) + + return count, err +} + // UsePreAuthKey atomically marks a [types.PreAuthKey] as used. The UPDATE is // guarded by `used = false` so two concurrent registrations racing for // the same single-use key cannot both succeed: the first commits and diff --git a/hscontrol/db/schema.sql b/hscontrol/db/schema.sql index d7172c56..6343ad81 100644 --- a/hscontrol/db/schema.sql +++ b/hscontrol/db/schema.sql @@ -50,6 +50,7 @@ CREATE TABLE pre_auth_keys( used numeric DEFAULT false, tags text, expiration datetime, + revoked datetime, created_at datetime, diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go index 6068dbbe..c4d70efd 100644 --- a/hscontrol/state/state.go +++ b/hscontrol/state/state.go @@ -1479,6 +1479,18 @@ func (s *State) GetPreAuthKeyByID(id uint64) (*types.PreAuthKey, error) { return s.db.GetPreAuthKeyByID(id) } +// RevokePreAuthKey soft-revokes a pre-authentication key: it is kept and stays +// retrievable (invalid) until the collector reaps it after the retention window. +func (s *State) RevokePreAuthKey(id uint64) error { + return s.db.RevokePreAuthKey(id) +} + +// DestroyRevokedPreAuthKeysBefore hard-deletes pre-auth keys revoked before +// cutoff, returning how many were removed. +func (s *State) DestroyRevokedPreAuthKeysBefore(cutoff time.Time) (int, error) { + return s.db.DestroyRevokedPreAuthKeysBefore(cutoff) +} + // ListPreAuthKeys returns all pre-authentication keys for a user. func (s *State) ListPreAuthKeys() ([]types.PreAuthKey, error) { return s.db.ListPreAuthKeys() diff --git a/hscontrol/types/config.go b/hscontrol/types/config.go index 852c2b62..4ef0ba34 100644 --- a/hscontrol/types/config.go +++ b/hscontrol/types/config.go @@ -78,6 +78,14 @@ type RouteConfig struct { HA HARouteConfig } +// PreAuthKeysConfig contains configuration for pre-auth key lifecycle. +type PreAuthKeysConfig struct { + // RevokedRetention is how long a soft-revoked pre-auth key (revoked via the + // v2 API's DELETE) is kept retrievable before the background collector + // hard-deletes it. A zero or negative duration disables the collector. + RevokedRetention time.Duration +} + // NodeConfig contains configuration for node lifecycle and expiry. type NodeConfig struct { // Expiry is the default key expiry duration for non-tagged nodes. @@ -100,6 +108,7 @@ type Config struct { MetricsAddr string TrustedProxies []netip.Prefix Node NodeConfig + PreAuthKeys PreAuthKeysConfig PrefixV4 *netip.Prefix PrefixV6 *netip.Prefix IPAllocation IPAllocationStrategy @@ -440,6 +449,7 @@ func LoadConfig(path string, isFile bool) error { viper.SetDefault("node.expiry", "0") viper.SetDefault("node.ephemeral.inactivity_timeout", "120s") + viper.SetDefault("preauth_keys.revoked_retention", "168h") viper.SetDefault("node.routes.ha.probe_interval", "10s") viper.SetDefault("node.routes.ha.probe_timeout", "5s") @@ -1204,6 +1214,10 @@ func LoadServerConfig() (*Config, error) { }, }, + PreAuthKeys: PreAuthKeysConfig{ + RevokedRetention: viper.GetDuration("preauth_keys.revoked_retention"), + }, + Database: databaseConfig(), TLS: tlsConfig(), diff --git a/hscontrol/types/preauth_key.go b/hscontrol/types/preauth_key.go index 30e4a627..27502f3d 100644 --- a/hscontrol/types/preauth_key.go +++ b/hscontrol/types/preauth_key.go @@ -66,6 +66,11 @@ type PreAuthKey struct { CreatedAt *time.Time Expiration *time.Time + + // Revoked is set when the key is revoked through the v2 API (Tailscale's + // DELETE). A revoked key is invalid but kept retrievable until the + // background collector reaps it after the configured retention window. + Revoked *time.Time } // PreAuthKeyNew is returned once when the key is created. @@ -92,6 +97,10 @@ func (pak *PreAuthKey) Validate() error { EmbedObject(pak). Msg("PreAuthKey.Validate: checking key") + if pak.Revoked != nil { + return PAKError("authkey revoked") + } + if pak.Expiration != nil && pak.Expiration.Before(time.Now()) { return PAKError("authkey expired") } diff --git a/hscontrol/types/types_clone.go b/hscontrol/types/types_clone.go index f3587ba0..1040e214 100644 --- a/hscontrol/types/types_clone.go +++ b/hscontrol/types/types_clone.go @@ -132,6 +132,9 @@ func (src *PreAuthKey) Clone() *PreAuthKey { if dst.Expiration != nil { dst.Expiration = new(*src.Expiration) } + if dst.Revoked != nil { + dst.Revoked = new(*src.Revoked) + } return dst } @@ -150,4 +153,5 @@ var _PreAuthKeyCloneNeedsRegeneration = PreAuthKey(struct { Tags []string CreatedAt *time.Time Expiration *time.Time + Revoked *time.Time }{}) diff --git a/hscontrol/types/types_view.go b/hscontrol/types/types_view.go index 312059c3..6adcdda1 100644 --- a/hscontrol/types/types_view.go +++ b/hscontrol/types/types_view.go @@ -416,6 +416,13 @@ func (v PreAuthKeyView) Expiration() views.ValuePointer[time.Time] { return views.ValuePointerOf(v.ж.Expiration) } +// Revoked is set when the key is revoked through the v2 API (Tailscale's +// DELETE). A revoked key is invalid but kept retrievable until the +// background collector reaps it after the configured retention window. +func (v PreAuthKeyView) Revoked() views.ValuePointer[time.Time] { + return views.ValuePointerOf(v.ж.Revoked) +} + // 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 @@ -431,4 +438,5 @@ var _PreAuthKeyViewNeedsRegeneration = PreAuthKey(struct { Tags []string CreatedAt *time.Time Expiration *time.Time + Revoked *time.Time }{})