net/tsdial, ipn/ipnlocal: stop using netmap.NetworkMap in Dialer

tsdial.Dialer.SetNetMap rebuilt an O(n peers) map of MagicDNS names on
every netmap change. As we move toward per-peer incremental deltas,
this becomes quadratic. This removes it and replaces it with
SetResolveMagicDNS, a callback into LocalBackend that looks up
hostnames from nodeBackend's new nodeByName index (populated alongside
nodeByAddr/nodeByKey on both full and delta paths). The index stores
both FQDNs and short names as keys.

This is the same treatment applied to netlog (8f210454d), wglog
(988b0905b), and drive (1d6989408): stop pushing *netmap.NetworkMap
into subsystems and instead have them pull from LocalBackend's live
data via callbacks.

Updates #12542

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I24557ab0c8a27636e08e4779bcfd3ec633db0a78
This commit is contained in:
Brad Fitzpatrick
2026-06-24 13:14:45 -07:00
committed by Brad Fitzpatrick
parent 8dde9b725b
commit aefb1531d1
15 changed files with 522 additions and 359 deletions
+1 -59
View File
@@ -658,6 +658,7 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo
e.SetPeerSessionStateFunc(b.onPeerWireGuardState)
e.SetNetLogNodeSource(netLogNodeSource{b})
e.SetWGPeerLookup(b.lookupPeerWireGuardString)
b.dialer.SetResolveMagicDNS(b.resolveMagicDNS)
if sys.InitialConfig != nil {
if err := b.initPrefsFromConfig(sys.InitialConfig); err != nil {
@@ -5817,64 +5818,6 @@ func (b *LocalBackend) NetMapWithPeers() *netmap.NetworkMap {
return b.currentNode().netMapWithPeers()
}
// lookupPeerByIP returns the node public key for the peer that owns the
// given IP address. It is the fast path for [Engine.SetPeerByIPPacketFunc],
// handling exact-IP matches against node addresses; subnet routes and exit
// nodes are handled by a BART-based fallback in userspaceEngine that uses
// the wireguard-filtered peer list (see lastCfgFull).
//
// It is called by wireguard-go on every outbound packet (not cached), so
// it must be fast.
func (b *LocalBackend) lookupPeerByIP(ip netip.Addr) (key.NodePublic, bool) {
nb := b.currentNode()
nid, ok := nb.NodeByAddr(ip)
if !ok {
return key.NodePublic{}, false
}
peer, ok := nb.NodeByID(nid)
if !ok {
return key.NodePublic{}, false
}
return peer.Key(), true
}
// peerForIP is the [wgengine.Engine.SetPeerForIPFunc] callback. It returns
// which peer is responsible for a given IP address. Despite the name, it
// can also return the self node (with IsSelf set). It handles both
// Tailscale IPs (returning the owning peer or self) and non-Tailscale
// addresses like subnet-routed IPs or exit-node global internet IPs
// (returning whichever peer would route that traffic).
func (b *LocalBackend) peerForIP(ip netip.Addr) (_ wgengine.PeerForIP, ok bool) {
nb := b.currentNode()
if tsaddr.IsTailscaleIP(ip) {
if nid, ok := nb.NodeByAddr(ip); ok {
if n, ok := nb.NodeByID(nid); ok {
self := nb.Self()
return wgengine.PeerForIP{
Node: n,
IsSelf: self.Valid() && self.ID() == nid,
Route: netip.PrefixFrom(ip, ip.BitLen()),
}, true
}
}
}
pk, route, ok := b.e.PeerKeyForIP(ip)
if !ok {
return wgengine.PeerForIP{}, false
}
nid, ok := nb.NodeByKey(pk)
if !ok {
return wgengine.PeerForIP{}, false
}
n, ok := nb.NodeByID(nid)
if !ok {
return wgengine.PeerForIP{}, false
}
return wgengine.PeerForIP{Node: n, Route: route}, true
}
// onPeerWireGuardState is called by wireguard-go, through wgengine, for
// serialized WireGuard session state transitions. wireguard-go is holding locks
// while calling this, so this must stay cheap, must not acquire b.mu, and must
@@ -7356,7 +7299,6 @@ func (b *LocalBackend) setNetMapLocked(nm *netmap.NetworkMap) {
oldNetMap := b.currentNode().NetMap()
oldSelf := oldNetMap.SelfNodeOrZero()
b.dialer.SetNetMap(nm)
if ns, ok := b.sys.Netstack.GetOK(); ok {
ns.UpdateNetstackIPs(nm)
}
+118 -66
View File
@@ -9,6 +9,7 @@ import (
"maps"
"net/netip"
"slices"
"strings"
"sync"
"sync/atomic"
@@ -23,6 +24,7 @@ import (
"tailscale.com/types/dnstype"
"tailscale.com/types/key"
"tailscale.com/types/logger"
"tailscale.com/types/mapx"
"tailscale.com/types/netmap"
"tailscale.com/types/views"
"tailscale.com/util/dnsname"
@@ -125,6 +127,17 @@ type nodeBackend struct {
// It is mutated in place (with mu held) and must not escape the [nodeBackend].
nodeByWGString map[string]tailcfg.NodeID
// nodeByName maps MagicDNS hostnames (lowercase, no trailing dot) to
// node IDs. Both the FQDN and the short name (suffix stripped) are
// keys. It is used by the tsdial MagicDNS resolution callback.
// It is mutated in place (with mu held) and must not escape the [nodeBackend].
nodeByName map[string]tailcfg.NodeID
// extraDNS stores DNS.ExtraRecords A/AAAA entries from the netmap
// (typically service VIPs pushed by control), keyed by canonicalized
// hostname (lowercase, no trailing dot).
extraDNS map[string]netip.Addr
// 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,
@@ -507,6 +520,7 @@ func (nb *nodeBackend) SetNetMap(nm *netmap.NetworkMap) {
nb.netMap = nm
nb.updateNodeByAddrLocked()
nb.updateNodeByKeyLocked()
nb.updateNodeByNameLocked()
nb.updatePeersLocked()
nb.signalKeyWaitersForTestLocked()
if nm != nil {
@@ -566,33 +580,21 @@ func (nb *nodeBackend) updateNodeByAddrLocked() {
return
}
// Update the nodeByAddr index.
if nb.nodeByAddr == nil {
nb.nodeByAddr = map[netip.Addr]tailcfg.NodeID{}
}
// First pass, mark everything unwanted.
for k := range nb.nodeByAddr {
nb.nodeByAddr[k] = 0
}
addNode := func(n tailcfg.NodeView) {
addNodeAddr := func(n tailcfg.NodeView) {
for _, ipp := range n.Addresses().All() {
if ipp.IsSingleIP() {
nb.nodeByAddr[ipp.Addr()] = n.ID()
}
}
}
if nm.SelfNode.Valid() {
addNode(nm.SelfNode)
}
for _, p := range nm.Peers {
addNode(p)
}
// Third pass, actually delete the unwanted items.
for k, v := range nb.nodeByAddr {
if v == 0 {
delete(nb.nodeByAddr, k)
mapx.RepopulateNonzero(&nb.nodeByAddr, func() {
if nm.SelfNode.Valid() {
addNodeAddr(nm.SelfNode)
}
}
for _, p := range nm.Peers {
addNodeAddr(p)
}
})
}
func (nb *nodeBackend) updateNodeByKeyLocked() {
@@ -603,40 +605,99 @@ func (nb *nodeBackend) updateNodeByKeyLocked() {
return
}
if nb.nodeByKey == nil {
nb.nodeByKey = map[key.NodePublic]tailcfg.NodeID{}
}
if nb.nodeByWGString == nil {
nb.nodeByWGString = map[string]tailcfg.NodeID{}
}
// First pass, mark everything unwanted.
for k := range nb.nodeByKey {
nb.nodeByKey[k] = 0
}
for k := range nb.nodeByWGString {
nb.nodeByWGString[k] = 0
}
addNode := func(n tailcfg.NodeView) {
nb.nodeByKey[n.Key()] = n.ID()
nb.nodeByWGString[n.Key().WireGuardGoString()] = n.ID()
}
if nm.SelfNode.Valid() {
addNode(nm.SelfNode)
}
for _, p := range nm.Peers {
addNode(p)
}
// Third pass, actually delete the unwanted items.
for k, v := range nb.nodeByKey {
if v == 0 {
delete(nb.nodeByKey, k)
mapx.RepopulateNonzero(&nb.nodeByKey, func() {
if nm.SelfNode.Valid() {
nb.nodeByKey[nm.SelfNode.Key()] = nm.SelfNode.ID()
}
}
for k, v := range nb.nodeByWGString {
if v == 0 {
delete(nb.nodeByWGString, k)
for _, p := range nm.Peers {
nb.nodeByKey[p.Key()] = p.ID()
}
})
mapx.RepopulateNonzero(&nb.nodeByWGString, func() {
if nm.SelfNode.Valid() {
nb.nodeByWGString[nm.SelfNode.Key().WireGuardGoString()] = nm.SelfNode.ID()
}
for _, p := range nm.Peers {
nb.nodeByWGString[p.Key().WireGuardGoString()] = p.ID()
}
})
}
// addNodeNameLocked adds both the FQDN and short-name keys for the given
// node to nb.nodeByName. nb.mu must be held.
func (nb *nodeBackend) addNodeNameLocked(name string, nid tailcfg.NodeID) {
if name == "" {
// We might support name-less nodes in the future; tailscale/corp#43949
return
}
canon := strings.ToLower(strings.TrimSuffix(name, "."))
mak.Set(&nb.nodeByName, canon, nid)
if suffix := nb.netMap.MagicDNSSuffix(); dnsname.HasSuffix(canon, suffix) {
mak.Set(&nb.nodeByName, dnsname.TrimSuffix(canon, suffix), nid)
}
}
// removeNodeNameLocked removes both the FQDN and short-name keys for the
// given node from nb.nodeByName. nb.mu must be held.
func (nb *nodeBackend) removeNodeNameLocked(name string) {
if name == "" {
// We might support name-less nodes in the future; tailscale/corp#43949
return
}
canon := strings.ToLower(strings.TrimSuffix(name, "."))
delete(nb.nodeByName, canon)
if suffix := nb.netMap.MagicDNSSuffix(); dnsname.HasSuffix(canon, suffix) {
delete(nb.nodeByName, dnsname.TrimSuffix(canon, suffix))
}
}
func (nb *nodeBackend) updateNodeByNameLocked() {
nm := nb.netMap
if nm == nil {
nb.nodeByName = nil
nb.extraDNS = nil
return
}
mapx.RepopulateNonzero(&nb.nodeByName, func() {
if nm.SelfNode.Valid() {
nb.addNodeNameLocked(nm.SelfNode.Name(), nm.SelfNode.ID())
}
for _, p := range nm.Peers {
nb.addNodeNameLocked(p.Name(), p.ID())
}
})
// Rebuild extraDNS from DNS.ExtraRecords (service VIPs, etc).
nb.extraDNS = nil
for _, rec := range nm.DNS.ExtraRecords {
if rec.Type != "" {
continue
}
ip, err := netip.ParseAddr(rec.Value)
if err != nil {
continue
}
mak.Set(&nb.extraDNS, strings.ToLower(strings.TrimSuffix(rec.Name, ".")), ip)
}
}
// NodeByName returns the node ID for a MagicDNS hostname. The input
// must be lowercase with no trailing dot; both short names ("foo") and
// FQDNs ("foo.tail-scale.ts.net") are accepted.
func (nb *nodeBackend) NodeByName(hostname string) (_ tailcfg.NodeID, ok bool) {
nb.mu.Lock()
defer nb.mu.Unlock()
nid, ok := nb.nodeByName[hostname]
return nid, ok
}
// ExtraDNSByName returns the IP for a DNS.ExtraRecords entry (e.g. service VIPs).
func (nb *nodeBackend) ExtraDNSByName(hostname string) (_ netip.Addr, ok bool) {
nb.mu.Lock()
defer nb.mu.Unlock()
ip, ok := nb.extraDNS[hostname]
return ip, ok
}
func (nb *nodeBackend) updatePeersLocked() {
@@ -646,22 +707,11 @@ func (nb *nodeBackend) updatePeersLocked() {
return
}
// First pass, mark everything unwanted.
for k := range nb.peers {
nb.peers[k] = tailcfg.NodeView{}
}
// Second pass, add everything wanted.
for _, p := range nm.Peers {
mak.Set(&nb.peers, p.ID(), p)
}
// Third pass, remove deleted things.
for k, v := range nb.peers {
if !v.Valid() {
delete(nb.peers, k)
mapx.RepopulateNonzero(&nb.peers, func() {
for _, p := range nm.Peers {
nb.peers[p.ID()] = p
}
}
})
}
// setPacketFilter stores the live packet filter rules and parsed
@@ -717,6 +767,7 @@ func (nb *nodeBackend) UpdateNetmapDelta(muts []netmap.NodeMutation) (handled bo
}
mak.Set(&nb.nodeByKey, m.Node.Key(), nid)
mak.Set(&nb.nodeByWGString, m.Node.Key().WireGuardGoString(), nid)
nb.addNodeNameLocked(m.Node.Name(), nid)
continue
case netmap.NodeMutationRemove:
nid := m.NodeIDBeingMutated()
@@ -728,6 +779,7 @@ func (nb *nodeBackend) UpdateNetmapDelta(muts []netmap.NodeMutation) (handled bo
}
delete(nb.nodeByKey, old.Key())
delete(nb.nodeByWGString, old.Key().WireGuardGoString())
nb.removeNodeNameLocked(old.Name())
delete(nb.peers, nid)
}
continue
+146
View File
@@ -0,0 +1,146 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package ipnlocal
import (
"net/netip"
"strings"
"tailscale.com/net/tsaddr"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"tailscale.com/wgengine"
)
// lookupPeerByIP returns the node public key for the peer that owns the
// given IP address. It is the fast path for [Engine.SetPeerByIPPacketFunc],
// handling exact-IP matches against node addresses; subnet routes and exit
// nodes are handled by a BART-based fallback in userspaceEngine that uses
// the wireguard-filtered peer list (see lastCfgFull).
//
// It is called by wireguard-go on every outbound packet (not cached), so
// it must be fast.
func (b *LocalBackend) lookupPeerByIP(ip netip.Addr) (key.NodePublic, bool) {
nb := b.currentNode()
nid, ok := nb.NodeByAddr(ip)
if !ok {
return key.NodePublic{}, false
}
peer, ok := nb.NodeByID(nid)
if !ok {
return key.NodePublic{}, false
}
return peer.Key(), true
}
// resolveMagicDNS resolves a MagicDNS hostname to the owning node's IP
// address, respecting the requested network address family ("tcp4",
// "tcp6", "tcp", etc.). It accepts peer FQDNs ("foo.tail-scale.ts.net"),
// short names ("foo"), and DNS.ExtraRecords entries (service VIPs).
// The hostname must be lowercase with no trailing dot. It is installed
// as the [tsdial.Dialer.SetResolveMagicDNS] callback.
func (b *LocalBackend) resolveMagicDNS(hostname, network string) (_ netip.Addr, ok bool) {
nb := b.currentNode()
if nid, ok := nb.NodeByName(hostname); ok {
n, ok := nb.NodeByID(nid)
if !ok {
b.logf("[unexpected] resolveMagicDNS: NodeByName(%q) returned node %v but NodeByID failed", hostname, nid)
return netip.Addr{}, false
}
if ip, ok := nodeAddrForNetwork(n, network); ok {
return ip, true
}
return netip.Addr{}, false
}
if ip, ok := nb.ExtraDNSByName(hostname); ok && addrFamilyMatch(ip, network) {
return ip, true
}
return netip.Addr{}, false
}
// nodeAddrForNetwork returns the best address from n for the given
// network ("tcp", "tcp4", "tcp6", "udp", "udp4", "udp6"). For
// unqualified networks ("tcp", "udp"), it prefers IPv4.
func nodeAddrForNetwork(n tailcfg.NodeView, network string) (_ netip.Addr, ok bool) {
addrs := n.Addresses()
if addrs.Len() == 0 {
return netip.Addr{}, false
}
want4 := strings.HasSuffix(network, "4")
want6 := strings.HasSuffix(network, "6")
var v6 netip.Addr
for _, pfx := range addrs.All() {
ip := pfx.Addr()
if want4 && ip.Is4() {
return ip, true
}
if want6 && ip.Is6() {
return ip, true
}
if !want4 && !want6 {
if ip.Is4() {
return ip, true
}
if !v6.IsValid() {
v6 = ip
}
}
}
if v6.IsValid() {
return v6, true
}
return netip.Addr{}, false
}
// addrFamilyMatch reports whether ip is compatible with the requested
// network address family.
func addrFamilyMatch(ip netip.Addr, network string) bool {
if strings.HasSuffix(network, "4") {
return ip.Is4()
}
if strings.HasSuffix(network, "6") {
return ip.Is6()
}
return true
}
// peerForIP returns which peer is responsible for a given IP address.
// Despite the name, it can also return the self node (with IsSelf set).
// It handles both Tailscale IPs (returning the owning peer or self) and
// non-Tailscale addresses like subnet-routed IPs or exit-node global
// internet IPs (returning whichever peer would route that traffic).
// It is installed as the [wgengine.Engine.SetPeerForIPFunc] callback.
func (b *LocalBackend) peerForIP(ip netip.Addr) (_ wgengine.PeerForIP, ok bool) {
nb := b.currentNode()
if tsaddr.IsTailscaleIP(ip) {
if nid, ok := nb.NodeByAddr(ip); ok {
n, ok := nb.NodeByID(nid)
if !ok {
b.logf("[unexpected] peerForIP: NodeByAddr(%v) returned node %v but NodeByID failed", ip, nid)
return wgengine.PeerForIP{}, false
}
self := nb.Self()
return wgengine.PeerForIP{
Node: n,
IsSelf: self.Valid() && self.ID() == nid,
Route: netip.PrefixFrom(ip, ip.BitLen()),
}, true
}
}
pk, route, ok := b.e.PeerKeyForIP(ip)
if !ok {
return wgengine.PeerForIP{}, false
}
nid, ok := nb.NodeByKey(pk)
if !ok {
return wgengine.PeerForIP{}, false
}
n, ok := nb.NodeByID(nid)
if !ok {
return wgengine.PeerForIP{}, false
}
return wgengine.PeerForIP{Node: n, Route: route}, true
}
+89
View File
@@ -0,0 +1,89 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package ipnlocal
import (
"net/netip"
"testing"
"tailscale.com/tailcfg"
"tailscale.com/types/netmap"
)
func TestResolveMagicDNS(t *testing.T) {
b := newTestLocalBackend(t)
nm := &netmap.NetworkMap{
SelfNode: (&tailcfg.Node{
ID: 1,
Name: "self.tail-scale.ts.net.",
Key: makeNodeKeyFromID(1),
DiscoKey: makeDiscoKeyFromID(1),
Addresses: []netip.Prefix{
netip.MustParsePrefix("100.64.0.1/32"),
netip.MustParsePrefix("fd7a:115c:a1e0::1/128"),
},
}).View(),
Peers: []tailcfg.NodeView{
(&tailcfg.Node{
ID: 2,
Name: "peer1.tail-scale.ts.net.",
Key: makeNodeKeyFromID(2),
DiscoKey: makeDiscoKeyFromID(2),
Addresses: []netip.Prefix{
netip.MustParsePrefix("100.64.0.2/32"),
netip.MustParsePrefix("fd7a:115c:a1e0::2/128"),
},
}).View(),
(&tailcfg.Node{
ID: 3,
Name: "v6only.tail-scale.ts.net.",
Key: makeNodeKeyFromID(3),
DiscoKey: makeDiscoKeyFromID(3),
Addresses: []netip.Prefix{
netip.MustParsePrefix("fd7a:115c:a1e0::3/128"),
},
}).View(),
},
DNS: tailcfg.DNSConfig{
ExtraRecords: []tailcfg.DNSRecord{
{Name: "svc-foo.tail-scale.ts.net.", Value: "100.11.22.33"},
},
},
}
nm.Domain = "tail-scale.ts.net"
b.currentNode().SetNetMap(nm)
tests := []struct {
name string
host string
network string
wantIP string
wantOK bool
}{
{name: "fqdn", host: "peer1.tail-scale.ts.net", network: "tcp", wantIP: "100.64.0.2", wantOK: true},
{name: "short_name", host: "peer1", network: "tcp", wantIP: "100.64.0.2", wantOK: true},
{name: "self_fqdn", host: "self.tail-scale.ts.net", network: "tcp", wantIP: "100.64.0.1", wantOK: true},
{name: "self_short", host: "self", network: "tcp", wantIP: "100.64.0.1", wantOK: true},
{name: "tcp4", host: "peer1", network: "tcp4", wantIP: "100.64.0.2", wantOK: true},
{name: "tcp6", host: "peer1", network: "tcp6", wantIP: "fd7a:115c:a1e0::2", wantOK: true},
{name: "v6only_tcp", host: "v6only", network: "tcp", wantIP: "fd7a:115c:a1e0::3", wantOK: true},
{name: "v6only_tcp4_miss", host: "v6only", network: "tcp4", wantOK: false},
{name: "extra_record", host: "svc-foo.tail-scale.ts.net", network: "tcp", wantIP: "100.11.22.33", wantOK: true},
{name: "extra_record_tcp6_miss", host: "svc-foo.tail-scale.ts.net", network: "tcp6", wantOK: false},
{name: "unknown", host: "nope", network: "tcp", wantOK: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ip, ok := b.resolveMagicDNS(tt.host, tt.network)
if ok != tt.wantOK {
t.Fatalf("resolveMagicDNS(%q, %q): ok=%v, want %v", tt.host, tt.network, ok, tt.wantOK)
}
if ok && ip.String() != tt.wantIP {
t.Fatalf("resolveMagicDNS(%q, %q): ip=%v, want %v", tt.host, tt.network, ip, tt.wantIP)
}
})
}
}