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
This commit is contained in:
Kristoffer Dalby
2026-03-01 22:53:55 +00:00
parent 4d0b273b90
commit 6337a3dbc4
3 changed files with 40 additions and 8 deletions

View File

@@ -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 = &regReq.Expiry
if !regReq.Expiry.IsZero() {
node.Expiry = &regReq.Expiry
} else if s.cfg.Node.Expiry > 0 {
exp := time.Now().Add(s.cfg.Node.Expiry)
node.Expiry = &exp
} else {
node.Expiry = &regReq.Expiry
}
}
})

View File

@@ -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(

View File

@@ -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(