mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-21 10:28:56 +00:00
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:
parent
048308511c
commit
84d2dfa41d
3 changed files with 88 additions and 0 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
71
hscontrol/state/issue_3371_test.go
Normal file
71
hscontrol/state/issue_3371_test.go
Normal 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")
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue