From 65fd320aa670d6e075dc5b20dc987f7748f22405 Mon Sep 17 00:00:00 2001 From: Brad Fitzpatrick Date: Wed, 15 Jul 2026 16:06:02 +0000 Subject: [PATCH] ipn/ipnlocal: use the live peer map, not the netmap's stale Peers slice The nodeBackend's netMap.Peers slice is frozen at the last full netmap install; the live per-peer state lives in the nodeBackend.peers map, updated by delta mutations. Three spots still read the stale slice or paid to materialize a fresh one: AppendMatchingPeers iterated netMap.Peers and re-looked-up each ID in the peers map (with a lock round-trip per peer), so peers added by a delta since the last full netmap were invisible to it. That affected its callers: taildrop's file-target list, exit node suggestions, and conn25's connector discovery. It now snapshots the peers map directly (sorted by node ID, matching the old netmap ordering). DebugPeerDiscoKeys read netMap.Peers and so returned stale disco keys after deltas. It now reads the peers map via the new nodeBackend.peerDiscoKeys. pingPeerAPI called NetMapWithPeers, building and sorting the full O(n) peer slice, just to linearly scan it for one IP. It now uses the nodeBackend's existing by-address index and the new O(1) PeerByID accessor, and passes the peers-free netmap to peerAPIBase, which only reads the self node's addresses. Updates #12542 Signed-off-by: Brad Fitzpatrick Change-Id: I2e57527d64733b4eb17006f896faaa907b1d128c --- ipn/ipnlocal/local.go | 25 +++++------------- ipn/ipnlocal/node_backend.go | 50 ++++++++++++++++++++++++------------ 2 files changed, 41 insertions(+), 34 deletions(-) diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index d754f35b4..8a006057a 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -4710,19 +4710,18 @@ func (b *LocalBackend) pingPeerAPI(ctx context.Context, ip netip.Addr) (peer tai var zero tailcfg.NodeView ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() - // PeerByTailscaleIP needs an up-to-date Peers slice. - nm := b.NetMapWithPeers() - if nm == nil { - return zero, "", errors.New("no netmap") + cn := b.currentNode() + var ok bool + if nid, addrOK := cn.NodeByAddr(ip); addrOK { + peer, ok = cn.PeerByID(nid) } - peer, ok := nm.PeerByTailscaleIP(ip) if !ok { return zero, "", fmt.Errorf("no peer found with Tailscale IP %v", ip) } if peer.Expired() { return zero, "", errors.New("peer's node key has expired") } - base := peerAPIBase(nm, peer) + base := peerAPIBase(cn.NetMap(), peer) if base == "" { return zero, "", fmt.Errorf("no PeerAPI base found for peer %v (%v)", peer.ID(), ip) } @@ -7968,22 +7967,12 @@ func (b *LocalBackend) DebugPeerRelayServers() set.Set[netip.Addr] { } // DebugPeerDiscoKeys returns the disco public keys this node has learned for -// each of its peers from the most recent network map. Intended for tests +// each of its current peers. Intended for tests // (the production [ipnstate.PeerStatus] purposefully does not surface disco // keys; surfacing them via the [ipnstate.Status] API would also pollute // every PeerStatus consumer with a non-comparable struct field). func (b *LocalBackend) DebugPeerDiscoKeys() map[key.NodePublic]key.DiscoPublic { - nm := b.currentNode().NetMap() - if nm == nil { - return nil - } - m := make(map[key.NodePublic]key.DiscoPublic, len(nm.Peers)) - for _, p := range nm.Peers { - if dk := p.DiscoKey(); !dk.IsZero() { - m[p.Key()] = dk - } - } - return m + return b.currentNode().peerDiscoKeys() } // ControlKnobs returns the node's control knobs. diff --git a/ipn/ipnlocal/node_backend.go b/ipn/ipnlocal/node_backend.go index ffc820be8..0d46af9af 100644 --- a/ipn/ipnlocal/node_backend.go +++ b/ipn/ipnlocal/node_backend.go @@ -345,6 +345,30 @@ func (nb *nodeBackend) Peers() []tailcfg.NodeView { return slicesx.MapValues(nb.peers) } +// PeerByID returns the current state of the peer (not self) node with +// the given ID, or ok=false if it is not a current peer. +func (nb *nodeBackend) PeerByID(id tailcfg.NodeID) (_ tailcfg.NodeView, ok bool) { + nb.mu.Lock() + defer nb.mu.Unlock() + n, ok := nb.peers[id] + return n, ok +} + +// peerDiscoKeys returns the disco public keys of all current peers, +// keyed by their node public keys. Peers without a disco key are +// omitted. +func (nb *nodeBackend) peerDiscoKeys() map[key.NodePublic]key.DiscoPublic { + nb.mu.Lock() + defer nb.mu.Unlock() + m := make(map[key.NodePublic]key.DiscoPublic, len(nb.peers)) + for _, p := range nb.peers { + if dk := p.DiscoKey(); !dk.IsZero() { + m[p.Key()] = dk + } + } + return m +} + func (nb *nodeBackend) PeersForTest() []tailcfg.NodeView { nb.mu.Lock() defer nb.mu.Unlock() @@ -363,28 +387,22 @@ func (nb *nodeBackend) CollectServices() bool { // AppendMatchingPeers returns base with all peers that match pred appended. // -// It acquires b.mu to read the netmap but releases it before calling pred. +// It acquires nb.mu to snapshot the peers but releases it before +// calling pred. func (nb *nodeBackend) AppendMatchingPeers(base []tailcfg.NodeView, pred func(tailcfg.NodeView) bool) []tailcfg.NodeView { - var peers []tailcfg.NodeView - nb.mu.Lock() - if nb.netMap != nil { - // All fields on b.netMap are immutable, so this is - // safe to copy and use outside the lock. - peers = nb.netMap.Peers - } + peers := slicesx.MapValues(nb.peers) nb.mu.Unlock() + // Sort by node ID for deterministic results; the map iteration + // above is randomly ordered. + slices.SortFunc(peers, func(a, b tailcfg.NodeView) int { + return cmp.Compare(a.ID(), b.ID()) + }) + ret := base for _, peer := range peers { - // The peers in b.netMap don't contain updates made via - // UpdateNetmapDelta. So only use PeerView in b.netMap for its NodeID, - // and then look up the latest copy in b.peers which is updated in - // response to UpdateNetmapDelta edits. - nb.mu.Lock() - peer, ok := nb.peers[peer.ID()] - nb.mu.Unlock() - if ok && pred(peer) { + if pred(peer) { ret = append(ret, peer) } }