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 134444ed8..720f1f9cb 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -228,6 +228,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 @@ -639,6 +640,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 { @@ -6095,7 +6101,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 6c13d5211..fcb0e29c6 100644 --- a/ipn/ipnlocal/node_backend.go +++ b/ipn/ipnlocal/node_backend.go @@ -305,6 +305,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 @@ -1126,10 +1132,122 @@ 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) { + // The resolver already lowercases query names, but lowercase + // again (nearly free when already lowercase) so that no other + // caller of the [resolver.MagicDNSHosts] hook can miss on case. + nid, ok := nb.nodeByName[strings.ToLower(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) { @@ -1234,9 +1352,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 } @@ -1269,63 +1387,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 { @@ -1363,6 +1447,14 @@ 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. Any netmap with a self node + // has such records. + dcfg.MagicDNSHostsUnrouted = nm.SelfNode.Valid() } addDefault := func(resolvers []*dnstype.Resolver) { diff --git a/ipn/ipnlocal/node_backend_test.go b/ipn/ipnlocal/node_backend_test.go index 9b09ed930..b8eb98479 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" @@ -559,3 +560,82 @@ func TestNodeBackendRouteManagerExtras(t *testing.T) { t.Errorf("Outbound still routes %v after extras cleared", transit.Addr()) } } + +// 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")) +} diff --git a/ipn/ipnlocal/resolve.go b/ipn/ipnlocal/resolve.go index fb28c622f..394f579aa 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 47cf595ed..14bc1ebd2 100644 --- a/ipn/ipnlocal/state_test.go +++ b/ipn/ipnlocal/state_test.go @@ -42,8 +42,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" @@ -1251,23 +1249,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 @@ -1328,9 +1309,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, }, }, { @@ -1385,9 +1367,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, }, }, { @@ -1467,9 +1451,10 @@ func TestEngineReconfigOnStateChange(t *testing.T) { Routes: routesWithQuad100(), }, 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, }, }, { @@ -1513,9 +1498,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, }, }, { @@ -1544,9 +1530,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, }, }, { @@ -1572,6 +1559,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..da19e7035 100644 --- a/net/dns/config.go +++ b/net/dns/config.go @@ -61,6 +61,14 @@ 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 are + // served on demand via [resolver.MagicDNSHosts] (so are not + // listed in Hosts) without being covered by any Routes entry; + // that is, MagicDNS domain routing is off. It preserves the + // effect the node 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 +135,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..4d54c2613 100644 --- a/net/dns/dns_view.go +++ b/net/dns/dns_view.go @@ -139,16 +139,26 @@ 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 are +// served on demand via [resolver.MagicDNSHosts] (so are not +// listed in Hosts) without being covered by any Routes entry; +// that is, MagicDNS domain routing is off. It preserves the +// effect the node 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..f447539f7 100644 --- a/net/dns/resolver/tsdns.go +++ b/net/dns/resolver/tsdns.go @@ -229,6 +229,53 @@ 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. Name lookups are +// case-insensitive: the resolver passes lowercase names, but +// implementations must not rely on that. +type MagicDNSHosts interface { + // LookupHost returns the IPs to answer for the node with the + // given MagicDNS FQDN, and whether the name is known. It returns + // all answerable IPs regardless of record type; the resolver + // filters them by the query's type, and a known name with no IPs + // of the query's family 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 +729,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 +920,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()