hscontrol: cancel ephemeral GC timer when a node is deleted

API and logout deletes left the disconnect timer armed; it fired later
and logged a bogus ERR for the missing node.
This commit is contained in:
Kristoffer Dalby authored and Kristoffer Dalby committed 2026-10-23 18:23:53 +02:00
1 parent 494e2a6808
commit 1b184cd2c7
2 files changed
+49 -2

No files matched your search

+10 -2
View File
@@ -161,8 +161,8 @@ func NewHeadscale(cfg *types.Config) (*Headscale, error) {
ephemeralGC := db.NewEphemeralGarbageCollector(func(ni types.NodeID) {
node, ok := app.state.GetNodeByID(ni)
if !ok {
log.Error().Uint64("node.id", ni.Uint64()).Msg("ephemeral node deletion failed")
log.Debug().Caller().Uint64("node.id", ni.Uint64()).Msg("ephemeral node deletion failed because node not found in NodeStore")
// A delete that raced this timer already did its job.
log.Debug().Caller().Uint64("node.id", ni.Uint64()).Msg("ephemeral node already deleted, skipping garbage collection")
return
}
@@ -1274,6 +1274,14 @@ func readOrCreatePrivateKey(path string) (*key.MachinePrivate, error) {
// All change should be enqueued here and empty will be automatically
// ignored.
func (h *Headscale) Change(cs ...change.Change) {
// Every delete path publishes its removal here; a deleted node's
// inactivity timer has nothing left to collect.
for _, c := range cs {
for _, id := range c.DeletedNodes {
h.ephemeralGC.Cancel(id)
}
}
h.mapBatcher.AddWork(slices.Concat(cs, h.state.DrainSelfRefreshes())...)
}
+39
View File
@@ -424,6 +424,45 @@ func TestEphemeralGCDoesNotDeleteReconnectedNode(t *testing.T) {
"an idle ephemeral node must still be garbage collected")
}
// TestDeleteNodeCancelsEphemeralGC deletes a disconnected ephemeral node
// through the API while its inactivity timer is armed. The timer belongs to
// the deleted node, so the delete must disarm it rather than leave it to fire
// at a node that is no longer there.
func TestDeleteNodeCancelsEphemeralGC(t *testing.T) {
t.Parallel()
app := createTestApp(t)
app.StartEphemeralGCForTest(t)
user := app.state.CreateUserForTest("eph-gc-delete-user")
pak, err := app.state.CreatePreAuthKey(user.TypedID(), false, true, nil, nil)
require.NoError(t, err)
nodeKey := key.NewNode()
_, err = app.handleRegister(context.Background(), tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{AuthKey: pak.Key},
NodeKey: nodeKey.Public(),
Hostinfo: &tailcfg.Hostinfo{Hostname: "eph-gc-delete-node"},
Expiry: time.Now().Add(24 * time.Hour),
}, key.NewMachine().Public())
require.NoError(t, err)
node, ok := app.state.GetNodeByNodeKey(nodeKey.Public())
require.True(t, ok)
require.True(t, node.IsEphemeral(), "test sanity: node must be ephemeral")
// The state a disconnect leaves behind.
app.ephemeralGC.Schedule(node.ID(), time.Hour)
require.True(t, app.ephemeralGC.IsScheduled(node.ID()), "test sanity: GC timer must be armed")
api := registerAPIV2(t, app)
require.Equal(t, http.StatusOK, api.Delete("/api/v2/device/"+node.ID().String()).Code)
assert.False(t, app.ephemeralGC.IsScheduled(node.ID()),
"deleting a node must cancel its ephemeral GC timer")
}
// TestGitHubIssue3129_TransientlyBlockedWriteDoesNotLeaveLiveStaleSession
// tests the scenario reported in
// https://github.com/juanfont/headscale/issues/3129.