From 9197dd14cc2439d8e3daa4fac5b3a3d293dffd27 Mon Sep 17 00:00:00 2001 From: Maisem Ali Date: Fri, 19 Aug 2022 10:19:50 -0700 Subject: [PATCH] net/dns: [win] add MagicDNS entries to etc/hosts This works around the 2.3s delay in short name lookups when SNR is enabled. C:\Windows\System32\drivers\etc\hosts file. We only add known hosts that match the search domains, and we populate the list in order of Search Domains so that our matching algorithm mimics what Windows would otherwise do itself if SNR was off. Updates #1659 Signed-off-by: Maisem Ali --- cmd/tailscaled/depaware.txt | 2 +- net/dns/manager.go | 45 ++++++++++++++ net/dns/manager_test.go | 106 ++++++++++++++++++++++++++++++++ net/dns/manager_windows.go | 91 ++++++++++++++++++++++++++- net/dns/manager_windows_test.go | 14 +++++ net/dns/osconfig.go | 10 +++ 6 files changed, 265 insertions(+), 3 deletions(-) diff --git a/cmd/tailscaled/depaware.txt b/cmd/tailscaled/depaware.txt index e035198df..627b18ec2 100644 --- a/cmd/tailscaled/depaware.txt +++ b/cmd/tailscaled/depaware.txt @@ -316,7 +316,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+ LD golang.org/x/crypto/ssh from tailscale.com/ssh/tailssh+ golang.org/x/exp/constraints from golang.org/x/exp/slices - golang.org/x/exp/slices from tailscale.com/ipn/ipnlocal + golang.org/x/exp/slices from tailscale.com/ipn/ipnlocal+ golang.org/x/net/bpf from github.com/mdlayher/genetlink+ golang.org/x/net/dns/dnsmessage from net+ golang.org/x/net/http/httpguts from golang.org/x/net/http2+ diff --git a/net/dns/manager.go b/net/dns/manager.go index b5a1fbe42..cace1fea5 100644 --- a/net/dns/manager.go +++ b/net/dns/manager.go @@ -16,6 +16,7 @@ import ( "sync/atomic" "time" + "golang.org/x/exp/slices" "tailscale.com/health" "tailscale.com/net/dns/resolver" "tailscale.com/net/packet" @@ -137,6 +138,47 @@ func (m *Manager) Set(cfg Config) error { return nil } +// compileHostEntries creates a list of single-label resolutions possible +// from the configured hosts and search domains. +// The entries are compiled in the order of the search domains, then the hosts. +// The returned list is sorted by the first hostname in each entry. +func compileHostEntries(cfg Config) (hosts []*HostEntry) { + didLabel := make(map[string]bool, len(cfg.Hosts)) + for _, sd := range cfg.SearchDomains { + for h, ips := range cfg.Hosts { + if !sd.Contains(h) || h.NumLabels() != (sd.NumLabels()+1) { + continue + } + ipHosts := []string{string(h.WithTrailingDot())} + if label := dnsname.FirstLabel(string(h)); !didLabel[label] { + didLabel[label] = true + ipHosts = append(ipHosts, label) + } + for _, ip := range ips { + if cfg.OnlyIPv6 && ip.Is4() { + continue + } + hosts = append(hosts, &HostEntry{ + Addr: ip, + Hosts: ipHosts, + }) + // Only add IPv4 or IPv6 per host, like we do in the resolver. + break + } + } + } + slices.SortFunc(hosts, func(a, b *HostEntry) bool { + if len(a.Hosts) == 0 { + return false + } + if len(b.Hosts) == 0 { + return true + } + return a.Hosts[0] < b.Hosts[0] + }) + return hosts +} + // compileConfig converts cfg into a quad-100 resolver configuration // and an OS-level configuration. func (m *Manager) compileConfig(cfg Config) (rcfg resolver.Config, ocfg OSConfig, err error) { @@ -154,6 +196,9 @@ func (m *Manager) compileConfig(cfg Config) (rcfg resolver.Config, ocfg OSConfig } // Similarly, the OS always gets search paths. ocfg.SearchDomains = cfg.SearchDomains + if runtime.GOOS == "windows" { + ocfg.Hosts = compileHostEntries(cfg) + } // Deal with trivial configs first. switch { diff --git a/net/dns/manager_test.go b/net/dns/manager_test.go index 84fb03d2e..3ab7636be 100644 --- a/net/dns/manager_test.go +++ b/net/dns/manager_test.go @@ -48,6 +48,112 @@ func (c *fakeOSConfigurator) GetBaseConfig() (OSConfig, error) { func (c *fakeOSConfigurator) Close() error { return nil } +func TestCompileHostEntries(t *testing.T) { + tests := []struct { + name string + cfg Config + want []*HostEntry + }{ + { + name: "empty", + }, + { + name: "no-search-domains", + cfg: Config{ + Hosts: map[dnsname.FQDN][]netip.Addr{ + "a.b.c.": {netip.MustParseAddr("1.1.1.1")}, + }, + }, + }, + { + name: "search-domains", + cfg: Config{ + Hosts: map[dnsname.FQDN][]netip.Addr{ + "a.foo.ts.net.": {netip.MustParseAddr("1.1.1.1")}, + "b.foo.ts.net.": {netip.MustParseAddr("1.1.1.2")}, + "c.foo.ts.net.": {netip.MustParseAddr("1.1.1.3")}, + "d.foo.beta.tailscale.net.": {netip.MustParseAddr("1.1.1.4")}, + "d.foo.ts.net.": {netip.MustParseAddr("1.1.1.4")}, + "e.foo.beta.tailscale.net.": {netip.MustParseAddr("1.1.1.5")}, + "random.example.com.": {netip.MustParseAddr("1.1.1.1")}, + "other.example.com.": {netip.MustParseAddr("1.1.1.2")}, + "othertoo.example.com.": {netip.MustParseAddr("1.1.5.2")}, + }, + SearchDomains: []dnsname.FQDN{"foo.ts.net.", "foo.beta.tailscale.net."}, + }, + want: []*HostEntry{ + {Addr: netip.MustParseAddr("1.1.1.1"), Hosts: []string{"a.foo.ts.net.", "a"}}, + {Addr: netip.MustParseAddr("1.1.1.2"), Hosts: []string{"b.foo.ts.net.", "b"}}, + {Addr: netip.MustParseAddr("1.1.1.3"), Hosts: []string{"c.foo.ts.net.", "c"}}, + {Addr: netip.MustParseAddr("1.1.1.4"), Hosts: []string{"d.foo.beta.tailscale.net."}}, + {Addr: netip.MustParseAddr("1.1.1.4"), Hosts: []string{"d.foo.ts.net.", "d"}}, + {Addr: netip.MustParseAddr("1.1.1.5"), Hosts: []string{"e.foo.beta.tailscale.net.", "e"}}, + }, + }, + { + name: "only-exact-subdomain-match", + cfg: Config{ + Hosts: map[dnsname.FQDN][]netip.Addr{ + "e.foo.ts.net.": {netip.MustParseAddr("1.1.1.5")}, + "e.foo.beta.tailscale.net.": {netip.MustParseAddr("1.1.1.5")}, + "e.ignored.foo.beta.tailscale.net.": {netip.MustParseAddr("1.1.1.6")}, + }, + SearchDomains: []dnsname.FQDN{"foo.ts.net.", "foo.beta.tailscale.net."}, + }, + want: []*HostEntry{ + {Addr: netip.MustParseAddr("1.1.1.5"), Hosts: []string{"e.foo.beta.tailscale.net."}}, + {Addr: netip.MustParseAddr("1.1.1.5"), Hosts: []string{"e.foo.ts.net.", "e"}}, + }, + }, + { + name: "unmatched-domains", + cfg: Config{ + Hosts: map[dnsname.FQDN][]netip.Addr{ + "d.foo.beta.tailscale.net.": {netip.MustParseAddr("1.1.1.4")}, + "d.foo.ts.net.": {netip.MustParseAddr("1.1.1.4")}, + "random.example.com.": {netip.MustParseAddr("1.1.1.1")}, + "other.example.com.": {netip.MustParseAddr("1.1.1.2")}, + "othertoo.example.com.": {netip.MustParseAddr("1.1.5.2")}, + }, + SearchDomains: []dnsname.FQDN{"foo.ts.net.", "foo.beta.tailscale.net."}, + }, + want: []*HostEntry{ + {Addr: netip.MustParseAddr("1.1.1.4"), Hosts: []string{"d.foo.beta.tailscale.net."}}, + {Addr: netip.MustParseAddr("1.1.1.4"), Hosts: []string{"d.foo.ts.net.", "d"}}, + }, + }, + { + name: "overlaps", + cfg: Config{ + Hosts: map[dnsname.FQDN][]netip.Addr{ + "h1.foo.ts.net.": {netip.MustParseAddr("1.1.1.3")}, + "h1.foo.beta.tailscale.net.": {netip.MustParseAddr("1.1.1.2")}, + "h2.foo.ts.net.": {netip.MustParseAddr("1.1.1.1")}, + "h2.foo.beta.tailscale.net.": {netip.MustParseAddr("1.1.1.1")}, + "example.com": {netip.MustParseAddr("1.1.1.1")}, + }, + SearchDomains: []dnsname.FQDN{"foo.ts.net.", "foo.beta.tailscale.net."}, + }, + want: []*HostEntry{ + {Addr: netip.MustParseAddr("1.1.1.2"), Hosts: []string{"h1.foo.beta.tailscale.net."}}, + {Addr: netip.MustParseAddr("1.1.1.3"), Hosts: []string{"h1.foo.ts.net.", "h1"}}, + {Addr: netip.MustParseAddr("1.1.1.1"), Hosts: []string{"h2.foo.beta.tailscale.net."}}, + {Addr: netip.MustParseAddr("1.1.1.1"), Hosts: []string{"h2.foo.ts.net.", "h2"}}, + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := compileHostEntries(tc.cfg) + if diff := cmp.Diff(tc.want, got, cmp.Comparer(func(a, b netip.Addr) bool { + return a == b + })); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + }) + } +} + func TestManager(t *testing.T) { if runtime.GOOS == "windows" { t.Skipf("test's assumptions break because of https://github.com/tailscale/corp/issues/1662") diff --git a/net/dns/manager_windows.go b/net/dns/manager_windows.go index d17416c61..843ab22ed 100644 --- a/net/dns/manager_windows.go +++ b/net/dns/manager_windows.go @@ -5,10 +5,14 @@ package dns import ( + "bufio" + "bytes" "errors" "fmt" "net/netip" + "os" "os/exec" + "path/filepath" "sort" "strings" "syscall" @@ -17,6 +21,7 @@ import ( "golang.org/x/sys/windows" "golang.org/x/sys/windows/registry" "golang.zx2c4.com/wireguard/windows/tunnel/winipcfg" + "tailscale.com/atomicfile" "tailscale.com/envknob" "tailscale.com/types/logger" "tailscale.com/util/dnsname" @@ -100,6 +105,71 @@ func (m windowsManager) setSplitDNS(resolvers []netip.Addr, domains []dnsname.FQ return m.nrptDB.WriteSplitDNSConfig(servers, domains) } +func setTailscaleHosts(prevHostsFile []byte, hosts []*HostEntry) ([]byte, error) { + b := bytes.ReplaceAll(prevHostsFile, []byte("\r\n"), []byte("\n")) + sc := bufio.NewScanner(bytes.NewReader(b)) + const ( + header = "# TailscaleHostsSectionStart" + footer = "# TailscaleHostsSectionEnd" + ) + var comments = []string{ + "# This section contains MagicDNS entries for Tailscale.", + "# Do not edit this section manually.", + } + var out bytes.Buffer + var inSection bool + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == header { + inSection = true + continue + } + if line == footer { + inSection = false + continue + } + if inSection { + continue + } + fmt.Fprintln(&out, line) + } + if err := sc.Err(); err != nil { + return nil, err + } + if len(hosts) > 0 { + fmt.Fprintln(&out, header) + for _, c := range comments { + fmt.Fprintln(&out, c) + } + fmt.Fprintln(&out) + for _, he := range hosts { + fmt.Fprintf(&out, "%s %s\n", he.Addr, strings.Join(he.Hosts, " ")) + } + fmt.Fprintln(&out) + fmt.Fprintln(&out, footer) + } + return bytes.ReplaceAll(out.Bytes(), []byte("\n"), []byte("\r\n")), nil +} + +// setHosts sets the hosts file to contain the given host entries. +func (m windowsManager) setHosts(hosts []*HostEntry) error { + systemDir, err := windows.GetSystemDirectory() + if err != nil { + return err + } + hostsFile := filepath.Join(systemDir, "drivers", "etc", "hosts") + b, err := os.ReadFile(hostsFile) + if err != nil { + return err + } + outB, err := setTailscaleHosts(b, hosts) + if err != nil { + return err + } + const fileMode = 0 // ignored on windows. + return atomicfile.WriteFile(hostsFile, outB, fileMode) +} + // setPrimaryDNS sets the given resolvers and domains as the Tailscale // interface's DNS configuration. // If resolvers is non-empty, those resolvers become the system's @@ -217,6 +287,9 @@ func (m windowsManager) SetDNS(cfg OSConfig) error { if err := m.setSplitDNS(nil, nil); err != nil { return err } + if err := m.setHosts(nil); err != nil { + return err + } if err := m.setPrimaryDNS(cfg.Nameservers, cfg.SearchDomains); err != nil { return err } @@ -226,11 +299,25 @@ func (m windowsManager) SetDNS(cfg OSConfig) error { if err := m.setSplitDNS(cfg.Nameservers, cfg.MatchDomains); err != nil { return err } - // Still set search domains on the interface, since NRPT only - // handles query routing and not search domain expansion. + // Unset the resolver on the interface to ensure that we do not become + // the primary resolver. Although this is what we want, at the moment + // (2022-08-13) it causes single label resolutions from the OS resolver + // to wait for a MDNS response from the Tailscale interface. + // See #1659 and #5366 for more details. + // + // Still set search domains on the interface, since NRPT only handles + // query routing and not search domain expansion. if err := m.setPrimaryDNS(nil, cfg.SearchDomains); err != nil { return err } + + // As we are not the primary resolver in this setup, we need to + // explicitly set some single name hosts to ensure that we can resolve + // them quickly and get around the 2.3s delay that otherwise occurs due + // to multicast timeouts. + if err := m.setHosts(cfg.Hosts); err != nil { + return err + } } // Force DNS re-registration in Active Directory. What we actually diff --git a/net/dns/manager_windows_test.go b/net/dns/manager_windows_test.go index 7ec502cbe..84cc02fac 100644 --- a/net/dns/manager_windows_test.go +++ b/net/dns/manager_windows_test.go @@ -5,6 +5,7 @@ package dns import ( + "bytes" "context" "fmt" "math/rand" @@ -21,6 +22,19 @@ import ( const testGPRuleID = "{7B1B6151-84E6-41A3-8967-62F7F7B45687}" +func TestHostFileNewLines(t *testing.T) { + in := []byte("#foo\r\n#bar\n#baz\n") + want := []byte("#foo\r\n#bar\r\n#baz\r\n") + + got, err := setTailscaleHosts(in, nil) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, want) { + t.Errorf("got %q, want %q\n", got, want) + } +} + func TestManagerWindowsLocal(t *testing.T) { if !isWindows10OrBetter() || !winutil.IsCurrentProcessElevated() { t.Skipf("test requires running as elevated user on Windows 10+") diff --git a/net/dns/osconfig.go b/net/dns/osconfig.go index 94a7f25d3..06b223e15 100644 --- a/net/dns/osconfig.go +++ b/net/dns/osconfig.go @@ -37,8 +37,18 @@ type OSConfigurator interface { Close() error } +// HostEntry represents a single line in the OS's hosts file. +type HostEntry struct { + Addr netip.Addr + Hosts []string +} + // OSConfig is an OS DNS configuration. type OSConfig struct { + // Hosts is a map of DNS FQDNs to their IPs, which should be added to the + // OS's hosts file. Currently, (2022-08-12) it is only populated for Windows + // in SplitDNS mode and with Smart Name Resolution turned on. + Hosts []*HostEntry // Nameservers are the IP addresses of the nameservers to use. Nameservers []netip.Addr // SearchDomains are the domain suffixes to use when expanding