From aefb1531d14eff4ae93b585a8634241eaa33afdc Mon Sep 17 00:00:00 2001 From: Brad Fitzpatrick Date: Wed, 24 Jun 2026 02:08:09 +0000 Subject: [PATCH] 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 Change-Id: I24557ab0c8a27636e08e4779bcfd3ec633db0a78 --- cmd/k8s-operator/depaware.txt | 2 +- cmd/tailscaled/depaware-min.txt | 2 +- cmd/tailscaled/depaware-minbox.txt | 2 +- cmd/tailscaled/depaware.txt | 2 +- cmd/tsidp/depaware.txt | 2 +- ipn/ipnlocal/local.go | 60 +--------- ipn/ipnlocal/node_backend.go | 184 ++++++++++++++++++----------- ipn/ipnlocal/resolve.go | 146 +++++++++++++++++++++++ ipn/ipnlocal/resolve_test.go | 89 ++++++++++++++ net/tsdial/dnsmap.go | 94 +-------------- net/tsdial/dnsmap_test.go | 125 -------------------- net/tsdial/tsdial.go | 50 ++++++-- tsnet/depaware.txt | 2 +- types/mapx/repopulate.go | 28 +++++ types/mapx/repopulate_test.go | 93 +++++++++++++++ 15 files changed, 522 insertions(+), 359 deletions(-) create mode 100644 ipn/ipnlocal/resolve.go create mode 100644 ipn/ipnlocal/resolve_test.go delete mode 100644 net/tsdial/dnsmap_test.go create mode 100644 types/mapx/repopulate.go create mode 100644 types/mapx/repopulate_test.go diff --git a/cmd/k8s-operator/depaware.txt b/cmd/k8s-operator/depaware.txt index 8847a041b..c871bf75a 100644 --- a/cmd/k8s-operator/depaware.txt +++ b/cmd/k8s-operator/depaware.txt @@ -849,7 +849,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/ tailscale.com/types/lazy from tailscale.com/ipn/ipnlocal+ tailscale.com/types/logger from tailscale.com/appc+ tailscale.com/types/logid from tailscale.com/ipn/ipnlocal+ - tailscale.com/types/mapx from tailscale.com/ipn/ipnext + tailscale.com/types/mapx from tailscale.com/ipn/ipnext+ tailscale.com/types/netlogfunc from tailscale.com/net/tstun+ tailscale.com/types/netlogtype from tailscale.com/wgengine/netlog tailscale.com/types/netmap from tailscale.com/control/controlclient+ diff --git a/cmd/tailscaled/depaware-min.txt b/cmd/tailscaled/depaware-min.txt index 5d451f0da..20ebac51b 100644 --- a/cmd/tailscaled/depaware-min.txt +++ b/cmd/tailscaled/depaware-min.txt @@ -140,7 +140,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de tailscale.com/types/lazy from tailscale.com/hostinfo+ tailscale.com/types/logger from tailscale.com/appc+ tailscale.com/types/logid from tailscale.com/cmd/tailscaled+ - tailscale.com/types/mapx from tailscale.com/ipn/ipnext + tailscale.com/types/mapx from tailscale.com/ipn/ipnext+ tailscale.com/types/netlogfunc from tailscale.com/net/tstun+ tailscale.com/types/netmap from tailscale.com/control/controlclient+ tailscale.com/types/nettype from tailscale.com/net/batching+ diff --git a/cmd/tailscaled/depaware-minbox.txt b/cmd/tailscaled/depaware-minbox.txt index 6f8be7832..e90289673 100644 --- a/cmd/tailscaled/depaware-minbox.txt +++ b/cmd/tailscaled/depaware-minbox.txt @@ -159,7 +159,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de tailscale.com/types/lazy from tailscale.com/hostinfo+ tailscale.com/types/logger from tailscale.com/appc+ tailscale.com/types/logid from tailscale.com/cmd/tailscaled+ - tailscale.com/types/mapx from tailscale.com/ipn/ipnext + tailscale.com/types/mapx from tailscale.com/ipn/ipnext+ tailscale.com/types/netlogfunc from tailscale.com/net/tstun+ tailscale.com/types/netmap from tailscale.com/control/controlclient+ tailscale.com/types/nettype from tailscale.com/net/batching+ diff --git a/cmd/tailscaled/depaware.txt b/cmd/tailscaled/depaware.txt index 58bc03616..20ade2f18 100644 --- a/cmd/tailscaled/depaware.txt +++ b/cmd/tailscaled/depaware.txt @@ -426,7 +426,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de tailscale.com/types/lazy from tailscale.com/ipn/ipnlocal+ tailscale.com/types/logger from tailscale.com/appc+ tailscale.com/types/logid from tailscale.com/cmd/tailscaled+ - tailscale.com/types/mapx from tailscale.com/ipn/ipnext + tailscale.com/types/mapx from tailscale.com/ipn/ipnext+ tailscale.com/types/netlogfunc from tailscale.com/net/tstun+ tailscale.com/types/netlogtype from tailscale.com/wgengine/netlog tailscale.com/types/netmap from tailscale.com/control/controlclient+ diff --git a/cmd/tsidp/depaware.txt b/cmd/tsidp/depaware.txt index c4660868d..e2f26d63f 100644 --- a/cmd/tsidp/depaware.txt +++ b/cmd/tsidp/depaware.txt @@ -247,7 +247,7 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar tailscale.com/types/lazy from tailscale.com/cmd/tsidp+ tailscale.com/types/logger from tailscale.com/appc+ tailscale.com/types/logid from tailscale.com/ipn/ipnlocal+ - tailscale.com/types/mapx from tailscale.com/ipn/ipnext + tailscale.com/types/mapx from tailscale.com/ipn/ipnext+ tailscale.com/types/netlogfunc from tailscale.com/net/tstun+ tailscale.com/types/netlogtype from tailscale.com/wgengine/netlog tailscale.com/types/netmap from tailscale.com/control/controlclient+ diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index f14da1f1b..d5283d97a 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -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) } diff --git a/ipn/ipnlocal/node_backend.go b/ipn/ipnlocal/node_backend.go index f33a4e258..69593d2fd 100644 --- a/ipn/ipnlocal/node_backend.go +++ b/ipn/ipnlocal/node_backend.go @@ -9,6 +9,7 @@ "maps" "net/netip" "slices" + "strings" "sync" "sync/atomic" @@ -23,6 +24,7 @@ "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 diff --git a/ipn/ipnlocal/resolve.go b/ipn/ipnlocal/resolve.go new file mode 100644 index 000000000..debc6e09d --- /dev/null +++ b/ipn/ipnlocal/resolve.go @@ -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 +} diff --git a/ipn/ipnlocal/resolve_test.go b/ipn/ipnlocal/resolve_test.go new file mode 100644 index 000000000..c00290de4 --- /dev/null +++ b/ipn/ipnlocal/resolve_test.go @@ -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) + } + }) + } +} diff --git a/net/tsdial/dnsmap.go b/net/tsdial/dnsmap.go index d7204463f..4c2815966 100644 --- a/net/tsdial/dnsmap.go +++ b/net/tsdial/dnsmap.go @@ -4,80 +4,21 @@ package tsdial import ( - "context" "errors" "fmt" "net" - "net/netip" "strconv" "strings" - - "tailscale.com/types/netmap" - "tailscale.com/util/dnsname" ) -// dnsMap maps MagicDNS names (both base + FQDN) to their first IP. -// It must not be mutated once created. -// -// Example keys are "foo.domain.tld.beta.tailscale.net" and "foo", -// both without trailing dots, and both always lowercase. -type dnsMap map[string]netip.Addr - -// canonMapKey canonicalizes its input s to be a dnsMap map key. +// canonMapKey canonicalizes its input s to be a MagicDNS lookup key: +// lowercase with no trailing dot. func canonMapKey(s string) string { return strings.ToLower(strings.TrimSuffix(s, ".")) } -func dnsMapFromNetworkMap(nm *netmap.NetworkMap) dnsMap { - if nm == nil { - return nil - } - ret := make(dnsMap) - suffix := nm.MagicDNSSuffix() - have4 := false - addrs := nm.GetAddresses() - if name := nm.SelfName(); name != "" && addrs.Len() > 0 { - ip := addrs.At(0).Addr() - ret[canonMapKey(name)] = ip - if dnsname.HasSuffix(name, suffix) { - ret[canonMapKey(dnsname.TrimSuffix(name, suffix))] = ip - } - for _, p := range addrs.All() { - if p.Addr().Is4() { - have4 = true - } - } - } - for _, p := range nm.Peers { - if p.Name() == "" { - continue - } - for _, pfx := range p.Addresses().All() { - ip := pfx.Addr() - if ip.Is4() && !have4 { - continue - } - ret[canonMapKey(p.Name())] = ip - if dnsname.HasSuffix(p.Name(), suffix) { - ret[canonMapKey(dnsname.TrimSuffix(p.Name(), suffix))] = ip - } - break - } - } - for _, rec := range nm.DNS.ExtraRecords { - if rec.Type != "" { - continue - } - ip, err := netip.ParseAddr(rec.Value) - if err != nil { - continue - } - ret[canonMapKey(rec.Name)] = ip - } - return ret -} - -// errUnresolved is a sentinel error returned by dnsMap.resolveMemory. +// errUnresolved is a sentinel error returned when a hostname is not +// resolvable via MagicDNS. var errUnresolved = errors.New("address well formed but not resolved") func splitHostPort(addr string) (host string, port uint16, err error) { @@ -91,30 +32,3 @@ func splitHostPort(addr string) (host string, port uint16, err error) { } return host, uint16(port16), nil } - -// Resolve resolves addr into an IP:port using first the MagicDNS contents -// of m, else using the system resolver. -// -// The error is [exactly] errUnresolved if the addr is a name that isn't known -// in the map. -func (m dnsMap) resolveMemory(ctx context.Context, network, addr string) (_ netip.AddrPort, err error) { - host, port, err := splitHostPort(addr) - if err != nil { - // addr malformed or invalid port. - return netip.AddrPort{}, err - } - if ip, err := netip.ParseAddr(host); err == nil { - // addr was literal ip:port. - return netip.AddrPortFrom(ip, port), nil - } - - // Host is not an IP, so assume it's a DNS name. - - // Try MagicDNS first, otherwise a real DNS lookup. - ip := m[canonMapKey(host)] - if ip.IsValid() { - return netip.AddrPortFrom(ip, port), nil - } - - return netip.AddrPort{}, errUnresolved -} diff --git a/net/tsdial/dnsmap_test.go b/net/tsdial/dnsmap_test.go deleted file mode 100644 index b2a50fa0c..000000000 --- a/net/tsdial/dnsmap_test.go +++ /dev/null @@ -1,125 +0,0 @@ -// Copyright (c) Tailscale Inc & contributors -// SPDX-License-Identifier: BSD-3-Clause - -package tsdial - -import ( - "net/netip" - "reflect" - "testing" - - "tailscale.com/tailcfg" - "tailscale.com/types/netmap" -) - -func nodeViews(v []*tailcfg.Node) []tailcfg.NodeView { - nv := make([]tailcfg.NodeView, len(v)) - for i, n := range v { - nv[i] = n.View() - } - return nv -} - -func TestDNSMapFromNetworkMap(t *testing.T) { - pfx := netip.MustParsePrefix - ip := netip.MustParseAddr - tests := []struct { - name string - nm *netmap.NetworkMap - want dnsMap - }{ - { - name: "self", - nm: &netmap.NetworkMap{ - SelfNode: (&tailcfg.Node{ - Name: "foo.tailnet.", - Addresses: []netip.Prefix{ - pfx("100.102.103.104/32"), - pfx("100::123/128"), - }, - }).View(), - }, - want: dnsMap{ - "foo": ip("100.102.103.104"), - "foo.tailnet": ip("100.102.103.104"), - }, - }, - { - name: "self_and_peers", - nm: &netmap.NetworkMap{ - SelfNode: (&tailcfg.Node{ - Name: "foo.tailnet.", - Addresses: []netip.Prefix{ - pfx("100.102.103.104/32"), - pfx("100::123/128"), - }, - }).View(), - Peers: []tailcfg.NodeView{ - (&tailcfg.Node{ - Name: "a.tailnet", - Addresses: []netip.Prefix{ - pfx("100.0.0.201/32"), - pfx("100::201/128"), - }, - }).View(), - (&tailcfg.Node{ - Name: "b.tailnet", - Addresses: []netip.Prefix{ - pfx("100::202/128"), - }, - }).View(), - }, - }, - want: dnsMap{ - "foo": ip("100.102.103.104"), - "foo.tailnet": ip("100.102.103.104"), - "a": ip("100.0.0.201"), - "a.tailnet": ip("100.0.0.201"), - "b": ip("100::202"), - "b.tailnet": ip("100::202"), - }, - }, - { - name: "self_has_v6_only", - nm: &netmap.NetworkMap{ - SelfNode: (&tailcfg.Node{ - Name: "foo.tailnet.", - Addresses: []netip.Prefix{ - pfx("100::123/128"), - }, - }).View(), - Peers: nodeViews([]*tailcfg.Node{ - { - Name: "a.tailnet", - Addresses: []netip.Prefix{ - pfx("100.0.0.201/32"), - pfx("100::201/128"), - }, - }, - { - Name: "b.tailnet", - Addresses: []netip.Prefix{ - pfx("100::202/128"), - }, - }, - }), - }, - want: dnsMap{ - "foo": ip("100::123"), - "foo.tailnet": ip("100::123"), - "a": ip("100::201"), - "a.tailnet": ip("100::201"), - "b": ip("100::202"), - "b.tailnet": ip("100::202"), - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := dnsMapFromNetworkMap(tt.nm) - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("mismatch:\n got %v\nwant %v\n", got, tt.want) - } - }) - } -} diff --git a/net/tsdial/tsdial.go b/net/tsdial/tsdial.go index 09c4da73f..ea2ddc632 100644 --- a/net/tsdial/tsdial.go +++ b/net/tsdial/tsdial.go @@ -32,7 +32,6 @@ "tailscale.com/net/tsaddr" "tailscale.com/syncs" "tailscale.com/types/logger" - "tailscale.com/types/netmap" "tailscale.com/util/clientmetric" "tailscale.com/util/eventbus" "tailscale.com/util/mak" @@ -90,9 +89,16 @@ type Dialer struct { routes atomic.Pointer[bart.Table[bool]] // or nil if UserDial should not use routes. `true` indicates routes that point into the Tailscale interface + // resolveMagicDNS, if non-nil, resolves a MagicDNS hostname (short + // name or FQDN, without trailing dot, lowercased) to an IP address. + // The network parameter ("tcp", "tcp4", "tcp6", "udp", "udp4", + // "udp6") constrains the address family of the result. The normal + // implementation is [ipnlocal.LocalBackend.resolveMagicDNS], + // installed at construction time. It is read without holding mu. + resolveMagicDNS atomic.Pointer[func(hostname, network string) (_ netip.Addr, ok bool)] + mu syncs.Mutex closed bool - dns dnsMap tunName string // tun device name netMon *netmon.Monitor netMonUnregister func() @@ -357,14 +363,34 @@ func (d *Dialer) PeerDialControlFunc() func(network, address string, c syscall.R return peerDialControlFunc(d) } -// SetNetMap sets the current network map and notably, the DNS names -// in its DNS configuration. -func (d *Dialer) SetNetMap(nm *netmap.NetworkMap) { - m := dnsMapFromNetworkMap(nm) +// SetResolveMagicDNS installs a callback that resolves MagicDNS hostnames +// to IP addresses for UserDial. +func (d *Dialer) SetResolveMagicDNS(fn func(hostname, network string) (_ netip.Addr, ok bool)) { + if fn == nil { + d.resolveMagicDNS.Store(nil) + return + } + d.resolveMagicDNS.Store(&fn) +} - d.mu.Lock() - defer d.mu.Unlock() - d.dns = m +// resolveAddr tries to resolve addr (a "host:port" string) via MagicDNS. +// The network parameter ("tcp", "tcp4", "tcp6", etc.) constrains the +// address family. It returns errUnresolved if the hostname is not a +// known MagicDNS name. +func (d *Dialer) resolveAddr(_ context.Context, network, addr string) (netip.AddrPort, error) { + host, port, err := splitHostPort(addr) + if err != nil { + return netip.AddrPort{}, err + } + if ip, err := netip.ParseAddr(host); err == nil { + return netip.AddrPortFrom(ip, port), nil + } + if fn := d.resolveMagicDNS.Load(); fn != nil { + if ip, ok := (*fn)(canonMapKey(host), network); ok { + return netip.AddrPortFrom(ip, port), nil + } + } + return netip.AddrPort{}, errUnresolved } // userDialResolveAll resolves addr as if a user initiating the dial. @@ -375,14 +401,12 @@ func (d *Dialer) SetNetMap(nm *netmap.NetworkMap) { // non-empty on a nil-error return. func (d *Dialer) userDialResolveAll(ctx context.Context, network, addr string) ([]netip.AddrPort, error) { d.mu.Lock() - dns := d.dns exitDNSDoH := d.exitDNSDoHBase d.mu.Unlock() // MagicDNS or otherwise baked into the NetworkMap? Try that first. - // dns.resolveMemory returns a single address; tailnet names have - // one IP each, so there's nothing to race. - ipp, err := dns.resolveMemory(ctx, network, addr) + // Tailnet names have one IP each, so there's nothing to race. + ipp, err := d.resolveAddr(ctx, network, addr) if err != errUnresolved { if err != nil { return nil, err diff --git a/tsnet/depaware.txt b/tsnet/depaware.txt index 26448c37b..fd228fca4 100644 --- a/tsnet/depaware.txt +++ b/tsnet/depaware.txt @@ -242,7 +242,7 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware) tailscale.com/types/lazy from tailscale.com/hostinfo+ tailscale.com/types/logger from tailscale.com/appc+ tailscale.com/types/logid from tailscale.com/ipn/ipnlocal+ - tailscale.com/types/mapx from tailscale.com/ipn/ipnext + tailscale.com/types/mapx from tailscale.com/ipn/ipnext+ tailscale.com/types/netlogfunc from tailscale.com/net/tstun+ tailscale.com/types/netlogtype from tailscale.com/wgengine/netlog tailscale.com/types/netmap from tailscale.com/control/controlclient+ diff --git a/types/mapx/repopulate.go b/types/mapx/repopulate.go new file mode 100644 index 000000000..a9e48638b --- /dev/null +++ b/types/mapx/repopulate.go @@ -0,0 +1,28 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package mapx + +// RepopulateNonzero re-uses an existing map (preserving its allocated +// buckets) by zeroing all values, calling populate to re-fill it, and +// then deleting any entries still at their zero value. If *m is nil it +// is lazily initialized. +// +// This avoids the allocation cost of creating a new map on every call, +// which matters for maps that are rebuilt frequently (e.g. on every +// netmap update). +func RepopulateNonzero[K comparable, V comparable](m *map[K]V, populate func()) { + if *m == nil { + *m = make(map[K]V) + } + var zero V + for k := range *m { + (*m)[k] = zero + } + populate() + for k, v := range *m { + if v == zero { + delete(*m, k) + } + } +} diff --git a/types/mapx/repopulate_test.go b/types/mapx/repopulate_test.go new file mode 100644 index 000000000..14134697e --- /dev/null +++ b/types/mapx/repopulate_test.go @@ -0,0 +1,93 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package mapx + +import ( + "fmt" + "testing" +) + +func TestRepopulateNonzero(t *testing.T) { + var m map[string]int + + // First call: nil map gets initialized and populated. + RepopulateNonzero(&m, func() { + m["a"] = 1 + m["b"] = 2 + }) + if got, want := len(m), 2; got != want { + t.Fatalf("len = %d, want %d", got, want) + } + if m["a"] != 1 || m["b"] != 2 { + t.Fatalf("got %v, want map[a:1 b:2]", m) + } + + // Second call: "b" is no longer populated, so it should be removed. + RepopulateNonzero(&m, func() { + m["a"] = 3 + m["c"] = 4 + }) + if got, want := len(m), 2; got != want { + t.Fatalf("len = %d, want %d", got, want) + } + if m["a"] != 3 || m["c"] != 4 { + t.Fatalf("got %v, want map[a:3 c:4]", m) + } + if _, ok := m["b"]; ok { + t.Fatal("key 'b' should have been removed") + } + + // Populating nothing should clear the map. + RepopulateNonzero(&m, func() {}) + if got := len(m); got != 0 { + t.Fatalf("len = %d after empty populate, want 0", got) + } +} + +func BenchmarkRepopulateNonzero(b *testing.B) { + for _, size := range []int{5, 100, 100_000} { + b.Run(fmt.Sprintf("size=%d", size), func(b *testing.B) { + b.Run("RepopulateNonzero", func(b *testing.B) { + var m map[string]int + keys := makeKeys(size) + // Seed the map so the first iteration isn't special. + RepopulateNonzero(&m, func() { + for i, k := range keys { + m[k] = i + 1 + } + }) + b.ResetTimer() + for range b.N { + RepopulateNonzero(&m, func() { + for i, k := range keys { + m[k] = i + 1 + } + }) + } + }) + b.Run("clear", func(b *testing.B) { + m := make(map[string]int, size) + keys := makeKeys(size) + for i, k := range keys { + m[k] = i + 1 + } + b.ResetTimer() + for range b.N { + m = make(map[string]int, size) + for i, k := range keys { + m[k] = i + 1 + } + } + }) + }) + } +} + +func makeKeys(n int) []string { + keys := make([]string, n) + for i := range keys { + keys[i] = fmt.Sprintf("key-%d", i) + } + return keys +}