mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-12 02:51:12 +09:00
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:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -50,6 +50,7 @@ CREATE TABLE pre_auth_keys(
|
||||
used numeric DEFAULT false,
|
||||
tags text,
|
||||
expiration datetime,
|
||||
revoked datetime,
|
||||
|
||||
created_at datetime,
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}{})
|
||||
|
||||
@@ -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
|
||||
}{})
|
||||
|
||||
Reference in New Issue
Block a user