ipn/ipnlocal: add netmap mutations to the ipn bus (#19120)

ipn/local: add netmap mutations to the ipn bus

updates tailscale/tailscale#1909

This adds a new new NotifyWatchOpt that allows watchers to
receive PeerChange events (derived from node mutations)
on the IPN bus in lieu of a complete netmap.  We'll continue
to send the full netmap for any map response that includes it,
but for  mutations, sending PeerChange events gives the client
the option to manage it's own models more selectively and cuts
way down on json serialization overhead.

On chatty tailnets, this will vastly reduce the amount of
chatter on the bus.

This change should be backwards compatible, it is
purely additive.  Clients that subscribe to NotifyNetmap will
get the full netmap for every delta.  New clients can
omit that and instead opt into NotifyPeerChanges.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
This commit is contained in:
Jonathan Nobels
2026-04-09 15:45:41 -04:00
committed by GitHub
parent 6b7caaf7ee
commit 03c3551ee5
4 changed files with 242 additions and 13 deletions
+55 -2
View File
@@ -8,6 +8,7 @@ import (
"time"
"tailscale.com/ipn"
"tailscale.com/tailcfg"
"tailscale.com/tstime"
)
@@ -116,8 +117,8 @@ func (s *rateLimitingBusSender) Run(ctx context.Context, ch <-chan *ipn.Notify)
}
}
// mergeBoringNotify merges new notify 'src' into possibly-nil 'dst',
// either mutating 'dst' or allocating a new one if 'dst' is nil,
// mergeBoringNotify merges new notify src into possibly-nil dst,
// either mutating dst or allocating a new one if dst is nil,
// returning the merged result.
//
// dst and src must both be "boring" (i.e. not notable per isNotifiableNotify).
@@ -127,6 +128,9 @@ func mergeBoringNotifies(dst, src *ipn.Notify) *ipn.Notify {
}
if src.NetMap != nil {
dst.NetMap = src.NetMap
dst.PeerChanges = nil // full netmap supersedes any accumulated deltas
} else if src.PeerChanges != nil {
dst.PeerChanges = mergePeerChanges(dst.PeerChanges, src.PeerChanges)
}
if src.Engine != nil {
dst.Engine = src.Engine
@@ -134,6 +138,55 @@ func mergeBoringNotifies(dst, src *ipn.Notify) *ipn.Notify {
return dst
}
// mergePeerChanges merges new peer changes from src into dst, either
// mutating dst or allocating a new slice if dst is nil, returning the merged result.
// Values in src override those in dst for the same NodeID.
func mergePeerChanges(dst, src []*tailcfg.PeerChange) []*tailcfg.PeerChange {
idxByNode := make(map[tailcfg.NodeID]int, len(dst))
for i, d := range dst {
idxByNode[d.NodeID] = i
}
for _, nd := range src {
if oi, ok := idxByNode[nd.NodeID]; ok {
dst[oi] = mergePeerChangeForIpnBus(dst[oi], nd)
continue
}
idxByNode[nd.NodeID] = len(dst)
dst = append(dst, nd)
}
return dst
}
// mergePeerChangeForIpnBus merges new with old, returning the result.
// Fields set in new override those in old; fields only set in old are preserved.
func mergePeerChangeForIpnBus(old, new *tailcfg.PeerChange) *tailcfg.PeerChange {
merged := *old
// This is a subset of PeerChange that reflects only the fields that can
// be changed via a NodeMutation. If future fields can be updated via
// NodeMutations from map responses (and they are relevant to the ipn bus), then
// they should be added here and merged in the same way.
if new.DERPRegion != 0 {
// netmap.NodeMutationDerpHome
merged.DERPRegion = new.DERPRegion
}
if new.Online != nil {
// netmap.NodeMutationOnline
merged.Online = new.Online
}
if new.LastSeen != nil {
// netmap.NodeMutationLastSeen
merged.LastSeen = new.LastSeen
}
if new.Endpoints != nil {
// netmap.NodeMutationEndpoints
merged.Endpoints = new.Endpoints
}
return &merged
}
// isNotableNotify reports whether n is a "notable" notification that
// should be sent on the IPN bus immediately (e.g. to GUIs) without
// rate limiting it for a few seconds.