state, change: notify clients only of effective node changes

Send endpoint and DERP reports only to peers. Suppress redundant online,
policy, and key updates on reconnect or unchanged-key login.
This commit is contained in:
Kristoffer Dalby committed 2026-11-02 19:15:40 +01:00
1 parent cb47fdff36
commit 65a4f0d6fc
6 files changed
+141 -79

No files matched your search

+1 -1
View File
@@ -273,7 +273,7 @@ func TestAddToBatchDifferential(t *testing.T) {
lb.b.nodes.Store(99, nil)
}
endpoint := change.EndpointOrDERPUpdate(1, &tailcfg.PeerChange{
endpoint := change.EndpointOrDERPUpdate(&tailcfg.PeerChange{
NodeID: 1, DERPRegion: 7, Online: new(true),
})
ping := change.PingNode(2, &tailcfg.PingRequest{URL: "https://example.com/one"})
@@ -59,26 +59,9 @@ var mapStreamSkipReasons = map[string]string{
"map-delivery/S3.5-remove/db1/frames": policyResponseToEveryNode,
"map-delivery/S3.5-restore/db1/frames": policyResponseToEveryNode,
"map-delivery/S2.1/laptop/frames": ownReportEchoed,
"map-delivery/S2.3/laptop/frames": ownReportEchoed,
"map-delivery/S2.5/laptop/self-endpoints": ownReportEchoed,
"map-delivery/S2.5/laptop/endpoints-gap-bystander": restartEndpointsKept,
"map-delivery/S2.5/laptop/endpoints-gap-svc": restartEndpointsKept,
"map-delivery/S3.1/appc/frames": reconnectAnnounced,
"map-delivery/S3.1/bystander/frames": reconnectAnnounced,
"map-delivery/S3.1/router/frames": reconnectAnnounced,
"map-delivery/S3.1/sshd/frames": reconnectAnnounced,
"map-delivery/S3.1/svc/frames": reconnectAnnounced,
"map-delivery/S3.4/appc/frames": routerReconnectRecompute,
"map-delivery/S3.4/bystander/frames": routerReconnectRecompute,
"map-delivery/S3.4/db1/frames": routerReconnectRecompute,
"map-delivery/S3.4/laptop/frames": routerReconnectRecompute,
"map-delivery/S3.4/sshd/frames": routerReconnectRecompute,
"map-delivery/S3.4/svc/frames": routerReconnectRecompute,
"map-delivery/S3.6/bystander/peers": sameUserPeers,
"map-delivery/S3.6/laptop/peers": sameUserPeers,
@@ -111,20 +94,13 @@ var mapStreamSkipReasons = map[string]string{
"policy-generation/G4/thor-1/peer-owners": sameUserPeers,
"policy-generation/G4/thor-2/peer-owners": sameUserPeers,
"user-role/S1-reconnect/own-a/frames": reconnectAnnounced,
"user-role/S1-reconnect/own-b/frames": reconnectAnnounced,
"user-role/S2-reconnect/own-a/frames": reconnectAnnounced,
"user-role/S0/tag-a/device-user": taggedDeviceUser,
"user-role/S0/tag-b/device-user": taggedDeviceUser,
}
const (
policyResponseToEveryNode = "POLICY_RESPONSE_TO_EVERY_NODE: headscale sends every connected node a policy response when the policy, a node's tags or approved routes, or the set of nodes change; SaaS sends only the nodes whose peers or filter change"
ownReportEchoed = "OWN_REPORT_ECHOED: headscale sends a node its own endpoint or DERP report back; SaaS sends it nothing, so its own entry keeps the endpoints of its last full map"
restartEndpointsKept = "RESTART_ENDPOINTS_KEPT: headscale keeps a restarted node's last endpoints at its peers until it reports new ones; SaaS sends peers the node without endpoints until then"
reconnectAnnounced = "RECONNECT_ANNOUNCED: headscale tells peers a node is online after a stream gap of a few seconds; SaaS sends them nothing"
routerReconnectRecompute = "ROUTER_RECONNECT_RECOMPUTE: headscale sends every node a policy response when a subnet router's stream comes back after a few seconds; SaaS sends nothing"
taggedDeviceUser = "TAGGED_DEVICE_USER: SaaS shows a tagged device's user as the login that made its key; headscale shows tagged-devices"
)
+34
View File
@@ -210,6 +210,40 @@ func TestConnectDisconnectSubnetRouterEmitsPolicyChangeNotFull(t *testing.T) {
})
}
// TestReconnectWhileOnlineAnnouncesNothing pins that a stream which comes
// back while an earlier session still holds the node online, as in the
// reconnect grace, tells peers nothing and recomputes nothing, as on Tailscale
// SaaS; once the node went offline, the next connect announces it again.
func TestReconnectWhileOnlineAnnouncesNothing(t *testing.T) {
_, s, nodeID := persistTestSetup(t)
t.Cleanup(func() { _ = s.Close() })
route := netip.MustParsePrefix("10.0.0.0/24")
_, ok := s.nodeStore.UpdateNode(nodeID, func(n *types.Node) {
n.Hostinfo = &tailcfg.Hostinfo{RoutableIPs: []netip.Prefix{route}}
n.ApprovedRoutes = []netip.Prefix{route}
})
require.True(t, ok)
cs, first := s.Connect(nodeID)
require.True(t, forcesPeerRecompute(cs))
cs, second := s.Connect(nodeID)
assert.Empty(t, cs, "a reconnect while online must change nothing peers see")
cs, err := s.Disconnect(nodeID, first)
require.NoError(t, err)
require.Empty(t, cs)
cs, err = s.Disconnect(nodeID, second)
require.NoError(t, err)
require.True(t, hasPeerPatch(cs))
cs, _ = s.Connect(nodeID)
assert.True(t, hasPeerPatch(cs), "a node that went offline must be announced online")
assert.True(t, forcesPeerRecompute(cs))
}
// drainSessions releases poll sessions on nodeID until the node goes
// offline, returning the changes from the final release (the one that
// emits the offline notifications). Fails the test if the node is
+28 -16
View File
@@ -528,8 +528,9 @@ func TestReauthPreservesEndpointsWhenClientOmitsThem(t *testing.T) {
// TestReauthChange covers the decision both re-auth paths share: a keys-only
// relogin must be an incremental peer patch (so the tailscale client takes its
// fast patch path), never a whole-node add (which strands a re-keyed,
// momentarily-endpoint-less peer disco-deaf); a policy change forces a full
// recompute; a new node is a whole-node add.
// momentarily-endpoint-less peer disco-deaf); a relogin peers read as before
// sends nothing; a policy change forces a full recompute; a new node is a
// whole-node add.
func TestReauthChange(t *testing.T) {
n := types.Node{
ID: 7,
@@ -538,24 +539,28 @@ func TestReauthChange(t *testing.T) {
}
node := n.View()
relogin := reauthChange(node, true, false)
assert.Len(t, relogin.PeerPatches, 1, "relogin must be a peer patch")
assert.Empty(t, relogin.PeersChanged, "relogin must not be a whole-node add")
keys := reauthChange(node, reloginKeys, false)
assert.Len(t, keys.PeerPatches, 1, "relogin must be a peer patch")
assert.Empty(t, keys.PeersChanged, "relogin must not be a whole-node add")
added := reauthChange(node, false, false)
assert.True(t, reauthChange(node, reloginNone, false).IsEmpty(),
"a relogin peers read as before must send nothing")
added := reauthChange(node, reloginWhole, false)
assert.Empty(t, added.PeerPatches)
assert.Len(t, added.PeersChanged, 1, "a new node must be a whole-node add")
pol := reauthChange(node, true, true)
pol := reauthChange(node, reloginKeys, true)
assert.Empty(t, pol.PeerPatches, "a policy change must not be a peer patch")
assert.Empty(t, pol.PeersChanged)
assert.False(t, pol.IsEmpty(), "a policy change must be non-empty")
}
// TestOnlyKeysChanged covers which relogins may ride the key-rotation patch:
// TestReloginChanged covers which relogins may ride the key-rotation patch:
// one that flips Expired or changes Hostinfo peers read must not, as
// [tailcfg.PeerChange] carries neither (#3531).
func TestOnlyKeysChanged(t *testing.T) {
// [tailcfg.PeerChange] carries neither (#3531); one on the same keys and
// expiry needs nothing at all.
func TestReloginChanged(t *testing.T) {
now := time.Now()
past, future := now.Add(-time.Minute), now.Add(time.Hour)
@@ -575,20 +580,27 @@ func TestOnlyKeysChanged(t *testing.T) {
withOSVersion.OSVersion = "6.1"
withService.Services = []tailcfg.Service{{Proto: tailcfg.PeerAPI4, Port: 4242}}
same := node(future, hi)
renewedNode := same.AsStruct()
renewedNode.Expiry = new(future.Add(time.Hour))
renewed := renewedNode.View()
tests := []struct {
name string
before, after types.NodeView
want bool
want relogin
}{
{name: "keys", before: node(future, hi), after: node(future, hi), want: true},
{name: "hostinfo peers do not read", before: node(future, hi), after: node(future, withOSVersion), want: true},
{name: "un-expired", before: node(past, hi), after: node(future, hi)},
{name: "hostinfo peers read", before: node(future, hi), after: node(future, withService)},
{name: "keys", before: node(future, hi), after: node(future, hi), want: reloginKeys},
{name: "hostinfo peers do not read", before: node(future, hi), after: node(future, withOSVersion), want: reloginKeys},
{name: "un-expired", before: node(past, hi), after: node(future, hi), want: reloginWhole},
{name: "hostinfo peers read", before: node(future, hi), after: node(future, withService), want: reloginWhole},
{name: "same keys", before: same, after: same, want: reloginNone},
{name: "same keys, new expiry", before: same, after: renewed, want: reloginKeys},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, onlyKeysChanged(tt.before, tt.after, now))
assert.Equal(t, tt.want, reloginChanged(tt.before, tt.after, now))
})
}
}
+72 -31
View File
@@ -826,9 +826,16 @@ func (s *State) Connect(id types.NodeID) ([]change.Change, uint64) {
// Reconnecting clears Unhealthy: the node just proved basic
// connectivity by completing the Noise handshake.
var epoch uint64
var (
epoch uint64
wasOnline bool
)
node, ok := s.nodeStore.UpdateNode(id, func(n *types.Node) {
// A stream that comes back within the reconnect grace finds the
// node still online: peers saw no change, as on Tailscale SaaS.
wasOnline = n.IsOnline != nil && *n.IsOnline
n.SessionEpoch++
epoch = n.SessionEpoch
@@ -846,17 +853,22 @@ func (s *State) Connect(id types.NodeID) ([]change.Change, uint64) {
return nil, 0
}
log.Info().EmbedObject(node).Msg("node connected")
// A node coming online sends a lightweight online peer patch. Subnet
// routers, relay targets, and via targets get their full peer recompute
// from the gated PolicyChange below, so no full update is needed here.
c := []change.Change{change.NodeOnline(node.ID())}
if !node.Online() {
c[0] = change.NodeAdded(node.ID())
var c []change.Change
switch {
case !node.Online():
c = append(c, change.NodeAdded(node.ID()))
case !wasOnline:
c = append(c, change.NodeOnline(node.ID()))
}
log.Info().EmbedObject(node).Msg("node connected")
if !maps.Equal(prevRoutes, s.nodeStore.PrimaryRoutes()) {
routesMoved := !maps.Equal(prevRoutes, s.nodeStore.PrimaryRoutes())
if routesMoved {
c = append(c, change.NodeAdded(id))
}
@@ -865,7 +877,7 @@ func (s *State) Connect(id types.NodeID) ([]change.Change, uint64) {
// An ordinary node coming online just sends the lightweight online
// patch above; emitting a PolicyChange for it would force every peer
// to rebuild its netmap on every reconnect.
if s.polMan.NodeNeedsPeerRecompute(node) {
if (!wasOnline || routesMoved) && s.polMan.NodeNeedsPeerRecompute(node) {
c = append(c, change.PolicyChange())
}
@@ -2964,7 +2976,7 @@ func (s *State) HandleNodeFromAuthPath(
var (
finalNode types.NodeView
before types.NodeView
keysOnly bool
changed relogin
)
if nodeExists && !existingNode.IsTagged() {
@@ -2975,7 +2987,7 @@ func (s *State) HandleNodeFromAuthPath(
return types.NodeView{}, s.policyChangeSince(genBefore), err
}
keysOnly = onlyKeysChanged(before, finalNode, time.Now())
changed = reloginChanged(before, finalNode, time.Now())
} else if nodeExists {
updateParams.ExistingNode = existingNode
updateParams.IsConvertFromTag = true
@@ -3034,10 +3046,10 @@ func (s *State) HandleNodeFromAuthPath(
policyChanged := !usersChange.IsEmpty() || !nodesChange.IsEmpty()
// keysOnly is set only for a same-user relogin; a tag->user conversion is
// changed is set only for a same-user relogin; a tag->user conversion is
// excluded, as it changes the peer's User — a structural change peers must
// see in full, not a key-rotation patch.
return s.approveRoutesAfterRegistration(before, finalNode, reauthChange(finalNode, keysOnly, policyChanged))
return s.approveRoutesAfterRegistration(before, finalNode, reauthChange(finalNode, changed, policyChanged))
}
func sortedPrefixes(p []netip.Prefix) []netip.Prefix {
@@ -3321,7 +3333,7 @@ func (s *State) HandleNodeFromPreAuthKey(
var (
finalNode types.NodeView
keysOnly bool
changed relogin
)
var before types.NodeView
@@ -3336,7 +3348,7 @@ func (s *State) HandleNodeFromPreAuthKey(
return types.NodeView{}, s.policyChangeSince(genBefore), err
}
keysOnly = onlyKeysChanged(before, finalNode, time.Now())
changed = reloginChanged(before, finalNode, time.Now())
} else {
// Node does not exist for this user with this machine key.
// For a user-owned key, check whether the machine key is already held
@@ -3436,7 +3448,7 @@ func (s *State) HandleNodeFromPreAuthKey(
policyChanged := !usersChange.IsEmpty() || !nodesChange.IsEmpty()
return s.approveRoutesAfterRegistration(before, finalNode, reauthChange(finalNode, keysOnly, policyChanged))
return s.approveRoutesAfterRegistration(before, finalNode, reauthChange(finalNode, changed, policyChanged))
}
// approveRoutesAfterRegistration runs route auto-approval after a
@@ -3747,32 +3759,61 @@ func (s *State) undoReregistration(prior, written *types.Node) {
}
}
// relogin is what a same-user relogin changed that its peers read.
type relogin int
const (
// reloginWhole: something [change.NodeKeyRotated] cannot patch.
reloginWhole relogin = iota
// reloginKeys: only what [change.NodeKeyRotated] patches.
reloginKeys
// reloginNone: nothing, as when a client logs in again on its node key;
// Tailscale SaaS sends its peers nothing then.
reloginNone
)
// reauthChange returns the [change.Change] to broadcast after an authentication
// that updated or created a node. keysOnly marks a same-user relogin that
// changed nothing peers read besides its keys; see [onlyKeysChanged].
// that updated or created a node; r is [reloginWhole] for any but a same-user
// relogin.
//
// Such a relogin is sent as the minimal peer patch [change.NodeKeyRotated].
// Anything else is a whole-node add, and a policy change forces a full
// recompute.
func reauthChange(node types.NodeView, keysOnly, policyChanged bool) change.Change {
// A keys-only relogin is sent as the minimal peer patch
// [change.NodeKeyRotated]. Anything else is a whole-node add, and a policy
// change forces a full recompute.
func reauthChange(node types.NodeView, r relogin, policyChanged bool) change.Change {
switch {
case policyChanged:
return change.PolicyChange()
case keysOnly:
case r == reloginNone:
return change.Change{}
case r == reloginKeys:
return change.NodeKeyRotated(node)
default:
return change.NodeAdded(node.ID())
}
}
// onlyKeysChanged reports whether peers read before and after the same, apart
// from what [change.NodeKeyRotated] patches. [tailcfg.PeerChange] carries
// neither [tailcfg.Node.Expired] nor Hostinfo: peers left holding an expired
// node drop its WireGuard handshakes, and the relogin stored the Hostinfo, so
// no later MapRequest shows the change.
func onlyKeysChanged(before, after types.NodeView, now time.Time) bool {
return before.IsExpiredAt(now) == after.IsExpiredAt(now) &&
peerHostinfoEqual(before.Hostinfo(), after.Hostinfo())
// reloginChanged compares what peers read before and after a relogin.
// [tailcfg.PeerChange] carries neither [tailcfg.Node.Expired] nor Hostinfo:
// peers left holding an expired node drop its WireGuard handshakes, and the
// relogin stored the Hostinfo, so no later MapRequest shows the change.
func reloginChanged(before, after types.NodeView, now time.Time) relogin {
expiry := func(n types.NodeView) time.Time {
e, _ := n.Expiry().GetOk()
return e
}
switch {
case before.IsExpiredAt(now) != after.IsExpiredAt(now) ||
!peerHostinfoEqual(before.Hostinfo(), after.Hostinfo()):
return reloginWhole
case before.NodeKey() == after.NodeKey() && before.DiscoKey() == after.DiscoKey() &&
expiry(before).Equal(expiry(after)) &&
views.SliceEqual(before.Endpoints(), after.Endpoints()):
return reloginNone
default:
return reloginKeys
}
}
// updatePolicyManagerUsers pushes the current user list into the policy
@@ -4302,7 +4343,7 @@ func buildMapRequestChangeResponse(
patch.DERPRegion = currentDERP
}
return change.EndpointOrDERPUpdate(id, patch)
return change.EndpointOrDERPUpdate(patch)
}
return change.Change{}
+6 -7
View File
@@ -595,13 +595,12 @@ func KeyExpiryFor(id types.NodeID, expiry time.Time) Change {
return c
}
// EndpointOrDERPUpdate returns a [Change] for when a node's endpoints or DERP region changes.
// The [Change.OriginNode] field enables self-update detection by the mapper.
func EndpointOrDERPUpdate(id types.NodeID, patch *tailcfg.PeerChange) Change {
c := PeerPatched("endpoint/DERP update", patch)
c.OriginNode = id
return c
// EndpointOrDERPUpdate returns a [Change] for when a node reports new
// endpoints or a new DERP region. It has no [Change.OriginNode]: the node
// already knows what it reported, so, as on Tailscale SaaS, only its peers
// are sent the patch.
func EndpointOrDERPUpdate(patch *tailcfg.PeerChange) Change {
return PeerPatched("endpoint/DERP update", patch)
}
// NodeKeyRotated returns a [Change] for a node re-logging in: its NodeKey (and