state: omit AuthKeyID/AuthKey in node Updates to prevent FK errors

When a PreAuthKey is deleted, the database correctly sets auth_key_id
to NULL on referencing nodes via ON DELETE SET NULL. However, the
NodeStore (in-memory cache) retains the old AuthKeyID value.

When nodes send MapRequests (e.g., after tailscaled restart), GORM's
Updates() tries to persist the stale AuthKeyID, causing a foreign key
constraint error when trying to reference a deleted PreAuthKey.

Fix this by adding AuthKeyID and AuthKey to the Omit() call in all
three places where nodes are updated via GORM's Updates():
- persistNodeToDB (MapRequest processing)
- HandleNodeFromAuthPath (re-auth via web/OIDC)
- HandleNodeFromPreAuthKey (re-registration with preauth key)

This tells GORM to never touch the auth_key_id column or AuthKey
association during node updates, letting the database handle the
foreign key relationship correctly.

Added TestDeletedPreAuthKeyNotRecreatedOnNodeUpdate to verify that
deleted PreAuthKeys are not recreated when nodes send MapRequests.
This commit is contained in:
Kristoffer Dalby 2026-01-26 08:54:14 +00:00
parent 49b70db7f2
commit 46daa659e2
2 changed files with 119 additions and 6 deletions

View file

@ -403,10 +403,14 @@ func (s *State) persistNodeToDB(node types.NodeView) (types.NodeView, change.Cha
nodePtr := node.AsStruct()
// Use Omit("expiry") to prevent overwriting expiry during MapRequest updates.
// Expiry should only be updated through explicit SetNodeExpiry calls or re-registration.
// See: https://github.com/juanfont/headscale/issues/2862
err := s.db.DB.Omit("expiry").Updates(nodePtr).Error
// Use Omit to prevent overwriting certain fields during MapRequest updates:
// - "expiry": should only be updated through explicit SetNodeExpiry calls or re-registration
// - "AuthKeyID", "AuthKey": prevents GORM from persisting stale PreAuthKey references that
// may exist in NodeStore after a PreAuthKey has been deleted. The database handles setting
// auth_key_id to NULL via ON DELETE SET NULL. Without this, Updates() would fail with a
// foreign key constraint error when trying to reference a deleted PreAuthKey.
// See also: https://github.com/juanfont/headscale/issues/2862
err := s.db.DB.Omit("expiry", "AuthKeyID", "AuthKey").Updates(nodePtr).Error
if err != nil {
return types.NodeView{}, change.Change{}, fmt.Errorf("saving node: %w", err)
}
@ -1433,7 +1437,8 @@ func (s *State) HandleNodeFromAuthPath(
_, err = hsdb.Write(s.db.DB, func(tx *gorm.DB) (*types.Node, error) {
// Use Updates() to preserve fields not modified by UpdateNode.
err := tx.Updates(updatedNodeView.AsStruct()).Error
// Omit AuthKeyID/AuthKey to prevent stale PreAuthKey references from causing FK errors.
err := tx.Omit("AuthKeyID", "AuthKey").Updates(updatedNodeView.AsStruct()).Error
if err != nil {
return nil, fmt.Errorf("failed to save node: %w", err)
}
@ -1685,7 +1690,8 @@ func (s *State) HandleNodeFromPreAuthKey(
_, err = hsdb.Write(s.db.DB, func(tx *gorm.DB) (*types.Node, error) {
// Use Updates() to preserve fields not modified by UpdateNode.
err := tx.Updates(updatedNodeView.AsStruct()).Error
// Omit AuthKeyID/AuthKey to prevent stale PreAuthKey references from causing FK errors.
err := tx.Omit("AuthKeyID", "AuthKey").Updates(updatedNodeView.AsStruct()).Error
if err != nil {
return nil, fmt.Errorf("failed to save node: %w", err)
}