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 +}