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 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