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.
This commit is contained in:
Kristoffer Dalby committed 2026-10-30 14:55:37 +00:00
1 parent 6aed0d5f07
commit 5c5fc4feb7
9 files changed
+39 -70

No files matched your search

+10
View File
@@ -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)
}
+8
View File
@@ -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)
}
+2 -59
View File
@@ -30,68 +30,11 @@ import (
// <test_id>/<step>/<node>/<check>. 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"
)
@@ -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
+2 -1
View File
@@ -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")
@@ -37,8 +37,6 @@ import (
// <test_id>/<step>[/<node>]/<check>. 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,
+5
View File
@@ -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 {
+8 -1
View File
@@ -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()
+4 -2
View File
@@ -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})