diff --git a/net/dns/direct.go b/net/dns/direct.go index 6fb18347b..0fd0e0ee0 100644 --- a/net/dns/direct.go +++ b/net/dns/direct.go @@ -13,6 +13,7 @@ import ( "io/ioutil" "os" "os/exec" + "path/filepath" "runtime" "strings" @@ -145,14 +146,14 @@ func isResolvedRunning() bool { // The caller must call Down before program shutdown // or as cleanup if the program terminates unexpectedly. type directManager struct { - fs pinholeFS + fs wholeFileFS } func newDirectManager() directManager { return directManager{fs: directFS{}} } -func newDirectManagerOnFS(fs pinholeFS) directManager { +func newDirectManagerOnFS(fs wholeFileFS) directManager { return directManager{fs: fs} } @@ -325,7 +326,7 @@ func (m directManager) Close() error { return nil } -func atomicWriteFile(fs pinholeFS, filename string, data []byte, perm os.FileMode) error { +func atomicWriteFile(fs wholeFileFS, filename string, data []byte, perm os.FileMode) error { var randBytes [12]byte if _, err := rand.Read(randBytes[:]); err != nil { return fmt.Errorf("atomicWriteFile: %w", err) @@ -340,9 +341,11 @@ func atomicWriteFile(fs pinholeFS, filename string, data []byte, perm os.FileMod return fs.Rename(tmpName, filename) } -// pinholeFS is a high-level file system abstraction designed just for use +// wholeFileFS is a high-level file system abstraction designed just for use // by directManager, with the goal that it is easy to implement over wsl.exe. -type pinholeFS interface { +// +// All name parameters are absolute paths. +type wholeFileFS interface { Stat(name string) (isRegular bool, err error) Rename(oldName, newName string) error Remove(name string) error @@ -350,13 +353,19 @@ type pinholeFS interface { WriteFile(name string, contents []byte, perm os.FileMode) error } -// directFS is a pinholeFS implemented directly on the OS. +// directFS is a wholeFileFS implemented directly on the OS. type directFS struct { - prefix string // file path prefix; used for testing + // prefix is file path prefix. + // + // All name parameters are absolute paths so this is typically a + // testing temporary directory like "/tmp". + prefix string } +func (fs directFS) path(name string) string { return filepath.Join(fs.prefix, name) } + func (fs directFS) Stat(name string) (isRegular bool, err error) { - fi, err := os.Stat(fs.prefix + name) + fi, err := os.Stat(fs.path(name)) if err != nil { return false, err } @@ -364,15 +373,15 @@ func (fs directFS) Stat(name string) (isRegular bool, err error) { } func (fs directFS) Rename(oldName, newName string) error { - return os.Rename(fs.prefix+oldName, fs.prefix+newName) + return os.Rename(fs.path(oldName), fs.path(newName)) } -func (fs directFS) Remove(name string) error { return os.Remove(fs.prefix + name) } +func (fs directFS) Remove(name string) error { return os.Remove(fs.path(name)) } func (fs directFS) ReadFile(name string) ([]byte, error) { - return ioutil.ReadFile(fs.prefix + name) + return ioutil.ReadFile(fs.path(name)) } func (fs directFS) WriteFile(name string, contents []byte, perm os.FileMode) error { - return ioutil.WriteFile(fs.prefix+name, contents, perm) + return ioutil.WriteFile(fs.path(name), contents, perm) } diff --git a/net/dns/ini.go b/net/dns/ini.go new file mode 100644 index 000000000..802eda5b0 --- /dev/null +++ b/net/dns/ini.go @@ -0,0 +1,29 @@ +// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package dns + +import ( + "regexp" + "strings" +) + +// parseIni parses a basic .ini file, used for wsl.conf. +func parseIni(data string) map[string]map[string]string { + sectionRE := regexp.MustCompile(`^\[([^]]+)\]`) + kvRE := regexp.MustCompile(`^\s*(\w+)\s*=\s*([^#]*)`) + + ini := map[string]map[string]string{} + var section string + for _, line := range strings.Split(data, "\n") { + if res := sectionRE.FindStringSubmatch(line); len(res) > 1 { + section = res[1] + ini[section] = map[string]string{} + } else if res := kvRE.FindStringSubmatch(line); len(res) > 2 { + k, v := strings.TrimSpace(res[1]), strings.TrimSpace(res[2]) + ini[section][k] = v + } + } + return ini +} diff --git a/net/dns/ini_test.go b/net/dns/ini_test.go new file mode 100644 index 000000000..d41fa9462 --- /dev/null +++ b/net/dns/ini_test.go @@ -0,0 +1,37 @@ +// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package dns + +import ( + "reflect" + "testing" +) + +func TestParseIni(t *testing.T) { + var tests = []struct { + src string + want map[string]map[string]string + }{ + { + src: `# appended wsl.conf file +[automount] + enabled = true + root=/mnt/ +# added by tailscale +[network] # trailing comment +generateResolvConf = false # trailing comment`, + want: map[string]map[string]string{ + "automount": map[string]string{"enabled": "true", "root": "/mnt/"}, + "network": map[string]string{"generateResolvConf": "false"}, + }, + }, + } + for _, test := range tests { + got := parseIni(test.src) + if !reflect.DeepEqual(got, test.want) { + t.Errorf("for:\n%s\ngot: %v\nwant: %v", test.src, got, test.want) + } + } +} diff --git a/net/dns/manager_windows.go b/net/dns/manager_windows.go index aec11c5a7..838cb5a2a 100644 --- a/net/dns/manager_windows.go +++ b/net/dns/manager_windows.go @@ -35,9 +35,10 @@ const ( ) type windowsManager struct { - logf logger.Logf - guid string - nrptWorks bool + logf logger.Logf + guid string + nrptWorks bool + wslManager *wslManager } func NewOSConfigurator(logf logger.Logf, interfaceName string) (OSConfigurator, error) { @@ -57,6 +58,11 @@ func NewOSConfigurator(logf logger.Logf, interfaceName string) (OSConfigurator, ret.delKey(nrptBase) } + if distros := wslDistros(logf); len(distros) > 0 { + logf("WSL distributions: %v", distros) + ret.wslManager = newWSLManager(logf, distros) + } + return ret, nil } @@ -296,6 +302,18 @@ func (m windowsManager) SetDNS(cfg OSConfig) error { } }() + // On initial setup of WSL, the restart caused by --shutdown is slow, + // so we do it out-of-line. + go func() { + if m.wslManager != nil { + if err := m.wslManager.SetDNS(cfg); err != nil { + m.logf("WSL SetDNS: %v", err) // continue + } else { + m.logf("WSL SetDNS: success") + } + } + }() + return nil } diff --git a/net/dns/wsl_windows.go b/net/dns/wsl_windows.go new file mode 100644 index 000000000..e50c33861 --- /dev/null +++ b/net/dns/wsl_windows.go @@ -0,0 +1,217 @@ +// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package dns + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "strings" + "syscall" + "unicode/utf16" + + "tailscale.com/types/logger" +) + +// wslDistros reports the names of the installed WSL2 linux distributions. +func wslDistros(logf logger.Logf) []string { + cmd := exec.Command("wsl.exe", "-l") + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + b, err := cmd.CombinedOutput() + if err != nil { + return nil + } + + // The first line of output is a WSL header. E.g. + // + // C:\tsdev>wsl.exe -l + // Windows Subsystem for Linux Distributions: + // Ubuntu-20.04 (Default) + // + // We can skip it by passing '-q', but here we put it to work. + // It turns out wsl.exe -l is broken, and outputs UTF-16 names + // that nothing can read. (Try `wsl.exe -l | more`.) + // So we look at the header to see if it's UTF-16. + // If so, we run the rest through a UTF-16 parser. + // + // https://github.com/microsoft/WSL/issues/4607 + var output string + if bytes.HasPrefix(b, []byte("W\x00i\x00n\x00d\x00o\x00w\x00s\x00")) { + output, err = decodeUTF16(b) + if err != nil { + logf("failed to decode wsl.exe -l output %q: %v", b, err) + return nil + } + } else { + output = string(b) + } + fmt.Printf("wslDistros: %q\n", output) + lines := strings.Split(output, "\n") + if len(lines) < 1 { + return nil + } + lines = lines[1:] // drop "Windows Subsystem For Linux" header + + var distros []string + for _, name := range lines { + name = strings.TrimSpace(name) + name = strings.TrimSuffix(name, " (Default)") + if name == "" { + continue + } + fmt.Printf("wslDistros: name=%q\n", name) + distros = append(distros, name) + } + return distros +} + +func decodeUTF16(b []byte) (string, error) { + if len(b) == 0 { + return "", nil + } else if len(b)%2 != 0 { + return "", fmt.Errorf("decodeUTF16: invalid length %d", len(b)) + } + var u16 []uint16 + for i := 0; i < len(b); i += 2 { + u16 = append(u16, uint16(b[i])+(uint16(b[i+1])<<8)) + } + return string(utf16.Decode(u16)), nil +} + +// wslManager is a DNS manager for WSL2 linux distributions. +// It configures /etc/wsl.conf and /etc/resolv.conf. +type wslManager struct { + logf logger.Logf + managers map[string]directManager // distro name -> manager +} + +func newWSLManager(logf logger.Logf, distros []string) *wslManager { + m := &wslManager{ + logf: logf, + managers: make(map[string]directManager), + } + for _, distro := range distros { + m.managers[distro] = newDirectManagerOnFS(wslFS{ + user: "root", + distro: distro, + }) + } + return m +} + +func (wm *wslManager) SetDNS(cfg OSConfig) error { + if !cfg.IsZero() { + if wm.setWSLConf() { + // What's this? So glad you asked. + // + // WSL2 writes the /etc/resolv.conf. + // It is aggressive about it. Every time you execute wsl.exe, + // it writes it. (Opening a terminal is done by running wsl.exe.) + // You can turn this off using /etc/wsl.conf! But: this wsl.conf + // file is only parsed when the VM boots up. To do that, we + // have to shut down WSL2. + // + // So we do it here, before we call wsl.exe to write resolv.conf. + if b, err := wslCommand("--shutdown").CombinedOutput(); err != nil { + wm.logf("WSL SetDNS shutdown: %v: %s", err, b) + } + } + } + + for distro, m := range wm.managers { + if err := m.SetDNS(cfg); err != nil { + wm.logf("WSL(%q) SetDNS: %v", distro, err) + } + } + return nil +} + +const wslConf = "/etc/wsl.conf" +const wslConfSection = `# added by tailscale +[network] +generateResolvConf = false +` + +// setWSLConf attempts to disable generateResolvConf in each WSL2 linux. +// If any are changed, it reports true. +func (wm *wslManager) setWSLConf() (changed bool) { + for distro, m := range wm.managers { + b, err := m.fs.ReadFile(wslConf) + if err != nil && !os.IsNotExist(err) { + wm.logf("WSL(%q) wsl.conf: read: %v", distro, err) + continue + } + ini := parseIni(string(b)) + if v := ini["network"]["generateResolvConf"]; v == "" { + b = append(b, wslConfSection...) + if err := m.fs.WriteFile(wslConf, b, 0644); err != nil { + wm.logf("WSL(%q) wsl.conf: write: %v", distro, err) + continue + } + changed = true + } + } + return changed +} + +func (m *wslManager) SupportsSplitDNS() bool { return false } +func (m *wslManager) Close() error { return m.SetDNS(OSConfig{}) } + +// wslFS is a pinholeFS implemented on top of wsl.exe. +// +// We access WSL2 file systems via wsl.exe instead of \\wsl$\ because +// the netpath appears to operate as the standard user, not root. +type wslFS struct { + user string + distro string +} + +func (fs wslFS) Stat(name string) (isRegular bool, err error) { + err = fs.cmd("test", "-f", name).Run() + if ee, _ := err.(*exec.ExitError); ee != nil { + if ee.ExitCode() == 1 { + return false, os.ErrNotExist + } + return false, err + } + return true, nil +} + +func (fs wslFS) Rename(oldName, newName string) error { + return fs.cmd("mv", "--", oldName, newName).Run() +} +func (fs wslFS) Remove(name string) error { return fs.cmd("rm", "--", name).Run() } + +func (fs wslFS) ReadFile(name string) ([]byte, error) { + b, err := fs.cmd("cat", "--", name).CombinedOutput() + if ee, _ := err.(*exec.ExitError); ee != nil && ee.ExitCode() == 1 { + return nil, os.ErrNotExist + } + return b, err +} + +func (fs wslFS) WriteFile(name string, contents []byte, perm os.FileMode) error { + cmd := fs.cmd("tee", "--", name) + cmd.Stdin = bytes.NewReader(contents) + cmd.Stdout = nil + if err := cmd.Run(); err != nil { + return err + } + return fs.cmd("chmod", "--", fmt.Sprintf("%04o", perm), name).Run() +} + +func (fs wslFS) cmd(args ...string) *exec.Cmd { + cmd := wslCommand("-u", fs.user, "-d", fs.distro, "-e") + cmd.Args = append(cmd.Args, args...) + fmt.Printf("wslFS.cmd: %v\n", cmd.Args) + return cmd +} + +func wslCommand(args ...string) *exec.Cmd { + cmd := exec.Command("wsl.exe", args...) + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + return cmd +}