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
This commit is contained in:
max 2026-07-07 18:56:26 +02:00
parent 048308511c
commit 84d2dfa41d
3 changed files with 88 additions and 0 deletions

View file

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

View file

@ -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 <same 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")
}

View file

@ -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
}
})