diff --git a/hscontrol/api/v1/nodes.go b/hscontrol/api/v1/nodes.go index a484171e4..4299d84e9 100644 --- a/hscontrol/api/v1/nodes.go +++ b/hscontrol/api/v1/nodes.go @@ -50,6 +50,7 @@ type Node struct { RegisterMethod string `enum:"REGISTER_METHOD_UNSPECIFIED,REGISTER_METHOD_AUTH_KEY,REGISTER_METHOD_CLI,REGISTER_METHOD_OIDC" json:"registerMethod"` GivenName string `json:"givenName"` Online bool `json:"online"` + Ephemeral bool `json:"ephemeral"` ApprovedRoutes []string `json:"approvedRoutes" nullable:"false"` AvailableRoutes []string `json:"availableRoutes" nullable:"false"` SubnetRoutes []string `json:"subnetRoutes" nullable:"false"` @@ -600,6 +601,7 @@ func nodeFromView(view types.NodeView) Node { RegisterMethod: registerMethodEnum(view.RegisterMethod()), GivenName: view.GivenName(), Online: view.IsOnline().Valid() && view.IsOnline().Get(), + Ephemeral: view.IsEphemeral(), ApprovedRoutes: nonNilStrings(util.PrefixesToString(view.ApprovedRoutes().AsSlice())), AvailableRoutes: nonNilStrings(util.PrefixesToString(view.AnnouncedRoutes())), SubnetRoutes: []string{}, diff --git a/hscontrol/apiv1_nodes_test.go b/hscontrol/apiv1_nodes_test.go index d6197dc01..dbc2968dc 100644 --- a/hscontrol/apiv1_nodes_test.go +++ b/hscontrol/apiv1_nodes_test.go @@ -119,6 +119,43 @@ func TestAPIV1NodeGet(t *testing.T) { assert.Contains(t, got.Node, "preAuthKey") assert.Contains(t, got.Node, "createdAt") }) + + // A login with a key of the other kind keeps the node's flag; the + // embedded key shows the key's own. + t.Run("ephemeral is the node's, not its key's", func(t *testing.T) { + for _, created := range []bool{true, false} { + h := newAPIV1Harness(t) + user := h.app.state.CreateUserForTest("dave") + machineKey := key.NewMachine() + + for _, ephemeral := range []bool{created, !created} { + pak, err := h.app.state.CreatePreAuthKey(user.TypedID(), false, ephemeral, nil, nil) + require.NoError(t, err) + + _, err = h.app.handleRegisterWithAuthKey(tailcfg.RegisterRequest{ + Auth: &tailcfg.RegisterResponseAuth{AuthKey: pak.Key}, + NodeKey: key.NewNode().Public(), + Hostinfo: &tailcfg.Hostinfo{Hostname: "node-d"}, + }, machineKey.Public()) + require.NoError(t, err) + } + + res := h.callHuma(http.MethodGet, "/api/v1/node/1", nil) + require.Equal(t, http.StatusOK, res.status) + + var got struct { + Node struct { + Ephemeral bool `json:"ephemeral"` + PreAuthKey struct { + Ephemeral bool `json:"ephemeral"` + } `json:"preAuthKey"` + } `json:"node"` + } + require.NoError(t, json.Unmarshal(res.body, &got)) + assert.Equal(t, created, got.Node.Ephemeral, "node created ephemeral=%v", created) + assert.Equal(t, !created, got.Node.PreAuthKey.Ephemeral, "node created ephemeral=%v", created) + } + }) } func TestAPIV1NodeList(t *testing.T) { diff --git a/hscontrol/app.go b/hscontrol/app.go index e7f456a0f..2ae50c9a3 100644 --- a/hscontrol/app.go +++ b/hscontrol/app.go @@ -183,8 +183,8 @@ func NewHeadscale(cfg *types.Config) (*Headscale, error) { return } - // Ephemerality can change after the timer is armed: re-registration - // with a permanent key, or a user claiming a tagged node. + // A user claiming a tagged node can clear its ephemeral flag after + // the timer is armed. if !node.IsEphemeral() { log.Debug().Caller().EmbedObject(node). Msg("node is no longer ephemeral, skipping garbage collection") diff --git a/hscontrol/db/db_test.go b/hscontrol/db/db_test.go index 5bab06c6a..fb1acfca5 100644 --- a/hscontrol/db/db_test.go +++ b/hscontrol/db/db_test.go @@ -952,6 +952,30 @@ func TestRevokedKeysBackingNodesSurviveCollection(t *testing.T) { assert.ElementsMatch(t, []types.NodeID{3, 5}, ids) } +// TestNodeEphemeralMigrationRerunKeepsFlags simulates a start that committed +// the node ephemeral migration but died before gormigrate recorded it, after +// which node 3 was made permanent: the next start must neither fail on the +// existing column nor backfill node 3 again. +func TestNodeEphemeralMigrationRerunKeepsFlags(t *testing.T) { + dbPath := t.TempDir() + "/headscale_test.db" + + require.NoError(t, createSQLiteFromSQLFile("testdata/sqlite/headscale_0.29.3_dump.sql", dbPath)) + + hsdb, err := NewHeadscaleDatabase(sqliteTestConfig(dbPath)) + require.NoError(t, err) + require.NoError(t, hsdb.DB.Exec(`UPDATE nodes SET ephemeral = false WHERE id = 3`).Error) + require.NoError(t, hsdb.DB.Exec(`DELETE FROM migrations WHERE id = '202610211300-node-ephemeral'`).Error) + require.NoError(t, hsdb.Close()) + + hsdb, err = NewHeadscaleDatabase(sqliteTestConfig(dbPath)) + require.NoError(t, err) + + ephemeral, err := hsdb.ListEphemeralNodes() + require.NoError(t, err) + require.Len(t, ephemeral, 1) + assert.Equal(t, types.NodeID(5), ephemeral[0].ID) +} + // TestInterruptedInitSchemaRecovers simulates a first start that created the // schema but died before gormigrate recorded the migrations, and asserts the // next start completes instead of failing on the existing credentials table. diff --git a/hscontrol/db/node.go b/hscontrol/db/node.go index 72175194f..2c72d10d3 100644 --- a/hscontrol/db/node.go +++ b/hscontrol/db/node.go @@ -97,9 +97,7 @@ func (hsdb *HSDatabase) ListEphemeralNodes() (types.Nodes, error) { return Read(hsdb.DB, func(rx *gorm.DB) (types.Nodes, error) { nodes := types.Nodes{} - err := rx.Joins("AuthKey"). - Where(`"AuthKey"."ephemeral" = true AND "AuthKey"."kind" = ?`, types.CredentialPreAuthKey). - Find(&nodes).Error + err := rx.Joins("AuthKey").Where("nodes.ephemeral = true").Find(&nodes).Error if err != nil { return nil, err } diff --git a/hscontrol/db/node_test.go b/hscontrol/db/node_test.go index 9f72afc38..d8d0fc4e5 100644 --- a/hscontrol/db/node_test.go +++ b/hscontrol/db/node_test.go @@ -557,6 +557,7 @@ func TestListEphemeralNodes(t *testing.T) { UserID: &user.ID, RegisterMethod: util.RegisterMethodAuthKey, AuthKeyID: &pakEphID, + Ephemeral: true, } err = db.DB.Save(&node).Error diff --git a/hscontrol/db/preauth_keys.go b/hscontrol/db/preauth_keys.go index c3d975eae..89a2795aa 100644 --- a/hscontrol/db/preauth_keys.go +++ b/hscontrol/db/preauth_keys.go @@ -339,9 +339,9 @@ func (hsdb *HSDatabase) GetPreAuthKeyByID(id uint64) (*types.PreAuthKey, error) } // DestroyPreAuthKey destroys a preauthkey. Returns error if the [types.PreAuthKey] -// does not exist. Referenced ephemeral keys are revoked instead, because their -// nodes need the key row to retain ephemerality. Other keys are removed along -// with their nodes' auth_key_id references. +// does not exist. Referenced ephemeral keys are revoked instead, so their +// nodes keep the key they joined with. Other keys are removed along with their +// nodes' auth_key_id references. func DestroyPreAuthKey(tx *gorm.DB, id uint64) error { return tx.Transaction(func(db *gorm.DB) error { res := db.Model(&types.Credential{}). @@ -421,8 +421,7 @@ func RevokePreAuthKey(tx *gorm.DB, id uint64) error { // DestroyRevokedPreAuthKeysBefore hard-deletes every key revoked before cutoff, // returning how many were removed. The background collector calls this to reap // soft-revoked keys after the retention window. Keys still referenced by a node -// are kept: the node's ephemerality lives on its key, and revoking a key must -// not change nodes already registered with it. +// are kept, so revoking a key does not change nodes already registered with it. func (hsdb *HSDatabase) DestroyRevokedPreAuthKeysBefore(cutoff time.Time) (int, error) { var count int diff --git a/hscontrol/db/preauth_keys_test.go b/hscontrol/db/preauth_keys_test.go index 4381f46b3..a60d8114e 100644 --- a/hscontrol/db/preauth_keys_test.go +++ b/hscontrol/db/preauth_keys_test.go @@ -124,6 +124,7 @@ func TestDestroyPreAuthKeyKeepsReferencedEphemeralKey(t *testing.T) { node := &types.Node{ Hostname: "ephemeral-key-node", UserID: &user.ID, AuthKeyID: &pak.ID, + Ephemeral: true, } require.NoError(t, db.DB.Create(node).Error) require.NoError(t, db.DeletePreAuthKey(pak.ID)) diff --git a/hscontrol/db/users_test.go b/hscontrol/db/users_test.go index 561fa4d37..b33a3acd2 100644 --- a/hscontrol/db/users_test.go +++ b/hscontrol/db/users_test.go @@ -29,6 +29,7 @@ func TestDestroyUserKeepsTaggedNodeEphemeral(t *testing.T) { Hostname: "tagged-ephemeral-node", Tags: []string{"tag:server"}, AuthKeyID: &pak.ID, + Ephemeral: true, } require.NoError(t, db.DB.Create(node).Error) require.NoError(t, db.DestroyUser(types.UserID(user.ID))) diff --git a/hscontrol/handlers_test.go b/hscontrol/handlers_test.go index 4777e7d37..2e0d25020 100644 --- a/hscontrol/handlers_test.go +++ b/hscontrol/handlers_test.go @@ -142,7 +142,7 @@ func TestHandleVerifyRequest_AdmitsByNodeKey(t *testing.T) { app.state.PutNodeInStoreForTest(*tagged) ephemeral := app.state.CreateNodeForTest(user, "ephemeral") - ephemeral.AuthKey = &types.Credential{Ephemeral: true} + ephemeral.Ephemeral = true app.state.PutNodeInStoreForTest(*ephemeral) expired := app.state.CreateNodeForTest(user, "expired") diff --git a/hscontrol/poll_test.go b/hscontrol/poll_test.go index c541f2cd9..0a4ec47ea 100644 --- a/hscontrol/poll_test.go +++ b/hscontrol/poll_test.go @@ -501,9 +501,10 @@ func TestStaleGCTimerSparesPermanentNode(t *testing.T) { app.cfg.Node.Ephemeral.InactivityTimeout = time.Hour app.StartEphemeralGCForTest(t) user := app.state.CreateUserForTest("gc-permanent-user") - ephemeral, err := app.state.CreatePreAuthKey(user.TypedID(), true, true, nil, nil) + require.NoError(t, app.state.UpdatePolicyManagerUsersForTest()) + _, err := app.state.SetPolicy([]byte(`{"tagOwners":{"tag:server":["gc-permanent-user@"]}}`)) require.NoError(t, err) - permanent, err := app.state.CreatePreAuthKey(user.TypedID(), true, false, nil, nil) + ephemeral, err := app.state.CreatePreAuthKey(user.TypedID(), true, true, nil, []string{"tag:server"}) require.NoError(t, err) machineKey := key.NewMachine().Public() @@ -523,8 +524,15 @@ func TestStaleGCTimerSparesPermanentNode(t *testing.T) { _, epoch := app.state.Connect(node.ID()) app.ephemeralGC.Cancel(node.ID()) - req.Auth.AuthKey = permanent.Key - _, err = app.handleRegisterWithAuthKey(req, machineKey) + // The user claiming the tagged node makes it permanent. + authID := types.MustAuthID() + app.state.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(&types.RegistrationData{ + MachineKey: machineKey, + NodeKey: key.NewNode().Public(), + Hostname: "gc-permanent-node", + Hostinfo: &tailcfg.Hostinfo{Hostname: "gc-permanent-node"}, + })) + _, _, err = app.state.HandleNodeFromAuthPath(authID, types.UserID(user.ID), nil, "webauth") require.NoError(t, err) fresh, ok := app.state.GetNodeByID(node.ID()) diff --git a/hscontrol/servertest/lifecycle_compat_test.go b/hscontrol/servertest/lifecycle_compat_test.go index 9a11bf1b9..b4c01dfa1 100644 --- a/hscontrol/servertest/lifecycle_compat_test.go +++ b/hscontrol/servertest/lifecycle_compat_test.go @@ -42,23 +42,12 @@ var lifecycleSkipReasons = map[string]string{ "ephemeral-lifecycle/E3-key-delete/2/keys": deletedKeyListed, "ephemeral-lifecycle/E3-key-delete/3/keys": deletedKeyListed, "ephemeral-lifecycle/E3-key-delete/4/status": deletedKeyDeleteNotFound, - - "ephemeral-lifecycle/E4-reauth-other-key/eph-to-perm/ephemeral": ephemeralFollowsKey, - "ephemeral-lifecycle/E4-reauth-other-key/perm-to-eph/ephemeral": ephemeralFollowsKey, - - "ephemeral-lifecycle/collection/eph-to-perm/left": ephemeralFollowsKey, - "ephemeral-lifecycle/collection/eph-to-perm/removed-observer": ephemeralFollowsKey, - "ephemeral-lifecycle/collection/eph-to-perm/present-observer": ephemeralFollowsKey, - "ephemeral-lifecycle/collection/perm-to-eph/left": ephemeralFollowsKey, - "ephemeral-lifecycle/collection/perm-to-eph/removed-observer": ephemeralFollowsKey, - "ephemeral-lifecycle/collection/perm-to-eph/present-observer": ephemeralFollowsKey, } const ( logoutAnswerUnauthorized = "LOGOUT_ANSWER_UNAUTHORIZED: headscale answers the logout that deletes an ephemeral node with MachineAuthorized false; SaaS answers true" deletedKeyListed = "DELETED_KEY_LISTED: headscale lists a deleted auth key; SaaS leaves it out of both key lists, while a read by ID still answers" deletedKeyDeleteNotFound = "DELETED_KEY_DELETE_NOT_FOUND: headscale answers 404 to a second delete of an auth key; SaaS answers 200" - ephemeralFollowsKey = "EPHEMERAL_FOLLOWS_KEY: headscale takes a node's ephemeral flag from the key it last logged in with; SaaS keeps the flag the device was created with, so it is collected, or kept, as created" ) // TestRouteApprovalCompat replays a user-owned router through admin diff --git a/hscontrol/state/auth_tagged_expiry_test.go b/hscontrol/state/auth_tagged_expiry_test.go index ee35cb864..60e173e2a 100644 --- a/hscontrol/state/auth_tagged_expiry_test.go +++ b/hscontrol/state/auth_tagged_expiry_test.go @@ -814,6 +814,7 @@ func TestTaggedReauthUntagClearsEphemeralAuthKey(t *testing.T) { nd.RegisterMethod = util.RegisterMethodAuthKey nd.AuthKey = pak.AsCredential() nd.AuthKeyID = &pak.ID + nd.Ephemeral = pak.Ephemeral }) require.True(t, ok) require.True(t, seeded.IsEphemeral(), "precondition: node is ephemeral (tagged ephemeral key)") @@ -1552,17 +1553,15 @@ func newRetagTestState(t *testing.T) *State { return s } -// TestPreAuthKeyReauthPersistsAuthKeyID pins the root cause behind the -// ephemeral-flag revert bug found while auditing #3370: on re-registration -// with a *different* key, HandleNodeFromPreAuthKey updates node.AuthKeyID in -// the NodeStore (state.go, in the in-place closure) but AuthKeyID is excluded -// from nodeUpdateColumns (added for #2862 to avoid persisting a *deleted* key's -// stale reference on MapRequest). The exclusion is correct for MapRequest, but -// on the re-registration path the presented key is freshly loaded and valid, so -// the new auth_key_id must be persisted. Otherwise a control-plane restart -// reloads the OLD key, and any key-scoped property (notably Ephemeral) silently -// reverts — an ephemeral->non-ephemeral re-auth leaves a node that looks -// persistent in memory but is ephemeral again after restart and can be GC'd. +// TestPreAuthKeyReauthPersistsAuthKeyID pins a bug found while working on #3370: +// on re-registration with a *different* key, HandleNodeFromPreAuthKey updates +// node.AuthKeyID in the NodeStore (state.go, in the in-place closure) but +// AuthKeyID is excluded from nodeUpdateColumns (added for #2862 to avoid +// persisting a *deleted* key's stale reference on MapRequest). The exclusion is +// correct for MapRequest, but on the re-registration path the presented key is +// freshly loaded and valid, so the new auth_key_id must be persisted. Otherwise +// a control-plane restart reloads the OLD key. The ephemeral flag is the node's +// own and stays as the node was created, before and after the restart. // // Drives the real handler twice, then reopens the DB to prove the association // survives the reload. @@ -1610,8 +1609,8 @@ func TestPreAuthKeyReauthPersistsAuthKeyID(t *testing.T) { require.NotNil(t, second.AuthKeyID().Get()) require.Equal(t, keyB.ID, second.AuthKeyID().Get(), "NodeStore must reference the key just used to re-authenticate") - require.False(t, second.IsEphemeral(), - "re-auth with a non-ephemeral key must make the node non-ephemeral") + require.True(t, second.IsEphemeral(), + "re-auth with another key keeps the ephemeral flag the node was created with") // Restart the control plane: reload state from the database only. require.NoError(t, s.Close()) @@ -1623,14 +1622,11 @@ func TestPreAuthKeyReauthPersistsAuthKeyID(t *testing.T) { reloaded, ok := s2.GetNodeByID(nodeID) require.True(t, ok, "node must reload from DB after restart") - // This is the assertion that fails today: auth_key_id was never persisted on - // re-registration, so the reload resurrects KEY A and the node is ephemeral - // again — at risk of GC on the next disconnect. require.NotNil(t, reloaded.AuthKeyID().Get()) require.Equal(t, keyB.ID, reloaded.AuthKeyID().Get(), "auth_key_id must persist across restart, not revert to the old key") - require.False(t, reloaded.IsEphemeral(), - "ephemerality must not silently revert after a control-plane restart") + require.True(t, reloaded.IsEphemeral(), + "the ephemeral flag must survive a control-plane restart") } // TestTaggedNodeCanHaveKeyExpiry matches Tailscale: a tagged node has key @@ -2245,6 +2241,63 @@ func TestPAKReregisterRollbackKeepsPriorWrite(t *testing.T) { require.Equal(t, c.regReq.NodeKey, ns.NodeKey) } +// TestPAKClaimOfTaggedNodeClearsEphemeral: a user's key taking an +// admin-tagged node back makes it a permanent device, as an untagging login +// does. A claim the database rejects leaves the flag as it was. +func TestPAKClaimOfTaggedNodeClearsEphemeral(t *testing.T) { + s := newRetagTestState(t) + user := s.CreateUserForTest("claim-user") + + k0, err := s.CreatePreAuthKey(user.TypedID(), true, true, nil, nil) + require.NoError(t, err) + + c := registerForPAKRereg(t, s, k0) + _, err = s.SetPolicy([]byte(`{"tagOwners":{"tag:prod":["claim-user@"]}}`)) + require.NoError(t, err) + _, _, err = s.SetNodeTags(c.node.ID(), []string{"tag:prod"}) + require.NoError(t, err) + + k, err := s.CreatePreAuthKey(user.TypedID(), false, false, nil, nil) + require.NoError(t, err) + + regReq := c.regReq + regReq.Auth = &tailcfg.RegisterResponseAuth{AuthKey: k.Key} + regReq.NodeKey = key.NewNode().Public() + + view, pak := c.lookup(t, k.Key) + + // Another registration consumes the key after the lookup read its row, so + // the transaction's compare-and-set fails. + err = s.db.Write(func(tx *gorm.DB) error { + return db.UsePreAuthKey(tx, &types.PreAuthKey{ID: pak.ID}) + }) + require.NoError(t, err) + + _, err = c.reregister(view, pak, regReq) + require.ErrorIs(t, err, types.PAKError("authkey already used")) + + node, ok := s.GetNodeByID(c.node.ID()) + require.True(t, ok) + require.True(t, node.IsTagged(), "rollback keeps the node tagged") + require.True(t, node.IsEphemeral(), "rollback keeps the flag") + + k2, err := s.CreatePreAuthKey(user.TypedID(), true, false, nil, nil) + require.NoError(t, err) + + regReq.Auth = &tailcfg.RegisterResponseAuth{AuthKey: k2.Key} + _, _, err = s.HandleNodeFromPreAuthKey(regReq, c.machineKey) + require.NoError(t, err) + + node, ok = s.GetNodeByID(c.node.ID()) + require.True(t, ok) + require.False(t, node.IsTagged()) + require.False(t, node.IsEphemeral()) + + row, err := s.db.GetNodeByID(c.node.ID()) + require.NoError(t, err) + require.False(t, row.Ephemeral) +} + // TestPAKReregisterConcurrentAdminExpire races a same-key restart with a spent // key against an admin expiry. Either serial order leaves the node expired. func TestPAKReregisterConcurrentAdminExpire(t *testing.T) { diff --git a/hscontrol/state/debug.go b/hscontrol/state/debug.go index 2d26e2d11..f337d7f7d 100644 --- a/hscontrol/state/debug.go +++ b/hscontrol/state/debug.go @@ -313,7 +313,7 @@ func (s *State) DebugOverviewJSON() DebugOverviewInfo { info.Nodes.Expired++ } - if node.AuthKey().Valid() && node.AuthKey().Ephemeral() { + if node.IsEphemeral() { info.Nodes.Ephemeral++ } } diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go index 014553036..9702840a7 100644 --- a/hscontrol/state/state.go +++ b/hscontrol/state/state.go @@ -116,6 +116,7 @@ var nodeUpdateColumns = []string{ "UserID", "RegisterMethod", "Tags", + "Ephemeral", "Expiry", "LastSeen", "ApprovedRoutes", @@ -1028,8 +1029,7 @@ func (s *State) ListEphemeralNodes() views.Slice[types.NodeView] { var ephemeralNodes []types.NodeView for _, node := range allNodes.All() { - // Check if node is ephemeral by checking its AuthKey - if node.AuthKey().Valid() && node.AuthKey().Ephemeral() { + if node.IsEphemeral() { ephemeralNodes = append(ephemeralNodes, node) } } @@ -2415,8 +2415,8 @@ func (s *State) applyAuthNodeUpdate(params authNodeUpdateParams) (types.NodeView // 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 + // must persist or the node reloads bound to the tagged key after a + // restart. 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 @@ -2506,6 +2506,7 @@ func (s *State) createAndSaveNewNode(params newNodeParams) (types.NodeView, erro nodeToRegister.AuthKey = params.PreAuthKey.AsCredential() nodeToRegister.AuthKeyID = ¶ms.PreAuthKey.ID + nodeToRegister.Ephemeral = params.PreAuthKey.Ephemeral } else { // Non-PreAuthKey registration (OIDC, CLI) - always user-owned nodeToRegister.UserID = ¶ms.User.ID @@ -2702,14 +2703,13 @@ func (s *State) processReauthTags( 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. + // the tagged auth key, nor its ephemeral flag: an ephemeral node 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 + node.Ephemeral = false } return nil @@ -3490,10 +3490,12 @@ func (s *State) reregisterNodeWithPAK( prior.NodeKey != regReq.NodeKey { // A user key that speaks for a tagged node, with a new node key, // takes it back as that user's device, as on Tailscale SaaS. A - // relogin under the same node key keeps it tagged. + // relogin under the same node key keeps it tagged. As at a login + // that untags it, the claimed device is not ephemeral. node.Tags = nil node.UserID = &pak.User.ID node.User = pak.User + node.Ephemeral = false } node.AuthKey = pak.AsCredential() @@ -3602,6 +3604,10 @@ func (s *State) reregisterNodeWithPAK( n.User = prior.User } + if n.Ephemeral == written.Ephemeral { + n.Ephemeral = prior.Ephemeral + } + if reflect.DeepEqual(n.Expiry, written.Expiry) { n.Expiry = prior.Expiry } diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1AuthRegister_happy_path_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1AuthRegister_happy_path_parity.json index 628457d4d..bb7b041b1 100644 --- a/hscontrol/testdata/apiv1_golden/TestAPIV1AuthRegister_happy_path_parity.json +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1AuthRegister_happy_path_parity.json @@ -6,6 +6,7 @@ "availableRoutes": [], "createdAt": "\u003ctimestamp\u003e", "discoKey": "\u003csecret\u003e", + "ephemeral": false, "expiry": null, "givenName": "regnode", "id": "1", diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeGet_happy_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeGet_happy_parity.json index 82318d034..d77237634 100644 --- a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeGet_happy_parity.json +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeGet_happy_parity.json @@ -6,6 +6,7 @@ "availableRoutes": [], "createdAt": "\u003ctimestamp\u003e", "discoKey": "\u003csecret\u003e", + "ephemeral": false, "expiry": null, "givenName": "node-a", "id": "1", diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeGet_tagged_node_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeGet_tagged_node_parity.json index a55a4e79a..948d949a6 100644 --- a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeGet_tagged_node_parity.json +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeGet_tagged_node_parity.json @@ -6,6 +6,7 @@ "availableRoutes": [], "createdAt": "\u003ctimestamp\u003e", "discoKey": "\u003csecret\u003e", + "ephemeral": false, "expiry": null, "givenName": "node-b", "id": "1", diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeList_all_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeList_all_parity.json index 2dae3fb75..599be07d1 100644 --- a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeList_all_parity.json +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeList_all_parity.json @@ -7,6 +7,7 @@ "availableRoutes": [], "createdAt": "\u003ctimestamp\u003e", "discoKey": "\u003csecret\u003e", + "ephemeral": false, "expiry": null, "givenName": "node-a", "id": "1", @@ -55,6 +56,7 @@ "availableRoutes": [], "createdAt": "\u003ctimestamp\u003e", "discoKey": "\u003csecret\u003e", + "ephemeral": false, "expiry": null, "givenName": "node-b", "id": "2", diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeList_filter_by_user_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeList_filter_by_user_parity.json index 867d783c8..ddd3f03f2 100644 --- a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeList_filter_by_user_parity.json +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeList_filter_by_user_parity.json @@ -7,6 +7,7 @@ "availableRoutes": [], "createdAt": "\u003ctimestamp\u003e", "discoKey": "\u003csecret\u003e", + "ephemeral": false, "expiry": null, "givenName": "node-a", "id": "1", diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeRegister_happy_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeRegister_happy_parity.json index 647d42c11..a2728ca5e 100644 --- a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeRegister_happy_parity.json +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeRegister_happy_parity.json @@ -6,6 +6,7 @@ "availableRoutes": [], "createdAt": "\u003ctimestamp\u003e", "discoKey": "\u003csecret\u003e", + "ephemeral": false, "expiry": null, "givenName": "registered-node", "id": "1", diff --git a/hscontrol/types/node.go b/hscontrol/types/node.go index d3be272a7..1be366233 100644 --- a/hscontrol/types/node.go +++ b/hscontrol/types/node.go @@ -278,7 +278,7 @@ func (node *Node) ShouldBeOnline() bool { // IsEphemeral returns if the node is registered as an Ephemeral node. // https://tailscale.com/docs/features/ephemeral-nodes func (node *Node) IsEphemeral() bool { - return node.AuthKey != nil && node.AuthKey.Ephemeral + return node.Ephemeral } // IPs returns the node's allocated Tailscale addresses. Order is