hscontrol: keep a re-keyed node from its stale ephemeral GC timer

Re-registering with a permanent key left the timer armed, and the poll
path only cancels for ephemeral nodes. Recheck when the timer fires.
This commit is contained in:
Kristoffer Dalby committed 2026-10-30 17:05:36 +01:00
1 parent 1ff8e368b6
commit 7721d8c299
3 files changed
+57

No files matched your search

+1
View File
@@ -152,6 +152,7 @@ the ACME DNS-01 challenge through a built-in DNS provider: `acmedns`,
- Reject overlapping TCP listener bindings (e.g. `listen_addr` vs ACME HTTP-01 on port 80) at config load [#3236](https://github.com/juanfont/headscale/pull/3236)
- Improve config and bind-failure errors: every violation reported in one pass, named YAML keys, actionable operator hints [#3236](https://github.com/juanfont/headscale/pull/3236)
- Ephemeral nodes that register but never connect are removed after `node.ephemeral.inactivity_timeout` instead of staying until restart [#3545](https://github.com/juanfont/headscale/pull/3545)
- A node re-registered with a permanent key is no longer deleted by a timer armed while it was ephemeral [#3545](https://github.com/juanfont/headscale/pull/3545)
## 0.29.7 (2026-10-30)
+8
View File
@@ -169,6 +169,14 @@ func NewHeadscale(cfg *types.Config) (*Headscale, error) {
return
}
// Re-registering with a permanent key cancels nothing, and the poll
// path only cancels for ephemeral nodes, so the old timer still fires.
if !node.IsEphemeral() {
log.Debug().Caller().EmbedObject(node).Msg("node is no longer ephemeral, skipping garbage collection")
return
}
// Schedule and Cancel run outside the session transition, so a
// disconnecting session can arm a timer after a reconnect already
// cancelled it. The session count is authoritative; a stale timer
+48
View File
@@ -574,6 +574,54 @@ func TestEphemeralGCArmedWhenPolicyRefreshFails(t *testing.T) {
"a saved ephemeral node must have a GC timer")
}
// TestEphemeralGCKeepsNodeReKeyedToPermanent pins that a timer armed while a
// node was ephemeral does not delete it once a permanent key took over.
func TestEphemeralGCKeepsNodeReKeyedToPermanent(t *testing.T) {
t.Parallel()
const timeout = time.Second
app := createTestApp(t)
app.cfg.Node.Ephemeral.InactivityTimeout = timeout
app.StartEphemeralGCForTest(t)
user := app.state.CreateUserForTest("eph-gc-rekey-user")
ephemeralKey, err := app.state.CreatePreAuthKey(user.TypedID(), false, true, nil, nil)
require.NoError(t, err)
permanentKey, err := app.state.CreatePreAuthKey(user.TypedID(), false, false, nil, nil)
require.NoError(t, err)
nodeKey := key.NewNode()
machineKey := key.NewMachine()
for _, authKey := range []string{ephemeralKey.Key, permanentKey.Key} {
_, err = app.handleRegister(context.Background(), tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{
AuthKey: authKey,
},
NodeKey: nodeKey.Public(),
Hostinfo: &tailcfg.Hostinfo{
Hostname: "eph-gc-rekey-node",
},
Expiry: time.Now().Add(24 * time.Hour),
}, machineKey.Public())
require.NoError(t, err)
}
nodeView, ok := app.state.GetNodeByNodeKey(nodeKey.Public())
require.True(t, ok)
require.False(t, nodeView.IsEphemeral(), "test sanity: node must be permanent")
require.True(t, app.ephemeralGC.IsScheduled(nodeView.ID()),
"test sanity: the ephemeral registration's timer must still be armed")
assert.Never(t, func() bool {
_, ok := app.state.GetNodeByID(nodeView.ID())
return !ok
}, 2*timeout, 20*time.Millisecond,
"a stale timer must not delete a node that is no longer ephemeral")
}
// TestGitHubIssue3129_BlockedWriteSessionLifecycle
// tests the scenario reported in
// https://github.com/juanfont/headscale/issues/3129.