ipn/ipnlocal, all: split LocalBackend.NetMap into NetMapNoPeers / NetMapWithPeers

Add two narrower accessors alongside the existing
[LocalBackend.NetMap], with docs that distinguish their semantics:

  - NetMapNoPeers: cheap (returns the cached *netmap.NetworkMap with
    a possibly-stale Peers slice). For callers that only read non-Peers
    fields like SelfNode, DNS, PacketFilter, capabilities.
  - NetMapWithPeers: documented as returning an up-to-date Peers slice.
    For callers that genuinely need to iterate Peers or call
    PeerByXxx.

Mark the existing NetMap deprecated and point readers at the two new
accessors. NetMap, NetMapNoPeers, and NetMapWithPeers all currently
return the same value (b.currentNode().NetMap()): this commit is a
no-op behaviorally, just a renaming and migration of in-tree callers.
A subsequent change in the same series will switch
NetMapWithPeers to actually rebuild the Peers slice from the live
per-node-backend peers map (O(N) per call), at which point the
distinction between the two new accessors becomes load-bearing.

Migrate in-tree callers to the appropriate accessor based on what
fields they read:

  - NetMapNoPeers (most common): localapi handlers, peerapi accept,
    GetCertPEMWithValidity, web client noise request, doctor DNS
    resolver check, tsnet CertDomains/TailscaleIPs, ssh/tailssh
    SSH-policy/cap reads, several LocalBackend internals
    (isLocalIP, allowExitNodeDNSProxyToServeName, pauseForNetwork
    nil-check, serve config).
  - NetMapWithPeers: writeNetmapToDiskLocked (persist full netmap to
    disk for fast restart), PeerByTailscaleIP lookup.

Tests still call the legacy NetMap; they'll see the deprecation
warning but otherwise behave identically.

Also add two pieces of plumbing the next change in this series will
need, but which are already useful on their own:

  - [client/local.GetDebugResultJSON]: a generic [Client.DebugResultJSON]
    that decodes directly into a target type T, avoiding the
    marshal/unmarshal roundtrip callers otherwise need.
  - localapi "current-netmap" debug action: returns the current
    netmap (with peers) as JSON. Documented as debug-only — the
    netmap.NetworkMap shape is internal and may change without notice.

This commit is part of a series breaking up a larger change for
review; on its own it is a no-op refactor.

Updates #12542

Change-Id: Idbb30707414f8da3149c44ca0273262708375b02
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
Brad Fitzpatrick
2026-04-30 16:27:55 +00:00
committed by Brad Fitzpatrick
parent 92179b1fc7
commit 159cf8707a
15 changed files with 102 additions and 23 deletions

View File

@@ -607,6 +607,24 @@ func (lc *Client) DebugResultJSON(ctx context.Context, action string) (any, erro
return x, nil return x, nil
} }
// GetDebugResultJSON invokes a debug action and decodes the JSON response
// into a value of type T. It avoids the marshal/unmarshal roundtrip that
// callers of [Client.DebugResultJSON] otherwise need to do to get a typed
// value.
//
// These are development tools and subject to change or removal over time.
func GetDebugResultJSON[T any](ctx context.Context, lc *Client, action string) (T, error) {
var v T
body, err := lc.send(ctx, "POST", "/localapi/v0/debug?action="+url.QueryEscape(action), 200, nil)
if err != nil {
return v, fmt.Errorf("error %w: %s", err, body)
}
if err := json.Unmarshal(body, &v); err != nil {
return v, err
}
return v, nil
}
// QueryOptionalFeatures queries the optional features supported by the Tailscale daemon. // QueryOptionalFeatures queries the optional features supported by the Tailscale daemon.
func (lc *Client) QueryOptionalFeatures(ctx context.Context) (*apitype.OptionalFeatures, error) { func (lc *Client) QueryOptionalFeatures(ctx context.Context) (*apitype.OptionalFeatures, error) {
body, err := lc.send(ctx, "POST", "/localapi/v0/debug-optional-features", 200, nil) body, err := lc.send(ctx, "POST", "/localapi/v0/debug-optional-features", 200, nil)

View File

@@ -63,7 +63,7 @@ func visitDoctor(ctx context.Context, b *ipnlocal.LocalBackend, logf logger.Logf
// IPs; this can interfere with our ability to connect to the Tailscale // IPs; this can interfere with our ability to connect to the Tailscale
// controlplane. // controlplane.
checks = append(checks, doctor.CheckFunc("dns-resolvers", func(_ context.Context, logf logger.Logf) error { checks = append(checks, doctor.CheckFunc("dns-resolvers", func(_ context.Context, logf logger.Logf) error {
nm := b.NetMap() nm := b.NetMapNoPeers()
if nm == nil { if nm == nil {
return nil return nil
} }

View File

@@ -909,7 +909,7 @@ func (b *LocalBackend) resolveCertDomain(domain string) (string, error) {
} }
// Read the netmap once to get both CertDomains and capabilities atomically. // Read the netmap once to get both CertDomains and capabilities atomically.
nm := b.NetMap() nm := b.NetMapNoPeers()
if nm == nil { if nm == nil {
return "", errors.New("no netmap available") return "", errors.New("no netmap available")
} }

View File

@@ -701,7 +701,9 @@ func (b *LocalBackend) onHomeDERPUpdateLocked(du magicsock.HomeDERPChanged) {
return return
} }
if err := b.writeNetmapToDiskLocked(b.NetMap()); err != nil { // Persist the full netmap (including up-to-date Peers) to disk for
// fast restart.
if err := b.writeNetmapToDiskLocked(b.NetMapWithPeers()); err != nil {
b.logf("write netmap to cache: %v", err) b.logf("write netmap to cache: %v", err)
} }
} }
@@ -1023,7 +1025,7 @@ func (b *LocalBackend) pauseOrResumeControlClientLocked() {
return return
} }
networkUp := b.interfaceState.AnyInterfaceUp() networkUp := b.interfaceState.AnyInterfaceUp()
pauseForNetwork := (b.state == ipn.Stopped && b.NetMap() != nil) || (!networkUp && !testenv.InTest() && !envknob.AssumeNetworkUp()) pauseForNetwork := (b.state == ipn.Stopped && b.NetMapNoPeers() != nil) || (!networkUp && !testenv.InTest() && !envknob.AssumeNetworkUp())
prefs := b.pm.CurrentPrefs() prefs := b.pm.CurrentPrefs()
pauseForSyncPref := prefs.Valid() && prefs.Sync().EqualBool(false) pauseForSyncPref := prefs.Valid() && prefs.Sync().EqualBool(false)
@@ -4057,7 +4059,8 @@ func (b *LocalBackend) pingPeerAPI(ctx context.Context, ip netip.Addr) (peer tai
var zero tailcfg.NodeView var zero tailcfg.NodeView
ctx, cancel := context.WithTimeout(ctx, 10*time.Second) ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel() defer cancel()
nm := b.NetMap() // PeerByTailscaleIP needs an up-to-date Peers slice.
nm := b.NetMapWithPeers()
if nm == nil { if nm == nil {
return zero, "", errors.New("no netmap") return zero, "", errors.New("no netmap")
} }
@@ -4967,7 +4970,7 @@ func (b *LocalBackend) handlePeerAPIConn(remote, local netip.AddrPort, c net.Con
} }
func (b *LocalBackend) isLocalIP(ip netip.Addr) bool { func (b *LocalBackend) isLocalIP(ip netip.Addr) bool {
nm := b.NetMap() nm := b.NetMapNoPeers()
return nm != nil && views.SliceContains(nm.GetAddresses(), netip.PrefixFrom(ip, ip.BitLen())) return nm != nil && views.SliceContains(nm.GetAddresses(), netip.PrefixFrom(ip, ip.BitLen()))
} }
@@ -5119,10 +5122,46 @@ func extractPeerAPIPorts(services []tailcfg.Service) portPair {
// NetMap returns the latest cached network map received from // NetMap returns the latest cached network map received from
// controlclient, or nil if no network map was received yet. // controlclient, or nil if no network map was received yet.
//
// Deprecated: callers should declare their needs explicitly by calling
// either [LocalBackend.NetMapNoPeers] (cheap; for code that reads
// non-Peers fields like SelfNode, DNS, PacketFilter, capabilities) or
// [LocalBackend.NetMapWithPeers] (currently the same; will be made to
// return an up-to-date Peers slice in a follow-up change, at the cost of
// O(N) work per call). NetMap will eventually be removed.
func (b *LocalBackend) NetMap() *netmap.NetworkMap { func (b *LocalBackend) NetMap() *netmap.NetworkMap {
return b.currentNode().NetMap() return b.currentNode().NetMap()
} }
// NetMapNoPeers returns the latest cached network map received from
// controlclient WITHOUT a freshly-built Peers slice.
//
// On a tailnet with frequent peer churn the cached netmap's Peers slice
// can be stale relative to the live per-node-backend peers map; non-Peers
// fields (SelfNode, DNS, PacketFilter, capabilities, ...) are always
// 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.
func (b *LocalBackend) NetMapNoPeers() *netmap.NetworkMap {
return b.currentNode().NetMap()
}
// NetMapWithPeers returns the latest network map with the Peers slice
// populated.
//
// 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.
func (b *LocalBackend) NetMapWithPeers() *netmap.NetworkMap {
return b.currentNode().NetMap()
}
// lookupPeerByIP returns the node public key for the peer that owns the // lookupPeerByIP returns the node public key for the peer that owns the
// given IP address. It is the fast path for [Engine.SetPeerByIPPacketFunc], // given IP address. It is the fast path for [Engine.SetPeerByIPPacketFunc],
// handling exact-IP matches against node addresses; subnet routes and exit // handling exact-IP matches against node addresses; subnet routes and exit
@@ -6978,7 +7017,7 @@ func (b *LocalBackend) AppConnector() *appc.AppConnector {
func (b *LocalBackend) allowExitNodeDNSProxyToServeName(name string) bool { func (b *LocalBackend) allowExitNodeDNSProxyToServeName(name string) bool {
b.mu.Lock() b.mu.Lock()
defer b.mu.Unlock() defer b.mu.Unlock()
nm := b.NetMap() nm := b.NetMapNoPeers()
if nm == nil { if nm == nil {
return false return false
} }

View File

@@ -192,7 +192,7 @@ func (pln *peerAPIListener) ServeConn(src netip.AddrPort, c net.Conn) {
c.Close() c.Close()
return return
} }
nm := pln.lb.NetMap() nm := pln.lb.NetMapNoPeers()
if nm == nil || !nm.SelfNode.Valid() { if nm == nil || !nm.SelfNode.Valid() {
logf("peerapi: no netmap") logf("peerapi: no netmap")
c.Close() c.Close()

View File

@@ -276,7 +276,7 @@ func (b *LocalBackend) updateServeTCPPortNetMapAddrListenersLocked(ports []uint1
} }
} }
nm := b.NetMap() nm := b.NetMapNoPeers()
if nm == nil { if nm == nil {
b.logf("netMap is nil") b.logf("netMap is nil")
return return
@@ -333,7 +333,7 @@ func (b *LocalBackend) setServeConfigLocked(config *ipn.ServeConfig, etag string
return errors.New("can't reconfigure tailscaled when using a config file; config file is locked") return errors.New("can't reconfigure tailscaled when using a config file; config file is locked")
} }
nm := b.NetMap() nm := b.NetMapNoPeers()
if nm == nil { if nm == nil {
return errors.New("netMap is nil") return errors.New("netMap is nil")
} }

View File

@@ -173,7 +173,7 @@ func (b *LocalBackend) waitWebClientAuthURL(ctx context.Context, id string, src
// one to be completed, based on the presence or absence of the // one to be completed, based on the presence or absence of the
// provided id value. // provided id value.
func (b *LocalBackend) doWebClientNoiseRequest(ctx context.Context, id string, src tailcfg.NodeID) (*tailcfg.WebClientAuthResponse, error) { func (b *LocalBackend) doWebClientNoiseRequest(ctx context.Context, id string, src tailcfg.NodeID) (*tailcfg.WebClientAuthResponse, error) {
nm := b.NetMap() nm := b.NetMapNoPeers()
if nm == nil || !nm.SelfNode.Valid() { if nm == nil || !nm.SelfNode.Valid() {
return nil, errors.New("[unexpected] no self node") return nil, errors.New("[unexpected] no self node")
} }

View File

@@ -9,6 +9,7 @@
"cmp" "cmp"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net" "net"
@@ -249,6 +250,22 @@ func (h *Handler) serveDebug(w http.ResponseWriter, r *http.Request) {
} }
case "clear-netmap-cache": case "clear-netmap-cache":
h.b.ClearNetmapCache(r.Context()) h.b.ClearNetmapCache(r.Context())
case "current-netmap":
// Return the current netmap (with peers populated) as JSON. This
// is a debug-only path: the netmap.NetworkMap shape is an
// internal type and may change without notice. Production
// callers should fetch the narrower bits they need via their
// own LocalAPI methods instead.
nm := h.b.NetMapWithPeers()
if nm == nil {
err = errors.New("no netmap")
break
}
w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(nm)
if err == nil {
return
}
case "": case "":
err = fmt.Errorf("missing parameter 'action'") err = fmt.Errorf("missing parameter 'action'")
default: default:
@@ -284,7 +301,7 @@ func (h *Handler) serveDebugPacketFilterRules(w http.ResponseWriter, r *http.Req
http.Error(w, "debug access denied", http.StatusForbidden) http.Error(w, "debug access denied", http.StatusForbidden)
return return
} }
nm := h.b.NetMap() nm := h.b.NetMapNoPeers()
if nm == nil { if nm == nil {
http.Error(w, "no netmap", http.StatusNotFound) http.Error(w, "no netmap", http.StatusNotFound)
return return
@@ -301,7 +318,7 @@ func (h *Handler) serveDebugPacketFilterMatches(w http.ResponseWriter, r *http.R
http.Error(w, "debug access denied", http.StatusForbidden) http.Error(w, "debug access denied", http.StatusForbidden)
return return
} }
nm := h.b.NetMap() nm := h.b.NetMapNoPeers()
if nm == nil { if nm == nil {
http.Error(w, "no netmap", http.StatusNotFound) http.Error(w, "no netmap", http.StatusNotFound)
return return

View File

@@ -347,7 +347,7 @@ func (h *Handler) serveIDToken(w http.ResponseWriter, r *http.Request) {
http.Error(w, "id-token access denied", http.StatusForbidden) http.Error(w, "id-token access denied", http.StatusForbidden)
return return
} }
nm := h.b.NetMap() nm := h.b.NetMapNoPeers()
if nm == nil { if nm == nil {
http.Error(w, "no netmap", http.StatusServiceUnavailable) http.Error(w, "no netmap", http.StatusServiceUnavailable)
return return
@@ -417,7 +417,7 @@ func (h *Handler) serveBugReport(w http.ResponseWriter, r *http.Request) {
} }
// Information about the current node from the netmap // Information about the current node from the netmap
if nm := h.b.NetMap(); nm != nil { if nm := h.b.NetMapNoPeers(); nm != nil {
if self := nm.SelfNode; self.Valid() { if self := nm.SelfNode; self.Valid() {
h.logf("user bugreport node info: nodeid=%q stableid=%q expiry=%q", self.ID(), self.StableID(), self.KeyExpiry().Format(time.RFC3339)) h.logf("user bugreport node info: nodeid=%q stableid=%q expiry=%q", self.ID(), self.StableID(), self.KeyExpiry().Format(time.RFC3339))
} }
@@ -1476,7 +1476,7 @@ func (h *Handler) serveQueryFeature(w http.ResponseWriter, r *http.Request) {
http.Error(w, "missing feature", http.StatusInternalServerError) http.Error(w, "missing feature", http.StatusInternalServerError)
return return
} }
nm := h.b.NetMap() nm := h.b.NetMapNoPeers()
if nm == nil { if nm == nil {
http.Error(w, "no netmap", http.StatusServiceUnavailable) http.Error(w, "no netmap", http.StatusServiceUnavailable)
return return
@@ -1731,7 +1731,7 @@ func (h *Handler) serveServices(w http.ResponseWriter, r *http.Request) {
http.Error(w, "only GET allowed", http.StatusMethodNotAllowed) http.Error(w, "only GET allowed", http.StatusMethodNotAllowed)
return return
} }
nm := h.b.NetMap() nm := h.b.NetMapNoPeers()
if nm == nil { if nm == nil {
http.Error(w, "no netmap", http.StatusServiceUnavailable) http.Error(w, "no netmap", http.StatusServiceUnavailable)
return return

View File

@@ -202,7 +202,7 @@ func (ss *sshSession) newIncubatorCommand(logf logger.Logf) (cmd *exec.Cmd, err
incubatorArgs = append(incubatorArgs, "--is-selinux-enforcing") incubatorArgs = append(incubatorArgs, "--is-selinux-enforcing")
} }
nm := ss.conn.srv.lb.NetMap() nm := ss.conn.srv.lb.NetMapNoPeers()
forceV1Behavior := nm.HasCap(tailcfg.NodeAttrSSHBehaviorV1) && !nm.HasCap(tailcfg.NodeAttrSSHBehaviorV2) forceV1Behavior := nm.HasCap(tailcfg.NodeAttrSSHBehaviorV1) && !nm.HasCap(tailcfg.NodeAttrSSHBehaviorV2)
if forceV1Behavior { if forceV1Behavior {
incubatorArgs = append(incubatorArgs, "--force-v1-behavior") incubatorArgs = append(incubatorArgs, "--force-v1-behavior")

View File

@@ -92,7 +92,7 @@ func (ss *sshSession) newIncubatorCommand(logf logger.Logf) (cmd *exec.Cmd, err
"--tty-name=", // updated in-place by startWithPTY "--tty-name=", // updated in-place by startWithPTY
} }
nm := ss.conn.srv.lb.NetMap() nm := ss.conn.srv.lb.NetMapNoPeers()
forceV1Behavior := nm.HasCap(tailcfg.NodeAttrSSHBehaviorV1) && !nm.HasCap(tailcfg.NodeAttrSSHBehaviorV2) forceV1Behavior := nm.HasCap(tailcfg.NodeAttrSSHBehaviorV1) && !nm.HasCap(tailcfg.NodeAttrSSHBehaviorV2)
if forceV1Behavior { if forceV1Behavior {
incubatorArgs = append(incubatorArgs, "--force-v1-behavior") incubatorArgs = append(incubatorArgs, "--force-v1-behavior")

View File

@@ -76,6 +76,7 @@
type ipnLocalBackend interface { type ipnLocalBackend interface {
ShouldRunSSH() bool ShouldRunSSH() bool
NetMap() *netmap.NetworkMap NetMap() *netmap.NetworkMap
NetMapNoPeers() *netmap.NetworkMap
WhoIs(proto string, ipp netip.AddrPort) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool) WhoIs(proto string, ipp netip.AddrPort) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool)
DoNoiseRequest(req *http.Request) (*http.Response, error) DoNoiseRequest(req *http.Request) (*http.Response, error)
Dialer() *tsdial.Dialer Dialer() *tsdial.Dialer
@@ -598,7 +599,7 @@ func (c *conn) sshPolicy() (_ *tailcfg.SSHPolicy, ok bool) {
if !lb.ShouldRunSSH() { if !lb.ShouldRunSSH() {
return nil, false return nil, false
} }
nm := lb.NetMap() nm := lb.NetMapNoPeers()
if nm == nil { if nm == nil {
return nil, false return nil, false
} }
@@ -717,7 +718,7 @@ func (c *conn) handleSessionPostSSHAuth(s gliderssh.Session) {
} }
func (c *conn) expandDelegateURLLocked(actionURL string) string { func (c *conn) expandDelegateURLLocked(actionURL string) string {
nm := c.srv.lb.NetMap() nm := c.srv.lb.NetMapNoPeers()
ci := c.info ci := c.info
lu := c.localUser lu := c.localUser
var dstNodeID string var dstNodeID string

View File

@@ -910,6 +910,8 @@ func (tb *testBackend) NetMap() *netmap.NetworkMap {
} }
} }
func (tb *testBackend) NetMapNoPeers() *netmap.NetworkMap { return tb.NetMap() }
func (tb *testBackend) WhoIs(_ string, ipp netip.AddrPort) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool) { func (tb *testBackend) WhoIs(_ string, ipp netip.AddrPort) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool) {
return (&tailcfg.Node{}).View(), tailcfg.UserProfile{ return (&tailcfg.Node{}).View(), tailcfg.UserProfile{
LoginName: tb.localUser + "@example.com", LoginName: tb.localUser + "@example.com",

View File

@@ -422,6 +422,8 @@ func (ts *localState) NetMap() *netmap.NetworkMap {
} }
} }
func (ts *localState) NetMapNoPeers() *netmap.NetworkMap { return ts.NetMap() }
func (ts *localState) WhoIs(proto string, ipp netip.AddrPort) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool) { func (ts *localState) WhoIs(proto string, ipp netip.AddrPort) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool) {
if proto != "tcp" { if proto != "tcp" {
return tailcfg.NodeView{}, tailcfg.UserProfile{}, false return tailcfg.NodeView{}, tailcfg.UserProfile{}, false

View File

@@ -668,7 +668,7 @@ func (s *Server) doInit() {
// Server. // Server.
// If the server is not running, it returns nil. // If the server is not running, it returns nil.
func (s *Server) CertDomains() []string { func (s *Server) CertDomains() []string {
nm := s.lb.NetMap() nm := s.lb.NetMapNoPeers()
if nm == nil { if nm == nil {
return nil return nil
} }
@@ -679,7 +679,7 @@ func (s *Server) CertDomains() []string {
// has not yet joined a tailnet or is otherwise unaware of its own IP addresses, // has not yet joined a tailnet or is otherwise unaware of its own IP addresses,
// the returned ip4, ip6 will be !netip.IsValid(). // the returned ip4, ip6 will be !netip.IsValid().
func (s *Server) TailscaleIPs() (ip4, ip6 netip.Addr) { func (s *Server) TailscaleIPs() (ip4, ip6 netip.Addr) {
nm := s.lb.NetMap() nm := s.lb.NetMapNoPeers()
if nm == nil { if nm == nil {
return return
} }