Compare commits

...

11 Commits

Author SHA1 Message Date
Kristoffer Dalby 5aff68b5b9 mkdocs: bump version
Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
(cherry picked from commit 565fd254d0)
2026-07-29 14:35:31 +02:00
Kristoffer Dalby 235a57ec31 CHANGELOG: add 0.29.3
Updates #3385

(cherry picked from commit 8bb26c5967)
2026-07-29 14:35:31 +02:00
Kristoffer Dalby bdc3e996de hscontrol: prefer completed auth over expired ctx in followup wait
waitForFollowup selected on ctx.Done() and the verdict channel with equal
priority; when both were ready, select picked at random and discarded a
successful registration as a spurious 401 timeout. Check for a completed
verdict first, race the deadline only if none is ready.

Fixes #3385

(cherry picked from commit d28a6b111a)
2026-07-29 14:35:31 +02:00
Igor Serganov 5fb514e6e0 poll: do not cancel ephemeral GC until Connect succeeds
With node.ephemeral.inactivity_timeout set, ephemeral nodes are
usually deleted after they go offline, but under reconnect churn some
departed nodes stayed in the node list as disconnected indefinitely
until removed manually or until Headscale restarted.

Ephemeral cleanup is timer-based via EphemeralGarbageCollector, not a
periodic LastSeen scan. serveLongPoll cancelled any pending GC timer
at the very start of a long-poll attempt and only rescheduled on a
clean disconnect after Connect. If a reconnect cancelled the timer and
then failed before Connect (for example an UpdateNodeFromMapRequest
error), the deferred cleanup saw connectGen == 0 and returned without
Schedule. The node remained offline with no deletion timer and no
reconciler to recover it.

Cancel the ephemeral GC timer only after a successful Connect, so a
failed reconnect leaves an already-armed inactivity timer intact.
Successful reconnects still cancel GC once the node is online, and a
later disconnect reschedules as before.

Add TestFailedReconnectDoesNotCancelEphemeralGC to lock in the
ordering, plus IsScheduled and DeleteNodeFromStoreForTest helpers for
the test.

Fixes #3382

Co-authored-by: Cursor <cursoragent@cursor.com>
(cherry picked from commit cfd845cb53)
2026-07-29 14:35:31 +02:00
Kristoffer Dalby 4a1e77359d policy,state: authorize reauth tags against the authenticating user
Re-authenticating a tagged node with --advertise-tags checked the tag-owned
node, not the authenticating user, so every tag was rejected.

Fixes #3374

(cherry picked from commit 6275e3a356)
2026-07-29 14:35:31 +02:00
Kristoffer Dalby 1fccdb18bd state: apply a new pre-auth key's tags on re-registration
Re-registering a tagged node with a different key discarded the new key's
tags and left a stale auth-key reference; retag on key change and persist it.

Fixes #3370

(cherry picked from commit fc16cc6905)
2026-07-29 14:35:31 +02:00
Kristoffer Dalby d202883200 state: do not expire tagged nodes on logout
Tagged nodes never expire, but handleLogout stamped a past expiry on them,
leaving them stuck expired and unable to re-authenticate.

Fixes #3371

(cherry picked from commit 1ed5693fa4)
2026-07-29 14:35:31 +02:00
Kristoffer Dalby 9609a0b87d hscontrol: gate /key on supported capability version
/key handed out the Noise public key for any v>=39, a floor unrelated
to the handshake's capver.MinSupportedCapabilityVersion. Reject below
the supported floor, matching /ts2021, and drop the stale constant.

Fixes #3380

(cherry picked from commit 5b6e1e17be)
2026-07-29 14:35:31 +02:00
Arpit Jain fba84ca232 auth: check machine key on the followup registration path
waitForFollowup returns nodeToRegisterResponse for a completed
registration without checking that the Noise session polling for the
result was started with the machine key that opened the registration.
That response carries the registering user's User and Login, so the auth
ID in the followup URL is the only thing protecting it.

handleRegister and handleLogout both call machineKeyMismatch before
handing back a node, so this is the one path of the three that does not.
The key is already available: HandleNodeFromAuthPath resolves the node
from the MachineKey cached in RegistrationData, so on the normal path the
node and the session agree and the check is a no-op.

The auth ID is 96 bits of randomness and is not guessable, so this is not
reachable by brute force. It is logged at info level when a registration
is created, which makes log access the realistic way to obtain one.

The existing followup_registration_success case built its node with
CreateNodeForTest, which picks a random machine key that no real
registration would produce. Set the registering machine key so the
fixture matches the production path.

Signed-off-by: Arpit Jain <arpitjain099@gmail.com>
(cherry picked from commit 0ce3356b89)
2026-07-29 14:35:31 +02:00
Florian Preinstorfer 089d6c4109 Explicitly select lunr as search provider
Keep the existing search provider instead (and the separator) option.

(cherry picked from commit 7a1ee34f71)
2026-07-29 14:35:31 +02:00
Kristoffer Dalby 12928418b8 build: bump Go toolchain to 1.26.5
Tailscale HEAD go.mod now requires go >= 1.26.5; build images pinned
1.26.4. Bump go.mod, the four Go Dockerfiles, and the nixpkgs pin
(staging-next-26.05 ships go_1_26 1.26.5; unstable still lags).

(cherry picked from commit 8eec2dbdc2)
2026-07-29 14:35:31 +02:00
27 changed files with 2961 additions and 79 deletions
+6
View File
@@ -340,6 +340,8 @@ jobs:
- TestTagsAuthKeyWithTagCannotAddViaCLI
- TestTagsAuthKeyWithTagCannotChangeViaCLI
- TestTagsAuthKeyWithTagAdminOverrideReauthPreserves
- TestTagsReauthDifferentKeyRetagsNode
- TestTagsReauthDifferentKeyRemovesTag
- TestTagsAuthKeyWithTagCLICannotModifyAdminTags
- TestTagsAuthKeyWithoutTagCannotRequestTags
- TestTagsAuthKeyWithoutTagRegisterNoTags
@@ -367,6 +369,10 @@ jobs:
- TestTagsAuthKeyWithoutUserInheritsTags
- TestTagsAuthKeyWithoutUserRejectsAdvertisedTags
- TestTagsAuthKeyConvertToUserViaCLIRegister
- TestTaggedNodeLogoutReloginSingleUseKeyOnline
- TestTaggedNodeLogoutReloginReusableKeyOnline
- TestTagsOIDCReauthAddOwnedTag
- TestTagsReauthEmptyTagsReturnsToUserSurvives
- TestTS2021WebSocketGET
- TestTS2021WASMClientUnderNode
- TestTailscaleRustAxum
+14
View File
@@ -1,5 +1,19 @@
# CHANGELOG
## 0.29.3 (2026-07-29)
**Minimum supported Tailscale client version: v1.80.0**
### Changes
- Fix tagged node stuck expired after `tailscale logout`, unable to re-authenticate [#3394](https://github.com/juanfont/headscale/pull/3394)
- Re-registering a tagged node with a different pre-auth key now applies the new key's tags instead of silently keeping the old ones [#3394](https://github.com/juanfont/headscale/pull/3394)
- Fix re-authenticating an already-tagged node with `--advertise-tags` being rejected when the authenticating user owns the tags [#3394](https://github.com/juanfont/headscale/pull/3394)
- Fix ephemeral nodes lingering as disconnected after reconnect churn [#3383](https://github.com/juanfont/headscale/pull/3383)
- Fix node registration falsely returning `401 registration timed out` when auth completes as the request context expires [#3392](https://github.com/juanfont/headscale/pull/3392)
- Check the machine key on the followup registration poll so a leaked auth ID cannot return the registering user's identity [#3393](https://github.com/juanfont/headscale/pull/3393)
- Reject `/key` requests below the supported capability version floor, matching `/ts2021` [#3391](https://github.com/juanfont/headscale/pull/3391)
## 0.29.2 (2026-07-01)
**Minimum supported Tailscale client version: v1.80.0**
+1 -1
View File
@@ -1,6 +1,6 @@
# For testing purposes only
FROM golang:1.26.4-alpine AS build-env
FROM golang:1.26.5-alpine AS build-env
WORKDIR /go/src
+1 -1
View File
@@ -2,7 +2,7 @@
# and are in no way endorsed by Headscale's maintainers as an
# official nor supported release or distribution.
FROM docker.io/golang:1.26.4-trixie AS builder
FROM docker.io/golang:1.26.5-trixie AS builder
ARG VERSION=dev
ENV GOPATH /go
WORKDIR /go/src/headscale
+1 -1
View File
@@ -4,7 +4,7 @@
# This Dockerfile is more or less lifted from tailscale/tailscale
# to ensure a similar build process when testing the HEAD of tailscale.
FROM golang:1.26.4-alpine AS build-env
FROM golang:1.26.5-alpine AS build-env
WORKDIR /go/src
+1 -1
View File
@@ -7,7 +7,7 @@
# to drive a real browser-style WebSocket GET against headscale's /ts2021,
# guarding the regression in issue #3357.
FROM golang:1.26.4-alpine AS build
FROM golang:1.26.5-alpine AS build
WORKDIR /src
Generated
+25 -3
View File
@@ -1,5 +1,27 @@
{
"nodes": {
"flake-checks": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": [
"nixpkgs"
],
"treefmt-nix": "treefmt-nix"
},
"locked": {
"lastModified": 1783015821,
"narHash": "sha256-vmzYTZxIAy3OYXwkRL5GQYk8fNnh8TLWcxQRIO3ywOU=",
"owner": "kradalby",
"repo": "flake-checks",
"rev": "3c821706eb0bd07f515f9a7726b650ccae927433",
"type": "github"
},
"original": {
"owner": "kradalby",
"repo": "flake-checks",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
@@ -20,11 +42,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1781153106,
"narHash": "sha256-yzsroLCcuRG4KdGMxWt0eXKOrRSgQT8/xjYngeq9ujU=",
"lastModified": 1784421286,
"narHash": "sha256-YKn3t7FvaargtyNHbrOw+qzDM+93q5kSCREUPNxeZQs=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "9ee75f111a06d7ab2b2f729698a8eff53d54e070",
"rev": "62eaae30cd05c04718500ddb44e7ef85cd45ddd5",
"type": "github"
},
"original": {
+8 -7
View File
@@ -2,11 +2,12 @@
description = "headscale - Open Source Tailscale Control server";
inputs = {
# Pinned to staging-next-26.05 for Go 1.26.4 (security fix GO-2026-5037/5039):
# nixpkgs-unstable still ships 1.26.3 — the bump is merged to nixpkgs staging
# but the large-rebuild staging->unstable pipeline lags. The 26.05 line is
# otherwise current (dev tools match unstable). Switch back to nixpkgs-unstable
# once it ships go_1_26 >= 1.26.4.
# Pinned to staging-next-26.05 for Go 1.26.5: the Tailscale HEAD build
# (Dockerfile.tailscale-HEAD) requires go >= 1.26.5, and nixpkgs-unstable
# still ships 1.26.4 — the bump is merged to nixpkgs staging but the
# large-rebuild staging->unstable pipeline lags. The 26.05 line is otherwise
# current (dev tools match unstable). Switch back to nixpkgs-unstable once it
# ships go_1_26 >= 1.26.5.
nixpkgs.url = "github:NixOS/nixpkgs/staging-next-26.05";
flake-utils.url = "github:numtide/flake-utils";
};
@@ -31,7 +32,7 @@
overlays.default = _: prev:
let
pkgs = nixpkgs.legacyPackages.${prev.stdenv.hostPlatform.system};
# Go 1.26 builder; resolves to Go 1.26.4 from the pinned nixpkgs.
# Go 1.26 builder; resolves to Go 1.26.5 from the pinned nixpkgs.
buildGo = pkgs.buildGo126Module;
vendorHash = (builtins.fromJSON (builtins.readFile ./flakehashes.json)).vendor.sri;
in
@@ -101,7 +102,7 @@
};
# Build golangci-lint with stock Go 1.26 (upstream uses hardcoded Go
# version); it does not build against the pinned 1.26.4.
# version); it does not build against the pinned 1.26.5.
golangci-lint = buildGo rec {
pname = "golangci-lint";
version = "2.12.2";
+1 -1
View File
@@ -1,6 +1,6 @@
{
"vendor": {
"goModSum": "sha256-csVm5v6HZ49PBp/FCX+yK1sjV8/nuUQz3GKN21Ne1mg=",
"goModSum": "sha256-GFiMC2ktrQnpSVzJHabVP1TYwJvn5+a/BDLhvmGTUWc=",
"sri": "sha256-fzKyXNMw/2yAEhaTZu0n1NXatPO2IP0HFA2ey1vZIYM="
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/juanfont/headscale
go 1.26.4
go 1.26.5
require (
github.com/arl/statsviz v0.8.0
+58 -10
View File
@@ -23,6 +23,18 @@ type AuthProvider interface {
AuthURL(authID types.AuthID) string
}
// machineKeyMismatch fails closed when a node looked up by NodeKey was started
// in a Noise session with a different machine key. Without this anyone holding a
// target's NodeKey could open a session with a throwaway machine key and act on
// the owner's node. Returns a 401 [HTTPError] on mismatch, nil otherwise.
func machineKeyMismatch(node types.NodeView, machineKey key.MachinePublic) error {
if node.MachineKey() != machineKey {
return NewHTTPError(http.StatusUnauthorized, "node exists with a different machine key", nil)
}
return nil
}
func (h *Headscale) handleRegister(
ctx context.Context,
req tailcfg.RegisterRequest,
@@ -223,6 +235,20 @@ func (h *Headscale) handleLogout(
Msg("Node is not ephemeral, setting expiry instead of deleting")
}
// Tagged nodes have key expiry permanently disabled (they are owned by
// their tags, not a user, and never expire - KB 1068). Logging one out has
// no expiry semantics, so do not stamp an expiry on it: doing so leaves the
// node IsExpired() forever and it can never re-authenticate (#3371). The
// admin path `headscale nodes expire` remains free to set a deliberate
// expiry via SetNodeExpiry; only the logout path is guarded here.
if node.IsTagged() {
log.Debug().
EmbedObject(node).
Msg("Tagged node logout: not stamping expiry (tagged nodes never expire)")
return nodeToRegisterResponse(node), nil
}
// Update the internal state with the nodes new expiry, meaning it is
// logged out.
//
@@ -291,19 +317,41 @@ func (h *Headscale) waitForFollowup(
}
if reg, ok := h.state.GetAuthCacheEntry(followupReg); ok {
var verdict types.AuthVerdict
select {
case <-ctx.Done():
return nil, NewHTTPError(http.StatusUnauthorized, "registration timed out", err)
case verdict := <-reg.WaitForAuth():
if verdict.Accept() {
if !verdict.Node.Valid() {
// registration is expired in the cache, instruct the client to try a new registration
return h.reqToNewRegisterResponse(req, machineKey)
}
return nodeToRegisterResponse(verdict.Node), nil
// Prefer a completed registration even if the context has also
// expired. When both are ready, a plain select picks at random and
// would discard a successful registration as a spurious timeout
// (issue #3385).
case verdict = <-reg.WaitForAuth():
default:
select {
case <-ctx.Done():
return nil, NewHTTPError(http.StatusUnauthorized, "registration timed out", ctx.Err())
case verdict = <-reg.WaitForAuth():
}
}
if verdict.Accept() {
if !verdict.Node.Valid() {
// registration is expired in the cache, instruct the client to try a new registration
return h.reqToNewRegisterResponse(req, machineKey)
}
// The followup poll is only authenticated by the auth ID in the
// URL, so fail closed unless the Noise session asking for the
// result was started with the same machine key that opened the
// registration. [State.HandleNodeFromAuthPath] resolves the node
// from the cached [types.RegistrationData.MachineKey], so the two
// match on the normal path. [Headscale.handleRegister] and
// [Headscale.handleLogout] apply the same check.
err := machineKeyMismatch(verdict.Node, machineKey)
if err != nil {
return nil, err
}
return nodeToRegisterResponse(verdict.Node), nil
}
}
// if the follow-up registration isn't found anymore, instruct the client to try a new registration
+389
View File
@@ -1338,3 +1338,392 @@ func TestReregistrationZeroExpiryStaysNil(t *testing.T) {
assert.False(t, node2.Expiry().Valid(),
"re-registration with zero client expiry and no default should leave expiry nil, not pointer to zero time")
}
// tsLogoutSentinelExpiry is the past expiry a real tailscale client sends on
// `tailscale logout`: time.Unix(123, 0) (controlclient/direct.go). The issue
// #3371 trace shows it verbatim as `expiry=123`. Using it here (rather than a
// generic time.Now().Add(-1h)) keeps the reproduction faithful to the wire
// behaviour: handleRegister must classify this as a logout, and handleLogout
// must clamp it to now.
func tsLogoutSentinelExpiry() time.Time {
return time.Unix(123, 0)
}
// TestIssue3371_TaggedNodeLogoutReloginSingleUseKey reproduces
// https://github.com/juanfont/headscale/issues/3371 through the real
// register/logout HTTP-handler path (handleRegister -> handleLogout ->
// handleRegister), not by poking SetNodeExpiry directly.
//
// Root cause (a): `tailscale logout` sends a past expiry; handleLogout stamps
// it on the node via SetNodeExpiry with no IsTagged guard, so a tagged node —
// which must have key-expiry disabled — becomes Expired.
//
// Root cause (b): on the next `tailscale up --auth-key <fresh key>`,
// HandleNodeFromPreAuthKey sees an expired node, takes the expired-node
// validation path, consumes the fresh single-use key on the in-place
// re-registration, yet leaves the node expired (the expiry-refresh block is
// gated `!node.IsTagged()`). The response carries NodeKeyExpired=true, so the
// client rotates its node key and retries with the now-spent key, which is
// rejected with "authkey already used" forever.
//
// Faithful to the artifacts: the client rotates its NodeKey on relogin, the
// logout carries BOTH a past expiry AND an auth key, and a BRAND NEW key is
// presented for the relogin (the trace shows tag:tag2 keys burned while the
// node kept tag:tag1).
func TestIssue3371_TaggedNodeLogoutReloginSingleUseKey(t *testing.T) {
app := createTestApp(t)
user := app.state.CreateUserForTest("tag-logout-user")
tags := []string{"tag:tag1"}
// `headscale preauthkeys create --tags tag:tag1` (single-use).
pak, err := app.state.CreatePreAuthKey(user.TypedID(), false, false, nil, tags)
require.NoError(t, err)
machineKey := key.NewMachine()
nodeKey := key.NewNode()
// `tailscale up --auth-key $KEY1`: initial join.
regReq := tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{AuthKey: pak.Key},
NodeKey: nodeKey.Public(),
Hostinfo: &tailcfg.Hostinfo{Hostname: "headscale-debug"},
}
resp, err := app.handleRegister(context.Background(), regReq, machineKey.Public())
require.NoError(t, err)
require.True(t, resp.MachineAuthorized)
require.False(t, resp.NodeKeyExpired)
node, found := app.state.GetNodeByNodeKey(nodeKey.Public())
require.True(t, found)
require.True(t, node.IsTagged(), "precondition: node is tagged")
require.False(t, node.Expiry().Valid(), "precondition: tagged node has expiry disabled")
// `tailscale logout`: client sends a past-expiry register with the auth key
// still attached (handleRegister must treat past expiry as logout regardless
// of Auth). Reuse the same node key: logout does not rotate it.
logoutReq := tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{AuthKey: pak.Key},
NodeKey: nodeKey.Public(),
Expiry: tsLogoutSentinelExpiry(),
}
_, err = app.handleRegister(context.Background(), logoutReq, machineKey.Public())
require.NoError(t, err)
// A tagged node must NOT be expired by logout — tagged nodes never expire.
// This is root cause (a); it fails before the fix.
nodeAfterLogout, found := app.state.GetNodeByNodeKey(nodeKey.Public())
require.True(t, found)
assert.False(t, nodeAfterLogout.IsExpired(),
"issue #3371 root cause (a): logout must not expire a tagged node")
assert.False(t, nodeAfterLogout.Expiry().Valid(),
"issue #3371 root cause (a): tagged node must keep key-expiry disabled after logout")
// `tailscale up --auth-key $KEY2`: a BRAND NEW single-use key, and the client
// rotates its node key (as the real client does on relogin).
pak2, err := app.state.CreatePreAuthKey(user.TypedID(), false, false, nil, tags)
require.NoError(t, err)
nodeKey2 := key.NewNode()
reloginReq := tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{AuthKey: pak2.Key},
NodeKey: nodeKey2.Public(),
Hostinfo: &tailcfg.Hostinfo{Hostname: "headscale-debug"},
}
reloginResp, err := app.handleRegister(context.Background(), reloginReq, machineKey.Public())
require.NoError(t, err,
"issue #3371: a fresh valid key must re-authenticate the tagged node after logout")
require.NotNil(t, reloginResp)
// The whole point: the node comes back online, not stuck expired.
assert.False(t, reloginResp.NodeKeyExpired,
"issue #3371: relogin response must not report the node key as expired")
assert.True(t, reloginResp.MachineAuthorized)
relogged, found := app.state.GetNodeByNodeKey(nodeKey2.Public())
require.True(t, found)
assert.True(t, relogged.IsTagged(), "node stays tagged after relogin")
assert.False(t, relogged.IsExpired(),
"issue #3371: tagged node must be online (not expired) after relogin")
assert.False(t, relogged.Expiry().Valid(),
"issue #3371: tagged node must have key-expiry disabled after relogin")
assert.Equal(t, node.ID(), relogged.ID(), "must re-use the same node, not duplicate")
assert.Equal(t, 1, app.state.ListNodes().Len(), "machine maps to exactly one node")
}
// TestIssue3371_TaggedNodeLogoutReloginReusableKey is the reusable-key variant
// from the issue ("tailscale up then hangs indefinitely instead of erroring").
// With a reusable key the relogin does not hit "authkey already used", but the
// node still stays expired without the fix — so the client never observes a
// non-expired node and hangs. The observable failure here is the persisted
// expired state after relogin.
func TestIssue3371_TaggedNodeLogoutReloginReusableKey(t *testing.T) {
app := createTestApp(t)
user := app.state.CreateUserForTest("tag-logout-reusable")
tags := []string{"tag:tag1"}
// `headscale preauthkeys create --reusable --tags tag:tag1`.
pak, err := app.state.CreatePreAuthKey(user.TypedID(), true, false, nil, tags)
require.NoError(t, err)
machineKey := key.NewMachine()
nodeKey := key.NewNode()
regReq := tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{AuthKey: pak.Key},
NodeKey: nodeKey.Public(),
Hostinfo: &tailcfg.Hostinfo{Hostname: "reusable-tagged"},
}
_, err = app.handleRegister(context.Background(), regReq, machineKey.Public())
require.NoError(t, err)
node, found := app.state.GetNodeByNodeKey(nodeKey.Public())
require.True(t, found)
require.True(t, node.IsTagged())
require.False(t, node.Expiry().Valid())
// Logout.
logoutReq := tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{AuthKey: pak.Key},
NodeKey: nodeKey.Public(),
Expiry: tsLogoutSentinelExpiry(),
}
_, err = app.handleRegister(context.Background(), logoutReq, machineKey.Public())
require.NoError(t, err)
// Relogin with the same reusable key, rotating the node key.
nodeKey2 := key.NewNode()
reloginReq := tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{AuthKey: pak.Key},
NodeKey: nodeKey2.Public(),
Hostinfo: &tailcfg.Hostinfo{Hostname: "reusable-tagged"},
}
reloginResp, err := app.handleRegister(context.Background(), reloginReq, machineKey.Public())
require.NoError(t, err)
require.NotNil(t, reloginResp)
assert.False(t, reloginResp.NodeKeyExpired,
"issue #3371: reusable-key relogin must not report node key expired")
relogged, found := app.state.GetNodeByNodeKey(nodeKey2.Public())
require.True(t, found)
assert.True(t, relogged.IsTagged())
assert.False(t, relogged.IsExpired(),
"issue #3371: tagged node must be online (not expired) after reusable-key relogin")
assert.False(t, relogged.Expiry().Valid(),
"issue #3371: tagged node must have key-expiry disabled after reusable-key relogin")
}
// TestIssue3371_TaggedNodeLogoutDoesNotSetExpiry pins the deepest root cause
// (a) in isolation: `tailscale logout` on a tagged node must not stamp an
// expiry at all. This is the assertion PR #3372 does not make — it leaves
// handleLogout expiring tagged nodes and only unwinds the damage on the next
// registration. Keeping this separate from the relogin tests means a
// regression that re-introduces logout-sets-expiry is caught even if the
// re-registration cleanup masks it.
func TestIssue3371_TaggedNodeLogoutDoesNotSetExpiry(t *testing.T) {
app := createTestApp(t)
user := app.state.CreateUserForTest("tag-logout-noexpiry")
tags := []string{"tag:tag1"}
pak, err := app.state.CreatePreAuthKey(user.TypedID(), true, false, nil, tags)
require.NoError(t, err)
machineKey := key.NewMachine()
nodeKey := key.NewNode()
regReq := tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{AuthKey: pak.Key},
NodeKey: nodeKey.Public(),
Hostinfo: &tailcfg.Hostinfo{Hostname: "noexpiry-tagged"},
}
_, err = app.handleRegister(context.Background(), regReq, machineKey.Public())
require.NoError(t, err)
logoutReq := tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{AuthKey: pak.Key},
NodeKey: nodeKey.Public(),
Expiry: tsLogoutSentinelExpiry(),
}
_, err = app.handleRegister(context.Background(), logoutReq, machineKey.Public())
require.NoError(t, err)
nodeAfterLogout, found := app.state.GetNodeByNodeKey(nodeKey.Public())
require.True(t, found)
assert.True(t, nodeAfterLogout.IsTagged(), "node stays tagged through logout")
assert.False(t, nodeAfterLogout.Expiry().Valid(),
"issue #3371 root cause (a): logout must not set an expiry on a tagged node")
assert.False(t, nodeAfterLogout.IsExpired(),
"issue #3371 root cause (a): tagged node must not be expired by logout")
// The database column must be NULL, not a clamped 'now' timestamp — a
// persisted expiry survives restart and re-triggers the lockout.
var dbNode types.Node
require.NoError(t,
app.state.DB().DB.First(&dbNode, nodeAfterLogout.ID().Uint64()).Error)
assert.Nil(t, dbNode.Expiry,
"issue #3371 root cause (a): tagged node's DB expiry must remain NULL after logout")
}
// TestIssue3371_UserOwnedNodeLogoutStillExpires is the guard rail: the fix for
// tagged nodes must not change logout for ordinary user-owned nodes. A
// user-owned node that logs out MUST still be expired (that is what logout
// means for it).
func TestIssue3371_UserOwnedNodeLogoutStillExpires(t *testing.T) {
app := createTestApp(t)
user := app.state.CreateUserForTest("user-logout")
pak, err := app.state.CreatePreAuthKey(user.TypedID(), true, false, nil, nil)
require.NoError(t, err)
machineKey := key.NewMachine()
nodeKey := key.NewNode()
regReq := tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{AuthKey: pak.Key},
NodeKey: nodeKey.Public(),
Hostinfo: &tailcfg.Hostinfo{Hostname: "user-node"},
Expiry: time.Now().Add(24 * time.Hour),
}
_, err = app.handleRegister(context.Background(), regReq, machineKey.Public())
require.NoError(t, err)
node, found := app.state.GetNodeByNodeKey(nodeKey.Public())
require.True(t, found)
require.False(t, node.IsTagged(), "precondition: user-owned node")
logoutReq := tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{AuthKey: pak.Key},
NodeKey: nodeKey.Public(),
Expiry: tsLogoutSentinelExpiry(),
}
logoutResp, err := app.handleRegister(context.Background(), logoutReq, machineKey.Public())
require.NoError(t, err)
require.NotNil(t, logoutResp)
nodeAfterLogout, found := app.state.GetNodeByNodeKey(nodeKey.Public())
require.True(t, found)
assert.True(t, nodeAfterLogout.IsExpired(),
"user-owned node must still be expired by logout (fix must not regress this)")
assert.True(t, logoutResp.NodeKeyExpired,
"logout response for a user-owned node must report the key expired")
}
// TestIssue3371_TaggedNodeFutureExpirySurvivesRelogin is the discriminator
// guard rail for the fix. A tagged node may carry a DELIBERATE future expiry
// set by an admin (`headscale nodes expire`); TestTaggedNodeCanHaveKeyExpiry
// establishes that is legal. The #3371 fix clears only a STALE PAST expiry (the
// logout stamp) on re-registration — it must NOT wipe a future expiry. This
// test locks that boundary: without care, a "tagged => clear expiry" fix would
// silently destroy the admin's setting.
//
// Passes before the fix (re-registration currently never touches a tagged
// node's expiry) and must keep passing after.
func TestIssue3371_TaggedNodeFutureExpirySurvivesRelogin(t *testing.T) {
app := createTestApp(t)
user := app.state.CreateUserForTest("tag-future-expiry")
tags := []string{"tag:tag1"}
pak, err := app.state.CreatePreAuthKey(user.TypedID(), true, false, nil, tags)
require.NoError(t, err)
machineKey := key.NewMachine()
nodeKey := key.NewNode()
regReq := tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{AuthKey: pak.Key},
NodeKey: nodeKey.Public(),
Hostinfo: &tailcfg.Hostinfo{Hostname: "future-expiry-tagged"},
}
_, err = app.handleRegister(context.Background(), regReq, machineKey.Public())
require.NoError(t, err)
node, found := app.state.GetNodeByNodeKey(nodeKey.Public())
require.True(t, found)
require.True(t, node.IsTagged())
// Admin sets a deliberate future expiry (`headscale nodes expire`).
future := time.Now().Add(24 * time.Hour)
_, _, err = app.state.SetNodeExpiry(node.ID(), &future)
require.NoError(t, err)
withFuture, found := app.state.GetNodeByNodeKey(nodeKey.Public())
require.True(t, found)
require.True(t, withFuture.Expiry().Valid(), "precondition: future expiry set")
require.False(t, withFuture.IsExpired(), "precondition: future expiry is not expired")
// Node re-registers (rotating its node key). The future expiry must survive.
nodeKey2 := key.NewNode()
reregReq := tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{AuthKey: pak.Key},
NodeKey: nodeKey2.Public(),
Hostinfo: &tailcfg.Hostinfo{Hostname: "future-expiry-tagged"},
}
_, err = app.handleRegister(context.Background(), reregReq, machineKey.Public())
require.NoError(t, err)
after, found := app.state.GetNodeByNodeKey(nodeKey2.Public())
require.True(t, found)
require.True(t, after.IsTagged(), "node stays tagged")
assert.True(t, after.Expiry().Valid(),
"deliberate future expiry must survive re-registration (not cleared by #3371 fix)")
assert.WithinDuration(t, future, after.Expiry().Get(), 5*time.Second,
"the surviving expiry must be the admin-set future value, unchanged")
}
// TestIssue3371_EphemeralTaggedNodeLogoutDeletes is a regression guard for the
// ephemeral+tagged combination. A tagged pre-auth key can also be ephemeral.
// handleLogout deletes ephemeral nodes (before any expiry stamp), so the #3371
// fix (which suppresses the expiry stamp for tagged nodes) must not divert an
// ephemeral tagged node away from deletion.
//
// Passes before the fix and must keep passing after.
func TestIssue3371_EphemeralTaggedNodeLogoutDeletes(t *testing.T) {
app := createTestApp(t)
user := app.state.CreateUserForTest("tag-ephemeral")
tags := []string{"tag:tag1"}
// Ephemeral + tagged key.
pak, err := app.state.CreatePreAuthKey(user.TypedID(), true, true, nil, tags)
require.NoError(t, err)
machineKey := key.NewMachine()
nodeKey := key.NewNode()
regReq := tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{AuthKey: pak.Key},
NodeKey: nodeKey.Public(),
Hostinfo: &tailcfg.Hostinfo{Hostname: "ephemeral-tagged"},
}
_, err = app.handleRegister(context.Background(), regReq, machineKey.Public())
require.NoError(t, err)
node, found := app.state.GetNodeByNodeKey(nodeKey.Public())
require.True(t, found)
require.True(t, node.IsTagged(), "precondition: node is tagged")
require.True(t, node.IsEphemeral(), "precondition: node is ephemeral")
// Logout: an ephemeral node is deleted, not expired.
logoutReq := tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{AuthKey: pak.Key},
NodeKey: nodeKey.Public(),
Expiry: tsLogoutSentinelExpiry(),
}
_, err = app.handleRegister(context.Background(), logoutReq, machineKey.Public())
require.NoError(t, err)
require.EventuallyWithT(t, func(c *assert.CollectT) {
_, stillThere := app.state.GetNodeByNodeKey(nodeKey.Public())
assert.False(c, stillThere,
"ephemeral tagged node must be deleted on logout, not expired")
}, 2*time.Second, 50*time.Millisecond, "waiting for ephemeral node deletion")
}
+153
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"testing"
@@ -692,6 +693,11 @@ func TestAuthenticationFlows(t *testing.T) {
user := app.state.CreateUserForTest("followup-user")
node := app.state.CreateNodeForTest(user, "followup-success-node")
// [State.HandleNodeFromAuthPath] resolves the node from the
// machine key cached when the registration was opened, so on
// the real path the node carries the polling session's
// machine key. CreateNodeForTest picks a random one.
node.MachineKey = machineKey1.Public()
nodeToRegister.FinishAuth(types.AuthVerdict{Node: node.View()})
}()
@@ -4101,3 +4107,150 @@ func TestHandleNodeFromAuthPath_OldUserNil_NoPanic(t *testing.T) {
assert.NotEqual(t, types.NodeID(99002), node.ID(), "new node, not orphan")
assert.Equal(t, userB.ID, node.UserID().Get(), "new node belongs to userB")
}
// TestWaitForFollowupMachineKeyMismatch covers the followup poll in
// [Headscale.waitForFollowup]. That poll is authenticated only by the auth ID
// embedded in the followup URL, so without a machine-key check anyone who
// learns an ID gets the registering user's User/Login back in the
// [tailcfg.RegisterResponse].
//
// [Headscale.handleRegister] and [Headscale.handleLogout] already fail closed
// here; see the "existing_node_machine_key_mismatch" case in
// [TestAuthenticationFlows] for the equivalent assertion on that path.
//
// The nodes are given the registering session's machine key because that is
// what production produces: [State.HandleNodeFromAuthPath] resolves the node
// from the machine key cached in [types.RegistrationData] when the
// registration was opened.
func TestWaitForFollowupMachineKeyMismatch(t *testing.T) {
app := createTestApp(t)
victimMachineKey := key.NewMachine()
attackerMachineKey := key.NewMachine()
// Park a completed registration in the auth cache, as a node that is
// already polling for its verdict would see it.
newPendingFollowup := func(hostname string) string {
authID := types.MustAuthID()
regEntry := types.NewRegisterAuthRequest(&types.RegistrationData{
MachineKey: victimMachineKey.Public(),
NodeKey: key.NewNode().Public(),
Hostname: hostname,
})
app.state.SetAuthCacheEntry(authID, regEntry)
user := app.state.CreateUserForTest(hostname + "-user")
node := app.state.CreateNodeForTest(user, hostname)
node.MachineKey = victimMachineKey.Public()
// CreateNodeForTest only sets UserID, but nodeToRegisterResponse reads
// the owner, and the owner's identity is exactly what must not leak.
node.User = user
regEntry.FinishAuth(types.AuthVerdict{Node: node.View()})
return fmt.Sprintf("http://localhost:8080/register/%s", authID)
}
followup := func(url string, machineKey key.MachinePublic) (*tailcfg.RegisterResponse, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return app.handleRegister(ctx, tailcfg.RegisterRequest{
Followup: url,
NodeKey: key.NewNode().Public(),
}, machineKey)
}
t.Run("mismatched machine key is rejected", func(t *testing.T) {
resp, err := followup(newPendingFollowup("followup-mismatch"), attackerMachineKey.Public())
require.Error(t, err, "followup with a foreign machine key must not succeed")
assert.Nil(t, resp, "no registration details should be returned")
var httpErr HTTPError
require.ErrorAs(t, err, &httpErr)
assert.Equal(t, http.StatusUnauthorized, httpErr.Code)
})
// Positive control. Without it a regression that stops the poll from
// finding the cache entry at all would still pass the case above, because
// waitForFollowup falls back to handing out a fresh AuthURL.
t.Run("matching machine key still completes", func(t *testing.T) {
resp, err := followup(newPendingFollowup("followup-match"), victimMachineKey.Public())
require.NoError(t, err)
require.NotNil(t, resp)
assert.True(t, resp.MachineAuthorized)
assert.NotEmpty(t, resp.User.DisplayName, "the owner's identity is returned on the legitimate path")
})
}
// TestFollowupWaitPrefersCompletedAuthOverExpiredContext reproduces
// https://github.com/juanfont/headscale/issues/3385.
//
// Root cause: [Headscale.waitForFollowup] selects on ctx.Done() and the auth
// verdict channel with equal priority. When the registration has ALREADY
// completed (verdict buffered) but the request context has ALSO expired, Go's
// select picks a ready case at random, so roughly half the time it returns
// "registration timed out" and discards a successful registration.
//
// The v0.28.0 hscontrol test suite hit this because the followup context
// timeout was only 100ms while the setup goroutine (create user + node in
// SQLite) frequently took longer on slower/constrained builders (ppc64le,
// Alpine CI). Both channels ended up ready at once and the flake surfaced as
// TestAuthenticationFlows/followup_registration_success failing with
// "http error[401]: registration timed out".
//
// The fix must give the completed-auth case priority over context
// cancellation. This test forces both cases ready on every iteration; it must
// never report a timeout.
func TestFollowupWaitPrefersCompletedAuthOverExpiredContext(t *testing.T) {
app := createTestApp(t)
machineKey := key.NewMachine().Public()
nodeKey := key.NewNode().Public()
const iterations = 300
timeouts, authorized := 0, 0
for i := range iterations {
regID, err := types.NewAuthID()
require.NoError(t, err)
authReq := types.NewRegisterAuthRequest(&types.RegistrationData{
Hostname: "followup-race-node",
})
app.state.SetAuthCacheEntry(regID, authReq)
// Registration completes BEFORE we wait: verdict is buffered.
user := app.state.CreateUserForTest(fmt.Sprintf("followup-race-user-%d", i))
node := app.state.CreateNodeForTest(user, "followup-race-node")
// waitForFollowup fails closed unless the node carries the polling
// session's machine key; production sets this via the cached
// RegistrationData. CreateNodeForTest picks a random one.
node.MachineKey = machineKey
authReq.FinishAuth(types.AuthVerdict{Node: node.View()})
// Context is expired BEFORE we wait: both select cases are ready.
ctx, cancel := context.WithCancel(context.Background())
cancel()
req := tailcfg.RegisterRequest{
Followup: fmt.Sprintf("http://localhost:8080/register/%s", regID),
NodeKey: nodeKey,
}
resp, err := app.waitForFollowup(ctx, req, machineKey)
switch {
case err != nil:
timeouts++
case resp != nil && resp.MachineAuthorized:
authorized++
}
}
assert.Zero(t, timeouts,
"waitForFollowup must never report a timeout when auth has already completed; got %d/%d timeouts",
timeouts, iterations)
assert.Equal(t, iterations, authorized, "every completed registration must be returned as authorized")
}
+27
View File
@@ -781,6 +781,33 @@ WHERE user_id IS NULL
},
Rollback: func(db *gorm.DB) error { return nil },
},
{
// Clear stale key expiry on tagged nodes. A tagged node is
// owned by its tags and never expires (KB 1068), but a buggy
// handleLogout stamped a past expiry on it, leaving it
// permanently Expired and unable to re-authenticate. The
// buggy writer is fixed, so this only repairs rows written
// before the upgrade; a fixed server cannot recreate them.
// Match the tagged-node predicate the earlier
// clear-tagged-node-user-id migration uses (a nil tags slice
// marshals to 'null', so exclude it).
// Fixes: https://github.com/juanfont/headscale/issues/3371
ID: "202607241200-clear-tagged-node-expiry",
Migrate: func(tx *gorm.DB) error {
err := tx.Exec(`
UPDATE nodes
SET expiry = NULL
WHERE tags IS NOT NULL AND tags != '[]' AND tags != '' AND tags != 'null'
AND expiry IS NOT NULL;
`).Error
if err != nil {
return fmt.Errorf("clearing expiry on tagged nodes: %w", err)
}
return nil
},
Rollback: func(db *gorm.DB) error { return nil },
},
},
)
+58
View File
@@ -295,6 +295,64 @@ func TestSQLiteMigrationAndDataValidation(t *testing.T) {
assert.Equal(t, uint(1), *node4.UserID, "node4 should still belong to user1")
},
},
// Test for the clear-tagged-node-expiry migration
// (202607241200-clear-tagged-node-expiry). A buggy handleLogout stamped
// a key expiry on tagged nodes, which never expire (KB 1068), leaving
// them permanently Expired. The migration clears expiry on tagged rows
// only, preserving user-owned nodes' expiry.
// Fixes: https://github.com/juanfont/headscale/issues/3371
{
dbPath: "testdata/sqlite/clear_tagged_node_expiry_migration_test.sql",
wantFunc: func(t *testing.T, hsdb *HSDatabase) {
t.Helper()
nodes, err := Read(hsdb.DB, func(rx *gorm.DB) (types.Nodes, error) {
return ListNodes(rx)
})
require.NoError(t, err)
require.Len(t, nodes, 5, "should have all 5 nodes")
byHostname := make(map[string]*types.Node, len(nodes))
for _, n := range nodes {
byHostname[n.Hostname] = n
}
// Node 1: tagged with a stale PAST expiry (the bug). Cleared.
node1 := byHostname["node1"]
require.NotNil(t, node1, "node1 should exist")
assert.True(t, node1.IsTagged(), "node1 should be tagged")
assert.Nil(t, node1.Expiry, "node1 (tagged) stale expiry should be cleared")
assert.False(t, node1.IsExpired(), "node1 must not be reported expired")
// Node 2: tagged with a FUTURE expiry. Tagged nodes never expire,
// so this is cleared too.
node2 := byHostname["node2"]
require.NotNil(t, node2, "node2 should exist")
assert.True(t, node2.IsTagged(), "node2 should be tagged")
assert.Nil(t, node2.Expiry, "node2 (tagged) expiry should be cleared")
// Node 3: tagged, expiry already NULL. Stays NULL.
node3 := byHostname["node3"]
require.NotNil(t, node3, "node3 should exist")
assert.True(t, node3.IsTagged(), "node3 should be tagged")
assert.Nil(t, node3.Expiry, "node3 (tagged) NULL expiry should be preserved")
// Node 4: untagged (tags='null') with a PAST expiry. PRESERVED —
// the migration must not touch user-owned nodes.
node4 := byHostname["node4"]
require.NotNil(t, node4, "node4 should exist")
assert.False(t, node4.IsTagged(), "node4 (tags='null') should be untagged")
require.NotNil(t, node4.Expiry, "node4 (user-owned) expiry must be preserved")
assert.Equal(t, 2020, node4.Expiry.UTC().Year(), "node4 past expiry preserved")
// Node 5: untagged (tags='[]') with a FUTURE expiry. PRESERVED.
node5 := byHostname["node5"]
require.NotNil(t, node5, "node5 should exist")
assert.False(t, node5.IsTagged(), "node5 (tags='[]') should be untagged")
require.NotNil(t, node5.Expiry, "node5 (user-owned) expiry must be preserved")
assert.Equal(t, 2099, node5.Expiry.UTC().Year(), "node5 future expiry preserved")
},
},
}
for _, tt := range tests {
+10
View File
@@ -496,6 +496,16 @@ func (e *EphemeralGarbageCollector) Cancel(nodeID types.NodeID) {
}
}
// IsScheduled reports whether a deletion timer is currently armed for nodeID.
func (e *EphemeralGarbageCollector) IsScheduled(nodeID types.NodeID) bool {
e.mu.Lock()
defer e.mu.Unlock()
_, ok := e.toBeDeleted[nodeID]
return ok
}
// Start starts the garbage collector.
func (e *EphemeralGarbageCollector) Start() {
for {
@@ -0,0 +1,85 @@
-- Test SQL dump for the clear-tagged-node-expiry migration
-- (202607241200-clear-tagged-node-expiry)
--
-- A buggy handleLogout stamped a past key expiry on tagged nodes. Tagged
-- nodes are owned by their tags and never expire (KB 1068), so such a row is
-- reported as permanently Expired and can never re-authenticate. The migration
-- clears expiry on tagged rows while leaving user-owned nodes untouched.
-- Fixes: https://github.com/juanfont/headscale/issues/3371
PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION;
-- Migrations table: entries applied up to (but not including) the fix. The
-- intervening expiry migrations (clear-zero-time) also run against this dump;
-- their predicates (expiry < 1900) do not match the post-2000 dates below, so
-- they leave these rows for the new migration to handle.
CREATE TABLE `migrations` (`id` text,PRIMARY KEY (`id`));
INSERT INTO migrations VALUES('202312101416');
INSERT INTO migrations VALUES('202312101430');
INSERT INTO migrations VALUES('202402151347');
INSERT INTO migrations VALUES('2024041121742');
INSERT INTO migrations VALUES('202406021630');
INSERT INTO migrations VALUES('202409271400');
INSERT INTO migrations VALUES('202407191627');
INSERT INTO migrations VALUES('202408181235');
INSERT INTO migrations VALUES('202501221827');
INSERT INTO migrations VALUES('202501311657');
INSERT INTO migrations VALUES('202502070949');
INSERT INTO migrations VALUES('202502131714');
INSERT INTO migrations VALUES('202502171819');
INSERT INTO migrations VALUES('202505091439');
INSERT INTO migrations VALUES('202505141324');
INSERT INTO migrations VALUES('202507021200');
INSERT INTO migrations VALUES('202510311551');
INSERT INTO migrations VALUES('202511101554-drop-old-idx');
INSERT INTO migrations VALUES('202511011637-preauthkey-bcrypt');
INSERT INTO migrations VALUES('202511122344-remove-newline-index');
INSERT INTO migrations VALUES('202511131445-node-forced-tags-to-tags');
INSERT INTO migrations VALUES('202601121700-migrate-hostinfo-request-tags');
INSERT INTO migrations VALUES('202602201200-clear-tagged-node-user-id');
-- Users table
CREATE TABLE `users` (`id` integer PRIMARY KEY AUTOINCREMENT,`created_at` datetime,`updated_at` datetime,`deleted_at` datetime,`name` text,`display_name` text,`email` text,`provider_identifier` text,`provider` text,`profile_pic_url` text);
INSERT INTO users VALUES(1,'2024-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00',NULL,'user1','User One','user1@example.com',NULL,NULL,NULL);
-- Pre-auth keys table
CREATE TABLE `pre_auth_keys` (`id` integer PRIMARY KEY AUTOINCREMENT,`key` text,`user_id` integer,`reusable` numeric,`ephemeral` numeric DEFAULT false,`used` numeric DEFAULT false,`tags` text,`created_at` datetime,`expiration` datetime,`prefix` text,`hash` blob,CONSTRAINT `fk_pre_auth_keys_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE SET NULL);
-- API keys table
CREATE TABLE `api_keys` (`id` integer PRIMARY KEY AUTOINCREMENT,`prefix` text,`hash` blob,`created_at` datetime,`expiration` datetime,`last_seen` datetime);
-- Nodes table - current schema (after the tags rename + last_seen/expiry reordering)
CREATE TABLE IF NOT EXISTS "nodes" (`id` integer PRIMARY KEY AUTOINCREMENT,`machine_key` text,`node_key` text,`disco_key` text,`endpoints` text,`host_info` text,`ipv4` text,`ipv6` text,`hostname` text,`given_name` varchar(63),`user_id` integer,`register_method` text,`tags` text,`auth_key_id` integer,`last_seen` datetime,`expiry` datetime,`approved_routes` text,`created_at` datetime,`updated_at` datetime,`deleted_at` datetime,CONSTRAINT `fk_nodes_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE,CONSTRAINT `fk_nodes_auth_key` FOREIGN KEY (`auth_key_id`) REFERENCES `pre_auth_keys`(`id`));
-- Node 1: TAGGED, user_id NULL, stale PAST expiry (the #3371 bug). After migration: expiry NULL.
INSERT INTO nodes VALUES(1,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e01','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605501','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57701','[]','{}','100.64.0.1','fd7a:115c:a1e0::1','node1','node1',NULL,'authkey','["tag:foo"]',NULL,'2024-01-01 00:00:00+00:00','2020-06-01 00:00:00+00:00','[]','2024-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00',NULL);
-- Node 2: TAGGED, user_id NULL, FUTURE expiry. Tagged nodes never expire, so cleared to NULL too.
INSERT INTO nodes VALUES(2,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e02','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605502','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57702','[]','{}','100.64.0.2','fd7a:115c:a1e0::2','node2','node2',NULL,'authkey','["tag:foo"]',NULL,'2024-01-01 00:00:00+00:00','2099-01-01 00:00:00+00:00','[]','2024-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00',NULL);
-- Node 3: TAGGED, user_id NULL, expiry already NULL. After migration: still NULL.
INSERT INTO nodes VALUES(3,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e03','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605503','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57703','[]','{}','100.64.0.3','fd7a:115c:a1e0::3','node3','node3',NULL,'authkey','["tag:foo"]',NULL,'2024-01-01 00:00:00+00:00',NULL,'[]','2024-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00',NULL);
-- Node 4: UNTAGGED user-owned (tags='null'), PAST expiry. Must be PRESERVED (guard against
-- the #3323-class over-match: a nil tags slice marshals to the literal 'null').
INSERT INTO nodes VALUES(4,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e04','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605504','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57704','[]','{}','100.64.0.4','fd7a:115c:a1e0::4','node4','node4',1,'cli','null',NULL,'2024-01-01 00:00:00+00:00','2020-06-01 00:00:00+00:00','[]','2024-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00',NULL);
-- Node 5: UNTAGGED user-owned (tags='[]'), FUTURE expiry. Must be PRESERVED.
INSERT INTO nodes VALUES(5,'mkey:a0ab77456320823945ae0331823e3c0d516fae9585bd42698dfa1ac3d7679e05','nodekey:7c84167ab68f494942de14deb83587fd841843de2bac105b6c670048c1605505','discokey:53075b3c6cad3b62a2a29caea61beeb93f66b8c75cb89dac465236a5bbf57705','[]','{}','100.64.0.5','fd7a:115c:a1e0::5','node5','node5',1,'cli','[]',NULL,'2024-01-01 00:00:00+00:00','2099-01-01 00:00:00+00:00','[]','2024-01-01 00:00:00+00:00','2024-01-01 00:00:00+00:00',NULL);
-- Policies table (empty)
CREATE TABLE `policies` (`id` integer PRIMARY KEY AUTOINCREMENT,`created_at` datetime,`updated_at` datetime,`deleted_at` datetime,`data` text);
DELETE FROM sqlite_sequence;
INSERT INTO sqlite_sequence VALUES('users',1);
INSERT INTO sqlite_sequence VALUES('nodes',5);
CREATE INDEX idx_users_deleted_at ON users(deleted_at);
CREATE UNIQUE INDEX idx_api_keys_prefix ON api_keys(prefix);
CREATE INDEX idx_policies_deleted_at ON policies(deleted_at);
CREATE UNIQUE INDEX idx_provider_identifier ON users(provider_identifier) WHERE provider_identifier IS NOT NULL;
CREATE UNIQUE INDEX idx_name_provider_identifier ON users(name, provider_identifier);
CREATE UNIQUE INDEX idx_name_no_provider_identifier ON users(name) WHERE provider_identifier IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_pre_auth_keys_prefix ON pre_auth_keys(prefix) WHERE prefix IS NOT NULL AND prefix != '';
COMMIT;
+20 -24
View File
@@ -19,17 +19,6 @@ import (
)
const (
// NoiseCapabilityVersion is used by Tailscale clients to indicate
// their codebase version. Tailscale clients can communicate over TS2021
// from CapabilityVersion 28, but we only have good support for it
// since https://github.com/tailscale/tailscale/pull/4323 (Noise in any HTTPS port).
//
// Related to this change, there is https://github.com/tailscale/tailscale/pull/5379,
// where CapabilityVersion 39 is introduced to indicate #4323 was merged.
//
// See also https://github.com/tailscale/tailscale/blob/main/tailcfg/tailcfg.go
NoiseCapabilityVersion = 39
reservedResponseHeaderSize = 4
)
@@ -202,21 +191,28 @@ func (h *Headscale) KeyHandler(
return
}
// TS2021 (Tailscale v2 protocol) requires to have a different key
if capVer >= NoiseCapabilityVersion {
resp := tailcfg.OverTLSPublicKeyResponse{
PublicKey: h.noisePrivateKey.Public(),
}
writer.Header().Set("Content-Type", "application/json")
err := json.NewEncoder(writer).Encode(resp)
if err != nil {
log.Error().Err(err).Msg("failed to encode public key response")
}
// Only disclose the Noise public key to clients this server can
// actually complete a handshake with. Gating on the same floor the
// Noise handshake enforces (capver.MinSupportedCapabilityVersion, see
// isSupportedVersion in noise.go) keeps /key consistent with /ts2021:
// versions the handshake would reject get a clear rejection here
// instead of a key that only serves as a version-boundary oracle.
// See https://github.com/juanfont/headscale/issues/3380.
if !isSupportedVersion(capVer) {
httpError(writer, NewHTTPError(http.StatusBadRequest, "unsupported client version", unsupportedClientError(capVer)))
return
}
resp := tailcfg.OverTLSPublicKeyResponse{
PublicKey: h.noisePrivateKey.Public(),
}
writer.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(writer).Encode(resp)
if err != nil {
log.Error().Err(err).Msg("failed to encode public key response")
}
}
func (h *Headscale) HealthHandler(
+49
View File
@@ -4,12 +4,15 @@ import (
"bytes"
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/juanfont/headscale/hscontrol/capver"
"github.com/stretchr/testify/assert"
"tailscale.com/types/key"
)
var errTestUnexpected = errors.New("unexpected failure")
@@ -47,6 +50,52 @@ func TestHandleVerifyRequest_OversizedBodyRejected(t *testing.T) {
"oversized body must surface 413")
}
// TestKeyHandler_UnsupportedCapVerDoesNotLeakKey reproduces
// https://github.com/juanfont/headscale/issues/3380. The /key handler
// must gate key disclosure on the same floor the Noise handshake
// enforces (capver.MinSupportedCapabilityVersion). A capability version
// below that floor can never complete a handshake, so it must be
// rejected rather than handed the server's Noise public key, which would
// otherwise serve only as a fingerprint / version-boundary oracle.
func TestKeyHandler_UnsupportedCapVerDoesNotLeakKey(t *testing.T) {
t.Parallel()
noise := key.NewMachine()
h := &Headscale{noisePrivateKey: &noise}
unsupported := capver.MinSupportedCapabilityVersion - 1
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodGet,
fmt.Sprintf("/key?v=%d", unsupported),
nil,
)
h.KeyHandler(rec, req)
assert.Equal(t, http.StatusBadRequest, rec.Code,
"a client below the supported floor must be rejected")
assert.NotContains(t, rec.Body.String(), noise.Public().String(),
"must not disclose Noise public key to a client below the supported floor")
// A supported client still receives the key.
recOK := httptest.NewRecorder()
reqOK := httptest.NewRequestWithContext(
context.Background(),
http.MethodGet,
fmt.Sprintf("/key?v=%d", capver.MinSupportedCapabilityVersion),
nil,
)
h.KeyHandler(recOK, reqOK)
assert.Equal(t, http.StatusOK, recOK.Code)
assert.Contains(t, recOK.Body.String(), noise.Public().String(),
"a supported client must receive the Noise public key")
}
// errorAsHTTPError is a small local helper that unwraps an [HTTPError]
// from an error chain.
func errorAsHTTPError(err error) (HTTPError, bool) {
+6
View File
@@ -30,6 +30,12 @@ type PolicyManager interface {
// NodeCanHaveTag reports whether the given node can have the given tag.
NodeCanHaveTag(node types.NodeView, tag string) bool
// UserCanHaveTag reports whether the given user owns the given tag, i.e.
// is listed (directly or via a group) in the tag's tagOwners. This is the
// user half of NodeCanHaveTag, used to authorise re-auth tag changes
// against the authenticating user rather than the node's stale ownership.
UserCanHaveTag(user types.UserView, tag string) bool
// TagExists reports whether the given tag is defined in the policy.
TagExists(tag string) bool
+31
View File
@@ -964,6 +964,37 @@ func (pm *PolicyManager) NodeCanHaveTag(node types.NodeView, tag string) bool {
return false
}
// UserCanHaveTag reports whether the given user is one of the tag's owners
// (directly or via a group). It is the user half of [PolicyManager.NodeCanHaveTag]:
// re-authentication authorises requested tags against the authenticating user,
// because a tag-owned node carries no user and its IP is not in any owner set,
// so only the user presenting the credential can prove ownership.
func (pm *PolicyManager) UserCanHaveTag(user types.UserView, tag string) bool {
if pm == nil || !user.Valid() {
return false
}
pm.mu.RLock()
defer pm.mu.RUnlock()
if pm.pol == nil {
return false
}
owners, exists := pm.pol.TagOwners[Tag(tag)]
if !exists {
return false
}
for _, owner := range owners {
if pm.userMatchesOwner(user, owner) {
return true
}
}
return false
}
// userMatchesOwner checks if a user matches a tag owner entry.
// This is used as a fallback when the node's IP is not in the [PolicyManager.tagOwnerMap].
func (pm *PolicyManager) userMatchesOwner(user types.UserView, owner Owner) bool {
+7 -8
View File
@@ -96,12 +96,6 @@ func (m *mapSession) stopFromBatcher() {
}
}
func (m *mapSession) beforeServeLongPoll() {
if m.node.IsEphemeral() {
m.h.ephemeralGC.Cancel(m.node.ID)
}
}
// afterServeLongPoll is called when a long-polling session ends and the node
// is disconnected.
func (m *mapSession) afterServeLongPoll() {
@@ -144,8 +138,6 @@ func (m *mapSession) serve() {
//
//nolint:gocyclo
func (m *mapSession) serveLongPoll() {
m.beforeServeLongPoll()
m.log.Trace().Caller().Msg("long poll session started")
// connectGen is set by [state.State.Connect] below and captured by the deferred cleanup closure.
@@ -248,6 +240,13 @@ func (m *mapSession) serveLongPoll() {
connectChanges, connectGen = m.h.state.Connect(m.node.ID)
// Cancel ephemeral GC only after Connect succeeds. Cancelling at the start
// of serveLongPoll left departed nodes without a deletion timer when a
// reconnect attempt failed before Connect (issue #3382).
if m.node.IsEphemeral() {
m.h.ephemeralGC.Cancel(m.node.ID)
}
m.log.Info().Caller().Str(zf.Chan, fmt.Sprintf("%p", m.ch)).Msg("node has connected")
// TODO(kradalby): Redo the comments here
+62
View File
@@ -15,6 +15,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
)
type delayedSuccessResponseWriter struct {
@@ -216,6 +217,67 @@ func TestServeLongPollWritesErrorWhenInitialMapFails(t *testing.T) {
"serveLongPoll must write an HTTP error response when the initial map cannot be built, not an empty 200")
}
// TestFailedReconnectDoesNotCancelEphemeralGC proves that a
// long-poll reconnect attempt which fails before [state.State.Connect] must
// not cancel a previously armed ephemeral GC timer. Cancelling at the start of
// [mapSession.serveLongPoll] left departed ephemeral nodes stuck offline with
// no deletion scheduled (https://github.com/juanfont/headscale/issues/3382).
func TestFailedReconnectDoesNotCancelEphemeralGC(t *testing.T) {
t.Parallel()
app := createTestApp(t)
app.StartEphemeralGCForTest(t)
user := app.state.CreateUserForTest("eph-gc-cancel-user")
pak, err := app.state.CreatePreAuthKey(user.TypedID(), false, true, nil, nil)
require.NoError(t, err)
machineKey := key.NewMachine()
nodeKey := key.NewNode()
_, err = app.handleRegister(context.Background(), tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{
AuthKey: pak.Key,
},
NodeKey: nodeKey.Public(),
Hostinfo: &tailcfg.Hostinfo{
Hostname: "eph-gc-cancel-node",
},
Expiry: time.Now().Add(24 * time.Hour),
}, machineKey.Public())
require.NoError(t, err)
nodeView, ok := app.state.GetNodeByNodeKey(nodeKey.Public())
require.True(t, ok)
require.True(t, nodeView.IsEphemeral(), "node must be ephemeral so Cancel would arm on long-poll")
node := nodeView.AsStruct()
// Arm a long-lived deletion timer — the state after a normal disconnect
// has called afterServeLongPoll. A long expiry avoids racing the
// fail-before-Connect path below.
app.ephemeralGC.Schedule(node.ID, time.Hour)
require.True(t, app.ephemeralGC.IsScheduled(node.ID), "test sanity: GC timer must be armed")
// Drop the node from the NodeStore so UpdateNodeFromMapRequest fails before
// Connect, while the session still carries an ephemeral AuthKey (so the
// old Cancel-on-entry path would clear the timer).
app.state.DeleteNodeFromStoreForTest(node.ID)
writer := &recordingResponseWriter{}
session := app.newMapSession(context.Background(), tailcfg.MapRequest{
Stream: true,
Version: tailcfg.CapabilityVersion(100),
}, writer, node)
session.serveLongPoll()
assert.GreaterOrEqual(t, writer.statusCode(), http.StatusInternalServerError,
"failed reconnect must write an HTTP error before Connect")
assert.True(t, app.ephemeralGC.IsScheduled(node.ID),
"failed reconnect must not cancel the ephemeral GC timer (issue #3382)")
}
// TestGitHubIssue3129_TransientlyBlockedWriteDoesNotLeaveLiveStaleSession
// tests the scenario reported in
// https://github.com/juanfont/headscale/issues/3129.
File diff suppressed because it is too large Load Diff
+153 -19
View File
@@ -1446,6 +1446,13 @@ func (s *State) PutNodeInStoreForTest(node types.Node) types.NodeView {
return s.nodeStore.PutNode(node)
}
// DeleteNodeFromStoreForTest removes a node from the in-memory [NodeStore]
// without touching the database. Used to force [State.UpdateNodeFromMapRequest]
// failures in poll-session tests while keeping the DB row intact for later restore.
func (s *State) DeleteNodeFromStoreForTest(id types.NodeID) {
s.nodeStore.DeleteNode(id)
}
// CreateRegisteredNodeForTest creates a test node with allocated IPs. This is a convenience wrapper around the database layer.
func (s *State) CreateRegisteredNodeForTest(user *types.User, hostname ...string) *types.Node {
return s.db.CreateRegisteredNodeForTest(user, hostname...)
@@ -1615,7 +1622,17 @@ func (s *State) applyAuthNodeUpdate(params authNodeUpdateParams) (types.NodeView
// Validate tags BEFORE calling [NodeStore.UpdateNode] to ensure we don't modify
// [NodeStore] if validation fails. This maintains consistency between [NodeStore]
// and database.
rejectedTags := s.validateRequestTags(params.ExistingNode, requestTags)
//
// A tag-owned node carries no user and its IP is not in any tag owner's set,
// so checking the node alone rejects every tag on re-auth (#3374). Authorise
// against the authenticating user too: they are the one presenting the
// credential and may own the requested tags.
var authUser types.UserView
if params.User != nil {
authUser = params.User.View()
}
rejectedTags := s.validateRequestTagsForReauth(params.ExistingNode, authUser, requestTags)
if len(rejectedTags) > 0 {
return types.NodeView{}, fmt.Errorf(
"%w %v are invalid or not permitted",
@@ -1709,8 +1726,14 @@ func (s *State) applyAuthNodeUpdate(params authNodeUpdateParams) (types.NodeView
} else {
node.Expiry = regData.Expiry
}
case isTagged && node.IsExpired():
// Tagged → Tagged, but carrying a stale PAST expiry from an older
// headscale's logout stamp (#3371). Tagged nodes never expire, so
// clear it; a deliberate future expiry has IsExpired() == false and
// falls through to the no-op below.
node.Expiry = nil
}
// Tagged → Tagged: keep existing expiry (nil) - no action needed
// Tagged → Tagged with no stale expiry: keep existing expiry - no action.
// Apply default node expiry for non-tagged nodes when the
// resolved expiry is still nil or zero (e.g., CLI registration
@@ -1731,8 +1754,21 @@ func (s *State) applyAuthNodeUpdate(params authNodeUpdateParams) (types.NodeView
// Persist to database.
// Explicitly select all node columns so GORM includes nil/zero-value fields
// (see nodeUpdateColumns comment).
//
// AuthKeyID is excluded from nodeUpdateColumns (#2862: never persist a
// possibly-deleted key's stale reference on the shared update path). But
// when a re-auth untags a node it clears AuthKeyID to nil (above), and that
// must persist or the node reloads as tagged/ephemeral after a restart and
// is garbage-collected. Writing NULL can never cause an FK error, so include
// the column only in that clearing case; the other transitions keep the
// #2862-safe column set untouched.
updateColumns := nodeUpdateColumns
if !updatedNodeView.AuthKeyID().Valid() {
updateColumns = append(slices.Clone(nodeUpdateColumns), "AuthKeyID")
}
_, err := hsdb.Write(s.db.DB, func(tx *gorm.DB) (*types.Node, error) {
err := tx.Select(nodeUpdateColumns).Updates(updatedNodeView.AsStruct()).Error
err := tx.Select(updateColumns).Updates(updatedNodeView.AsStruct()).Error
if err != nil {
return nil, fmt.Errorf("saving node: %w", err)
}
@@ -1936,6 +1972,34 @@ func (s *State) validateRequestTags(node types.NodeView, requestTags []string) [
return rejectedTags
}
// validateRequestTagsForReauth authorises re-auth request tags against the
// existing node OR the authenticating user. A tag-owned node (#3374) has no
// user and its IP is in no owner set, so NodeCanHaveTag alone rejects every
// tag; the authenticating user who owns the tags must also be consulted.
// Tags neither the node nor the user owns are still rejected, so this
// authorises the user, it does not skip authorisation.
func (s *State) validateRequestTagsForReauth(node types.NodeView, authUser types.UserView, requestTags []string) []string {
if len(requestTags) == 0 {
return nil
}
var rejectedTags []string
for _, tag := range requestTags {
if s.polMan.NodeCanHaveTag(node, tag) {
continue
}
if authUser.Valid() && s.polMan.UserCanHaveTag(authUser, tag) {
continue
}
rejectedTags = append(rejectedTags, tag)
}
return rejectedTags
}
// processReauthTags handles tag changes during node re-authentication.
// It processes RequestTags from the client and updates node tags accordingly.
// Returns rejected tags (if any) for post-validation error handling.
@@ -1970,16 +2034,34 @@ func (s *State) processReauthTags(
node.Tags = []string{}
node.UserID = &user.ID
node.User = user
// The node is no longer tagged, so it must not keep a reference to
// the tagged auth key. Leaving AuthKey set means a node created by a
// tagged+ephemeral key stays IsEphemeral() after converting to
// user-owned and is garbage-collected on its next disconnect,
// silently deleting the user's just-claimed device. Clearing the
// reference is persisted via the AuthKeyID column added to this
// path's write below.
node.AuthKey = nil
node.AuthKeyID = nil
}
return nil
}
// Non-empty RequestTags: validate and apply
// Non-empty RequestTags: validate and apply. Authorise each tag against the
// node OR the authenticating user, matching the pre-check in
// validateRequestTagsForReauth. Without the user half, a tag-owned node
// (#3374) has every tag rejected here even after the pre-check passed, so
// this returns the tags as rejected and the re-advertised tag is silently
// dropped despite a success response.
authUser := user.View()
var approvedTags, rejectedTags []string
for _, tag := range requestTags {
if s.polMan.NodeCanHaveTag(node.View(), tag) {
if s.polMan.NodeCanHaveTag(node.View(), tag) ||
(authUser.Valid() && s.polMan.UserCanHaveTag(authUser, tag)) {
approvedTags = append(approvedTags, tag)
} else {
rejectedTags = append(rejectedTags, tag)
@@ -2329,7 +2411,14 @@ 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 never expire (KB 1068), so an
// IsExpired() tagged node only reflects a stale logout stamp left by an
// older headscale (#3371). Forcing it down the re-validation path burns its
// fresh key and blocks re-auth forever; treat it as a plain re-registration
// and clear the stale expiry in the update below.
isExpired := existsSameUser && existingNodeSameUser.Valid() &&
!existingNodeSameUser.IsTagged() &&
existingNodeSameUser.IsExpired()
// A tagged key presented for a currently user-owned node converts that node
@@ -2338,7 +2427,17 @@ func (s *State) HandleNodeFromPreAuthKey(
isOwnershipConversion := existsSameUser && existingNodeSameUser.Valid() &&
pak.IsTagged() && !existingNodeSameUser.IsTagged()
if isExistingNodeReregistering && !isNodeKeyRotation && !isExpired && !isOwnershipConversion {
// A tagged key that differs from the one the node last authed with retags
// the node (see the in-place update below). Applying a key's tags is an
// authorisation decision, so the key must be validated rather than ride the
// skip-validation fast-path; otherwise a spent, revoked or expired tagged
// key could still retag a node that reuses its node key. Like isExpired,
// this boundary must not depend on the client rotating its key.
isRetag := existsSameUser && existingNodeSameUser.Valid() &&
pak.IsTagged() && existingNodeSameUser.IsTagged() &&
(!existingNodeSameUser.AuthKeyID().Valid() || existingNodeSameUser.AuthKeyID().Get() != pak.ID)
if isExistingNodeReregistering && !isNodeKeyRotation && !isExpired && !isOwnershipConversion && !isRetag {
// Existing, still-valid node re-registering with same NodeKey: skip
// validation. Pre-auth keys are only needed for initial authentication.
// Critical for containers that run "tailscale up --authkey=KEY" on every
@@ -2386,8 +2485,10 @@ func (s *State) HandleNodeFromPreAuthKey(
var finalNode types.NodeView
// If this node exists for this user, update the node in place.
// Note: For tags-only keys (pak.User == nil), existsSameUser is always false.
// If this node exists for this user, update the node in place. For a
// tags-only key (pak.User == nil) this is true when the machine already has
// a tagged node (findExistingNodeForPAK matches it under UserID 0); for a
// user-owned key it is true when the same user already has the node.
if existsSameUser && existingNodeSameUser.Valid() {
log.Trace().
Caller().
@@ -2429,17 +2530,37 @@ func (s *State) HandleNodeFromPreAuthKey(
node.RegisterMethod = util.RegisterMethodAuthKey
// Tags from PreAuthKey are only applied during initial registration.
// On re-registration the node keeps its existing tags and ownership,
// except when a tagged key converts a user-owned node: that adopts
// the key's tags and drops user ownership (tagged nodes are
// user-less and never expire). Only update AuthKey reference
// otherwise.
if pak.IsTagged() && !node.IsTagged() {
node.Tags = pak.Proto().GetAclTags()
// Tags from a PreAuthKey are applied on initial registration and
// re-applied whenever a *different* key is presented on
// re-registration: re-keying is Tailscale's documented way to change
// an auth-key device's tags (KB 1068 - "generate a new auth key with
// the new set of tags ... doing so replaces the device's existing
// tags"). Presenting the SAME key again (container restart,
// #2830/#3312) preserves the node's current tags and any admin
// override. A tagged key presented for a user-owned node also converts
// it, dropping user ownership.
//
// node.AuthKeyID still holds the prior key's ID here (it is reassigned
// below), and SetNodeTags leaves AuthKeyID intact, so an admin retag
// cannot masquerade as a new key.
keyChanged := node.AuthKeyID == nil || *node.AuthKeyID != pak.ID
if pak.IsTagged() && (!node.IsTagged() || keyChanged) {
wasUserOwned := !node.IsTagged()
node.Tags = pak.Tags
node.UserID = nil
node.User = nil
node.Expiry = nil
// Converting a user-owned node to tagged drops the user's key
// expiry (tagged nodes never expire). But retagging an
// already-tagged node must preserve a deliberate FUTURE expiry
// set via `headscale nodes expire` - that is a node property, not
// tied to the auth key - and only clear a stale PAST expiry. This
// keeps the retag path symmetric with the same-key relogin path
// (#3371) rather than silently overriding an admin decision.
if wasUserOwned || node.IsExpired() {
node.Expiry = nil
}
}
node.AuthKey = pak
node.AuthKeyID = &pak.ID
@@ -2464,6 +2585,13 @@ func (s *State) HandleNodeFromPreAuthKey(
} else {
node.Expiry = nil
}
} else if node.IsExpired() {
// #3371: a tagged node must never carry key expiry. Clear a
// stale PAST expiry left by a logout (older headscale) so
// re-auth is not permanently blocked. A deliberate future
// expiry (headscale nodes expire) has IsExpired() == false and
// is left untouched.
node.Expiry = nil
}
})
@@ -2473,8 +2601,14 @@ func (s *State) HandleNodeFromPreAuthKey(
_, err = hsdb.Write(s.db.DB, func(tx *gorm.DB) (*types.Node, error) {
// Explicitly select all node columns so GORM includes nil/zero-value fields
// (see nodeUpdateColumns comment).
err := tx.Select(nodeUpdateColumns).Updates(updatedNodeView.AsStruct()).Error
// (see nodeUpdateColumns comment). AuthKeyID is normally excluded to
// avoid persisting a deleted key's stale reference on MapRequest
// (#2862), but re-registration presents a freshly-validated key, so
// its ID must be persisted here — otherwise a restart reloads the old
// key and any key-scoped property (e.g. Ephemeral) silently reverts.
reregColumns := append(slices.Clone(nodeUpdateColumns), "AuthKeyID")
err := tx.Select(reregColumns).Updates(updatedNodeView.AsStruct()).Error
if err != nil {
return nil, fmt.Errorf("saving node: %w", err)
}
+654
View File
@@ -11,6 +11,7 @@ import (
"github.com/juanfont/headscale/integration/hsic"
"github.com/juanfont/headscale/integration/integrationutil"
"github.com/juanfont/headscale/integration/tsic"
"github.com/oauth2-proxy/mockoidc"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tailscale.com/tailcfg"
@@ -579,6 +580,213 @@ func TestTagsAuthKeyWithTagAdminOverrideReauthPreserves(t *testing.T) {
t.Logf("Test 2.5 PASS: Admin tags preserved through reauth (admin decisions are authoritative)")
}
// TestTagsReauthDifferentKeyRetagsNode reproduces issue #3370 end-to-end with a
// real tailscale client: a node registered with a single-use tag:valid-owned
// key is re-authenticated via `tailscale up --force-reauth` with a *fresh*
// single-use tag:second key. Tailscale's documented behaviour (KB 1068) is that
// re-keying replaces the device's tags, verified by the reporter against SaaS on
// the same node/IP. Before the fix headscale consumes the new key but keeps the
// old tag; after the fix the node retags in place.
//
// Unlike Test 2.5 (same reusable key + admin override -> tags preserved), this
// presents a *different* key, so it exercises the opposite arm of the retag
// discriminator. It also asserts what a state-unit test cannot: the new tag
// propagates to the node's own Self view and netmap, and the node ID and IPs are
// unchanged.
//
// https://github.com/juanfont/headscale/issues/3370
func TestTagsReauthDifferentKeyRetagsNode(t *testing.T) {
IntegrationSkip(t)
spec := ScenarioSpec{
NodesPerUser: 0,
Users: []string{tagTestUser},
}
scenario, err := NewScenario(spec)
require.NoError(t, err)
defer scenario.ShutdownAssertNoPanics(t)
err = scenario.CreateHeadscaleEnv(
[]tsic.Option{},
hsic.WithACLPolicy(tagsTestPolicy()),
hsic.WithTestName("tags-rekey-retag"),
)
requireNoErrHeadscaleEnv(t, err)
headscale, err := scenario.Headscale()
requireNoErrGetHeadscale(t, err)
userMap, err := headscale.MapUsers()
require.NoError(t, err)
userID := userMap[tagTestUser].GetId()
// KEY1: single-use tag:valid-owned.
key1, err := scenario.CreatePreAuthKeyWithTags(userID, false, false, []string{"tag:valid-owned"})
require.NoError(t, err)
client, err := scenario.CreateTailscaleNode(
"head",
tsic.WithNetwork(scenario.networks[scenario.testDefaultNetwork]),
)
require.NoError(t, err)
err = client.Login(headscale.GetEndpoint(), key1.GetKey())
require.NoError(t, err)
var (
initialNodeID uint64
initialIPs []string
)
assert.EventuallyWithT(t, func(c *assert.CollectT) {
nodes, err := headscale.ListNodes()
assert.NoError(c, err)
assert.Len(c, nodes, 1)
if len(nodes) == 1 {
initialNodeID = nodes[0].GetId()
initialIPs = nodes[0].GetIpAddresses()
assertNodeHasTagsWithCollect(c, nodes[0], []string{"tag:valid-owned"})
}
}, integrationutil.StatusReadyTimeout, integrationutil.SlowPoll, "waiting for initial registration")
t.Logf("Step 1: node %d registered with tag:valid-owned, IPs %v", initialNodeID, initialIPs)
// KEY2: fresh single-use tag:second. Re-key via --force-reauth.
key2, err := scenario.CreatePreAuthKeyWithTags(userID, false, false, []string{"tag:second"})
require.NoError(t, err)
//nolint:errcheck // result is verified via EventuallyWithT below
client.Execute([]string{
"tailscale", "up",
"--login-server=" + headscale.GetEndpoint(),
"--hostname=" + client.Hostname(),
"--authkey=" + key2.GetKey(),
"--force-reauth",
})
// Server-side: node retagged in place, same node ID and IPs, no duplicate.
assert.EventuallyWithT(t, func(c *assert.CollectT) {
nodes, err := headscale.ListNodes()
assert.NoError(c, err)
assert.Len(c, nodes, 1, "must not duplicate the node")
if len(nodes) == 1 {
assertNodeHasTagsWithCollect(c, nodes[0], []string{"tag:second"})
assert.Equal(c, initialNodeID, nodes[0].GetId(), "node ID must be unchanged")
assert.ElementsMatch(c, initialIPs, nodes[0].GetIpAddresses(), "IPs must be preserved")
}
}, integrationutil.ScaledTimeout(20*time.Second), integrationutil.SlowPoll, "server must reflect the retag")
// Node self view: the new tag propagates to the client (issue #2978 surface).
assert.EventuallyWithT(t, func(c *assert.CollectT) {
assertNodeSelfHasTagsWithCollect(c, client, []string{"tag:second"})
}, integrationutil.StatusReadyTimeout, integrationutil.SlowPoll, "node self view must reflect the retag")
// Netmap: independent serialization surface.
assert.EventuallyWithT(t, func(c *assert.CollectT) {
assertNetmapSelfHasTagsWithCollect(c, client, []string{"tag:second"})
}, integrationutil.StatusReadyTimeout, integrationutil.SlowPoll, "netmap self must reflect the retag")
t.Logf("Test #3370 PASS: re-keying with a different tagged key retagged the node in place")
}
// TestTagsReauthDifferentKeyRemovesTag is the sharpest proof of the KB 1068
// "replaces, not merges" rule at the integration level: a node registered with
// a two-tag key is re-keyed with a single-tag key, and the second tag must be
// *removed*, not retained. Tag removal is the highest-risk propagation path
// (peers must stop seeing the removed tag), so it is worth a real-client test.
//
// https://github.com/juanfont/headscale/issues/3370
func TestTagsReauthDifferentKeyRemovesTag(t *testing.T) {
IntegrationSkip(t)
spec := ScenarioSpec{
NodesPerUser: 0,
Users: []string{tagTestUser},
}
scenario, err := NewScenario(spec)
require.NoError(t, err)
defer scenario.ShutdownAssertNoPanics(t)
err = scenario.CreateHeadscaleEnv(
[]tsic.Option{},
hsic.WithACLPolicy(tagsTestPolicy()),
hsic.WithTestName("tags-rekey-remove"),
)
requireNoErrHeadscaleEnv(t, err)
headscale, err := scenario.Headscale()
requireNoErrGetHeadscale(t, err)
userMap, err := headscale.MapUsers()
require.NoError(t, err)
userID := userMap[tagTestUser].GetId()
// KEY1: single-use with BOTH tags.
key1, err := scenario.CreatePreAuthKeyWithTags(userID, false, false, []string{"tag:valid-owned", "tag:second"})
require.NoError(t, err)
client, err := scenario.CreateTailscaleNode(
"head",
tsic.WithNetwork(scenario.networks[scenario.testDefaultNetwork]),
)
require.NoError(t, err)
err = client.Login(headscale.GetEndpoint(), key1.GetKey())
require.NoError(t, err)
var initialNodeID uint64
assert.EventuallyWithT(t, func(c *assert.CollectT) {
nodes, err := headscale.ListNodes()
assert.NoError(c, err)
assert.Len(c, nodes, 1)
if len(nodes) == 1 {
initialNodeID = nodes[0].GetId()
assertNodeHasTagsWithCollect(c, nodes[0], []string{"tag:second", "tag:valid-owned"})
}
}, integrationutil.StatusReadyTimeout, integrationutil.SlowPoll, "waiting for initial registration")
// KEY2: fresh single-use with ONLY tag:valid-owned. Re-key.
key2, err := scenario.CreatePreAuthKeyWithTags(userID, false, false, []string{"tag:valid-owned"})
require.NoError(t, err)
//nolint:errcheck // result is verified via EventuallyWithT below
client.Execute([]string{
"tailscale", "up",
"--login-server=" + headscale.GetEndpoint(),
"--hostname=" + client.Hostname(),
"--authkey=" + key2.GetKey(),
"--force-reauth",
})
// tag:second must be gone on every surface.
assert.EventuallyWithT(t, func(c *assert.CollectT) {
nodes, err := headscale.ListNodes()
assert.NoError(c, err)
assert.Len(c, nodes, 1)
if len(nodes) == 1 {
assertNodeHasTagsWithCollect(c, nodes[0], []string{"tag:valid-owned"})
assert.Equal(c, initialNodeID, nodes[0].GetId())
}
}, integrationutil.ScaledTimeout(20*time.Second), integrationutil.SlowPoll, "re-keying must replace (remove tag:second), not merge")
assert.EventuallyWithT(t, func(c *assert.CollectT) {
assertNodeSelfHasTagsWithCollect(c, client, []string{"tag:valid-owned"})
}, integrationutil.StatusReadyTimeout, integrationutil.SlowPoll, "removed tag must clear from node self view")
t.Logf("Test #3370 PASS: re-keying replaced the tag set (tag:second removed)")
}
// TestTagsAuthKeyWithTagCLICannotModifyAdminTags tests that the client CLI
// cannot modify admin-assigned tags.
//
@@ -3203,3 +3411,449 @@ func TestTagsAuthKeyConvertToUserViaCLIRegister(t *testing.T) {
}
}, integrationutil.HAConvergeTimeout, 1*time.Second, "node should be user-owned after conversion via CLI register")
}
// TestTaggedNodeLogoutReloginSingleUseKeyOnline reproduces issue #3371
// end-to-end with a real tailscale client: a tagged node registered with a
// single-use key logs out (`tailscale logout`) and re-authenticates with a
// FRESH single-use tagged key. Tagged nodes never expire (KB 1068), so logout
// must not stamp an expiry; before the fix the node was left permanently
// expired and the fresh key was consumed on a re-registration that still
// reported NodeKeyExpired, locking the node out forever.
//
// The observable proof at the integration level is that after relogin the node
// is back online with a NULL expiry and the same node ID — not stuck expired.
//
// https://github.com/juanfont/headscale/issues/3371
func TestTaggedNodeLogoutReloginSingleUseKeyOnline(t *testing.T) {
IntegrationSkip(t)
spec := ScenarioSpec{
NodesPerUser: 0,
Users: []string{tagTestUser},
}
scenario, err := NewScenario(spec)
require.NoError(t, err)
defer scenario.ShutdownAssertNoPanics(t)
err = scenario.CreateHeadscaleEnv(
[]tsic.Option{},
hsic.WithACLPolicy(tagsTestPolicy()),
hsic.WithTestName("tags-logout-single"),
)
requireNoErrHeadscaleEnv(t, err)
headscale, err := scenario.Headscale()
requireNoErrGetHeadscale(t, err)
userMap, err := headscale.MapUsers()
require.NoError(t, err)
userID := userMap[tagTestUser].GetId()
// KEY1: single-use tag:valid-owned. Initial join.
key1, err := scenario.CreatePreAuthKeyWithTags(userID, false, false, []string{"tag:valid-owned"})
require.NoError(t, err)
client, err := scenario.CreateTailscaleNode(
"head",
tsic.WithNetwork(scenario.networks[scenario.testDefaultNetwork]),
)
require.NoError(t, err)
err = client.Login(headscale.GetEndpoint(), key1.GetKey())
require.NoError(t, err)
var initialNodeID uint64
assert.EventuallyWithT(t, func(c *assert.CollectT) {
nodes, err := headscale.ListNodes()
assert.NoError(c, err)
assert.Len(c, nodes, 1)
if len(nodes) == 1 {
initialNodeID = nodes[0].GetId()
assertNodeHasTagsWithCollect(c, nodes[0], []string{"tag:valid-owned"})
assert.Nil(c, nodes[0].GetExpiry(), "tagged node must have no expiry")
}
}, integrationutil.StatusReadyTimeout, integrationutil.SlowPoll, "waiting for initial registration")
// `tailscale logout`. A tagged node must not be expired by this.
err = client.Logout()
require.NoError(t, err)
err = client.WaitForNeedsLogin(integrationutil.ScaledTimeout(60 * time.Second))
require.NoError(t, err)
// The node must remain in the DB, tagged, and crucially NOT carry a
// stale expiry. This is the #3371 root cause (a) surface.
assert.EventuallyWithT(t, func(c *assert.CollectT) {
nodes, err := headscale.ListNodes()
assert.NoError(c, err)
assert.Len(c, nodes, 1, "node must persist through logout")
if len(nodes) == 1 {
assertNodeHasTagsWithCollect(c, nodes[0], []string{"tag:valid-owned"})
assert.Nil(c, nodes[0].GetExpiry(), "#3371: logout must not stamp expiry on a tagged node")
}
}, integrationutil.StatusReadyTimeout, integrationutil.SlowPoll, "tagged node must survive logout without expiry")
// KEY2: a FRESH single-use tagged key. Relogin.
key2, err := scenario.CreatePreAuthKeyWithTags(userID, false, false, []string{"tag:valid-owned"})
require.NoError(t, err)
err = client.Login(headscale.GetEndpoint(), key2.GetKey())
require.NoError(t, err,
"#3371: a fresh key must re-authenticate the tagged node after logout")
// Back online, same node, still tagged, still no expiry.
assert.EventuallyWithT(t, func(c *assert.CollectT) {
nodes, err := headscale.ListNodes()
assert.NoError(c, err)
assert.Len(c, nodes, 1, "must not duplicate the node")
if len(nodes) == 1 {
assert.Equal(c, initialNodeID, nodes[0].GetId(), "node ID must be unchanged")
assertNodeHasTagsWithCollect(c, nodes[0], []string{"tag:valid-owned"})
assert.Nil(c, nodes[0].GetExpiry(), "#3371: tagged node must have no expiry after relogin")
assert.True(c, nodes[0].GetOnline(), "#3371: tagged node must be online after relogin, not stuck expired")
}
}, integrationutil.ScaledTimeout(60*time.Second), integrationutil.SlowPoll, "tagged node must come back online after relogin")
t.Logf("Test #3371 PASS: tagged node logged out and re-authenticated online with a fresh single-use key")
}
// TestTaggedNodeLogoutReloginReusableKeyOnline is the reusable-key variant of
// issue #3371 (the "tailscale up hangs indefinitely" report). With a reusable
// key the relogin does not hit "authkey already used", but before the fix the
// node still stayed expired, so the client never observed a non-expired node.
// The observable proof is the same: online with NULL expiry after relogin.
//
// https://github.com/juanfont/headscale/issues/3371
func TestTaggedNodeLogoutReloginReusableKeyOnline(t *testing.T) {
IntegrationSkip(t)
spec := ScenarioSpec{
NodesPerUser: 0,
Users: []string{tagTestUser},
}
scenario, err := NewScenario(spec)
require.NoError(t, err)
defer scenario.ShutdownAssertNoPanics(t)
err = scenario.CreateHeadscaleEnv(
[]tsic.Option{},
hsic.WithACLPolicy(tagsTestPolicy()),
hsic.WithTestName("tags-logout-reuse"),
)
requireNoErrHeadscaleEnv(t, err)
headscale, err := scenario.Headscale()
requireNoErrGetHeadscale(t, err)
userMap, err := headscale.MapUsers()
require.NoError(t, err)
userID := userMap[tagTestUser].GetId()
// A single REUSABLE tag:valid-owned key used for both login and relogin.
key, err := scenario.CreatePreAuthKeyWithTags(userID, true, false, []string{"tag:valid-owned"})
require.NoError(t, err)
client, err := scenario.CreateTailscaleNode(
"head",
tsic.WithNetwork(scenario.networks[scenario.testDefaultNetwork]),
)
require.NoError(t, err)
err = client.Login(headscale.GetEndpoint(), key.GetKey())
require.NoError(t, err)
var initialNodeID uint64
assert.EventuallyWithT(t, func(c *assert.CollectT) {
nodes, err := headscale.ListNodes()
assert.NoError(c, err)
assert.Len(c, nodes, 1)
if len(nodes) == 1 {
initialNodeID = nodes[0].GetId()
assert.Nil(c, nodes[0].GetExpiry(), "tagged node must have no expiry")
}
}, integrationutil.StatusReadyTimeout, integrationutil.SlowPoll, "waiting for initial registration")
err = client.Logout()
require.NoError(t, err)
err = client.WaitForNeedsLogin(integrationutil.ScaledTimeout(60 * time.Second))
require.NoError(t, err)
// Relogin with the SAME reusable key.
err = client.Login(headscale.GetEndpoint(), key.GetKey())
require.NoError(t, err)
assert.EventuallyWithT(t, func(c *assert.CollectT) {
nodes, err := headscale.ListNodes()
assert.NoError(c, err)
assert.Len(c, nodes, 1, "must not duplicate the node")
if len(nodes) == 1 {
assert.Equal(c, initialNodeID, nodes[0].GetId(), "node ID must be unchanged")
assertNodeHasTagsWithCollect(c, nodes[0], []string{"tag:valid-owned"})
assert.Nil(c, nodes[0].GetExpiry(), "#3371: tagged node must have no expiry after reusable-key relogin")
assert.True(c, nodes[0].GetOnline(), "#3371: tagged node must be online after reusable-key relogin")
}
}, integrationutil.ScaledTimeout(60*time.Second), integrationutil.SlowPoll, "tagged node must come back online after reusable-key relogin")
t.Logf("Test #3371 PASS: tagged node logged out and re-authenticated online with a reusable key")
}
// TestTagsOIDCReauthAddOwnedTag reproduces issue #3374 through the interactive
// OIDC path with a real client. A node is registered tag-owned (no user) via
// --advertise-tags, then re-authenticates via OIDC advertising an ADDITIONAL
// owned tag. Because a tag-owned node has no user and its IP is in no owner
// set, the pre-fix authorization (which asked only "can this NODE have the
// tag") rejected the whole set, leaving the node logged out. The fix also
// authorises against the authenticating user, so the added owned tag is
// accepted.
//
// This is the OIDC/interactive twin of the #3370 PAK-path retag tests, and it
// hard-asserts the resulting tag SET (the pre-existing web-auth add-tag test
// only logs), so it catches the silent-drop where the pre-check passes but the
// apply-time re-check in processReauthTags still rejects.
//
// https://github.com/juanfont/headscale/issues/3374
func TestTagsOIDCReauthAddOwnedTag(t *testing.T) {
IntegrationSkip(t)
oidcUser := "oidcuser"
// Each OIDC login consumes one mock user, so the same identity must be
// listed twice: once for the initial login and once for the reauth.
spec := ScenarioSpec{
NodesPerUser: 0,
OIDCUsers: []mockoidc.MockUser{
oidcMockUser(oidcUser, true),
oidcMockUser(oidcUser, true),
},
}
scenario, err := NewScenario(spec)
require.NoError(t, err)
defer scenario.ShutdownAssertNoPanics(t)
oidcMap := map[string]string{
"HEADSCALE_OIDC_ISSUER": scenario.mockOIDC.Issuer(),
"HEADSCALE_OIDC_CLIENT_ID": scenario.mockOIDC.ClientID(),
"CREDENTIALS_DIRECTORY_TEST": "/tmp",
"HEADSCALE_OIDC_CLIENT_SECRET_PATH": "${CREDENTIALS_DIRECTORY_TEST}/hs_client_oidc_secret",
}
// The OIDC user owns both tags. Ownership is what authorises the reauth tag
// change once the node is tag-owned. Reference the user by email; it already
// contains an "@", so no trailing "@" is added (that suffix is only for
// non-email usernames).
owner := new(policyv2.Username(oidcUser + "@headscale.net"))
policy := &policyv2.Policy{
TagOwners: policyv2.TagOwners{
"tag:valid-owned": policyv2.Owners{owner},
"tag:second": policyv2.Owners{owner},
},
ACLs: []policyv2.ACL{
{
Action: "accept",
Sources: []policyv2.Alias{policyv2.Wildcard},
Destinations: []policyv2.AliasWithPorts{{Alias: policyv2.Wildcard, Ports: []tailcfg.PortRange{tailcfg.PortRangeAny}}},
},
},
}
err = scenario.CreateHeadscaleEnvWithLoginURL(
[]tsic.Option{
tsic.WithExtraLoginArgs([]string{"--advertise-tags=tag:valid-owned"}),
},
hsic.WithTestName("tags-oidc-addtag"),
hsic.WithConfigEnv(oidcMap),
hsic.WithFileInContainer("/tmp/hs_client_oidc_secret", []byte(scenario.mockOIDC.ClientSecret())),
hsic.WithACLPolicy(policy),
)
requireNoErrHeadscaleEnv(t, err)
headscale, err := scenario.Headscale()
requireNoErrGetHeadscale(t, err)
client, err := scenario.CreateTailscaleNode(
"unstable",
tsic.WithNetwork(scenario.networks[scenario.testDefaultNetwork]),
tsic.WithExtraLoginArgs([]string{"--advertise-tags=tag:valid-owned"}),
)
require.NoError(t, err)
// Initial OIDC login advertising tag:valid-owned.
u, err := client.LoginWithURL(headscale.GetEndpoint())
require.NoError(t, err)
_, err = doLoginURL(client.Hostname(), u)
require.NoError(t, err)
var initialNodeID uint64
assert.EventuallyWithT(t, func(c *assert.CollectT) {
nodes, err := headscale.ListNodes()
assert.NoError(c, err)
assert.Len(c, nodes, 1)
if len(nodes) == 1 {
initialNodeID = nodes[0].GetId()
assertNodeHasTagsWithCollect(c, nodes[0], []string{"tag:valid-owned"})
}
}, integrationutil.StatusReadyTimeout, integrationutil.SlowPoll, "waiting for initial tag-owned registration")
// Re-authenticate advertising an ADDITIONAL owned tag via --force-reauth,
// which drives the register/auth path (HandleNodeFromAuthPath), not a poll.
// Parse the login URL from this command's own output; issuing a separate
// `tailscale up` while a reauth is pending errors server-side.
command := []string{
"tailscale", "up",
"--login-server=" + headscale.GetEndpoint(),
"--hostname=" + client.Hostname(),
"--advertise-tags=tag:valid-owned,tag:second",
"--force-reauth",
}
stdout, stderr, _ := client.Execute(command)
t.Logf("reauth command output: stdout=%s stderr=%s", stdout, stderr)
loginURL, err := util.ParseLoginURLFromCLILogin(stdout + stderr)
require.NoError(t, err, "failed to parse login URL from reauth command")
_, err = doLoginURL(client.Hostname(), loginURL)
require.NoError(t, err)
// Both tags must be present — not rejected, not silently dropped.
assert.EventuallyWithT(t, func(c *assert.CollectT) {
nodes, err := headscale.ListNodes()
assert.NoError(c, err)
assert.Len(c, nodes, 1, "must not duplicate the node")
if len(nodes) == 1 {
assert.Equal(c, initialNodeID, nodes[0].GetId(), "node ID must be unchanged")
assertNodeHasTagsWithCollect(c, nodes[0], []string{"tag:valid-owned", "tag:second"})
}
}, integrationutil.ScaledTimeout(30*time.Second), integrationutil.SlowPoll, "#3374: added owned tag must be accepted on OIDC reauth")
t.Logf("Test #3374 PASS: OIDC reauth added an owned tag to a tag-owned node")
}
// TestTagsReauthEmptyTagsReturnsToUserSurvives covers the #3374 untag path with
// a real client: a tag-owned node created by a tagged+ephemeral key
// re-authenticates via user login with an EMPTY tag set. It must return to the
// user, and — crucially — survive: clearing the tags must also clear the
// node's reference to the ephemeral auth key, or the node stays IsEphemeral()
// and is garbage-collected on its next disconnect, silently deleting the
// user's just-claimed device.
//
// https://github.com/juanfont/headscale/issues/3374
func TestTagsReauthEmptyTagsReturnsToUserSurvives(t *testing.T) {
IntegrationSkip(t)
spec := ScenarioSpec{
NodesPerUser: 0,
Users: []string{tagTestUser},
}
scenario, err := NewScenario(spec)
require.NoError(t, err)
defer scenario.ShutdownAssertNoPanics(t)
err = scenario.CreateHeadscaleEnvWithLoginURL(
[]tsic.Option{},
hsic.WithACLPolicy(tagsTestPolicy()),
hsic.WithTestName("tags-untag-survive"),
)
requireNoErrHeadscaleEnv(t, err)
headscale, err := scenario.Headscale()
requireNoErrGetHeadscale(t, err)
userMap, err := headscale.MapUsers()
require.NoError(t, err)
userID := userMap[tagTestUser].GetId()
// A tagged + EPHEMERAL key: the node is tag-owned and ephemeral.
key, err := scenario.CreatePreAuthKeyWithTags(userID, false, true, []string{"tag:valid-owned"})
require.NoError(t, err)
client, err := scenario.CreateTailscaleNode(
"head",
tsic.WithNetwork(scenario.networks[scenario.testDefaultNetwork]),
)
require.NoError(t, err)
err = client.Login(headscale.GetEndpoint(), key.GetKey())
require.NoError(t, err)
err = client.WaitForRunning(integrationutil.PeerSyncTimeout())
require.NoError(t, err)
var initialNodeID uint64
assert.EventuallyWithT(t, func(c *assert.CollectT) {
nodes, err := headscale.ListNodes()
assert.NoError(c, err)
assert.Len(c, nodes, 1)
if len(nodes) == 1 {
initialNodeID = nodes[0].GetId()
assertNodeHasTagsWithCollect(c, nodes[0], []string{"tag:valid-owned"})
}
}, integrationutil.StatusReadyTimeout, integrationutil.SlowPoll, "waiting for initial tag-owned ephemeral registration")
// Re-authenticate with an EMPTY tag set via --force-reauth. An
// already-authenticated node only emits a fresh login URL when forced, so
// parse the URL from this command's own output rather than issuing a
// second `tailscale up` (which would error with "no URL found").
command := []string{
"tailscale", "up",
"--login-server=" + headscale.GetEndpoint(),
"--hostname=" + client.Hostname(),
"--advertise-tags=",
"--force-reauth",
}
stdout, stderr, _ := client.Execute(command)
t.Logf("reauth command output: stdout=%s stderr=%s", stdout, stderr)
loginURL, err := util.ParseLoginURLFromCLILogin(stdout + stderr)
require.NoError(t, err, "failed to parse login URL from reauth command")
body, err := doLoginURL(client.Hostname(), loginURL)
require.NoError(t, err)
// CLI user-login registration untags the node and returns it to the user.
err = scenario.runHeadscaleRegister(tagTestUser, body)
require.NoError(t, err)
// Node returns to the user, keeps its ID, and does NOT vanish.
assert.EventuallyWithT(t, func(c *assert.CollectT) {
nodes, err := headscale.ListNodes()
assert.NoError(c, err)
assert.Len(c, nodes, 1, "#3374: untagged node must survive, not be GC'd as ephemeral")
if len(nodes) == 1 {
assert.Equal(c, initialNodeID, nodes[0].GetId(), "node ID must be unchanged")
assert.Empty(c, nodes[0].GetTags(), "#3374: node must have no tags after untag")
// A user-owned node reports its real user; a tagged node would
// report the special "tagged-devices" user instead.
assert.Equal(c, tagTestUser, nodes[0].GetUser().GetName(), "#3374: untagged node must return to the authenticating user")
}
}, integrationutil.ScaledTimeout(30*time.Second), integrationutil.SlowPoll, "#3374: empty-tags reauth returns node to user and it survives")
t.Logf("Test #3374 PASS: empty-tags reauth returned the ephemeral tag-owned node to its user and it survived")
}
+4 -2
View File
@@ -68,7 +68,9 @@ exclude_docs: |
# Plugins
plugins:
- search:
separator: '[\s\-,:!=\[\]()"`/]+|\.(?!\d)|&[lg]t;|(?!\b)(?=[A-Z][a-z])'
provider: lunr
lunr:
separator: '[\s\-,:!=\[\]()"`/]+|\.(?!\d)|&[lg]t;|(?!\b)(?=[A-Z][a-z])'
- macros:
- include-markdown:
- minify:
@@ -111,7 +113,7 @@ extra:
- icon: fontawesome/brands/discord
link: https://discord.gg/c84AZQhmpx
headscale:
version: 0.29.1
version: 0.29.3
# Extensions
markdown_extensions: