mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-30 20:00:46 +09:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5aff68b5b9 | |||
| 235a57ec31 | |||
| bdc3e996de | |||
| 5fb514e6e0 | |||
| 4a1e77359d | |||
| 1fccdb18bd | |||
| d202883200 | |||
| 9609a0b87d | |||
| fba84ca232 | |||
| 089d6c4109 | |||
| 12928418b8 |
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"vendor": {
|
||||
"goModSum": "sha256-csVm5v6HZ49PBp/FCX+yK1sjV8/nuUQz3GKN21Ne1mg=",
|
||||
"goModSum": "sha256-GFiMC2ktrQnpSVzJHabVP1TYwJvn5+a/BDLhvmGTUWc=",
|
||||
"sri": "sha256-fzKyXNMw/2yAEhaTZu0n1NXatPO2IP0HFA2ey1vZIYM="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user