From 6337a3dbc4b958c9ad1965b2a44ef57881a566f6 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sun, 1 Mar 2026 22:53:55 +0000 Subject: [PATCH] state: apply default node key expiry on registration Use the node.expiry config to apply a default expiry to non-tagged nodes when the client does not request a specific expiry. This covers all registration paths: new node creation, re-authentication, and pre-auth key re-registration. Tagged nodes remain exempt and never expire. Fixes #1711 --- hscontrol/state/state.go | 35 +++++++++++++++++++++++++++++++++-- integration/auth_oidc_test.go | 2 +- integration/cli_test.go | 11 ++++++----- 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go index ee868538..2bc828ad 100644 --- a/hscontrol/state/state.go +++ b/hscontrol/state/state.go @@ -1431,6 +1431,17 @@ func (s *State) applyAuthNodeUpdate(params authNodeUpdateParams) (types.NodeView } } // Tagged → Tagged: keep existing expiry (nil) - no action needed + + // Apply default node expiry for non-tagged nodes when the + // resolved expiry is still nil or zero (e.g., CLI registration + // where the client did not request a specific expiry). + needsDefaultExpiry := !node.IsTagged() && + (node.Expiry == nil || node.Expiry.IsZero()) && + s.cfg.Node.Expiry > 0 + if needsDefaultExpiry { + exp := time.Now().Add(s.cfg.Node.Expiry) + node.Expiry = &exp + } }) if !ok { @@ -1553,6 +1564,17 @@ func (s *State) createAndSaveNewNode(params newNodeParams) (types.NodeView, erro } } + // Apply default node expiry for non-tagged nodes when the client + // did not request a specific expiry. + // Tagged nodes are exempt — they never expire. + needsDefaultExpiry := !nodeToRegister.IsTagged() && + (nodeToRegister.Expiry == nil || nodeToRegister.Expiry.IsZero()) && + s.cfg.Node.Expiry > 0 + if needsDefaultExpiry { + exp := time.Now().Add(s.cfg.Node.Expiry) + nodeToRegister.Expiry = &exp + } + // Validate before saving err := validateNodeOwnership(&nodeToRegister) if err != nil { @@ -2030,9 +2052,18 @@ func (s *State) HandleNodeFromPreAuthKey( node.LastSeen = new(time.Now()) // Tagged nodes keep their existing expiry (disabled). - // User-owned nodes update expiry from the client request. + // User-owned nodes update expiry from the client request, + // falling back to the configured default if the client + // did not request a specific expiry. if !node.IsTagged() { - node.Expiry = ®Req.Expiry + if !regReq.Expiry.IsZero() { + node.Expiry = ®Req.Expiry + } else if s.cfg.Node.Expiry > 0 { + exp := time.Now().Add(s.cfg.Node.Expiry) + node.Expiry = &exp + } else { + node.Expiry = ®Req.Expiry + } } }) diff --git a/integration/auth_oidc_test.go b/integration/auth_oidc_test.go index 8c79e434..88b8a371 100644 --- a/integration/auth_oidc_test.go +++ b/integration/auth_oidc_test.go @@ -1354,7 +1354,7 @@ func TestOIDCExpiryAfterRestart(t *testing.T) { "HEADSCALE_OIDC_CLIENT_ID": scenario.mockOIDC.ClientID(), "CREDENTIALS_DIRECTORY_TEST": "/tmp", "HEADSCALE_OIDC_CLIENT_SECRET_PATH": "${CREDENTIALS_DIRECTORY_TEST}/hs_client_oidc_secret", - "HEADSCALE_OIDC_EXPIRY": "72h", + "HEADSCALE_NODE_EXPIRY": "72h", } err = scenario.CreateHeadscaleEnvWithLoginURL( diff --git a/integration/cli_test.go b/integration/cli_test.go index 49ff2a0a..15fd51a3 100644 --- a/integration/cli_test.go +++ b/integration/cli_test.go @@ -1394,11 +1394,12 @@ func TestNodeExpireCommand(t *testing.T) { assert.Len(t, listAll, 5) - assert.True(t, listAll[0].GetExpiry().AsTime().IsZero()) - assert.True(t, listAll[1].GetExpiry().AsTime().IsZero()) - assert.True(t, listAll[2].GetExpiry().AsTime().IsZero()) - assert.True(t, listAll[3].GetExpiry().AsTime().IsZero()) - assert.True(t, listAll[4].GetExpiry().AsTime().IsZero()) + // With node.expiry defaulting to 0, non-tagged nodes have zero expiry + // (never expire unless explicitly expired). + for i := range 5 { + assert.True(t, listAll[i].GetExpiry().AsTime().IsZero(), + "node %d should have zero expiry (no default node.expiry)", i) + } for idx := range 3 { _, err := headscale.Execute(