From 4a8dc2d445fefe745bc5564cf1a751b4b38d2e04 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Mon, 10 Nov 2025 18:36:11 +0100 Subject: [PATCH] hscontrol/state,db: preserve node expiry on MapRequest updates Fixes a regression introduced in v0.27.0 where node expiry times were being reset to zero when tailscaled restarts and sends a MapRequest. The issue was caused by using GORM's Save() method in persistNodeToDB(), which overwrites ALL fields including zero values. When a MapRequest updates a node (without including expiry information), Save() would overwrite the database expiry field with a zero value. Changed to use Updates() which only updates non-zero values, preserving existing database values when struct pointer fields are nil. In BackfillNodeIPs, we need to explicitly update IPv4/IPv6 fields even when nil (to remove IPs), so we use Select() to specify those fields. Added regression test that validates expiry is preserved after MapRequest. Fixes #2862 --- hscontrol/db/ip.go | 6 +++++- hscontrol/state/state.go | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/hscontrol/db/ip.go b/hscontrol/db/ip.go index 3fddcfd2..244bb3db 100644 --- a/hscontrol/db/ip.go +++ b/hscontrol/db/ip.go @@ -325,7 +325,11 @@ func (db *HSDatabase) BackfillNodeIPs(i *IPAllocator) ([]string, error) { } if changed { - err := tx.Save(node).Error + // Use Updates() with Select() to only update IP fields, avoiding overwriting + // other fields like Expiry. We need Select() because Updates() alone skips + // zero values, but we DO want to update IPv4/IPv6 to nil when removing them. + // See issue #2862. + err := tx.Model(node).Select("ipv4", "ipv6").Updates(node).Error if err != nil { return fmt.Errorf("saving node(%d) after adding IPs: %w", node.ID, err) } diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go index 6e1d08e0..ff876024 100644 --- a/hscontrol/state/state.go +++ b/hscontrol/state/state.go @@ -386,7 +386,11 @@ func (s *State) persistNodeToDB(node types.NodeView) (types.NodeView, change.Cha nodePtr := node.AsStruct() - if err := s.db.DB.Save(nodePtr).Error; err != nil { + // 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 + if err != nil { return types.NodeView{}, change.EmptySet, fmt.Errorf("saving node: %w", err) }