Repository: juanfont/headscale

Issue:
[Bug] `Last seen` is missing in beta
### Is this a support request?

- [x] This is not a support request

### Is there an existing issue for this?

- [x] I have searched the existing issues

### Current Behavior

When issuing `headscale node list` the column `Last seen` is empty.

![Image](https://github.com/user-attachments/assets/664cae12-1f61-45a2-adce-0ba7a61ee2f3)

Even through the API `lastSeen` is empty:

![Image](https://github.com/user-attachments/assets/439c774e-71c1-472d-8df1-a1520f124181)

### Expected Behavior

Column `Last seen` not empty.

### Steps To Reproduce

1. Update container to latest beta
2. Make necessary changes to `acls.hujson` if in place
3. List nodes either through `headscale` command directly or through API
4. `Last seen` is empty

### Environment

```markdown
- OS: Unraid 7.1.1-rc.1.1 (Slackware Current)
- Headscale version: 0.26.0-beta.1
- Tailscale version: 1.82.5
```

### Runtime environment

- [x] Headscale is behind a (reverse) proxy
- [x] Headscale runs in a container

### Debug information

If more information are needed I'm happy to provide them

Discussion:
@happytrudy: me too

Solution patch:
diff --git a/cmd/headscale/cli/utils.go b/cmd/headscale/cli/utils.go
index ff1137be..0347c0a9 100644
--- a/cmd/headscale/cli/utils.go
+++ b/cmd/headscale/cli/utils.go
@@ -27,14 +27,14 @@ func newHeadscaleServerWithConfig() (*hscontrol.Headscale, error) {
 	cfg, err := types.LoadServerConfig()
 	if err != nil {
 		return nil, fmt.Errorf(
-			"failed to load configuration while creating headscale instance: %w",
+			"loading configuration: %w",
 			err,
 		)
 	}
 
 	app, err := hscontrol.NewHeadscale(cfg)
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("creating new headscale: %w", err)
 	}
 
 	return app, nil
diff --git a/hscontrol/app.go b/hscontrol/app.go
index 3b4be52f..d62acb34 100644
--- a/hscontrol/app.go
+++ b/hscontrol/app.go
@@ -145,7 +145,7 @@ func NewHeadscale(cfg *types.Config) (*Headscale, error) {
 		registrationCache,
 	)
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("new database: %w", err)
 	}
 
 	app.ipAlloc, err = db.NewIPAllocator(app.db, cfg.PrefixV4, cfg.PrefixV6, cfg.IPAllocation)
@@ -160,7 +160,7 @@ func NewHeadscale(cfg *types.Config) (*Headscale, error) {
 	})
 
 	if err = app.loadPolicyManager(); err != nil {
-		return nil, fmt.Errorf("failed to load ACL policy: %w", err)
+		return nil, fmt.Errorf("loading ACL policy: %w", err)
 	}
 
 	var authProvider AuthProvider
diff --git a/hscontrol/db/db.go b/hscontrol/db/db.go
index d299771f..74f51ddc 100644
--- a/hscontrol/db/db.go
+++ b/hscontrol/db/db.go
@@ -672,7 +672,24 @@ AND auth_key_id NOT IN (
 			{
 				ID: "202502171819",
 				Migrate: func(tx *gorm.DB) error {
-					_ = tx.Migrator().DropColumn(&types.Node{}, "last_seen")
+					// This migration originally removed the last_seen column
+					// from the node table, but it was added back in
+					// 202505091439.
+					return nil
+				},
+				Rollback: func(db *gorm.DB) error { return nil },
+			},
+			// Add back last_seen column to node table.
+			{
+				ID: "202505091439",
+				Migrate: func(tx *gorm.DB) error {
+					// Add back last_seen column to node table if it does not exist.
+					// This is a workaround for the fact that the last_seen column
+					// was removed in the 202502171819 migration, but only for some
+					// beta testers.
+					if !tx.Migrator().HasColumn(&types.Node{}, "last_seen") {
+						_ = tx.Migrator().AddColumn(&types.Node{}, "last_seen")
+					}
 
 					return nil
 				},
diff --git a/hscontrol/db/node.go b/hscontrol/db/node.go
index ed9e1f73..c91687da 100644
--- a/hscontrol/db/node.go
+++ b/hscontrol/db/node.go
@@ -251,6 +251,20 @@ func SetApprovedRoutes(
 	return nil
 }
 
+// SetLastSeen sets a node's last seen field indicating that we
+// have recently communicating with this node.
+func (hsdb *HSDatabase) SetLastSeen(nodeID types.NodeID, lastSeen time.Time) error {
+	return hsdb.Write(func(tx *gorm.DB) error {
+		return SetLastSeen(tx, nodeID, lastSeen)
+	})
+}
+
+// SetLastSeen sets a node's last seen field indicating that we
+// have recently communicating with this node.
+func SetLastSeen(tx *gorm.DB, nodeID types.NodeID, lastSeen time.Time) error {
+	return tx.Model(&types.Node{}).Where("id = ?", nodeID).Update("last_seen", lastSeen).Error
+}
+
 // RenameNode takes a Node struct and a new GivenName for the nodes
 // and renames it. If the name is not unique, it will return an error.
 func RenameNode(tx *gorm.DB,
diff --git a/hscontrol/poll.go b/hscontrol/poll.go
index e4178f43..763ab85b 100644
--- a/hscontrol/poll.go
+++ b/hscontrol/poll.go
@@ -409,6 +409,10 @@ func (h *Headscale) updateNodeOnlineStatus(online bool, node *types.Node) {
 		change.LastSeen = &now
 	}
 
+	if node.LastSeen != nil {
+		h.db.SetLastSeen(node.ID, *node.LastSeen)
+	}
+
 	ctx := types.NotifyCtx(context.Background(), "poll-nodeupdate-onlinestatus", node.Hostname)
 	h.nodeNotifier.NotifyWithIgnore(ctx, types.UpdatePeerPatch(change), node.ID)
 }
diff --git a/hscontrol/types/node.go b/hscontrol/types/node.go
index 2749237e..da185563 100644
--- a/hscontrol/types/node.go
+++ b/hscontrol/types/node.go
@@ -98,11 +98,7 @@ type Node struct {
 
 	// LastSeen is when the node was last in contact with
 	// headscale. It is best effort and not persisted.
-	LastSeen *time.Time `gorm:"-"`
-
-	// DEPRECATED: Use the ApprovedRoutes field instead.
-	// TODO(kradalby): remove when ApprovedRoutes is used all over the code.
-	// Routes []Route `gorm:"constraint:OnDelete:CASCADE;"`
+	LastSeen *time.Time `gorm:"column:last_seen"`
 
 	// ApprovedRoutes is a list of routes that the node is allowed to announce
 	// as a subnet router. They are not necessarily the routes that the node



Test patch:
diff --git a/integration/auth_key_test.go b/integration/auth_key_test.go
index ca5c8d0d..d54ff593 100644
--- a/integration/auth_key_test.go
+++ b/integration/auth_key_test.go
@@ -9,6 +9,7 @@ import (
 
 	"slices"
 
+	v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
 	"github.com/juanfont/headscale/integration/hsic"
 	"github.com/juanfont/headscale/integration/tsic"
 	"github.com/samber/lo"
@@ -44,6 +45,9 @@ func TestAuthKeyLogoutAndReloginSameUser(t *testing.T) {
 			allClients, err := scenario.ListTailscaleClients()
 			assertNoErrListClients(t, err)
 
+			allIps, err := scenario.ListTailscaleClientsIPs()
+			assertNoErrListClientIPs(t, err)
+
 			err = scenario.WaitForTailscaleSync()
 			assertNoErrSync(t, err)
 
@@ -66,6 +70,10 @@ func TestAuthKeyLogoutAndReloginSameUser(t *testing.T) {
 			nodeCountBeforeLogout := len(listNodes)
 			t.Logf("node count before logout: %d", nodeCountBeforeLogout)
 
+			for _, node := range listNodes {
+				assertLastSeenSet(t, node)
+			}
+
 			for _, client := range allClients {
 				err := client.Logout()
 				if err != nil {
@@ -78,6 +86,13 @@ func TestAuthKeyLogoutAndReloginSameUser(t *testing.T) {
 
 			t.Logf("all clients logged out")
 
+			listNodes, err = headscale.ListNodes()
+			require.Equal(t, nodeCountBeforeLogout, len(listNodes))
+
+			for _, node := range listNodes {
+				assertLastSeenSet(t, node)
+			}
+
 			// if the server is not running with HTTPS, we have to wait a bit before
 			// reconnection as the newest Tailscale client has a measure that will only
 			// reconnect over HTTPS if they saw a noise connection previously.
@@ -105,8 +120,9 @@ func TestAuthKeyLogoutAndReloginSameUser(t *testing.T) {
 			listNodes, err = headscale.ListNodes()
 			require.Equal(t, nodeCountBeforeLogout, len(listNodes))
 
-			allIps, err := scenario.ListTailscaleClientsIPs()
-			assertNoErrListClientIPs(t, err)
+			for _, node := range listNodes {
+				assertLastSeenSet(t, node)
+			}
 
 			allAddrs := lo.Map(allIps, func(x netip.Addr, index int) string {
 				return x.String()
@@ -137,8 +153,20 @@ func TestAuthKeyLogoutAndReloginSameUser(t *testing.T) {
 					}
 				}
 			}
+
+			listNodes, err = headscale.ListNodes()
+			require.Equal(t, nodeCountBeforeLogout, len(listNodes))
+			for _, node := range listNodes {
+				assertLastSeenSet(t, node)
+			}
 		})
 	}
+
+}
+
+func assertLastSeenSet(t *testing.T, node *v1.Node) {
+	assert.NotNil(t, node)
+	assert.NotNil(t, node.LastSeen)
 }
 
 // This test will first log in two sets of nodes to two sets of users, then