From 23ef758c8d70d3c42e2a3ea84c280eaf36f8f011 Mon Sep 17 00:00:00 2001 From: Brad Fitzpatrick Date: Sat, 11 Jul 2026 22:34:44 +0000 Subject: [PATCH] ipn/ipnlocal,net/dns/resolver: serve MagicDNS names from live indexes Every netmap change, including an incremental delta of a single peer, rebuilt the full MagicDNS state twice: dnsConfigForNetmap walked all peers to build the dns.Config.Hosts map, and resolver.SetConfig then walked that map again to build its reverse (PTR) index. On a tailnet with 10k peers that is a lot of garbage per delta. Instead, add a resolver.MagicDNSHosts hook, installed once by LocalBackend, that the quad-100 resolver consults on demand at query time. It is backed by nodeBackend's nodeByName, nodeByAddr, and peers indexes, which are already maintained incrementally as netmap deltas arrive. The subdomain-resolve capability check also moves to the hook (checking the node's CapMap at query time), so dns.Config's SubdomainHosts is no longer populated. dns.Config.Hosts remains for control's DNS.ExtraRecords, which are few and which feed the split-DNS decisions in dns.Manager's compileConfig, and on Windows it still carries every node's records because the hosts-file fallback path (compileHostEntries) needs the complete enumerable set. Those compileConfig decisions also consulted the per-node Hosts entries (hasHostsWithoutSplitDNSRoutes), so a new Config.MagicDNSHostsUnrouted bit preserves that signal now that node records are not listed: with MagicDNS names present but MagicDNS domain routing off, quad-100 stays in the OS resolver path. One small behavior change: reverse (PTR) lookups now also answer for node addresses whose forward records are filtered out by the IPv6-suppression rule (issue #1152), since nodeByAddr indexes all node addresses. Previously such addresses were absent from the pushed Hosts map and thus from the reverse index. Updates #12542 Updates tailscale/corp#43949 Change-Id: I63b99199c2b3b124c08cb8bbaea1f63165095294 Signed-off-by: Brad Fitzpatrick --- ipn/ipnlocal/dnsconfig_test.go | 19 ++- ipn/ipnlocal/local.go | 8 +- ipn/ipnlocal/node_backend.go | 216 ++++++++++++++++++++++-------- ipn/ipnlocal/node_backend_test.go | 80 +++++++++++ ipn/ipnlocal/resolve.go | 34 +++++ ipn/ipnlocal/state_test.go | 62 ++++----- net/dns/config.go | 18 ++- net/dns/dns_clone.go | 15 ++- net/dns/dns_view.go | 29 ++-- net/dns/resolver/tsdns.go | 54 ++++++++ net/dns/resolver/tsdns_test.go | 105 +++++++++++++++ 11 files changed, 515 insertions(+), 125 deletions(-) diff --git a/ipn/ipnlocal/dnsconfig_test.go b/ipn/ipnlocal/dnsconfig_test.go index 39f03e439..f4f4f1b38 100644 --- a/ipn/ipnlocal/dnsconfig_test.go +++ b/ipn/ipnlocal/dnsconfig_test.go @@ -72,6 +72,7 @@ func TestDNSConfigForNetmap(t *testing.T) { }, { name: "self_name_and_peers", + os: "windows", // the full Hosts map is built only for the Windows hosts-file path nm: &netmap.NetworkMap{ SelfNode: (&tailcfg.Node{ Name: "myname.net.", @@ -108,6 +109,7 @@ func TestDNSConfigForNetmap(t *testing.T) { }, { name: "subdomain_resolve_capability", + os: "windows", // the full Hosts map is built only for the Windows hosts-file path nm: &netmap.NetworkMap{ SelfNode: (&tailcfg.Node{ Name: "myname.net.", @@ -136,7 +138,6 @@ func TestDNSConfigForNetmap(t *testing.T) { "peer-with-cap.net.": ips("100.102.0.1"), "peer-without-cap.net.": ips("100.102.0.2"), }, - SubdomainHosts: set.Of[dnsname.FQDN]("myname.net.", "peer-with-cap.net."), }, }, { @@ -144,6 +145,7 @@ func TestDNSConfigForNetmap(t *testing.T) { // should get IPv6 records for all its peers, // even if they have IPv4. name: "v6_only_self", + os: "windows", // the full Hosts map is built only for the Windows hosts-file path nm: &netmap.NetworkMap{ SelfNode: (&tailcfg.Node{ Name: "myname.net.", @@ -198,9 +200,8 @@ func TestDNSConfigForNetmap(t *testing.T) { want: &dns.Config{ Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, Hosts: map[dnsname.FQDN][]netip.Addr{ - "myname.net.": ips("100.101.101.101"), - "foo.com.": ips("1.2.3.4"), - "bar.com.": ips("1::6"), + "foo.com.": ips("1.2.3.4"), + "bar.com.": ips("1::6"), }, }, }, @@ -425,23 +426,21 @@ func TestDNSConfigForNetmap(t *testing.T) { }, want: &dns.Config{ AcceptDNS: true, - Hosts: map[dnsname.FQDN][]netip.Addr{ - "a.": ips("100.101.101.101"), - "p1.": ips("100.102.0.1"), - }, + Hosts: map[dnsname.FQDN][]netip.Addr{}, Routes: map[dnsname.FQDN][]*dnstype.Resolver{ dnsname.FQDN("example.com."): { {Addr: "tailscale-app:app1"}, }, }, + MagicDNSHostsUnrouted: true, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - verOS := cmp.Or(tt.os, "linux") + goos := cmp.Or(tt.os, "linux") var log tstest.MemLogger - got := dnsConfigForNetmap(tt.nm, peersMap(tt.peers), tt.prefs.View(), tt.expired, log.Logf, verOS) + got := dnsConfigForNetmap(tt.nm, peersMap(tt.peers), tt.prefs.View(), tt.expired, log.Logf, goos) if !reflect.DeepEqual(got, tt.want) { gotj, _ := json.MarshalIndent(got, "", "\t") wantj, _ := json.MarshalIndent(tt.want, "", "\t") diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 492ec6cfc..08f55ddb9 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -230,6 +230,7 @@ type LocalBackend struct { keyLogf logger.Logf // for printing list of peers on change statsLogf logger.Logf // for printing peers stats on change sys *tsd.System + goos string // runtime.GOOS usually, except in tests eventClient *eventbus.Client appcTask execqueue.ExecQueue // handles updates from appc @@ -641,6 +642,11 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo e.SetNetLogSource(netLogNodeSource{b}) e.SetWGPeerLookup(b.lookupPeerWireGuardString) b.dialer.SetResolveMagicDNS(b.resolveMagicDNS) + if buildfeatures.HasDNS { + if dm, ok := sys.DNSManager.GetOK(); ok { + dm.Resolver().SetMagicDNSHosts(magicDNSHosts{b}) + } + } if sys.InitialConfig != nil { if err := b.initPrefsFromConfig(sys.InitialConfig); err != nil { @@ -6096,7 +6102,7 @@ func (b *LocalBackend) authReconfigLocked() { hasPAC := b.interfaceState.HasPAC() disableSubnetsIfPAC := cn.SelfHasCap(tailcfg.NodeAttrDisableSubnetsIfPAC) dohURL, dohURLOK := cn.exitNodeCanProxyDNS(prefs.ExitNodeID()) - dcfg := cn.dnsConfigForNetmap(prefs, b.keyExpired, version.OS()) + dcfg := cn.dnsConfigForNetmap(prefs, b.keyExpired, cmp.Or(b.goos, runtime.GOOS)) // If the current node is an app connector, ensure the app connector machine is started b.reconfigAppConnectorLocked(nm.SelfNode, prefs) diff --git a/ipn/ipnlocal/node_backend.go b/ipn/ipnlocal/node_backend.go index 0c4040b3e..abd94a634 100644 --- a/ipn/ipnlocal/node_backend.go +++ b/ipn/ipnlocal/node_backend.go @@ -304,6 +304,12 @@ func (nb *nodeBackend) NodeByWireGuardString(s string) (_ tailcfg.NodeID, ok boo func (nb *nodeBackend) NodeByID(id tailcfg.NodeID) (_ tailcfg.NodeView, ok bool) { nb.mu.Lock() defer nb.mu.Unlock() + return nb.nodeByIDLocked(id) +} + +// nodeByIDLocked returns the node (peer or self) with the given node +// ID. nb.mu must be held. +func (nb *nodeBackend) nodeByIDLocked(id tailcfg.NodeID) (_ tailcfg.NodeView, ok bool) { if nb.netMap != nil { if self := nb.netMap.SelfNode; self.Valid() && self.ID() == id { return self, true @@ -1112,10 +1118,119 @@ func (nb *nodeBackend) setFilter(f *filter.Filter) { nb.filterAtomic.Store(f) } -func (nb *nodeBackend) dnsConfigForNetmap(prefs ipn.PrefsView, selfExpired bool, versionOS string) *dns.Config { +func (nb *nodeBackend) dnsConfigForNetmap(prefs ipn.PrefsView, selfExpired bool, goos string) *dns.Config { nb.mu.Lock() defer nb.mu.Unlock() - return dnsConfigForNetmap(nb.netMap, nb.peers, prefs, selfExpired, nb.logf, versionOS) + return dnsConfigForNetmap(nb.netMap, nb.peers, prefs, selfExpired, nb.logf, goos) +} + +// magicDNSHostAddrs returns the MagicDNS A/AAAA answer for fqdn from +// the live node indexes, and whether the name is known. It backs +// [resolver.MagicDNSHosts.LookupHost], replacing the full +// dns.Config.Hosts map so that netmap deltas need no per-peer DNS +// rebuild. +func (nb *nodeBackend) magicDNSHostAddrs(fqdn dnsname.FQDN) (ips []netip.Addr, ok bool) { + if !buildfeatures.HasDNS { + return nil, false + } + nb.mu.Lock() + defer nb.mu.Unlock() + n, ok := nb.nodeByFQDNLocked(fqdn) + if !ok || n.Addresses().Len() == 0 { + return nil, false + } + nm := nb.netMap + selfV6Only := nm.GetAddresses().ContainsFunc(tsaddr.PrefixIs6) && + !nm.GetAddresses().ContainsFunc(tsaddr.PrefixIs4) + wantAAAA := nm.AllCaps.Contains(tailcfg.NodeAttrMagicDNSPeerAAAA) + return magicDNSAddrs(n.Addresses(), selfV6Only, wantAAAA), true +} + +// magicDNSPTR returns the MagicDNS name of the node (peer or self) +// that owns ip, backing [resolver.MagicDNSHosts.LookupPTR]. +func (nb *nodeBackend) magicDNSPTR(ip netip.Addr) (_ dnsname.FQDN, ok bool) { + if !buildfeatures.HasDNS { + return "", false + } + nb.mu.Lock() + defer nb.mu.Unlock() + nid, ok := nb.nodeByAddr[ip] + if !ok { + return "", false + } + n, ok := nb.nodeByIDLocked(nid) + if !ok { + return "", false + } + fqdn, err := dnsname.ToFQDN(n.Name()) + if err != nil { + return "", false + } + return fqdn, true +} + +// magicDNSSubdomainHost reports whether fqdn names a node with the +// [tailcfg.NodeAttrDNSSubdomainResolve] attribute, backing +// [resolver.MagicDNSHosts.SubdomainHost]. +func (nb *nodeBackend) magicDNSSubdomainHost(fqdn dnsname.FQDN) bool { + if !buildfeatures.HasDNS { + return false + } + nb.mu.Lock() + defer nb.mu.Unlock() + n, ok := nb.nodeByFQDNLocked(fqdn) + if !ok { + return false + } + if nm := nb.netMap; nm != nil && nm.SelfNode.Valid() && nm.SelfNode.ID() == n.ID() { + return nm.AllCaps.Contains(tailcfg.NodeAttrDNSSubdomainResolve) + } + return n.CapMap().Contains(tailcfg.NodeAttrDNSSubdomainResolve) +} + +// nodeByFQDNLocked returns the node (peer or self) with the given +// MagicDNS FQDN. nb.mu must be held. +func (nb *nodeBackend) nodeByFQDNLocked(fqdn dnsname.FQDN) (_ tailcfg.NodeView, ok bool) { + nid, ok := nb.nodeByName[strings.TrimSuffix(string(fqdn), ".")] + if !ok { + return tailcfg.NodeView{}, false + } + return nb.nodeByIDLocked(nid) +} + +// magicDNSAddrs returns the MagicDNS answer addresses for a node +// owning the given addresses: all of its IPv6 addresses if this node +// (the querier) is IPv6-only, otherwise everything except the node's +// IPv6 addresses when it also has IPv4, unless wantAAAA. The result +// may be empty (name exists, no records of the desired family). +func magicDNSAddrs(addrs views.Slice[netip.Prefix], selfV6Only, wantAAAA bool) (ips []netip.Addr) { + var have4 bool + for _, addr := range addrs.All() { + if addr.Addr().Is4() { + have4 = true + break + } + } + for _, addr := range addrs.All() { + if selfV6Only { + if addr.Addr().Is6() { + ips = append(ips, addr.Addr()) + } + continue + } + // If this node has an IPv4 address, then remove peers' + // IPv6 addresses for now, as we don't guarantee that + // the peer node actually can speak IPv6 correctly. + // + // https://github.com/tailscale/tailscale/issues/1152 + // tracks adding the right capability reporting to + // enable AAAA in MagicDNS. + if addr.Addr().Is6() && have4 && !wantAAAA { + continue + } + ips = append(ips, addr.Addr()) + } + return ips } func (nb *nodeBackend) exitNodeCanProxyDNS(exitNodeID tailcfg.StableNodeID) (dohURL string, ok bool) { @@ -1220,9 +1335,9 @@ func useWithExitNodeRoutes(routes map[string][]*dnstype.Resolver) map[string][]* // dnsConfigForNetmap returns a *dns.Config for the given netmap, // prefs, client OS version, and cloud hosting environment. // -// The versionOS is a Tailscale-style version ("iOS", "macOS") and not -// a runtime.GOOS. -func dnsConfigForNetmap(nm *netmap.NetworkMap, peers map[tailcfg.NodeID]tailcfg.NodeView, prefs ipn.PrefsView, selfExpired bool, logf logger.Logf, versionOS string) *dns.Config { +// The goos is runtime.GOOS usually, except in tests, where it may +// vary to test any OS's behavior from any host. +func dnsConfigForNetmap(nm *netmap.NetworkMap, peers map[tailcfg.NodeID]tailcfg.NodeView, prefs ipn.PrefsView, selfExpired bool, logf logger.Logf, goos string) *dns.Config { if nm == nil { return nil } @@ -1255,63 +1370,29 @@ func dnsConfigForNetmap(nm *netmap.NetworkMap, peers map[tailcfg.NodeID]tailcfg. wantAAAA := nm.AllCaps.Contains(tailcfg.NodeAttrMagicDNSPeerAAAA) - // Populate MagicDNS records. We do this unconditionally so that - // quad-100 can always respond to MagicDNS queries, even if the OS - // isn't configured to make MagicDNS resolution truly - // magic. Details in - // https://github.com/tailscale/tailscale/issues/1886. - set := func(name string, addrs views.Slice[netip.Prefix]) { - if addrs.Len() == 0 || name == "" { - return - } - fqdn, err := dnsname.ToFQDN(name) - if err != nil { - return // TODO: propagate error? - } - var have4 bool - for _, addr := range addrs.All() { - if addr.Addr().Is4() { - have4 = true - break + // Populate MagicDNS records. The internal quad-100 resolver pulls + // per-node records on demand from the live node indexes (see + // [nodeBackend.magicDNSHostAddrs]), so quad-100 can always respond + // to MagicDNS queries without dcfg.Hosts; details in + // https://github.com/tailscale/tailscale/issues/1886. dcfg.Hosts + // carries only what must be enumerable in full: control's + // DNS.ExtraRecords below, plus every node's records on Windows, + // whose hosts-file fallback path (see compileHostEntries in + // net/dns) needs the complete set. + if goos == "windows" { + set := func(name string, addrs views.Slice[netip.Prefix]) { + if addrs.Len() == 0 || name == "" { + return } - } - var ips []netip.Addr - for _, addr := range addrs.All() { - if selfV6Only { - if addr.Addr().Is6() { - ips = append(ips, addr.Addr()) - } - continue + fqdn, err := dnsname.ToFQDN(name) + if err != nil { + return // TODO: propagate error? } - // If this node has an IPv4 address, then - // remove peers' IPv6 addresses for now, as we - // don't guarantee that the peer node actually - // can speak IPv6 correctly. - // - // https://github.com/tailscale/tailscale/issues/1152 - // tracks adding the right capability reporting to - // enable AAAA in MagicDNS. - if addr.Addr().Is6() && have4 && !wantAAAA { - continue - } - ips = append(ips, addr.Addr()) + dcfg.Hosts[fqdn] = magicDNSAddrs(addrs, selfV6Only, wantAAAA) } - dcfg.Hosts[fqdn] = ips - } - set(nm.SelfName(), nm.GetAddresses()) - if nm.AllCaps.Contains(tailcfg.NodeAttrDNSSubdomainResolve) { - if fqdn, err := dnsname.ToFQDN(nm.SelfName()); err == nil { - dcfg.SubdomainHosts.Make() - dcfg.SubdomainHosts.Add(fqdn) - } - } - for _, peer := range peers { - set(peer.Name(), peer.Addresses()) - if peer.CapMap().Contains(tailcfg.NodeAttrDNSSubdomainResolve) { - if fqdn, err := dnsname.ToFQDN(peer.Name()); err == nil { - dcfg.SubdomainHosts.Make() - dcfg.SubdomainHosts.Add(fqdn) - } + set(nm.SelfName(), nm.GetAddresses()) + for _, peer := range peers { + set(peer.Name(), peer.Addresses()) } } for _, rec := range nm.DNS.ExtraRecords { @@ -1349,6 +1430,23 @@ func dnsConfigForNetmap(nm *netmap.NetworkMap, peers map[tailcfg.NodeID]tailcfg. for _, dom := range magicDNSRootDomains(nm) { dcfg.Routes[dom] = nil // resolve internally with dcfg.Hosts } + } else { + // Without MagicDNS domain routing, no Routes entry covers the + // node records served on demand via [magicDNSHosts]. Tell + // dns.Manager they exist, so it keeps quad-100 in the OS + // resolver path and the names still resolve, as they did when + // they were listed in dcfg.Hosts. The existence check short + // circuits, so it is O(1) in practice. + haveNodeRecords := nm.SelfName() != "" && nm.GetAddresses().Len() > 0 + if !haveNodeRecords { + for _, peer := range peers { + if peer.Name() != "" && peer.Addresses().Len() > 0 { + haveNodeRecords = true + break + } + } + } + dcfg.MagicDNSHostsUnrouted = haveNodeRecords } addDefault := func(resolvers []*dnstype.Resolver) { diff --git a/ipn/ipnlocal/node_backend_test.go b/ipn/ipnlocal/node_backend_test.go index ed8abbbce..576b4f8af 100644 --- a/ipn/ipnlocal/node_backend_test.go +++ b/ipn/ipnlocal/node_backend_test.go @@ -18,6 +18,7 @@ "tailscale.com/types/key" "tailscale.com/types/netmap" "tailscale.com/types/views" + "tailscale.com/util/dnsname" "tailscale.com/util/eventbus" "tailscale.com/util/mak" "tailscale.com/util/set" @@ -425,6 +426,85 @@ func TestNodeBackendRouteManagerExtras(t *testing.T) { // detection: a peer whose disco key changes has restarted and needs its // WireGuard session reset, unless the new key was already learned over // TSMP (that is, over a working WireGuard session with the peer). +// Tests the live MagicDNS lookup methods backing +// [resolver.MagicDNSHosts]: forward, reverse, and subdomain-cap +// lookups must serve from the node indexes and stay correct across +// netmap deltas without any full Hosts map rebuild. +func TestNodeBackendMagicDNSHosts(t *testing.T) { + nb := newNodeBackend(t.Context(), tstest.WhileTestRunningLogger(t), eventbus.New()) + + self := &tailcfg.Node{ + ID: 1, + Name: "self.example.ts.net.", + Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")}, + } + p1 := &tailcfg.Node{ + ID: 2, + Key: key.NewNode().Public(), + Name: "p1.example.ts.net.", + Addresses: []netip.Prefix{ + netip.MustParsePrefix("100.64.0.2/32"), + netip.MustParsePrefix("fd7a:115c:a1e0::2/128"), + }, + CapMap: tailcfg.NodeCapMap{tailcfg.NodeAttrDNSSubdomainResolve: nil}, + } + nb.SetNetMap(&netmap.NetworkMap{ + SelfNode: self.View(), + Peers: []tailcfg.NodeView{p1.View()}, + }) + + wantHost := func(fqdn dnsname.FQDN, want ...netip.Addr) { + t.Helper() + ips, ok := nb.magicDNSHostAddrs(fqdn) + if len(want) == 0 { + if ok { + t.Errorf("magicDNSHostAddrs(%q) = %v; want no match", fqdn, ips) + } + return + } + if !ok || !slices.Equal(ips, want) { + t.Errorf("magicDNSHostAddrs(%q) = %v, %v; want %v", fqdn, ips, ok, want) + } + } + + // The self node has IPv4, so the peer's IPv6 address is + // filtered out (issue 1152). + wantHost("p1.example.ts.net.", netip.MustParseAddr("100.64.0.2")) + wantHost("self.example.ts.net.", netip.MustParseAddr("100.64.0.1")) + wantHost("unknown.example.ts.net.") + + if fqdn, ok := nb.magicDNSPTR(netip.MustParseAddr("100.64.0.2")); !ok || fqdn != "p1.example.ts.net." { + t.Errorf("magicDNSPTR(100.64.0.2) = %q, %v; want p1's name", fqdn, ok) + } + if got, want := nb.magicDNSSubdomainHost("p1.example.ts.net."), true; got != want { + t.Errorf("magicDNSSubdomainHost(p1) = %v; want %v", got, want) + } + if got, want := nb.magicDNSSubdomainHost("self.example.ts.net."), false; got != want { + t.Errorf("magicDNSSubdomainHost(self) = %v; want %v", got, want) + } + + // Removing the peer via a delta drops its records. + if handled, _, _ := nb.UpdateNetmapDelta([]netmap.NodeMutation{netmap.MakeNodeMutationRemove(2)}); !handled { + t.Fatal("UpdateNetmapDelta not handled") + } + wantHost("p1.example.ts.net.") + if fqdn, ok := nb.magicDNSPTR(netip.MustParseAddr("100.64.0.2")); ok { + t.Errorf("magicDNSPTR(100.64.0.2) after removal = %q; want no match", fqdn) + } + + // Adding a peer via a delta serves it immediately. + p3 := &tailcfg.Node{ + ID: 3, + Key: key.NewNode().Public(), + Name: "p3.example.ts.net.", + Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.3/32")}, + } + if handled, _, _ := nb.UpdateNetmapDelta([]netmap.NodeMutation{netmap.NodeMutationUpsert{Node: p3.View()}}); !handled { + t.Fatal("UpdateNetmapDelta not handled") + } + wantHost("p3.example.ts.net.", netip.MustParseAddr("100.64.0.3")) +} + func TestNodeBackendDiscoChanged(t *testing.T) { nb := newNodeBackend(t.Context(), tstest.WhileTestRunningLogger(t), eventbus.New()) diff --git a/ipn/ipnlocal/resolve.go b/ipn/ipnlocal/resolve.go index c872b7e6d..2ee7ff7b6 100644 --- a/ipn/ipnlocal/resolve.go +++ b/ipn/ipnlocal/resolve.go @@ -7,9 +7,12 @@ "net/netip" "strings" + "tailscale.com/feature/buildfeatures" + "tailscale.com/net/dns/resolver" "tailscale.com/net/tsaddr" "tailscale.com/tailcfg" "tailscale.com/types/key" + "tailscale.com/util/dnsname" "tailscale.com/wgengine" ) @@ -72,6 +75,37 @@ func (b *LocalBackend) resolveMagicDNS(hostname, network string) (_ netip.Addr, return netip.Addr{}, false } +// magicDNSHosts implements [resolver.MagicDNSHosts] on top of the +// current nodeBackend's live node indexes, so the quad-100 resolver +// pulls each MagicDNS answer on demand rather than LocalBackend +// pushing a Hosts map of every node into it on every netmap change. +// It is installed once at LocalBackend construction; going through +// currentNode makes profile switches take effect immediately. +type magicDNSHosts struct{ b *LocalBackend } + +func (m magicDNSHosts) LookupHost(fqdn dnsname.FQDN) (ips []netip.Addr, ok bool) { + if !buildfeatures.HasDNS { + return nil, false + } + return m.b.currentNode().magicDNSHostAddrs(fqdn) +} + +func (m magicDNSHosts) LookupPTR(ip netip.Addr) (_ dnsname.FQDN, ok bool) { + if !buildfeatures.HasDNS { + return "", false + } + return m.b.currentNode().magicDNSPTR(ip) +} + +func (m magicDNSHosts) SubdomainHost(fqdn dnsname.FQDN) bool { + if !buildfeatures.HasDNS { + return false + } + return m.b.currentNode().magicDNSSubdomainHost(fqdn) +} + +var _ resolver.MagicDNSHosts = magicDNSHosts{} + // 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. diff --git a/ipn/ipnlocal/state_test.go b/ipn/ipnlocal/state_test.go index 24b1f2589..3ce554fac 100644 --- a/ipn/ipnlocal/state_test.go +++ b/ipn/ipnlocal/state_test.go @@ -44,8 +44,6 @@ "tailscale.com/types/preftype" "tailscale.com/util/dnsname" "tailscale.com/util/eventbus/eventbustest" - "tailscale.com/util/mak" - "tailscale.com/util/must" "tailscale.com/wgengine" "tailscale.com/wgengine/filter" "tailscale.com/wgengine/magicsock" @@ -1253,23 +1251,6 @@ func TestEngineReconfigOnStateChange(t *testing.T) { routesWithQuad100 := func(extra ...netip.Prefix) []netip.Prefix { return append(extra, netip.MustParsePrefix("100.100.100.100/32")) } - hostsFor := func(nm *netmap.NetworkMap) map[dnsname.FQDN][]netip.Addr { - var hosts map[dnsname.FQDN][]netip.Addr - appendNode := func(n tailcfg.NodeView) { - addrs := make([]netip.Addr, 0, n.Addresses().Len()) - for _, addr := range n.Addresses().All() { - addrs = append(addrs, addr.Addr()) - } - mak.Set(&hosts, must.Get(dnsname.ToFQDN(n.Name())), addrs) - } - if nm != nil && nm.SelfNode.Valid() { - appendNode(nm.SelfNode) - } - for _, n := range nm.Peers { - appendNode(n) - } - return hosts - } tests := []struct { name string @@ -1330,9 +1311,10 @@ func TestEngineReconfigOnStateChange(t *testing.T) { Routes: routesWithQuad100(), }, wantDNSCfg: &dns.Config{ - AcceptDNS: true, - Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, - Hosts: hostsFor(node1), + AcceptDNS: true, + Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, + Hosts: map[dnsname.FQDN][]netip.Addr{}, + MagicDNSHostsUnrouted: true, }, }, { @@ -1386,9 +1368,10 @@ func TestEngineReconfigOnStateChange(t *testing.T) { Routes: routesWithQuad100(), }, wantDNSCfg: &dns.Config{ - AcceptDNS: true, - Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, - Hosts: hostsFor(node2), + AcceptDNS: true, + Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, + Hosts: map[dnsname.FQDN][]netip.Addr{}, + MagicDNSHostsUnrouted: true, }, }, { @@ -1434,9 +1417,10 @@ func TestEngineReconfigOnStateChange(t *testing.T) { Routes: routesWithQuad100(), }, wantDNSCfg: &dns.Config{ - AcceptDNS: true, - Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, - Hosts: hostsFor(node1), + AcceptDNS: true, + Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, + Hosts: map[dnsname.FQDN][]netip.Addr{}, + MagicDNSHostsUnrouted: true, }, }, { @@ -1461,9 +1445,10 @@ func TestEngineReconfigOnStateChange(t *testing.T) { Routes: routesWithQuad100(netip.MustParsePrefix("100.64.1.1/32"), netip.MustParsePrefix("100.64.1.2/32")), }, wantDNSCfg: &dns.Config{ - AcceptDNS: true, - Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, - Hosts: hostsFor(node3), + AcceptDNS: true, + Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, + Hosts: map[dnsname.FQDN][]netip.Addr{}, + MagicDNSHostsUnrouted: true, }, }, { @@ -1506,9 +1491,10 @@ func TestEngineReconfigOnStateChange(t *testing.T) { Routes: routesWithQuad100(), }, wantDNSCfg: &dns.Config{ - AcceptDNS: true, - Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, - Hosts: hostsFor(node1), + AcceptDNS: true, + Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, + Hosts: map[dnsname.FQDN][]netip.Addr{}, + MagicDNSHostsUnrouted: true, }, }, { @@ -1536,9 +1522,10 @@ func TestEngineReconfigOnStateChange(t *testing.T) { Routes: routesWithQuad100(), }, wantDNSCfg: &dns.Config{ - AcceptDNS: true, - Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, - Hosts: hostsFor(node1), + AcceptDNS: true, + Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, + Hosts: map[dnsname.FQDN][]netip.Addr{}, + MagicDNSHostsUnrouted: true, }, }, { @@ -1564,6 +1551,7 @@ func TestEngineReconfigOnStateChange(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { lb, engine, cc := newLocalBackendWithMockEngineAndControl(t, enableLogging) + lb.goos = "linux" // so the expectations below are the same on every host OS if tt.steps != nil { tt.steps(t, lb, cc) diff --git a/net/dns/config.go b/net/dns/config.go index 0b09fe1a8..564935507 100644 --- a/net/dns/config.go +++ b/net/dns/config.go @@ -61,6 +61,15 @@ type Config struct { // OnlyIPv6, if true, uses the IPv6 service IP (for MagicDNS) // instead of the IPv4 version (100.100.100.100). OnlyIPv6 bool + // MagicDNSHostsUnrouted is whether MagicDNS host records exist + // that are served on demand via [resolver.MagicDNSHosts] (so are + // not listed in Hosts) and are not covered by any Routes entry; + // that is, the tailnet has MagicDNS names but MagicDNS domain + // routing is off. It preserves the effect those records had when + // they were listed in Hosts: hasHostsWithoutSplitDNSRoutes + // reports true, keeping quad-100 in the OS resolver path so the + // names still resolve. + MagicDNSHostsUnrouted bool } var magicDNSDualStack = envknob.RegisterBool("TS_DEBUG_MAGIC_DNS_DUAL_STACK") @@ -127,8 +136,13 @@ func (c Config) hasDefaultIPResolversOnly() bool { // hasHostsWithoutSplitDNSRoutes reports whether c contains any Host entries // that aren't covered by a SplitDNS route suffix. func (c Config) hasHostsWithoutSplitDNSRoutes() bool { - // TODO(bradfitz): this could be more efficient, but we imagine - // the number of SplitDNS routes and/or hosts will be small. + if c.MagicDNSHostsUnrouted { + return true + } + // Hosts is small here on most platforms: per-node records are + // served via [resolver.MagicDNSHosts] (accounted for above), so + // only control's DNS.ExtraRecords remain. On Windows it still + // carries every node's records for the hosts-file path. for host := range c.Hosts { if !c.hasSplitDNSRouteForHost(host) { return true diff --git a/net/dns/dns_clone.go b/net/dns/dns_clone.go index 724e36dac..64294db12 100644 --- a/net/dns/dns_clone.go +++ b/net/dns/dns_clone.go @@ -61,13 +61,14 @@ func (src *Config) Clone() *Config { // A compilation failure here means this code must be regenerated, with the command at the top of this file. var _ConfigCloneNeedsRegeneration = Config(struct { - AcceptDNS bool - DefaultResolvers []*dnstype.Resolver - Routes map[dnsname.FQDN][]*dnstype.Resolver - SearchDomains []dnsname.FQDN - Hosts map[dnsname.FQDN][]netip.Addr - SubdomainHosts set.Set[dnsname.FQDN] - OnlyIPv6 bool + AcceptDNS bool + DefaultResolvers []*dnstype.Resolver + Routes map[dnsname.FQDN][]*dnstype.Resolver + SearchDomains []dnsname.FQDN + Hosts map[dnsname.FQDN][]netip.Addr + SubdomainHosts set.Set[dnsname.FQDN] + OnlyIPv6 bool + MagicDNSHostsUnrouted bool }{}) // Clone duplicates src into dst and reports whether it succeeded. diff --git a/net/dns/dns_view.go b/net/dns/dns_view.go index 70cb89dca..c4c04de2e 100644 --- a/net/dns/dns_view.go +++ b/net/dns/dns_view.go @@ -139,16 +139,27 @@ func (v ConfigView) SubdomainHosts() views.Map[dnsname.FQDN, struct{}] { // OnlyIPv6, if true, uses the IPv6 service IP (for MagicDNS) // instead of the IPv4 version (100.100.100.100). -func (v ConfigView) OnlyIPv6() bool { return v.ж.OnlyIPv6 } -func (v ConfigView) Equal(v2 ConfigView) bool { return v.ж.Equal(v2.ж) } +func (v ConfigView) OnlyIPv6() bool { return v.ж.OnlyIPv6 } + +// MagicDNSHostsUnrouted is whether MagicDNS host records exist +// that are served on demand via [resolver.MagicDNSHosts] (so are +// not listed in Hosts) and are not covered by any Routes entry; +// that is, the tailnet has MagicDNS names but MagicDNS domain +// routing is off. It preserves the effect those records had when +// they were listed in Hosts: hasHostsWithoutSplitDNSRoutes +// reports true, keeping quad-100 in the OS resolver path so the +// names still resolve. +func (v ConfigView) MagicDNSHostsUnrouted() bool { return v.ж.MagicDNSHostsUnrouted } +func (v ConfigView) Equal(v2 ConfigView) bool { return v.ж.Equal(v2.ж) } // A compilation failure here means this code must be regenerated, with the command at the top of this file. var _ConfigViewNeedsRegeneration = Config(struct { - AcceptDNS bool - DefaultResolvers []*dnstype.Resolver - Routes map[dnsname.FQDN][]*dnstype.Resolver - SearchDomains []dnsname.FQDN - Hosts map[dnsname.FQDN][]netip.Addr - SubdomainHosts set.Set[dnsname.FQDN] - OnlyIPv6 bool + AcceptDNS bool + DefaultResolvers []*dnstype.Resolver + Routes map[dnsname.FQDN][]*dnstype.Resolver + SearchDomains []dnsname.FQDN + Hosts map[dnsname.FQDN][]netip.Addr + SubdomainHosts set.Set[dnsname.FQDN] + OnlyIPv6 bool + MagicDNSHostsUnrouted bool }{}) diff --git a/net/dns/resolver/tsdns.go b/net/dns/resolver/tsdns.go index 517606e23..d0b4e8368 100644 --- a/net/dns/resolver/tsdns.go +++ b/net/dns/resolver/tsdns.go @@ -229,6 +229,49 @@ type Resolver struct { hostToIP map[dnsname.FQDN][]netip.Addr ipToHost map[netip.Addr]dnsname.FQDN subdomainHosts set.Set[dnsname.FQDN] + magicHosts MagicDNSHosts // or nil if none installed +} + +// MagicDNSHosts is a live source of MagicDNS host records, installed +// via [Resolver.SetMagicDNSHosts]. +// +// It replaces the per-node entries of [Config.Hosts]: instead of the +// caller pushing a full snapshot of every node's name and addresses +// into the resolver on every (possibly incremental) netmap change, +// the resolver pulls the answer for one name on demand from the +// caller's live indexes. [Config.Hosts] remains for control's +// DNS.ExtraRecords entries, which are few, and is consulted first. +// +// Implementations must be safe for concurrent use and cheap: the +// methods are called on the DNS query serving path. +type MagicDNSHosts interface { + // LookupHost returns the IPs to answer for the node with the + // given MagicDNS FQDN, and whether the name is known. A known + // name may have no IPs of the queried family; that is "name + // exists, no records", not NXDOMAIN. + LookupHost(dnsname.FQDN) (ips []netip.Addr, ok bool) + + // LookupPTR returns the MagicDNS FQDN of the node that owns + // the given Tailscale IP, and whether the IP is known. + LookupPTR(netip.Addr) (_ dnsname.FQDN, ok bool) + + // SubdomainHost reports whether fqdn names a node with the + // [tailcfg.NodeAttrDNSSubdomainResolve] attribute, whose + // subdomains all resolve to the node's own addresses. + SubdomainHost(dnsname.FQDN) bool +} + +// SetMagicDNSHosts installs the live MagicDNS host source consulted +// by forward and reverse MagicDNS lookups that miss [Config.Hosts]. +// It is expected to be called once, before the resolver serves +// queries. +func (r *Resolver) SetMagicDNSHosts(h MagicDNSHosts) { + if !buildfeatures.HasDNS { + return + } + r.mu.Lock() + defer r.mu.Unlock() + r.magicHosts = h } type ForwardLinkSelector interface { @@ -682,15 +725,23 @@ func (r *Resolver) resolveLocal(domain dnsname.FQDN, typ dns.Type) (netip.Addr, hosts := r.hostToIP localDomains := r.localDomains subdomainHosts := r.subdomainHosts + magicHosts := r.magicHosts r.mu.Unlock() addrs, found := hosts[domain] + if !found && magicHosts != nil { + addrs, found = magicHosts.LookupHost(domain) + } if !found { for parent := domain.Parent(); parent != ""; parent = parent.Parent() { if subdomainHosts.Contains(parent) { addrs, found = hosts[parent] break } + if magicHosts != nil && magicHosts.SubdomainHost(parent) { + addrs, found = magicHosts.LookupHost(parent) + break + } } } if !found { @@ -865,6 +916,9 @@ func (r *Resolver) fqdnForIPLocked(ip netip.Addr, name dnsname.FQDN) (dnsname.FQ } ret, ok := r.ipToHost[ip] + if !ok && r.magicHosts != nil { + ret, ok = r.magicHosts.LookupPTR(ip) + } if !ok { for _, suffix := range r.localDomains { if suffix.Contains(name) { diff --git a/net/dns/resolver/tsdns_test.go b/net/dns/resolver/tsdns_test.go index a8c568d3d..98727dd4f 100644 --- a/net/dns/resolver/tsdns_test.go +++ b/net/dns/resolver/tsdns_test.go @@ -476,6 +476,111 @@ func TestResolveLocalSubdomain(t *testing.T) { } } +// fakeMagicDNSHosts is a MagicDNSHosts for tests, serving from fixed maps. +type fakeMagicDNSHosts struct { + hosts map[dnsname.FQDN][]netip.Addr + subdomain set.Set[dnsname.FQDN] + ptr map[netip.Addr]dnsname.FQDN +} + +func (f fakeMagicDNSHosts) LookupHost(fqdn dnsname.FQDN) (ips []netip.Addr, ok bool) { + ips, ok = f.hosts[fqdn] + return ips, ok +} + +func (f fakeMagicDNSHosts) LookupPTR(ip netip.Addr) (_ dnsname.FQDN, ok bool) { + name, ok := f.ptr[ip] + return name, ok +} + +func (f fakeMagicDNSHosts) SubdomainHost(fqdn dnsname.FQDN) bool { + return f.subdomain.Contains(fqdn) +} + +// Tests forward, subdomain, and reverse resolution served on demand +// via the MagicDNSHosts hook, and that entries pushed via Config.Hosts +// take precedence over the hook. +func TestResolveLocalMagicDNSHosts(t *testing.T) { + r := newResolver(t) + defer r.Close() + + r.SetConfig(Config{ + Hosts: map[dnsname.FQDN][]netip.Addr{ + "extra.ipn.dev.": {netip.MustParseAddr("100.100.1.1")}, + "both.ipn.dev.": {netip.MustParseAddr("100.100.2.2")}, + }, + LocalDomains: []dnsname.FQDN{"ipn.dev.", "64.100.in-addr.arpa."}, + }) + node4 := netip.MustParseAddr("100.64.0.7") + node6 := netip.MustParseAddr("fd7a:115c:a1e0::7") + r.SetMagicDNSHosts(fakeMagicDNSHosts{ + hosts: map[dnsname.FQDN][]netip.Addr{ + "node.ipn.dev.": {node4, node6}, + "v4only.ipn.dev.": {node4}, + "subber.ipn.dev.": {node4}, + "both.ipn.dev.": {netip.MustParseAddr("100.100.9.9")}, // masked by Config.Hosts + }, + subdomain: set.Of[dnsname.FQDN]("subber.ipn.dev."), + ptr: map[netip.Addr]dnsname.FQDN{node4: "node.ipn.dev."}, + }) + + tests := []struct { + name string + qname dnsname.FQDN + qtype dns.Type + ip netip.Addr + code dns.RCode + }{ + {"hook-ipv4", "node.ipn.dev.", dns.TypeA, node4, dns.RCodeSuccess}, + {"hook-ipv6", "node.ipn.dev.", dns.TypeAAAA, node6, dns.RCodeSuccess}, + // A known name with no records of the queried family is + // "name exists, no records", not NXDOMAIN. + {"hook-no-ipv6", "v4only.ipn.dev.", dns.TypeAAAA, netip.Addr{}, dns.RCodeSuccess}, + {"hook-nxdomain", "gone.ipn.dev.", dns.TypeA, netip.Addr{}, dns.RCodeNameError}, + {"hook-foreign", "google.com.", dns.TypeA, netip.Addr{}, dns.RCodeRefused}, + {"hook-subdomain", "foo.subber.ipn.dev.", dns.TypeA, node4, dns.RCodeSuccess}, + {"hook-subdomain-deep", "bar.foo.subber.ipn.dev.", dns.TypeA, node4, dns.RCodeSuccess}, + {"hook-subdomain-no-cap", "foo.node.ipn.dev.", dns.TypeA, netip.Addr{}, dns.RCodeNameError}, + // Config.Hosts entries (control's ExtraRecords) are + // consulted before the hook. + {"config-hosts", "extra.ipn.dev.", dns.TypeA, netip.MustParseAddr("100.100.1.1"), dns.RCodeSuccess}, + {"config-hosts-precedence", "both.ipn.dev.", dns.TypeA, netip.MustParseAddr("100.100.2.2"), dns.RCodeSuccess}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ip, code := r.resolveLocal(tt.qname, tt.qtype) + if code != tt.code { + t.Errorf("code = %v; want %v", code, tt.code) + } + if ip != tt.ip { + t.Errorf("ip = %v; want %v", ip, tt.ip) + } + }) + } + + revTests := []struct { + name string + q dnsname.FQDN + want dnsname.FQDN + code dns.RCode + }{ + {"hook-ptr", "7.0.64.100.in-addr.arpa.", "node.ipn.dev.", dns.RCodeSuccess}, + {"hook-ptr-nxdomain", "8.0.64.100.in-addr.arpa.", "", dns.RCodeNameError}, + {"hook-ptr-foreign", "5.4.3.2.in-addr.arpa.", "", dns.RCodeRefused}, + } + for _, tt := range revTests { + t.Run(tt.name, func(t *testing.T) { + name, code := r.resolveLocalReverse(tt.q) + if code != tt.code { + t.Errorf("code = %v; want %v", code, tt.code) + } + if name != tt.want { + t.Errorf("name = %v; want %v", name, tt.want) + } + }) + } +} + func TestResolveLocalReverse(t *testing.T) { r := newResolver(t) defer r.Close()