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