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.
This commit is contained in:
Kristoffer Dalby
2026-06-20 20:16:51 +00:00
parent ff7dd807fe
commit 6b413fe237
10 changed files with 150 additions and 2 deletions
+9 -2
View File
@@ -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()
}
+19
View File
@@ -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
+17
View File
@@ -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 },
},
},
)
+57
View File
@@ -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
+1
View File
@@ -50,6 +50,7 @@ CREATE TABLE pre_auth_keys(
used numeric DEFAULT false,
tags text,
expiration datetime,
revoked datetime,
created_at datetime,
+12
View File
@@ -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()
+14
View File
@@ -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(),
+9
View File
@@ -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")
}
+4
View File
@@ -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
}{})
+8
View File
@@ -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
}{})