From 84d2dfa41ddb32c7b33cea8aacbb7e36ff0a981d Mon Sep 17 00:00:00 2001 From: max Date: Tue, 7 Jul 2026 18:56:26 +0200 Subject: [PATCH] state: clear stale expiry so tagged nodes can re-authenticate after logout A tagged node that runs `tailscale logout` could never log back in with an auth key. `tailscale logout` sends a past expiry, and handleLogout stamps it on the node with no IsTagged guard, so a tagged node (which has key-expiry disabled and never expires) ends up Expired. On the next `tailscale up --auth-key`, HandleNodeFromPreAuthKey saw the node as expired and took the expired-node validation path, re-validating and rejecting the already-spent one-shot key with "authkey already used" (an infinite re-register loop for reusable keys). Exclude tagged nodes from the expired-node validation gate and clear a stale past expiry on re-registration, restoring the "tagged nodes never expire" invariant. A deliberately set future expiry (headscale nodes expire) is in the future, so IsExpired is false and it is left untouched. Fixes #3371 --- CHANGELOG.md | 1 + hscontrol/state/issue_3371_test.go | 71 ++++++++++++++++++++++++++++++ hscontrol/state/state.go | 16 +++++++ 3 files changed, 88 insertions(+) create mode 100644 hscontrol/state/issue_3371_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 785d7d855..f62aefe15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ keys remain all-access. - Expiring or deleting a non-existent pre-auth key now returns an error instead of silently succeeding [#3324](https://github.com/juanfont/headscale/pull/3324) - Improve systemd service file hardening [#3341](https://github.com/juanfont/headscale/pull/3341) +- A tagged node can re-authenticate after `tailscale logout` again; the stale past expiry a logout leaves on it is cleared on re-registration instead of locking it out [#3371](https://github.com/juanfont/headscale/issues/3371) ## 0.29.2 (2026-07-01) diff --git a/hscontrol/state/issue_3371_test.go b/hscontrol/state/issue_3371_test.go new file mode 100644 index 000000000..7d6094264 --- /dev/null +++ b/hscontrol/state/issue_3371_test.go @@ -0,0 +1,71 @@ +package state + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + "tailscale.com/tailcfg" + "tailscale.com/types/key" +) + +// TestIssue3371_TaggedNodeLogoutLocksOutSingleUseKey reproduces +// https://github.com/juanfont/headscale/issues/3371: +// +// A tagged node that runs `tailscale logout` can never log back in with an +// auth key. Two behaviours combine: +// +// 1. logout sets a past expiry even on a tagged node: handleLogout -> +// SetNodeExpiry runs unconditionally, with no IsTagged guard, so a tagged +// node (which is supposed to have key-expiry disabled) ends up Expired. +// 2. Re-registration does not clear it: the expiry-refresh block in +// HandleNodeFromPreAuthKey is gated on `!node.IsTagged()`, so a tagged +// node keeps its (now past) expiry. +// +// The node therefore stays expired, so re-registration takes the +// expired-node validation path (isExpired == true) and re-validates the +// already-spent one-shot key, rejecting it with "authkey already used". +func TestIssue3371_TaggedNodeLogoutLocksOutSingleUseKey(t *testing.T) { + dbPath := t.TempDir() + "/headscale.db" + cfg := persistTestConfig(dbPath) + + s, err := NewState(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = s.Close() }) + + _, err = s.SetPolicy([]byte(`{"tagOwners":{"tag:foo":["tagger@"]}}`)) + require.NoError(t, err) + + // Single-use, tags-only key: `headscale preauthkeys create --tags tag:foo`. + pak, err := s.CreatePreAuthKey(nil, false, false, nil, []string{"tag:foo"}) + require.NoError(t, err) + + machineKey := key.NewMachine() + regReq := tailcfg.RegisterRequest{ + Auth: &tailcfg.RegisterResponseAuth{AuthKey: pak.Key}, + NodeKey: key.NewNode().Public(), + Hostinfo: &tailcfg.Hostinfo{Hostname: "tagged-node"}, + } + + first, _, err := s.HandleNodeFromPreAuthKey(regReq, machineKey.Public()) + require.NoError(t, err) + require.True(t, first.IsTagged(), "precondition: node is tagged") + require.Nil(t, first.AsStruct().Expiry, "precondition: tagged node starts with no expiry") + + // `tailscale logout`: the client sends a past expiry. handleLogout clamps it + // to now and calls SetNodeExpiry with no IsTagged guard. + logoutExpiry := time.Now() + loggedOut, _, err := s.SetNodeExpiry(first.ID(), &logoutExpiry) + require.NoError(t, err) + require.True(t, loggedOut.IsExpired(), "after logout the tagged node is expired") + + // `tailscale up --auth-key `: the node re-registers with the same, + // now-spent one-shot key. It must be able to log back in. + second, _, err := s.HandleNodeFromPreAuthKey(regReq, machineKey.Public()) + require.NoError(t, err, + "tagged node must be able to log back in after logout (issue #3371)") + require.True(t, second.Valid()) + require.True(t, second.IsTagged(), "node stays tagged") + require.False(t, second.IsExpired(), + "tagged node must not remain expired after re-authentication") +} diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go index ddb410eb7..781310b04 100644 --- a/hscontrol/state/state.go +++ b/hscontrol/state/state.go @@ -2421,7 +2421,15 @@ func (s *State) HandleNodeFromPreAuthKey( // must present a valid key. Without this a node that re-uses its NodeKey // after expiry would skip validation and be re-authorised with a spent or // expired key; the boundary must not depend on the client rotating its key. + // + // Tagged nodes are excluded: they have key-expiry disabled and never + // expire. A tagged node can only look expired because a logout stamped a + // past expiry on it (handleLogout does not guard tagged nodes), which is a + // stale value cleared during this re-registration below, not a genuine key + // expiry. Treating it as expired here would re-validate and reject the + // already-spent one-shot key, locking the node out permanently. See #3371. isExpired := existsSameUser && existingNodeSameUser.Valid() && + !existingNodeSameUser.IsTagged() && existingNodeSameUser.IsExpired() // A tagged key presented for a currently user-owned node converts that node @@ -2557,6 +2565,14 @@ func (s *State) HandleNodeFromPreAuthKey( } else { node.Expiry = nil } + } else if node.IsExpired() { + // A tagged node must not carry key expiry (tagged nodes never + // expire). Clear a stale *past* expiry left by a logout so the + // node is not permanently expired after re-authentication + // (#3371). A deliberately set future expiry + // (headscale nodes expire) is in the future, so IsExpired is + // false and it is left untouched. + node.Expiry = nil } })