ipn/ipnlocal, control/controlclient: process node adds/removes in constant time

For large tailnets (~50k+ nodes) with frequent peer churn (ephemeral
GitHub Actions workers etc.), tailscaled used to rebuild the full
netmap and fan it out on the IPN bus on every MapResponse that
added or removed a peer. There were two O(N) costs per delta: the
full netmap rebuild + every Notify.NetMap encode to every bus watcher.

This change tackles both:

  1. Plumb O(1) peer add/remove through the delta path. PeersChanged
     and PeersRemoved no longer prevent the delta happy path; instead,
     they mutate the per-node-backend peer map in place.

  2. Restrict ipn.Notify.NetMap emission to the platforms whose host
     GUIs still depend on it (Windows, macOS, iOS) and migrate
     in-tree consumers off it everywhere else:

     - Migrate reactive consumers (containerboot, kube agents,
       sniproxy, tsconsensus, etc.) off Notify.NetMap to the
       previously-added Notify.SelfChange signal so they no longer
       have to subscribe to the full netmap.
     - Add ipn.NotifyNoNetMap so GUI clients on "legacy-emit" platforms
       that have already migrated can opt out of the per-watcher
       NetMap encode.
     - Gate Notify.NetMap emission on the producer side by a compile-
       time GOOS check, so the supporting code is dead-code-eliminated
       on Linux and other geese where no GUI consumer needs it.

Re-running BenchmarkGiantTailnet from tstest/largetailnet, which was
added along with baseline numbers on unmodified main in ad5436af0d,
the per-delta cost (one peer add+remove pair) is now ~O(1) regardless
of tailnet size N:

    N         no-watcher (ms/op)            bus-watcher (ms/op)
              before    now     factor      before    now     factor
     10000        32   0.11       300x         166   0.13      1300x
     50000       222   0.11      2000x         865   0.13      6700x
    100000       504   0.12      4100x        1765   0.13     13400x
    250000      1551   0.12     12500x        4696   0.15     32400x

Updates #12542

Change-Id: I94e34b37331d1a8ec74c299deffadf4d061fda9e
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
Brad Fitzpatrick
2026-05-21 09:26:19 -07:00
committed by Brad Fitzpatrick
parent 2703f91174
commit aa5da2e5f2
23 changed files with 1521 additions and 211 deletions
+37 -10
View File
@@ -5,13 +5,30 @@ package ipnlocal
import (
"context"
"runtime"
"time"
"tailscale.com/ipn"
"tailscale.com/tailcfg"
"tailscale.com/tstime"
"tailscale.com/util/mak"
)
// goosGetsLegacyNetmapNotify reports whether tailscaled, when running on the
// current GOOS, still emits the legacy [ipn.Notify.NetMap] field on runtime
// (non-initial) bus messages. It is true on platforms whose host GUIs have
// not yet finished migrating to the narrower bus signals
// ([ipn.Notify.SelfChange] / [ipn.Notify.PeerChanges]) and the on-demand
// [LocalClient.NetMap] fetch.
//
// runtime.GOOS is a compile-time constant, so the producer-side code that
// builds and ships NetMap on the bus is dead-code-eliminated on Linux and
// other geese where this is false.
const goosGetsLegacyNetmapNotify = runtime.GOOS == "windows" ||
runtime.GOOS == "darwin" ||
runtime.GOOS == "ios" ||
runtime.GOOS == "android"
type rateLimitingBusSender struct {
fn func(*ipn.Notify) (keepGoing bool)
lastFlush time.Time // last call to fn, or zero value if none
@@ -126,11 +143,21 @@ func mergeBoringNotifies(dst, src *ipn.Notify) *ipn.Notify {
if dst == nil {
dst = &ipn.Notify{Version: src.Version}
}
if src.NetMap != nil {
if goosGetsLegacyNetmapNotify && src.NetMap != nil {
// Full netmap supersedes any accumulated peer-change deltas.
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)
dst.PeerChangedPatch = nil
} else if src.PeerChangedPatch != nil {
dst.PeerChangedPatch = mergePeerChangedPatch(dst.PeerChangedPatch, src.PeerChangedPatch)
}
if len(src.PeersChanged) > 0 {
dst.PeersChanged = append(dst.PeersChanged, src.PeersChanged...)
}
if len(src.PeersRemoved) > 0 {
dst.PeersRemoved = append(dst.PeersRemoved, src.PeersRemoved...)
}
for id, up := range src.UserProfiles {
mak.Set(&dst.UserProfiles, id, up)
}
if src.Engine != nil {
dst.Engine = src.Engine
@@ -138,10 +165,10 @@ 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 {
// mergePeerChangedPatch merges new peer-changed patches 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 mergePeerChangedPatch(dst, src []*tailcfg.PeerChange) []*tailcfg.PeerChange {
idxByNode := make(map[tailcfg.NodeID]int, len(dst))
for i, d := range dst {
idxByNode[d.NodeID] = i
@@ -191,8 +218,7 @@ func mergePeerChangeForIpnBus(old, new *tailcfg.PeerChange) *tailcfg.PeerChange
// should be sent on the IPN bus immediately (e.g. to GUIs) without
// rate limiting it for a few seconds.
//
// It effectively reports whether n contains any field set that's
// not NetMap or Engine.
// PeerChanges and Engine are the only "boring" (rate-limitable) fields.
func isNotableNotify(n *ipn.Notify) bool {
if n == nil {
return false
@@ -206,6 +232,7 @@ func isNotableNotify(n *ipn.Notify) bool {
n.ErrMessage != nil ||
n.LoginFinished != nil ||
n.SelfChange != nil ||
n.InitialStatus != nil ||
!n.DriveShares.IsNil() ||
n.Health != nil ||
len(n.IncomingFiles) > 0 ||
+19 -14
View File
@@ -30,7 +30,10 @@ func TestIsNotableNotify(t *testing.T) {
{"empty", &ipn.Notify{}, false},
{"version", &ipn.Notify{Version: "foo"}, false},
{"netmap", &ipn.Notify{NetMap: new(netmap.NetworkMap)}, false},
{"peerchanges", &ipn.Notify{PeerChanges: []*tailcfg.PeerChange{{}}}, false},
{"peerchanges", &ipn.Notify{PeerChangedPatch: []*tailcfg.PeerChange{{}}}, false},
{"peerschanged", &ipn.Notify{PeersChanged: []*tailcfg.Node{{}}}, false},
{"peersremoved", &ipn.Notify{PeersRemoved: []tailcfg.NodeID{1}}, false},
{"userprofiles", &ipn.Notify{UserProfiles: map[tailcfg.UserID]tailcfg.UserProfileView{1: (&tailcfg.UserProfile{}).View()}}, false},
{"engine", &ipn.Notify{Engine: new(ipn.EngineStatus)}, false},
{"selfchange", &ipn.Notify{SelfChange: &tailcfg.Node{}}, true},
}
@@ -42,7 +45,7 @@ func TestIsNotableNotify(t *testing.T) {
for sf := range rt.Fields() {
n := &ipn.Notify{}
switch sf.Name {
case "_", "NetMap", "PeerChanges", "SelfChange", "Engine", "Version":
case "_", "NetMap", "PeerChangedPatch", "SelfChange", "PeersChanged", "PeersRemoved", "UserProfiles", "Engine", "Version":
// Already covered above or not applicable.
continue
case "DriveShares":
@@ -123,8 +126,10 @@ func (st *rateLimitingBusSenderTester) advance(d time.Duration) {
}
func TestRateLimitingBusSender(t *testing.T) {
nm1 := &ipn.Notify{NetMap: new(netmap.NetworkMap)}
nm2 := &ipn.Notify{NetMap: new(netmap.NetworkMap)}
// Both share NodeID 1 so merge collapses to a single PeerChange and
// the later one (nm2) wins.
nm1 := &ipn.Notify{PeerChangedPatch: []*tailcfg.PeerChange{{NodeID: 1, DERPRegion: 1}}}
nm2 := &ipn.Notify{PeerChangedPatch: []*tailcfg.PeerChange{{NodeID: 1, DERPRegion: 2}}}
eng1 := &ipn.Notify{Engine: new(ipn.EngineStatus)}
eng2 := &ipn.Notify{Engine: new(ipn.EngineStatus)}
@@ -163,8 +168,8 @@ func TestRateLimitingBusSender(t *testing.T) {
t.Fatalf("got %d items; want 2", len(st.got))
}
gotn := st.got[1]
if gotn.NetMap != nm2.NetMap {
t.Errorf("got wrong NetMap; got %p", gotn.NetMap)
if !reflect.DeepEqual(gotn.PeerChangedPatch, nm2.PeerChangedPatch) {
t.Errorf("got wrong PeerChangedPatch; got %v want %v", gotn.PeerChangedPatch, nm2.PeerChangedPatch)
}
if gotn.Engine != eng2.Engine {
t.Errorf("got wrong Engine; got %p", gotn.Engine)
@@ -208,8 +213,8 @@ func TestRateLimitingBusSender(t *testing.T) {
st.advance(5 * time.Second)
select {
case n := <-flushc:
if n.NetMap != nm2.NetMap {
t.Errorf("got wrong NetMap; got %p", n.NetMap)
if !reflect.DeepEqual(n.PeerChangedPatch, nm2.PeerChangedPatch) {
t.Errorf("got wrong PeerChangedPatch; got %v want %v", n.PeerChangedPatch, nm2.PeerChangedPatch)
}
case <-time.After(10 * time.Second):
t.Error("timeout")
@@ -221,7 +226,7 @@ func TestRateLimitingBusSender(t *testing.T) {
})
}
func TestMergePeerChanges(t *testing.T) {
func TestMergePeerChangedPatch(t *testing.T) {
online := true
offline := false
@@ -232,7 +237,7 @@ func TestMergePeerChanges(t *testing.T) {
new := []*tailcfg.PeerChange{
{NodeID: 2, DERPRegion: 2},
}
got := mergePeerChanges(old, new)
got := mergePeerChangedPatch(old, new)
if len(got) != 2 {
t.Fatalf("len = %d; want 2", len(got))
}
@@ -249,7 +254,7 @@ func TestMergePeerChanges(t *testing.T) {
new := []*tailcfg.PeerChange{
{NodeID: 1, DERPRegion: 5, Online: &offline},
}
got := mergePeerChanges(old, new)
got := mergePeerChangedPatch(old, new)
if len(got) != 2 {
t.Fatalf("len = %d; want 2 (merged, not appended)", len(got))
}
@@ -273,7 +278,7 @@ func TestMergePeerChanges(t *testing.T) {
{NodeID: 1, DERPRegion: 2},
{NodeID: 3, DERPRegion: 30},
}
got := mergePeerChanges(old, new)
got := mergePeerChangedPatch(old, new)
if len(got) != 2 {
t.Fatalf("len = %d; want 2", len(got))
}
@@ -292,7 +297,7 @@ func TestMergePeerChanges(t *testing.T) {
new := []*tailcfg.PeerChange{
{NodeID: 1, Online: &offline},
}
got := mergePeerChanges(old, new)
got := mergePeerChangedPatch(old, new)
if len(got) != 1 {
t.Fatalf("len = %d; want 1", len(got))
}
@@ -311,7 +316,7 @@ func TestMergePeerChanges(t *testing.T) {
new := []*tailcfg.PeerChange{
{NodeID: 1, DERPRegion: 1},
}
got := mergePeerChanges(nil, new)
got := mergePeerChangedPatch(nil, new)
if len(got) != 1 {
t.Fatalf("len = %d; want 1", len(got))
}
+308 -70
View File
@@ -152,6 +152,18 @@ type watchSession struct {
sessionID string
cancel context.CancelFunc // to shut down the session
mask ipn.NotifyWatchOpt // watch options for this session
// lastSentUserProfile is the per-UserID [tailcfg.UserProfileView]
// most recently delivered to this session via [Notify.UserProfiles].
// On a subsequent send, an incoming entry whose
// [tailcfg.UserProfileView.Equal] reports identity-or-equal-fields
// to the stored view for that UserID is dropped from the
// per-session copy of [Notify.UserProfiles], so the session only
// sees genuinely new or changed profiles. The views share backing
// memory with the producer's tracking maps, so the common
// "control re-announces the same profile" case is a pointer-cheap
// equality check.
lastSentUserProfile map[tailcfg.UserID]tailcfg.UserProfileView
}
var (
@@ -1338,7 +1350,13 @@ func (b *LocalBackend) UpdateStatus(sb *ipnstate.StatusBuilder) {
b.mu.Lock()
defer b.mu.Unlock()
b.updateStatusLocked(sb)
}
// updateStatusLocked is the b.mu-holding portion of [LocalBackend.UpdateStatus].
//
// b.mu must be held.
func (b *LocalBackend) updateStatusLocked(sb *ipnstate.StatusBuilder) {
cn := b.currentNode()
nm := cn.NetMap()
sb.MutateStatus(func(s *ipnstate.Status) {
@@ -1540,9 +1558,9 @@ func (b *LocalBackend) WhoIsNodeKey(k key.NodePublic) (n tailcfg.NodeView, u tai
cn := b.currentNode()
if nid, ok := cn.NodeByKey(k); ok {
if n, ok := cn.NodeByID(nid); ok {
up, ok := cn.NetMap().UserProfiles[n.User()]
up, _ := cn.UserByID(n.User())
u = profileFromView(up)
return n, u, ok
return n, u, true
}
}
return n, u, false
@@ -1638,14 +1656,32 @@ func (b *LocalBackend) PeerCapsForService(src netip.Addr, svcName tailcfg.Servic
// given NodeID, in O(1) time. It returns ok=false if no such peer is in
// the current netmap.
//
// It is intended for callers that need the latest state of a single peer
// without fetching the entire netmap.
// It is intended for callers that observed a peer-mutation signal (e.g.
// [ipn.Notify.PeerChangedPatch] or [ipn.Notify.PeersChanged]) and want
// the latest state of the affected node without having to apply the patch
// themselves — useful for older clients that don't recognize a new
// [tailcfg.PeerChange] field, or that just don't want to bother.
func (b *LocalBackend) PeerByID(id tailcfg.NodeID) (n tailcfg.NodeView, ok bool) {
return b.currentNode().NodeByID(id)
}
// UserProfile returns the current [tailcfg.UserProfile] for the given UserID,
// in O(1) time. It returns ok=false if no such User is in the current netmap.
//
// It is the LocalAPI/LocalBackend fallback for IPN-bus consumers that see a
// UserID they don't recognize and want to resolve it.
func (b *LocalBackend) UserProfile(id tailcfg.UserID) (u tailcfg.UserProfileView, ok bool) {
return b.currentNode().UserByID(id)
}
func (b *LocalBackend) GetFilterForTest() *filter.Filter {
testenv.AssertInTest()
// Take b.mu so the read serializes with [setControlClientStatusLocked],
// which installs the netmap and the filter at separate sub-steps. Without
// this, a test thread that observes the new netmap (via [NetMapWithPeers])
// can race ahead of the filter store and read the previous filter.
b.mu.Lock()
defer b.mu.Unlock()
nb := b.currentNode()
return nb.filterAtomic.Load()
}
@@ -1922,13 +1958,17 @@ func (b *LocalBackend) setControlClientStatusLocked(c controlclient.Client, st c
// Notify watchers that the self node may have changed. Reactive
// consumers (containerboot, kube agents, sniproxy, etc.) listen on
// this signal and re-fetch peers/DNS via the LocalAPI if they need
// more than self info.
// this signal and re-fetch peers/DNS via [LocalClient.NetMap] if
// they need more than self info.
var selfChange *tailcfg.Node
if st.NetMap.SelfNode.Valid() {
selfChange = st.NetMap.SelfNode.AsStruct()
}
b.sendLocked(ipn.Notify{NetMap: st.NetMap, SelfChange: selfChange})
notify := ipn.Notify{SelfChange: selfChange}
if goosGetsLegacyNetmapNotify {
notify.NetMap = st.NetMap
}
b.sendLocked(notify)
// The error here is unimportant as is the result. This will recalculate the suggested exit node
// cache the value and push any changes to the IPN bus.
@@ -2218,7 +2258,11 @@ func (b *LocalBackend) sysPolicyChanged(policy policyclient.PolicyChange) {
}
}
var _ controlclient.NetmapDeltaUpdater = (*LocalBackend)(nil)
var (
_ controlclient.NetmapDeltaUpdater = (*LocalBackend)(nil)
_ controlclient.PacketFilterUpdater = (*LocalBackend)(nil)
_ controlclient.UserProfileUpdater = (*LocalBackend)(nil)
)
// UpdateNetmapDelta implements controlclient.NetmapDeltaUpdater.
func (b *LocalBackend) UpdateNetmapDelta(muts []netmap.NodeMutation) (handled bool) {
@@ -2235,9 +2279,27 @@ func (b *LocalBackend) UpdateNetmapDelta(muts []netmap.NodeMutation) (handled bo
cn := b.currentNode()
cn.UpdateNetmapDelta(muts)
if ms, ok := b.sys.MagicSock.GetOK(); ok {
ms.UpdateNetmapDelta(muts)
// Dispatch Add/Remove per-peer to magicsock, and any per-field
// patches via the existing UpdateNetmapDelta path. The per-peer
// methods take c.mu themselves, so we can't call them from inside
// magicsock.UpdateNetmapDelta which already holds c.mu.
peersAddedOrRemoved := false
ms := b.MagicConn()
for _, m := range muts {
switch m := m.(type) {
case netmap.NodeMutationAdd:
ms.UpsertPeer(m.Node)
peersAddedOrRemoved = true
metricNetmapDeltaPeerAdded.Add(1)
case netmap.NodeMutationRemove:
ms.RemovePeer(m.NodeIDBeingMutated())
peersAddedOrRemoved = true
metricNetmapDeltaPeerRemoved.Add(1)
default:
metricNetmapDeltaPeerPatched.Add(1)
}
}
ms.UpdateNetmapDelta(muts)
// If auto exit nodes are enabled and our exit node went offline,
// we need to schedule picking a new one.
@@ -2268,15 +2330,30 @@ func (b *LocalBackend) UpdateNetmapDelta(muts []netmap.NodeMutation) (handled bo
return true
}
if mutationsAreWorthyOfTellingIPNBus(muts) {
// The notifier will strip the netmap based on the watchOpts mask if the watcher
// has indicated it can handle PeerChanges.
notify = &ipn.Notify{NetMap: cn.netMapWithPeers()}
if peerChanges, ok := ipnBusPeerChangesFromNodeMutations(muts); ok {
notify.PeerChanges = peerChanges
} else {
// A single MapResponse can carry adds/removes (full Nodes) AND
// per-field patches in the same delta. Build one Notify that
// reflects all of them; per-session stripping in [sendToLocked]
// hides fields the watcher didn't opt in to (and promotes patches
// into full Nodes for watchers that asked for PeerChanges but not
// PeerPatches).
if peersAddedOrRemoved || mutationsAreWorthyOfTellingIPNBus(muts) {
notify = &ipn.Notify{}
for _, m := range muts {
switch m := m.(type) {
case netmap.NodeMutationAdd:
notify.PeersChanged = append(notify.PeersChanged, m.Node.AsStruct())
case netmap.NodeMutationRemove:
notify.PeersRemoved = append(notify.PeersRemoved, m.NodeIDBeingMutated())
}
}
if patches, ok := ipnBusPeerChangedPatchFromNodeMutations(muts); ok && len(patches) > 0 {
notify.PeerChangedPatch = patches
} else if !ok {
b.logf("[unexpected] got mutations worthy of telling IPN bus but failed to convert to peer changes")
}
if goosGetsLegacyNetmapNotify {
notify.NetMap = cn.netMapWithPeers()
}
} else if testenv.InTest() {
// In tests, send an empty Notify as a wake-up so end-to-end
// integration tests in another repo can check on the status of
@@ -2286,6 +2363,58 @@ func (b *LocalBackend) UpdateNetmapDelta(muts []netmap.NodeMutation) (handled bo
return true
}
// UpdatePacketFilter implements [controlclient.PacketFilterUpdater].
//
// It is called by the controlclient when a MapResponse carries a new packet
// filter. Avoiding a full netmap rebuild matters here because the packet
// filter currently changes on every peer add on large tailnets.
func (b *LocalBackend) UpdatePacketFilter(rules views.Slice[tailcfg.FilterRule], parsed []filter.Match) bool {
b.mu.Lock()
defer b.mu.Unlock()
cn := b.currentNode()
if cn.NetMap() == nil {
// No netmap installed yet; the initial full-netmap path will
// take care of installing the filter.
return false
}
metricUpdatePacketFilter.Add(1)
cn.setPacketFilter(rules, parsed)
b.updateFilterLocked(b.pm.CurrentPrefs())
return true
}
// UpdateUserProfiles implements [controlclient.UserProfileUpdater].
//
// It is called by the controlclient when a MapResponse carries new or
// updated [tailcfg.UserProfileView] entries. It merges them into the
// current netmap's UserProfiles so [LocalBackend.UserProfile] can
// resolve them, and emits an [ipn.Notify] with [Notify.UserProfiles]
// populated so IPN-bus consumers (sessions opted in to
// NotifyPeerChanges / NotifyPeerPatches) get the new profiles before
// any subsequent PeersChanged / PeerChangedPatch entries that reference
// these UserIDs.
//
// The views in profiles share backing memory with the controlclient
// caller's tracking map; nodeBackend stores them as-is, and per-bus
// sessions can dedup via [UserProfileView.Equal] without copying.
func (b *LocalBackend) UpdateUserProfiles(profiles map[tailcfg.UserID]tailcfg.UserProfileView) bool {
if len(profiles) == 0 {
return true
}
b.mu.Lock()
defer b.mu.Unlock()
cn := b.currentNode()
if cn.NetMap() == nil {
// No netmap installed yet; the initial full-netmap path will
// take care of installing UserProfiles.
return false
}
metricUpdateUserProfiles.Add(1)
cn.mergeUserProfiles(profiles)
b.sendLocked(ipn.Notify{UserProfiles: profiles})
return true
}
// mustationsAreWorthyOfRecalculatingSuggestedExitNode reports whether any mutation type in muts is
// worthy of recalculating the suggested exit node.
func mutationsAreWorthyOfRecalculatingSuggestedExitNode(muts []netmap.NodeMutation, cn *nodeBackend, sid tailcfg.StableNodeID) bool {
@@ -2324,32 +2453,40 @@ func mutationsAreWorthyOfRecalculatingSuggestedExitNode(muts []netmap.NodeMutati
return false
}
// ipnBusPeerChangesFromNodeMutations converts a slice of NodeMutations to a slice of
// *tailcfg.PeerChange for use in ipn.Notify.PeerChanges.
// Multiple mutations to the same node are merged into a single PeerChange.
// If we encounter any mutations that we cannot convert to a PeerChange, we return (nil, false)
// to indicate that the caller should send a Notify with the full netmap instead of
// trying to send granular peer changes.
func ipnBusPeerChangesFromNodeMutations(muts []netmap.NodeMutation) ([]*tailcfg.PeerChange, bool) {
// ipnBusPeerChangedPatchFromNodeMutations converts the patch-shaped subset of
// muts (per-field updates that fit in a [tailcfg.PeerChange]) into a slice of
// [tailcfg.PeerChange] for use in [ipn.Notify.PeerChangedPatch]. Multiple
// mutations against the same node are merged into a single PeerChange.
//
// Add/Remove mutations are skipped (they ride
// [ipn.Notify.PeersChanged]/[ipn.Notify.PeersRemoved]). Any other mutation
// type that doesn't fit a [tailcfg.PeerChange] causes ok=false; the caller
// should fall back to a full netmap rebuild.
func ipnBusPeerChangedPatchFromNodeMutations(muts []netmap.NodeMutation) ([]*tailcfg.PeerChange, bool) {
byID := map[tailcfg.NodeID]*tailcfg.PeerChange{}
var ordered []*tailcfg.PeerChange
for _, m := range muts {
nid := m.NodeIDBeingMutated()
getOrAdd := func(nid tailcfg.NodeID) *tailcfg.PeerChange {
pc := byID[nid]
if pc == nil {
pc = &tailcfg.PeerChange{NodeID: nid}
byID[nid] = pc
ordered = append(ordered, pc)
}
return pc
}
for _, m := range muts {
switch v := m.(type) {
case netmap.NodeMutationAdd, netmap.NodeMutationRemove:
// These go in PeersChanged / PeersRemoved, not as patches.
continue
case netmap.NodeMutationOnline:
pc.Online = &v.Online
getOrAdd(v.NodeIDBeingMutated()).Online = &v.Online
case netmap.NodeMutationLastSeen:
pc.LastSeen = &v.LastSeen
getOrAdd(v.NodeIDBeingMutated()).LastSeen = &v.LastSeen
case netmap.NodeMutationDERPHome:
pc.DERPRegion = v.DERPRegion
getOrAdd(v.NodeIDBeingMutated()).DERPRegion = v.DERPRegion
case netmap.NodeMutationEndpoints:
pc.Endpoints = v.Endpoints
getOrAdd(v.NodeIDBeingMutated()).Endpoints = v.Endpoints
default:
return nil, false
}
@@ -2979,7 +3116,7 @@ func (b *LocalBackend) updateFilterLocked(prefs ipn.PrefsView) {
for i := range addrs.Len() {
localNetsB.AddPrefix(addrs.At(i))
}
packetFilter = netMap.PacketFilter
packetFilter = cn.PacketFilter()
if cn.unlockedNodesPermitted(packetFilter) {
b.health.SetUnhealthy(invalidPacketFilterWarnable, nil)
@@ -3317,9 +3454,21 @@ func (b *LocalBackend) WatchNotificationsAs(ctx context.Context, actor ipnauth.A
var ini *ipn.Notify
// Build the engine half of the InitialStatus before taking b.mu, since
// b.e.UpdateStatus has its own locking and shouldn't be called under
// b.mu (lock-ordering: outer-to-inner is b.mu -> engine, not the other
// way). The backend half is then populated under b.mu below, atomically
// with watcher registration so no events arrive on the watcher's
// channel before InitialStatus is delivered.
var statusSB *ipnstate.StatusBuilder
if mask&ipn.NotifyInitialStatus != 0 {
statusSB = &ipnstate.StatusBuilder{WantPeers: true}
b.e.UpdateStatus(statusSB)
}
b.mu.Lock()
const initialBits = ipn.NotifyInitialState | ipn.NotifyInitialPrefs | ipn.NotifyInitialNetMap | ipn.NotifyInitialDriveShares | ipn.NotifyInitialSuggestedExitNode | ipn.NotifyInitialClientVersion
const initialBits = ipn.NotifyInitialState | ipn.NotifyInitialPrefs | ipn.NotifyInitialNetMap | ipn.NotifyInitialStatus | ipn.NotifyInitialDriveShares | ipn.NotifyInitialSuggestedExitNode | ipn.NotifyInitialClientVersion
if mask&initialBits != 0 {
cn := b.currentNode()
ini = &ipn.Notify{Version: version.Long()}
@@ -3334,7 +3483,18 @@ func (b *LocalBackend) WatchNotificationsAs(ctx context.Context, actor ipnauth.A
ini.Prefs = new(b.sanitizedPrefsLocked())
}
if mask&ipn.NotifyInitialNetMap != 0 {
ini.NetMap = cn.NetMap()
if nm := cn.NetMap(); nm != nil && nm.SelfNode.Valid() {
ini.SelfChange = nm.SelfNode.AsStruct()
}
// The legacy initial NetMap is delivered cross-platform: it
// is what watchers asked for by setting NotifyInitialNetMap
// and is always a one-shot, so the cost of building it is
// paid once per bus subscription.
ini.NetMap = cn.netMapWithPeers()
}
if statusSB != nil {
b.updateStatusLocked(statusSB)
ini.InitialStatus = statusSB.Status()
}
if mask&ipn.NotifyInitialDriveShares != 0 && b.DriveSharingEnabled() {
ini.DriveShares = b.pm.prefs.DriveShares()
@@ -3463,16 +3623,6 @@ func (b *LocalBackend) DebugNotify(n ipn.Notify) {
b.send(n)
}
// DebugNotifyLastNetMap injects a fake notify message to clients,
// repeating whatever the last netmap was.
//
// It should only be used via the LocalAPI's debug handler.
func (b *LocalBackend) DebugNotifyLastNetMap() {
if nm := b.currentNode().NetMap(); nm != nil {
b.send(ipn.Notify{NetMap: nm})
}
}
// DebugForceNetmapUpdate forces a full no-op netmap update of the current
// netmap in all the various subsystems (wireguard, magicsock, LocalBackend).
//
@@ -3603,27 +3753,107 @@ func (b *LocalBackend) sendToLocked(n ipn.Notify, recipient notificationTarget)
if !recipient.match(sess.owner) {
continue
}
nOut := &n
if n.PeerChanges != nil {
// Take a shallow copy of n so we can elide the PeerChanges or the Netmap
// based on the session's mask.
nOut = new(n)
if sess.mask&ipn.NotifyPeerChanges != 0 {
// Skip the full Netmap
nOut.NetMap = nil
} else {
// Skip the PeerChanges
nOut.PeerChanges = nil
}
}
nForSess := b.notifyForSessionLocked(sess, &n)
select {
case sess.ch <- nOut:
case sess.ch <- nForSess:
default:
// Drop the notification if the channel is full.
}
}
}
// notifyForSessionLocked returns the [ipn.Notify] to deliver to sess,
// applying per-session field gating to n: stripping fields the session
// didn't opt in to receive, promoting [Notify.PeerChangedPatch] entries
// into full-Node [Notify.PeersChanged] entries for sessions that asked
// for peer changes but not patches, and tracking on sess which
// [tailcfg.UserProfileView]s have already been delivered so subsequent
// sends only carry new/changed profiles.
//
// The returned pointer is either n itself (no adjustments needed for
// this session) or a fresh *ipn.Notify with the adjusted fields. The
// caller's *ipn.Notify is not mutated.
//
// b.mu must be held.
func (b *LocalBackend) notifyForSessionLocked(sess *watchSession, n *ipn.Notify) *ipn.Notify {
// Visibility of peer-set fields is governed by the watcher's mask:
//
// - NotifyPeerChanges: PeersChanged + PeersRemoved
// - NotifyPeerPatches (implies): + PeerChangedPatch
//
// A watcher with NotifyPeerChanges but not NotifyPeerPatches still
// observes every per-peer mutation; we just promote each
// PeerChangedPatch entry into a full-Node entry in PeersChanged so
// the watcher doesn't have to handle the patch shape.
wantsPeerChanges := sess.mask&(ipn.NotifyPeerChanges|ipn.NotifyPeerPatches) != 0
wantsPeerPatches := sess.mask&ipn.NotifyPeerPatches != 0
stripNetMap := goosGetsLegacyNetmapNotify && n.NetMap != nil && sess.mask&ipn.NotifyNoNetMap != 0
stripPeersChanged := len(n.PeersChanged) > 0 && !wantsPeerChanges
stripPeersRemoved := len(n.PeersRemoved) > 0 && !wantsPeerChanges
stripPatches := len(n.PeerChangedPatch) > 0 && !wantsPeerPatches
promotePatches := len(n.PeerChangedPatch) > 0 && wantsPeerChanges && !wantsPeerPatches
// UserProfiles ride alongside peer changes and are gated on the
// same opt-in. Sessions that didn't ask for peer changes get the
// field stripped entirely; opted-in sessions get a per-session
// subset containing only profiles that differ from what was last
// delivered to that session, compared via
// [tailcfg.UserProfileView.Equal] (pointer-cheap when the view
// shares backing memory with the previous send).
stripUserProfiles := len(n.UserProfiles) > 0 && !wantsPeerChanges
var sessUserProfiles map[tailcfg.UserID]tailcfg.UserProfileView
if !stripUserProfiles && len(n.UserProfiles) > 0 {
for id, up := range n.UserProfiles {
if up.Equal(sess.lastSentUserProfile[id]) {
continue // already has this exact profile
}
mak.Set(&sessUserProfiles, id, up)
mak.Set(&sess.lastSentUserProfile, id, up)
}
if len(sessUserProfiles) == 0 {
// All entries deduped.
stripUserProfiles = true
}
}
replaceUserProfiles := !stripUserProfiles && len(sessUserProfiles) != len(n.UserProfiles)
if !stripNetMap && !stripPeersChanged && !stripPeersRemoved && !stripPatches && !stripUserProfiles && !replaceUserProfiles && !promotePatches {
return n
}
nCopy := *n
if stripNetMap {
nCopy.NetMap = nil
}
if stripPeersChanged {
nCopy.PeersChanged = nil
}
if stripPeersRemoved {
nCopy.PeersRemoved = nil
}
if promotePatches {
// Look up each patched peer's current Node and append it to
// PeersChanged. Watchers in this mode receive only full-Node
// updates; they never see PeerChangedPatch.
cn := b.currentNode()
for _, pc := range n.PeerChangedPatch {
nv, ok := cn.NodeByID(pc.NodeID)
if !ok {
continue
}
nCopy.PeersChanged = append(nCopy.PeersChanged, nv.AsStruct())
}
}
if stripPatches {
nCopy.PeerChangedPatch = nil
}
if stripUserProfiles {
nCopy.UserProfiles = nil
} else if replaceUserProfiles {
nCopy.UserProfiles = sessUserProfiles
}
return &nCopy
}
// setAuthURLLocked sets the authURL and triggers [LocalBackend.popBrowserAuthNow] if the URL has changed.
// This method is called when a new authURL is received from the control plane, meaning that either a user
// has started a new interactive login (e.g., by running `tailscale login` or clicking Login in the GUI),
@@ -4911,7 +5141,8 @@ func (b *LocalBackend) setPrefsLocked(newp *ipn.Prefs) ipn.PrefsView {
}
}
if netMap != nil {
newProfile := profileFromView(netMap.UserProfiles[netMap.User()])
selfProfileView, _ := cn.UserByID(netMap.User())
newProfile := profileFromView(selfProfileView)
if newLoginName := newProfile.LoginName; newLoginName != "" {
if !oldp.Persist().Valid() {
b.logf("active login: %s", newLoginName)
@@ -5173,24 +5404,20 @@ func (b *LocalBackend) NetMap() *netmap.NetworkMap {
// current. Use this for any caller that does not need to iterate Peers,
// since it's O(1) regardless of tailnet size.
//
// Returns nil if no network map has been received yet.
// It returns nil if no network map has been received yet.
func (b *LocalBackend) NetMapNoPeers() *netmap.NetworkMap {
return b.currentNode().NetMap()
}
// NetMapWithPeers returns the latest network map with the Peers slice
// populated.
// NetMapWithPeers returns a copy of the latest cached network map with
// its Peers slice populated from the live per-node-backend peers map
// (i.e. reflecting any incremental delta updates applied since the last
// full netmap install). It is O(N) in the size of the peer set; prefer
// [LocalBackend.NetMapNoPeers] when only non-Peers fields are needed.
//
// Currently this is the same as [LocalBackend.NetMapNoPeers]: the cached
// netmap's Peers slice may be stale relative to the live per-node-backend
// peers map. A follow-up change will switch this method to return a
// freshly-built netmap with up-to-date Peers, at O(N) cost per call.
// Callers that genuinely need the up-to-date peer set should use this
// method (and document why) so the upcoming change reaches them.
//
// Returns nil if no network map has been received yet.
// It returns nil if no netmap is yet available.
func (b *LocalBackend) NetMapWithPeers() *netmap.NetworkMap {
return b.currentNode().NetMap()
return b.currentNode().netMapWithPeers()
}
// lookupPeerByIP returns the node public key for the peer that owns the
@@ -8316,6 +8543,17 @@ func maybeUsernameOf(actor ipnauth.Actor) string {
var (
metricCurrentWatchIPNBus = clientmetric.NewGauge("localbackend_current_watch_ipn_bus")
metricIPForwardingCheckError = clientmetric.NewCounter("localbackend_ip_forwarding_check_error")
// Counters for the controlclient's delta-update fast path: each
// counts a destination-side call into [LocalBackend] from
// [mapSession.tryHandleIncrementally]. Useful as test signals that a
// MapResponse landed on the incremental path with the expected
// payload shape.
metricNetmapDeltaPeerAdded = clientmetric.NewCounter("localbackend_netmap_delta_peer_added")
metricNetmapDeltaPeerRemoved = clientmetric.NewCounter("localbackend_netmap_delta_peer_removed")
metricNetmapDeltaPeerPatched = clientmetric.NewCounter("localbackend_netmap_delta_peer_patched")
metricUpdatePacketFilter = clientmetric.NewCounter("localbackend_update_packet_filter")
metricUpdateUserProfiles = clientmetric.NewCounter("localbackend_update_user_profiles")
)
func (b *LocalBackend) stateEncrypted() opt.Bool {
+196 -4
View File
@@ -1693,18 +1693,18 @@ func TestExitNodeNotifyOrder(t *testing.T) {
// and an exit node ID notification (since an exit node is selected).
// The netmap notification should be sent first.
nw.watch(0, []wantedNotification{
wantNetmapNotify(clientNetmap),
wantSelfChangeNotify(selfNode),
wantExitNodeIDNotify(exitNode1.StableID()),
})
lb.SetControlClientStatus(lb.cc, controlclient.Status{NetMap: clientNetmap})
nw.check()
}
func wantNetmapNotify(want *netmap.NetworkMap) wantedNotification {
func wantSelfChangeNotify(want tailcfg.NodeView) wantedNotification {
return wantedNotification{
name: "Netmap",
name: "SelfChange",
cond: func(t testing.TB, _ ipnauth.Actor, n *ipn.Notify) bool {
return n.NetMap == want
return n.SelfChange != nil && want.Valid() && n.SelfChange.StableID == want.StableID()
},
}
}
@@ -2078,6 +2078,198 @@ func TestWatchNotificationsCallbacks(t *testing.T) {
}
}
// TestNotifyForSessionPeerVisibility verifies the per-session masking
// logic in [LocalBackend.notifyForSessionLocked] for the
// NotifyPeerChanges / NotifyPeerPatches flag pair:
//
// - A watcher with no peer-change bits should not see PeersChanged,
// PeersRemoved, or PeerChangedPatch.
// - A watcher with NotifyPeerChanges (but not NotifyPeerPatches) should
// see PeersChanged and PeersRemoved, AND any incoming
// PeerChangedPatch entries should be promoted to full Nodes in
// PeersChanged. PeerChangedPatch itself must be cleared.
// - A watcher with NotifyPeerPatches should see all three fields.
func TestNotifyForSessionPeerVisibility(t *testing.T) {
b := newTestLocalBackend(t)
// Install a netmap with two peers so the patch-promotion path can
// resolve PeerChangedPatch entries to full Nodes.
nm := &netmap.NetworkMap{}
for _, id := range []tailcfg.NodeID{10, 20} {
nm.Peers = append(nm.Peers, (&tailcfg.Node{
ID: id,
Key: makeNodeKeyFromID(id),
Addresses: []netip.Prefix{netip.MustParsePrefix(fmt.Sprintf("100.64.0.%d/32", id))},
}).View())
}
b.currentNode().SetNetMap(nm)
// Build a Notify carrying every peer-change kind: an added peer
// (PeersChanged), a removed peer (PeersRemoved), and a patch for an
// existing peer (PeerChangedPatch).
addedPeer := &tailcfg.Node{ID: 30, Key: makeNodeKeyFromID(30)}
online := true
notify := ipn.Notify{
PeersChanged: []*tailcfg.Node{addedPeer},
PeersRemoved: []tailcfg.NodeID{99},
PeerChangedPatch: []*tailcfg.PeerChange{{NodeID: 10, Online: &online}},
}
deliver := func(mask ipn.NotifyWatchOpt) *ipn.Notify {
sess := &watchSession{mask: mask}
b.mu.Lock()
defer b.mu.Unlock()
return b.notifyForSessionLocked(sess, &notify)
}
t.Run("no_peer_bits", func(t *testing.T) {
n := deliver(0)
if len(n.PeersChanged) != 0 {
t.Errorf("PeersChanged = %v; want empty", n.PeersChanged)
}
if len(n.PeersRemoved) != 0 {
t.Errorf("PeersRemoved = %v; want empty", n.PeersRemoved)
}
if len(n.PeerChangedPatch) != 0 {
t.Errorf("PeerChangedPatch = %v; want empty", n.PeerChangedPatch)
}
})
t.Run("peer_changes_only_promotes_patches", func(t *testing.T) {
n := deliver(ipn.NotifyPeerChanges)
if len(n.PeerChangedPatch) != 0 {
t.Errorf("PeerChangedPatch should be stripped; got %v", n.PeerChangedPatch)
}
if len(n.PeersRemoved) != 1 || n.PeersRemoved[0] != 99 {
t.Errorf("PeersRemoved = %v; want [99]", n.PeersRemoved)
}
// PeersChanged should contain the originally-added peer (30) AND
// a promoted full-Node entry for the patched peer (10).
ids := make(map[tailcfg.NodeID]bool, len(n.PeersChanged))
for _, p := range n.PeersChanged {
ids[p.ID] = true
}
if !ids[30] {
t.Errorf("PeersChanged missing added peer 30; got %+v", n.PeersChanged)
}
if !ids[10] {
t.Errorf("PeersChanged missing promoted peer 10; got %+v", n.PeersChanged)
}
})
t.Run("peer_patches_keeps_patch_field", func(t *testing.T) {
n := deliver(ipn.NotifyPeerPatches)
if len(n.PeerChangedPatch) != 1 || n.PeerChangedPatch[0].NodeID != 10 {
t.Errorf("PeerChangedPatch = %v; want [{NodeID:10,...}]", n.PeerChangedPatch)
}
if len(n.PeersChanged) != 1 || n.PeersChanged[0].ID != 30 {
t.Errorf("PeersChanged = %v; want [{ID:30}]", n.PeersChanged)
}
if len(n.PeersRemoved) != 1 || n.PeersRemoved[0] != 99 {
t.Errorf("PeersRemoved = %v; want [99]", n.PeersRemoved)
}
})
t.Run("both_bits_unchanged", func(t *testing.T) {
n := deliver(ipn.NotifyPeerChanges | ipn.NotifyPeerPatches)
if len(n.PeerChangedPatch) != 1 {
t.Errorf("PeerChangedPatch len = %d; want 1", len(n.PeerChangedPatch))
}
if len(n.PeersChanged) != 1 {
t.Errorf("PeersChanged len = %d; want 1", len(n.PeersChanged))
}
if len(n.PeersRemoved) != 1 {
t.Errorf("PeersRemoved len = %d; want 1", len(n.PeersRemoved))
}
})
}
// TestNotifyForSessionUserProfilesGating verifies that
// [Notify.UserProfiles] is only delivered to sessions opted in to
// NotifyPeerChanges/NotifyPeerPatches, and is deduped per-UserID
// against [watchSession.lastSentUserProfile] across successive sends.
func TestNotifyForSessionUserProfilesGating(t *testing.T) {
b := newTestLocalBackend(t)
deliver := func(sess *watchSession, profiles map[tailcfg.UserID]tailcfg.UserProfileView) *ipn.Notify {
b.mu.Lock()
defer b.mu.Unlock()
return b.notifyForSessionLocked(sess, &ipn.Notify{UserProfiles: profiles})
}
profiles := map[tailcfg.UserID]tailcfg.UserProfileView{
7: (&tailcfg.UserProfile{ID: 7, LoginName: "alice@example.com", DisplayName: "Alice"}).View(),
}
t.Run("no_bits_strips", func(t *testing.T) {
n := deliver(&watchSession{}, profiles)
if len(n.UserProfiles) != 0 {
t.Errorf("UserProfiles = %v; want empty", n.UserProfiles)
}
})
t.Run("peer_changes_delivers", func(t *testing.T) {
n := deliver(&watchSession{mask: ipn.NotifyPeerChanges}, profiles)
if got, want := len(n.UserProfiles), 1; got != want {
t.Fatalf("UserProfiles len = %d; want %d", got, want)
}
if n.UserProfiles[7].LoginName() != "alice@example.com" {
t.Errorf("got %+v; want alice", n.UserProfiles)
}
})
t.Run("peer_patches_delivers", func(t *testing.T) {
n := deliver(&watchSession{mask: ipn.NotifyPeerPatches}, profiles)
if got, want := len(n.UserProfiles), 1; got != want {
t.Fatalf("UserProfiles len = %d; want %d", got, want)
}
})
// The remaining cases share a single session so the dedup state on
// [watchSession.lastSentUserProfile] persists across deliveries.
sess := &watchSession{mask: ipn.NotifyPeerChanges}
t.Run("first_send", func(t *testing.T) {
n := deliver(sess, profiles)
if got, want := len(n.UserProfiles), 1; got != want {
t.Fatalf("UserProfiles len = %d; want %d", got, want)
}
})
t.Run("dedup_repeat_same_map", func(t *testing.T) {
// Resending the exact same map should deliver nothing.
n := deliver(sess, profiles)
if len(n.UserProfiles) != 0 {
t.Errorf("got UserProfiles=%v on repeat; want empty (deduped)", n.UserProfiles)
}
})
t.Run("per_user_dedup", func(t *testing.T) {
// A Notify with two profiles where only one changed should
// deliver only the changed one.
mixed := map[tailcfg.UserID]tailcfg.UserProfileView{
7: (&tailcfg.UserProfile{ID: 7, LoginName: "alice@example.com", DisplayName: "Alice"}).View(), // unchanged
8: (&tailcfg.UserProfile{ID: 8, LoginName: "bob@example.com", DisplayName: "Bob the New"}).View(), // new
}
n := deliver(sess, mixed)
if got, want := len(n.UserProfiles), 1; got != want {
t.Fatalf("UserProfiles len = %d; want %d (only the new user)", got, want)
}
if _, ok := n.UserProfiles[7]; ok {
t.Errorf("UserProfiles still includes user 7 (should have been deduped)")
}
if got := n.UserProfiles[8].LoginName(); got != "bob@example.com" {
t.Errorf("UserProfiles[8].LoginName = %q; want bob", got)
}
})
t.Run("changed_user_delivers", func(t *testing.T) {
// Updating an existing UserID re-sends just that one.
updated := map[tailcfg.UserID]tailcfg.UserProfileView{
7: (&tailcfg.UserProfile{ID: 7, LoginName: "alice@example.com", DisplayName: "Alice 2.0"}).View(),
}
n := deliver(sess, updated)
if n.UserProfiles[7].DisplayName() != "Alice 2.0" {
t.Errorf("got %+v; want updated alice", n.UserProfiles)
}
})
}
// tests LocalBackend.updateNetmapDeltaLocked
func TestUpdateNetmapDelta(t *testing.T) {
b := newTestLocalBackend(t)
+85 -9
View File
@@ -7,6 +7,7 @@ import (
"cmp"
"context"
"fmt"
"maps"
"net/netip"
"slices"
"sync"
@@ -116,6 +117,20 @@ type nodeBackend struct {
// It is mutated in place (with mu held) and must not escape the [nodeBackend].
nodeByKey map[key.NodePublic]tailcfg.NodeID
// userProfiles is the live set of user profiles, updated incrementally
// by mergeUserProfiles as deltas arrive. It parallels the peers map:
// netMap.UserProfiles is the frozen snapshot from the last full install,
// while this field reflects incremental updates. Readers that need a
// snapshot (e.g. the legacy Notify.NetMap path) must clone this map.
userProfiles map[tailcfg.UserID]tailcfg.UserProfileView
// packetFilterRules and packetFilter are the live packet filter state,
// updated by setPacketFilter as deltas arrive. Like userProfiles, they
// exist separately from netMap's frozen fields so that concurrent
// JSON-encoding of a Notify.NetMap snapshot doesn't race with writes.
packetFilterRules views.Slice[tailcfg.FilterRule]
packetFilter []filter.Match
// keyWaitersForTest is the test-only registry of channels waiting for
// a given peer key to first appear in the netmap. See
// [nodeBackend.AwaitNodeKeyForTest]. It is populated lazily and remains
@@ -239,12 +254,8 @@ func (nb *nodeBackend) PeerByStableID(id tailcfg.StableNodeID) (_ tailcfg.NodeVi
func (nb *nodeBackend) UserByID(id tailcfg.UserID) (_ tailcfg.UserProfileView, ok bool) {
nb.mu.Lock()
nm := nb.netMap
nb.mu.Unlock()
if nm == nil {
return tailcfg.UserProfileView{}, false
}
u, ok := nm.UserProfiles[id]
defer nb.mu.Unlock()
u, ok := nb.userProfiles[id]
return u, ok
}
@@ -465,6 +476,9 @@ func (nb *nodeBackend) netMapWithPeers() *netmap.NetworkMap {
slices.SortFunc(nm.Peers, func(a, b tailcfg.NodeView) int {
return cmp.Compare(a.ID(), b.ID())
})
nm.UserProfiles = maps.Clone(nb.userProfiles)
nm.PacketFilterRules = nb.packetFilterRules
nm.PacketFilter = nb.packetFilter
return nm
}
@@ -477,8 +491,14 @@ func (nb *nodeBackend) SetNetMap(nm *netmap.NetworkMap) {
nb.updatePeersLocked()
nb.signalKeyWaitersForTestLocked()
if nm != nil {
nb.userProfiles = maps.Clone(nm.UserProfiles)
nb.packetFilterRules = nm.PacketFilterRules
nb.packetFilter = nm.PacketFilter
nb.derpMapViewPub.Publish(nm.DERPMap.View())
} else {
nb.userProfiles = nil
nb.packetFilterRules = views.Slice[tailcfg.FilterRule]{}
nb.packetFilter = nil
nb.derpMapViewPub.Publish(tailcfg.DERPMapView{})
}
}
@@ -612,10 +632,39 @@ func (nb *nodeBackend) updatePeersLocked() {
}
}
// setPacketFilter stores the live packet filter rules and parsed
// matches. It does not touch the frozen netMap. nb.mu is acquired by
// this method.
func (nb *nodeBackend) setPacketFilter(rules views.Slice[tailcfg.FilterRule], parsed []filter.Match) {
nb.mu.Lock()
defer nb.mu.Unlock()
nb.packetFilterRules = rules
nb.packetFilter = parsed
}
// PacketFilter returns the current live packet filter matches.
func (nb *nodeBackend) PacketFilter() []filter.Match {
nb.mu.Lock()
defer nb.mu.Unlock()
return nb.packetFilter
}
// mergeUserProfiles merges new/updated [tailcfg.UserProfileView]
// entries into the live userProfiles map. It does not touch
// netMap.UserProfiles (which is frozen once set). Callers must hold
// [LocalBackend.mu]. nb.mu is acquired by this method.
func (nb *nodeBackend) mergeUserProfiles(profiles map[tailcfg.UserID]tailcfg.UserProfileView) {
nb.mu.Lock()
defer nb.mu.Unlock()
for id, up := range profiles {
mak.Set(&nb.userProfiles, id, up)
}
}
func (nb *nodeBackend) UpdateNetmapDelta(muts []netmap.NodeMutation) (handled bool) {
nb.mu.Lock()
defer nb.mu.Unlock()
if nb.netMap == nil || len(nb.peers) == 0 {
if nb.netMap == nil {
return false
}
@@ -625,9 +674,35 @@ func (nb *nodeBackend) UpdateNetmapDelta(muts []netmap.NodeMutation) (handled bo
var mutableNodes map[tailcfg.NodeID]*tailcfg.Node
for _, m := range muts {
n, ok := mutableNodes[m.NodeIDBeingMutated()]
switch m := m.(type) {
case netmap.NodeMutationAdd:
nid := m.Node.ID()
mak.Set(&nb.peers, nid, m.Node)
for _, ipp := range m.Node.Addresses().All() {
if ipp.IsSingleIP() {
mak.Set(&nb.nodeByAddr, ipp.Addr(), nid)
}
}
mak.Set(&nb.nodeByKey, m.Node.Key(), nid)
continue
case netmap.NodeMutationRemove:
nid := m.NodeIDBeingMutated()
if old, ok := nb.peers[nid]; ok {
for _, ipp := range old.Addresses().All() {
if ipp.IsSingleIP() {
delete(nb.nodeByAddr, ipp.Addr())
}
}
delete(nb.nodeByKey, old.Key())
delete(nb.peers, nid)
}
continue
}
// Per-field mutation.
nid := m.NodeIDBeingMutated()
n, ok := mutableNodes[nid]
if !ok {
nv, ok := nb.peers[m.NodeIDBeingMutated()]
nv, ok := nb.peers[nid]
if !ok {
// TODO(bradfitz): unexpected metric?
return false
@@ -640,6 +715,7 @@ func (nb *nodeBackend) UpdateNetmapDelta(muts []netmap.NodeMutation) (handled bo
for nid, n := range mutableNodes {
nb.peers[nid] = n.View()
}
nb.signalKeyWaitersForTestLocked()
return true
}