state: mark expired nodes offline without ending the session

Online now requires a live session and an unexpired key, derived in one
place by Node.ShouldBeOnline so every writer agrees what online means.

Fixes #3470

Connect keeps 0.29's NodeOnlineFor peer patch; only the not-online branch
is new here. The updateChanges hunk is dropped: 0.29 has no caller for it.

(cherry picked from commit 80c863b15a)
This commit is contained in:
Kristoffer Dalby
2026-09-23 07:45:17 +00:00
parent cc41593d9d
commit a91f708adc
5 changed files with 348 additions and 31 deletions
+29 -4
View File
@@ -176,6 +176,8 @@ type Node struct {
UpdatedAt time.Time
DeletedAt *time.Time
// IsOnline caches [Node.ShouldBeOnline]; read it through [Node.Online].
// Every writer must derive it, so online means the same thing everywhere.
IsOnline *bool `gorm:"-"`
// Unhealthy excludes the node from primary route election while
@@ -184,10 +186,9 @@ type Node struct {
// ActiveSessions counts live poll sessions for this node.
// [State.Connect] increments it and every session release
// ([State.Disconnect]) decrements it, so the node goes offline
// exactly when its last session ends — regardless of the order in
// which overlapping sessions' cleanups run. Never persisted, like
// SessionEpoch.
// ([State.Disconnect]) decrements it. Releasing the last session
// takes the node offline; expiry can take it offline while sessions
// remain. Never persisted, like SessionEpoch.
ActiveSessions int `gorm:"-"`
// SessionEpoch identifies a poll session generation; Connect bumps
@@ -223,6 +224,21 @@ func (node *Node) IsExpired() bool {
return time.Since(*node.Expiry) > 0
}
// Online reports the node's last known connectivity. Unknown counts as
// offline. Use [Node.ShouldBeOnline] to derive the value, not to read it.
func (node *Node) Online() bool {
return node.IsOnline != nil && *node.IsOnline
}
// ShouldBeOnline derives what [Node.IsOnline] must hold from its two inputs:
// a live control session and an unexpired node key. An expired client keeps
// polling control to receive auth updates, so a session alone is not enough.
// Call it only from a NodeStore write closure, where ActiveSessions is
// stable; elsewhere read [Node.Online].
func (node *Node) ShouldBeOnline() bool {
return node.ActiveSessions > 0 && !node.IsExpired()
}
// IsEphemeral returns if the node is registered as an Ephemeral node.
// https://tailscale.com/docs/features/ephemeral-nodes
func (node *Node) IsEphemeral() bool {
@@ -1000,6 +1016,15 @@ func (nv NodeView) IsExpired() bool {
return nv.ж.IsExpired()
}
// Online reports the node's last known connectivity.
func (nv NodeView) Online() bool {
if !nv.Valid() {
return false
}
return nv.ж.Online()
}
// IsEphemeral returns if the node is registered as an Ephemeral node.
// https://tailscale.com/docs/features/ephemeral-nodes
func (nv NodeView) IsEphemeral() bool {