From 3038aa907abdcd3a13e6722eccc5d3c4f0e35fc6 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Thu, 22 Oct 2026 09:43:31 +0000 Subject: [PATCH] mapper: show an expired peer online, without endpoints or DERP A connected expired node shows online to its peers. Neither its map entry nor endpoint, DERP or disco patches carry the paths to reach it. --- hscontrol/mapper/builder.go | 10 +++ hscontrol/mapper/mapper_test.go | 8 +++ hscontrol/servertest/expiry_compat_test.go | 61 +------------------ hscontrol/servertest/lifecycle_compat_test.go | 5 -- hscontrol/servertest/lifecycle_test.go | 3 +- hscontrol/servertest/reauth_compat_test.go | 2 - hscontrol/state/state.go | 5 ++ hscontrol/types/change/change.go | 9 ++- integration/general_test.go | 6 +- 9 files changed, 39 insertions(+), 70 deletions(-) diff --git a/hscontrol/mapper/builder.go b/hscontrol/mapper/builder.go index 2ff6ffacb..ec701e977 100644 --- a/hscontrol/mapper/builder.go +++ b/hscontrol/mapper/builder.go @@ -323,6 +323,16 @@ func (b *MapResponseBuilder) buildTailPeers(peers views.Slice[types.NodeView]) ( tn.CapMap = policyv2.PeerCapMap(peer, peerCapMaps[peer.ID()]) tn.Hostinfo = types.PeerHostinfo(tn.Hostinfo) + // As on Tailscale SaaS, a connected expired peer shows online, but + // without the endpoints and home DERP region that would reach it. + if peer.IsExpired() { + if peer.ActiveSessions() > 0 { + tn.Online, tn.LastSeen = new(true), nil + } + + tn.Endpoints, tn.HomeDERP = nil, 0 + } + tailPeers = append(tailPeers, tn) } diff --git a/hscontrol/mapper/mapper_test.go b/hscontrol/mapper/mapper_test.go index 7d80db824..641ebbe59 100644 --- a/hscontrol/mapper/mapper_test.go +++ b/hscontrol/mapper/mapper_test.go @@ -1776,6 +1776,14 @@ func (b *MapResponseBuilder) buildTailPeersCapMapsReference(peers views.Slice[ty tn.CapMap = policyv2.PeerCapMap(peer, allCapMaps[peer.ID()]) tn.Hostinfo = types.PeerHostinfo(tn.Hostinfo) + if peer.IsExpired() { + if peer.ActiveSessions() > 0 { + tn.Online, tn.LastSeen = new(true), nil + } + + tn.Endpoints, tn.HomeDERP = nil, 0 + } + tailPeers = append(tailPeers, tn) } diff --git a/hscontrol/servertest/expiry_compat_test.go b/hscontrol/servertest/expiry_compat_test.go index 1a37c4e7a..74a5ccbfb 100644 --- a/hscontrol/servertest/expiry_compat_test.go +++ b/hscontrol/servertest/expiry_compat_test.go @@ -30,68 +30,11 @@ import ( // ///. A listed check that matches fails, so a // fix must delete its entry. var expirySkipReasons = map[string]string{ - "expiry/C2-expire-r1/r1/online-observer": expiredPeerOffline, - "expiry/C2-expire-r1/r1/online-r2": expiredPeerOffline, - "expiry/C2-expire-r1/r1/paths-observer": expiredPeerPaths, - "expiry/C2-expire-r1/r1/paths-r2": expiredPeerPaths, - - "expiry/C4-expire-r2/r2/online-observer": expiredPeerOffline, - "expiry/C4-expire-r2/r2/online-r1": expiredPeerOffline, - "expiry/C4-expire-r2/r2/paths-observer": expiredPeerPaths, - "expiry/C4-expire-r2/r2/paths-r1": expiredPeerPaths, - - // A client asked for this expiry; once it passes the node is expired - // like any other. - "expiry/D3-past-expiry/subject/online-observer": expiredPeerOffline, - "expiry/D3-past-expiry/subject/paths-observer": expiredPeerPaths, - - "expiry/A2-expire/laptop/online-observer": expiredPeerOffline, - "expiry/A2-expire/laptop/online-peer1": expiredPeerOffline, - "expiry/A2-expire/laptop/paths-observer": expiredPeerPaths, - "expiry/A2-expire/laptop/paths-peer1": expiredPeerPaths, - - "expiry/A3-peer-change/laptop/online-observer": expiredPeerOffline, - "expiry/A3-peer-change/laptop/online-peer1": expiredPeerOffline, - "expiry/A3-peer-change/laptop/online-peer3": expiredPeerOffline, - "expiry/A3-peer-change/laptop/paths-observer": expiredPeerPaths, - "expiry/A3-peer-change/laptop/paths-peer1": expiredPeerPaths, - "expiry/A3-peer-change/laptop/paths-peer3": expiredPeerPaths, - - "expiry/A4-rename/laptop/online-observer": expiredPeerOffline, - "expiry/A4-rename/laptop/paths-observer": expiredPeerPaths, - - "expiry/A5-advertise/laptop/online-observer": expiredPeerOffline, - "expiry/A5-advertise/laptop/paths-observer": expiredPeerPaths, - - "expiry/A7-dns/laptop/dns": nameserversFromConfig, - "expiry/A7-dns/laptop/online-observer": expiredPeerOffline, - "expiry/A7-dns/laptop/paths-observer": expiredPeerPaths, - "expiry/A7-dns/observer/dns": nameserversFromConfig, - - "expiry/A8-policy/laptop/online-observer": expiredPeerOffline, - "expiry/A8-policy/laptop/paths-observer": expiredPeerPaths, - - "expiry/A9-ssh/laptop/online-observer": expiredPeerOffline, - "expiry/A9-ssh/laptop/paths-observer": expiredPeerPaths, - - "expiry/A10-reconnect/laptop/online-observer": expiredPeerOffline, - "expiry/A10-reconnect/laptop/paths-observer": expiredPeerPaths, - - "expiry/A6a-expire-again/laptop/online-observer": expiredPeerOffline, - "expiry/A6a-expire-again/laptop/online-peer1": expiredPeerOffline, - "expiry/A6a-expire-again/laptop/paths-observer": expiredPeerPaths, - "expiry/A6a-expire-again/laptop/paths-peer1": expiredPeerPaths, - - "expiry/A6b-rotate-disco/laptop/online-observer": expiredPeerOffline, - "expiry/A6b-rotate-disco/laptop/online-peer1": expiredPeerOffline, - "expiry/A6b-rotate-disco/laptop/online-peer3": expiredPeerOffline, - "expiry/A6b-rotate-disco/laptop/paths-observer": expiredPeerPaths, - "expiry/A6b-rotate-disco/laptop/paths-peer1": expiredPeerPaths, - "expiry/A6b-rotate-disco/laptop/paths-peer3": expiredPeerPaths, + "expiry/A7-dns/laptop/dns": nameserversFromConfig, + "expiry/A7-dns/observer/dns": nameserversFromConfig, } const ( - expiredPeerPaths = "EXPIRED_PEER_PATHS: headscale sends peers an expired node's endpoints and home DERP; SaaS leaves both out" nameserversFromConfig = "NAMESERVERS_FROM_CONFIG: headscale reads nameservers from its config and cannot change them while running; SaaS pushes a change to every node, an expired one too" ) diff --git a/hscontrol/servertest/lifecycle_compat_test.go b/hscontrol/servertest/lifecycle_compat_test.go index a8d430f8f..9a11bf1b9 100644 --- a/hscontrol/servertest/lifecycle_compat_test.go +++ b/hscontrol/servertest/lifecycle_compat_test.go @@ -61,11 +61,6 @@ const ( 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" ) -// Used by reauthSkipReasons. -const ( - expiredPeerOffline = "EXPIRED_PEER_OFFLINE: headscale shows an expired peer offline while it is connected; SaaS shows it online" -) - // TestRouteApprovalCompat replays a user-owned router through admin // approvals and revokes, same-key and forced re-auth, a hostname change, // policy pushes, a withdraw and re-advertise, then logout and a login with diff --git a/hscontrol/servertest/lifecycle_test.go b/hscontrol/servertest/lifecycle_test.go index f50dd831c..968587ff5 100644 --- a/hscontrol/servertest/lifecycle_test.go +++ b/hscontrol/servertest/lifecycle_test.go @@ -180,10 +180,11 @@ func TestNodeExpiryPreservesControlConnection(t *testing.T) { assert.True(c, client.Netmap().SelfNode.KeyExpiry().Before(time.Now())) assert.False(c, client.Netmap().SelfNode.Online().Get()) + // Peers still see the connected node online, as on Tailscale SaaS. peer, found := observer.PeerByName(client.Name) if assert.True(c, found) { assert.True(c, peer.Expired()) - assert.False(c, peer.Online().Get()) + assert.True(c, peer.Online().Get()) } }, 5*time.Second, 10*time.Millisecond, "expiry must take the node offline without ending its control session") diff --git a/hscontrol/servertest/reauth_compat_test.go b/hscontrol/servertest/reauth_compat_test.go index d050864ea..143b0b449 100644 --- a/hscontrol/servertest/reauth_compat_test.go +++ b/hscontrol/servertest/reauth_compat_test.go @@ -37,8 +37,6 @@ import ( // /[/]/. A listed check that matches fails, so a // fix must delete its entry. var reauthSkipReasons = map[string]string{ - "owner-routes/r-expired-retag/expire/online": expiredPeerOffline, - "reauth/late-pak-spent-key/late-pak/text": refusalText, "reauth/late-pak-spent-key/late-pak/legacy-text": refusalText, diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go index e6a153d63..9dd9c4d01 100644 --- a/hscontrol/state/state.go +++ b/hscontrol/state/state.go @@ -4211,6 +4211,11 @@ func buildMapRequestChangeResponse( return change.NodeAdded(id) } + // Peers get an expired node without its endpoints or home DERP region. + if node.IsExpired() { + delta.endpointBroadcast, delta.derpChanged = false, false + } + var currentDERP tailcfg.DERPRegionID if delta.derpChanged { diff --git a/hscontrol/types/change/change.go b/hscontrol/types/change/change.go index 14091bd13..4c757a058 100644 --- a/hscontrol/types/change/change.go +++ b/hscontrol/types/change/change.go @@ -6,6 +6,7 @@ package change import ( "fmt" + "net/netip" "slices" "time" @@ -612,12 +613,18 @@ func NodeKeyRotated(node types.NodeView) Change { expiry = e.UTC() } + // Peers get an expired node without its endpoints. + var endpoints []netip.AddrPort + if !node.IsExpired() { + endpoints = node.Endpoints().AsSlice() + } + c := PeerPatched("node key rotated (relogin)", &tailcfg.PeerChange{ NodeID: tailcfg.NodeID(node.ID()), //nolint:gosec // NodeID is bounded Key: &nk, DiscoKey: &dk, KeyExpiry: &expiry, - Endpoints: node.Endpoints().AsSlice(), + Endpoints: endpoints, }) c.OriginNode = node.ID() diff --git a/integration/general_test.go b/integration/general_test.go index 22624bfb9..db1f1c62c 100644 --- a/integration/general_test.go +++ b/integration/general_test.go @@ -1266,11 +1266,13 @@ func TestExpireNode(t *testing.T) { return } assert.True(c, expired.Expired) - assert.False(c, expired.Online) + // It keeps its control connection, so it stays online to + // its peers, as on Tailscale SaaS. + assert.True(c, expired.Online) if assert.NotNil(c, expired.KeyExpiry) { assert.True(c, expired.KeyExpiry.Before(time.Now())) } - }, integrationutil.StatusReadyTimeout, integrationutil.FastPoll, "peer must see the expired node offline") + }, integrationutil.StatusReadyTimeout, integrationutil.FastPoll, "peer must see the expired node expired and online") require.EventuallyWithT(t, func(c *assert.CollectT) { _, stderr, _ := peer.Execute([]string{"tailscale", "ping", node.Name})