node: implement disable key expiry via CLI and API

Add --disable flag to "headscale nodes expire" CLI command and
disable_expiry field handling in the gRPC API to allow disabling
key expiry for nodes. When disabled, the node's expiry is set to
NULL and IsExpired() returns false.

The CLI follows the new grpcRunE/RunE/printOutput patterns
introduced in the recent CLI refactor.

Also fix NodeSetExpiry to persist directly to the database instead
of going through persistNodeToDB which omits the expiry field.

Fixes #2681

Co-authored-by: Marco Santos <me@marcopsantos.com>
This commit is contained in:
Kristoffer Dalby
2026-02-20 10:58:49 +00:00
parent a8f7fedced
commit f20bd0cf08
9 changed files with 222 additions and 17 deletions

View File

@@ -1166,6 +1166,103 @@ func TestSetNodeExpiryInFuture(t *testing.T) {
}
}
// TestDisableNodeExpiry tests disabling key expiry for a node.
// First sets an expiry, then disables it and verifies the node never expires.
func TestDisableNodeExpiry(t *testing.T) {
IntegrationSkip(t)
spec := ScenarioSpec{
NodesPerUser: len(MustTestVersions),
Users: []string{"user1"},
}
scenario, err := NewScenario(spec)
require.NoError(t, err)
defer scenario.ShutdownAssertNoPanics(t)
err = scenario.CreateHeadscaleEnv([]tsic.Option{}, hsic.WithTestName("disableexpiry"))
requireNoErrHeadscaleEnv(t, err)
allClients, err := scenario.ListTailscaleClients()
requireNoErrListClients(t, err)
err = scenario.WaitForTailscaleSync()
requireNoErrSync(t, err)
headscale, err := scenario.Headscale()
require.NoError(t, err)
// First set an expiry on the node.
result, err := headscale.Execute(
[]string{
"headscale", "nodes", "expire",
"--identifier", "1",
"--output", "json",
"--expiry", time.Now().Add(time.Hour).Format(time.RFC3339),
},
)
require.NoError(t, err)
var node v1.Node
err = json.Unmarshal([]byte(result), &node)
require.NoError(t, err)
require.NotNil(t, node.GetExpiry(), "node should have an expiry set")
// Now disable the expiry.
result, err = headscale.Execute(
[]string{
"headscale", "nodes", "expire",
"--identifier", "1",
"--output", "json",
"--disable",
},
)
require.NoError(t, err)
var nodeDisabled v1.Node
err = json.Unmarshal([]byte(result), &nodeDisabled)
require.NoError(t, err)
// Expiry should be nil (or zero time) when disabled.
if nodeDisabled.GetExpiry() != nil {
require.True(t, nodeDisabled.GetExpiry().AsTime().IsZero(),
"node expiry should be zero/nil after disabling")
}
var nodeKey key.NodePublic
err = nodeKey.UnmarshalText([]byte(nodeDisabled.GetNodeKey()))
require.NoError(t, err)
// Verify peers see the node as not expired.
for _, client := range allClients {
if client.Hostname() == nodeDisabled.GetName() {
continue
}
assert.EventuallyWithT(
t, func(ct *assert.CollectT) {
status, err := client.Status()
assert.NoError(ct, err)
peerStatus, ok := status.Peer[nodeKey]
assert.True(ct, ok, "node key should be present in peer list")
if !ok {
return
}
// Node should not be expired.
assert.Falsef(
ct,
peerStatus.Expired,
"node %q should not be marked as expired after disabling expiry",
peerStatus.HostName,
)
}, 3*time.Minute, 5*time.Second, "waiting for disabled expiry to propagate",
)
}
}
func TestNodeOnlineStatus(t *testing.T) {
IntegrationSkip(t)