From 851f9b9742ce83188b5ffe3d5df96efb9ac0cea6 Mon Sep 17 00:00:00 2001 From: Cuong Manh Le Date: Fri, 3 Feb 2023 02:04:30 +0700 Subject: [PATCH] all: fork tailscale Linux dns manager package With modification to fit our use case. --- cmd/ctrld/os_linux.go | 22 +- cmd/ctrld/prog_linux.go | 4 +- go.mod | 8 +- go.sum | 2 + internal/dns/README.md | 2 + internal/dns/direct.go | 533 ++++++++++++++++++ internal/dns/direct_linux.go | 62 ++ internal/dns/direct_notlinux.go | 11 + internal/dns/direct_test.go | 199 +++++++ internal/dns/manager_linux.go | 387 +++++++++++++ internal/dns/manager_linux_test.go | 439 +++++++++++++++ internal/dns/nm.go | 269 +++++++++ internal/dns/osconfig.go | 124 ++++ internal/dns/osconfig_test.go | 44 ++ internal/dns/resolvconf.go | 26 + internal/dns/resolvconffile/resolvconffile.go | 119 ++++ .../dns/resolvconffile/resolvconffile_test.go | 104 ++++ internal/dns/resolved.go | 389 +++++++++++++ 18 files changed, 2735 insertions(+), 9 deletions(-) create mode 100644 internal/dns/README.md create mode 100644 internal/dns/direct.go create mode 100644 internal/dns/direct_linux.go create mode 100644 internal/dns/direct_notlinux.go create mode 100644 internal/dns/direct_test.go create mode 100644 internal/dns/manager_linux.go create mode 100644 internal/dns/manager_linux_test.go create mode 100644 internal/dns/nm.go create mode 100644 internal/dns/osconfig.go create mode 100644 internal/dns/osconfig_test.go create mode 100644 internal/dns/resolvconf.go create mode 100644 internal/dns/resolvconffile/resolvconffile.go create mode 100644 internal/dns/resolvconffile/resolvconffile_test.go create mode 100644 internal/dns/resolved.go diff --git a/cmd/ctrld/os_linux.go b/cmd/ctrld/os_linux.go index 582ae3c..50ff469 100644 --- a/cmd/ctrld/os_linux.go +++ b/cmd/ctrld/os_linux.go @@ -16,12 +16,16 @@ import ( "github.com/insomniacslk/dhcp/dhcpv4/nclient4" "github.com/insomniacslk/dhcp/dhcpv6" "github.com/insomniacslk/dhcp/dhcpv6/client6" - "tailscale.com/net/dns" "tailscale.com/util/dnsname" + "github.com/Control-D-Inc/ctrld/internal/dns" "github.com/Control-D-Inc/ctrld/internal/resolvconffile" ) +var logf = func(format string, args ...any) { + mainLog.Debug().Msgf(format, args...) +} + // allocate loopback ip // sudo ip a add 127.0.0.2/24 dev lo func allocateIP(ip string) error { @@ -46,16 +50,12 @@ const maxSetDNSAttempts = 5 // set the dns server for the provided network interface func setDNS(iface *net.Interface, nameservers []string) error { - logf := func(format string, args ...any) { - mainLog.Debug().Msgf(format, args...) - } - r, err := dns.NewOSConfigurator(logf, iface.Name) if err != nil { mainLog.Error().Err(err).Msg("failed to create DNS OS configurator") return err } - defer r.Close() + ns := make([]netip.Addr, 0, len(nameservers)) for _, nameserver := range nameservers { ns = append(ns, netip.MustParseAddr(nameserver)) @@ -80,6 +80,16 @@ func setDNS(iface *net.Interface, nameservers []string) error { } func resetDNS(iface *net.Interface) error { + if r, err := dns.NewOSConfigurator(logf, iface.Name); err == nil { + if err := r.Close(); err != nil { + mainLog.Error().Err(err).Msg("failed to rollback DNS setting") + return err + } + if r.Mode() == "direct" { + return nil + } + } + var ns []string c, err := nclient4.New(iface.Name) if err != nil { diff --git a/cmd/ctrld/prog_linux.go b/cmd/ctrld/prog_linux.go index 155c2fa..d1d5ee2 100644 --- a/cmd/ctrld/prog_linux.go +++ b/cmd/ctrld/prog_linux.go @@ -1,6 +1,8 @@ package main -import "github.com/kardianos/service" +import ( + "github.com/kardianos/service" +) func (p *prog) preRun() { if !service.Interactive() { diff --git a/go.mod b/go.mod index 433326b..a5cac4e 100644 --- a/go.mod +++ b/go.mod @@ -4,8 +4,11 @@ go 1.19 require ( github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534 + github.com/frankban/quicktest v1.14.3 github.com/go-playground/validator/v10 v10.11.1 + github.com/godbus/dbus/v5 v5.0.6 github.com/hashicorp/golang-lru/v2 v2.0.1 + github.com/illarion/gonotify v1.0.1 github.com/insomniacslk/dhcp v0.0.0-20211209223715-7d93572ebe8e github.com/kardianos/service v1.2.1 github.com/lucas-clemente/quic-go v0.29.1 @@ -29,17 +32,17 @@ require ( github.com/go-playground/locales v0.14.0 // indirect github.com/go-playground/universal-translator v0.18.0 // indirect github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 // indirect - github.com/godbus/dbus/v5 v5.0.6 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/mock v1.6.0 // indirect github.com/google/btree v1.0.1 // indirect github.com/google/go-cmp v0.5.9 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/hdevalence/ed25519consensus v0.0.0-20220222234857-c00d1f31bab3 // indirect - github.com/illarion/gonotify v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.0.0 // indirect github.com/josharian/native v1.0.0 // indirect github.com/jsimonetti/rtnetlink v1.1.2-0.20220408201609-d380b505068b // indirect + github.com/kr/pretty v0.3.0 // indirect + github.com/kr/text v0.2.0 // indirect github.com/leodido/go-urn v1.2.1 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/marten-seemann/qpack v0.2.1 // indirect @@ -57,6 +60,7 @@ require ( github.com/onsi/ginkgo v1.16.5 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rogpeppe/go-internal v1.8.1-0.20211023094830-115ce09fd6b4 // indirect github.com/spf13/afero v1.9.3 // indirect github.com/spf13/cast v1.5.0 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect diff --git a/go.sum b/go.sum index a450fe2..aee861f 100644 --- a/go.sum +++ b/go.sum @@ -68,6 +68,7 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7 github.com/fanliao/go-promise v0.0.0-20141029170127-1890db352a72/go.mod h1:PjfxuH4FZdUyfMdtBio2lsRr1AKEaVPwelzuHuh8Lqc= github.com/frankban/quicktest v1.14.0/go.mod h1:NeW+ay9A/U67EYXNFA1nPE8e/tnQv/09mUdL/ijj8og= github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE= +github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= @@ -259,6 +260,7 @@ github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/rogpeppe/go-internal v1.8.1-0.20211023094830-115ce09fd6b4 h1:Ha8xCaq6ln1a+R91Km45Oq6lPXj2Mla6CRJYcuV2h1w= +github.com/rogpeppe/go-internal v1.8.1-0.20211023094830-115ce09fd6b4/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/zerolog v1.28.0 h1:MirSo27VyNi7RJYP3078AA1+Cyzd2GB66qy3aUHvsWY= github.com/rs/zerolog v1.28.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0= diff --git a/internal/dns/README.md b/internal/dns/README.md new file mode 100644 index 0000000..aadc3a5 --- /dev/null +++ b/internal/dns/README.md @@ -0,0 +1,2 @@ +This is a fork of https://pkg.go.dev/tailscale.com@v1.34.2/net/dns with modification +to fit ctrld use case. \ No newline at end of file diff --git a/internal/dns/direct.go b/internal/dns/direct.go new file mode 100644 index 0000000..7258649 --- /dev/null +++ b/internal/dns/direct.go @@ -0,0 +1,533 @@ +// Copyright (c) 2020 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. + +//lint:file-ignore U1000 satisfy CI. + +package dns + +import ( + "bytes" + "context" + "crypto/rand" + "errors" + "fmt" + "io" + "io/fs" + "net/netip" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "time" + + "tailscale.com/health" + "tailscale.com/types/logger" + "tailscale.com/util/dnsname" + "tailscale.com/version/distro" + + "github.com/Control-D-Inc/ctrld/internal/dns/resolvconffile" +) + +const ( + backupConf = "/etc/resolv.pre-ctrld-backup.conf" + resolvConf = "/etc/resolv.conf" +) + +// writeResolvConf writes DNS configuration in resolv.conf format to the given writer. +func writeResolvConf(w io.Writer, servers []netip.Addr, domains []dnsname.FQDN) error { + c := &resolvconffile.Config{ + Nameservers: servers, + SearchDomains: domains, + } + return c.Write(w) +} + +func readResolv(r io.Reader) (OSConfig, error) { + c, err := resolvconffile.Parse(r) + if err != nil { + return OSConfig{}, err + } + return OSConfig{ + Nameservers: c.Nameservers, + SearchDomains: c.SearchDomains, + }, nil +} + +// resolvOwner returns the apparent owner of the resolv.conf +// configuration in bs - one of "resolvconf", "systemd-resolved" or +// "NetworkManager", or "" if no known owner was found. +func resolvOwner(bs []byte) string { + likely := "" + b := bytes.NewBuffer(bs) + for { + line, err := b.ReadString('\n') + if err != nil { + return likely + } + line = strings.TrimSpace(line) + if line == "" { + continue + } + if line[0] != '#' { + // First non-empty, non-comment line. Assume the owner + // isn't hiding further down. + return likely + } + + if strings.Contains(line, "systemd-resolved") { + likely = "systemd-resolved" + } else if strings.Contains(line, "NetworkManager") { + likely = "NetworkManager" + } else if strings.Contains(line, "resolvconf") { + likely = "resolvconf" + } + } +} + +// isResolvedRunning reports whether systemd-resolved is running on the system, +// even if it is not managing the system DNS settings. +func isResolvedRunning() bool { + if runtime.GOOS != "linux" { + return false + } + + // systemd-resolved is never installed without systemd. + _, err := exec.LookPath("systemctl") + if err != nil { + return false + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + err = exec.CommandContext(ctx, "systemctl", "is-active", "systemd-resolved.service").Run() + + // is-active exits with code 3 if the service is not active. + return err == nil +} + +func restartResolved() error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return exec.CommandContext(ctx, "systemctl", "restart", "systemd-resolved.service").Run() +} + +// directManager is an OSConfigurator which replaces /etc/resolv.conf with a file +// generated from the given configuration, creating a backup of its old state. +// +// This way of configuring DNS is precarious, since it does not react +// to the disappearance of the Tailscale interface. +// The caller must call Down before program shutdown +// or as cleanup if the program terminates unexpectedly. +type directManager struct { + logf logger.Logf + fs wholeFileFS + // renameBroken is set if fs.Rename to or from /etc/resolv.conf + // fails. This can happen in some container runtimes, where + // /etc/resolv.conf is bind-mounted from outside the container, + // and therefore /etc and /etc/resolv.conf are different + // filesystems as far as rename(2) is concerned. + // + // In those situations, we fall back to emulating rename with file + // copies and truncations, which is not as good (opens up a race + // where a reader can see an empty or partial /etc/resolv.conf), + // but is better than having non-functioning DNS. + renameBroken bool + + ctx context.Context // valid until Close + ctxClose context.CancelFunc // closes ctx + + mu sync.Mutex + wantResolvConf []byte // if non-nil, what we expect /etc/resolv.conf to contain + lastWarnContents []byte // last resolv.conf contents that we warned about +} + +func newDirectManagerOnFS(logf logger.Logf, fs wholeFileFS) *directManager { + ctx, cancel := context.WithCancel(context.Background()) + m := &directManager{ + logf: logf, + fs: fs, + ctx: ctx, + ctxClose: cancel, + } + go m.runFileWatcher() + return m +} + +func (m *directManager) readResolvFile(path string) (OSConfig, error) { + b, err := m.fs.ReadFile(path) + if err != nil { + return OSConfig{}, err + } + return readResolv(bytes.NewReader(b)) +} + +// ownedByCtrld reports whether /etc/resolv.conf seems to be a +// ctrld-managed file. +func (m *directManager) ownedByCtrld() (bool, error) { + isRegular, err := m.fs.Stat(resolvConf) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + if !isRegular { + return false, nil + } + bs, err := m.fs.ReadFile(resolvConf) + if err != nil { + return false, err + } + if bytes.Contains(bs, []byte("generated by ctrld")) { + return true, nil + } + return false, nil +} + +// backupConfig creates or updates a backup of /etc/resolv.conf, if +// resolv.conf does not currently contain a Tailscale-managed config. +func (m *directManager) backupConfig() error { + if _, err := m.fs.Stat(resolvConf); err != nil { + if os.IsNotExist(err) { + // No resolv.conf, nothing to back up. Also get rid of any + // existing backup file, to avoid restoring something old. + _ = m.fs.Remove(backupConf) + return nil + } + return err + } + + owned, err := m.ownedByCtrld() + if err != nil { + return err + } + if owned { + return nil + } + + return m.rename(resolvConf, backupConf) +} + +func (m *directManager) restoreBackup() (restored bool, err error) { + if _, err := m.fs.Stat(backupConf); err != nil { + if os.IsNotExist(err) { + // No backup, nothing we can do. + return false, nil + } + return false, err + } + owned, err := m.ownedByCtrld() + if err != nil { + return false, err + } + _, err = m.fs.Stat(resolvConf) + if err != nil && !os.IsNotExist(err) { + return false, err + } + resolvConfExists := !os.IsNotExist(err) + + if resolvConfExists && !owned { + // There's already a non-ctrld config in place, get rid of + // our backup. + _ = m.fs.Remove(backupConf) + return false, nil + } + + // We own resolv.conf, and a backup exists. + if err := m.rename(backupConf, resolvConf); err != nil { + return false, err + } + + return true, nil +} + +// rename tries to rename old to new using m.fs.Rename, and falls back +// to hand-copying bytes and truncating old if that fails. +// +// This is a workaround to /etc/resolv.conf being a bind-mounted file +// some container environments, which cannot be moved elsewhere in +// /etc (because that would be a cross-filesystem move) or deleted +// (because that would break the bind in surprising ways). +func (m *directManager) rename(old, new string) error { + if !m.renameBroken { + err := m.fs.Rename(old, new) + if err == nil { + return nil + } + if runtime.GOOS == "linux" && distro.Get() == distro.Synology { + // Fail fast. The fallback case below won't work anyway. + return err + } + m.logf("rename of %q to %q failed (%v), falling back to copy+delete", old, new, err) + m.renameBroken = true + } + + bs, err := m.fs.ReadFile(old) + if err != nil { + return fmt.Errorf("reading %q to rename: %w", old, err) + } + if err := m.fs.WriteFile(new, bs, 0644); err != nil { + return fmt.Errorf("writing to %q in rename of %q: %w", new, old, err) + } + + if err := m.fs.Remove(old); err != nil { + err2 := m.fs.Truncate(old) + if err2 != nil { + return fmt.Errorf("remove of %q failed (%w) and so did truncate: %v", old, err, err2) + } + } + return nil +} + +// setWant sets the expected contents of /etc/resolv.conf, if any. +// +// A value of nil means no particular value is expected. +// +// m takes ownership of want. +func (m *directManager) setWant(want []byte) { + m.mu.Lock() + defer m.mu.Unlock() + m.wantResolvConf = want +} + +var warnTrample = health.NewWarnable() + +// checkForFileTrample checks whether /etc/resolv.conf has been trampled +// by another program on the system. (e.g. a DHCP client) +func (m *directManager) checkForFileTrample() { + m.mu.Lock() + want := m.wantResolvConf + lastWarn := m.lastWarnContents + m.mu.Unlock() + + if want == nil { + return + } + + cur, err := m.fs.ReadFile(resolvConf) + if err != nil { + m.logf("trample: read error: %v", err) + return + } + if bytes.Equal(cur, want) { + warnTrample.Set(nil) + if lastWarn != nil { + m.mu.Lock() + m.lastWarnContents = nil + m.mu.Unlock() + m.logf("trample: resolv.conf again matches expected content") + } + return + } + if bytes.Equal(cur, lastWarn) { + // We already logged about this, so not worth doing it again. + return + } + + m.mu.Lock() + m.lastWarnContents = cur + m.mu.Unlock() + + show := cur + if len(show) > 1024 { + show = show[:1024] + } + m.logf("trample: resolv.conf changed from what we expected. did some other program interfere? current contents: %q", show) + //lint:ignore ST1005 This error is for human. + warnTrample.Set(errors.New("Linux DNS config not ideal. /etc/resolv.conf overwritten. See https://tailscale.com/s/dns-fight")) +} + +func (m *directManager) SetDNS(config OSConfig) (err error) { + defer func() { + if err != nil && errors.Is(err, fs.ErrPermission) && runtime.GOOS == "linux" && + distro.Get() == distro.Synology && os.Geteuid() != 0 { + // On Synology (notably DSM7 where we don't run as root), ignore all + // DNS configuration errors for now. We don't have permission. + // See https://github.com/tailscale/tailscale/issues/4017 + m.logf("ignoring SetDNS permission error on Synology (Issue 4017); was: %v", err) + err = nil + } + }() + m.setWant(nil) // reset our expectations before any work + var changed bool + if config.IsZero() { + changed, err = m.restoreBackup() + if err != nil { + return err + } + } else { + changed = true + if err := m.backupConfig(); err != nil { + return err + } + + buf := new(bytes.Buffer) + _ = writeResolvConf(buf, config.Nameservers, config.SearchDomains) + if err := m.atomicWriteFile(m.fs, resolvConf, buf.Bytes(), 0644); err != nil { + return err + } + + // Now that we've successfully written to the file, lock it in. + // If we see /etc/resolv.conf with different contents, we know somebody + // else trampled on it. + m.setWant(buf.Bytes()) + } + + // We might have taken over a configuration managed by resolved, + // in which case it will notice this on restart and gracefully + // start using our configuration. This shouldn't happen because we + // try to manage DNS through resolved when it's around, but as a + // best-effort fallback if we messed up the detection, try to + // restart resolved to make the system configuration consistent. + // + // We take care to only kick systemd-resolved if we've made some + // change to the system's DNS configuration, because this codepath + // can end up running in cases where the user has manually + // configured /etc/resolv.conf to point to systemd-resolved (but + // it's not managed explicitly by systemd-resolved), *and* has + // --accept-dns=false, meaning we pass an empty configuration to + // the running DNS manager. In that very edge-case scenario, we + // cause a disruptive DNS outage each time we reset an empty + // OS configuration. + if changed && isResolvedRunning() && !runningAsGUIDesktopUser() { + t0 := time.Now() + err := restartResolved() + d := time.Since(t0).Round(time.Millisecond) + if err != nil { + m.logf("error restarting resolved after %v: %v", d, err) + } else { + m.logf("restarted resolved after %v", d) + } + } + + return nil +} + +func (m *directManager) Close() error { + // We used to keep a file for the ctrld config and symlinked + // to it, but then we stopped because /etc/resolv.conf being a + // symlink to surprising places breaks snaps and other sandboxing + // things. Clean it up if it's still there. + _ = m.fs.Remove("/etc/resolv.ctrld.conf") + + if _, err := m.fs.Stat(backupConf); err != nil { + if os.IsNotExist(err) { + // No backup, nothing we can do. + return nil + } + return err + } + owned, err := m.ownedByCtrld() + if err != nil { + return err + } + _, err = m.fs.Stat(resolvConf) + if err != nil && !os.IsNotExist(err) { + return err + } + resolvConfExists := !os.IsNotExist(err) + + if resolvConfExists && !owned { + // There's already a non-ctrld config in place, get rid of + // our backup. + _ = m.fs.Remove(backupConf) + return nil + } + + // We own resolv.conf, and a backup exists. + if err := m.rename(backupConf, resolvConf); err != nil { + return err + } + + if isResolvedRunning() && !runningAsGUIDesktopUser() { + m.logf("restarting systemd-resolved...") + if err := restartResolved(); err != nil { + m.logf("restart of systemd-resolved failed: %v", err) + } else { + m.logf("restarted systemd-resolved") + } + } + + return nil +} + +func (m *directManager) Mode() string { + return "direct" +} + +func (m *directManager) 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) + } + + tmpName := fmt.Sprintf("%s.%x.tmp", filename, randBytes[:]) + defer fs.Remove(tmpName) + + if err := fs.WriteFile(tmpName, data, perm); err != nil { + return fmt.Errorf("atomicWriteFile: %w", err) + } + return m.rename(tmpName, filename) +} + +// 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. +// +// 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 + ReadFile(name string) ([]byte, error) + Truncate(name string) error + WriteFile(name string, contents []byte, perm os.FileMode) error +} + +// directFS is a wholeFileFS implemented directly on the OS. +type directFS struct { + // 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.path(name)) + if err != nil { + return false, err + } + return fi.Mode().IsRegular(), nil +} + +func (fs directFS) Rename(oldName, newName string) error { + return os.Rename(fs.path(oldName), fs.path(newName)) +} + +func (fs directFS) Remove(name string) error { return os.Remove(fs.path(name)) } + +func (fs directFS) ReadFile(name string) ([]byte, error) { + return os.ReadFile(fs.path(name)) +} + +func (fs directFS) Truncate(name string) error { + return os.Truncate(fs.path(name), 0) +} + +func (fs directFS) WriteFile(name string, contents []byte, perm os.FileMode) error { + return os.WriteFile(fs.path(name), contents, perm) +} + +// runningAsGUIDesktopUser reports whether it seems that this code is +// being run as a regular user on a Linux desktop. This is a quick +// hack to fix Issue 2672 where PolicyKit pops up a GUI dialog asking +// to proceed we do a best effort attempt to restart +// systemd-resolved.service. There's surely a better way. +func runningAsGUIDesktopUser() bool { + return os.Getuid() != 0 && os.Getenv("DISPLAY") != "" +} diff --git a/internal/dns/direct_linux.go b/internal/dns/direct_linux.go new file mode 100644 index 0000000..565c227 --- /dev/null +++ b/internal/dns/direct_linux.go @@ -0,0 +1,62 @@ +// Copyright (c) 2022 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 ( + "context" + + "github.com/illarion/gonotify" +) + +func (m *directManager) runFileWatcher() { + in, err := gonotify.NewInotify() + if err != nil { + // Oh well, we tried. This is all best effort for now, to + // surface warnings to users. + m.logf("dns: inotify new: %v", err) + return + } + ctx, cancel := context.WithCancel(m.ctx) + defer cancel() + go m.closeInotifyOnDone(ctx, in) + + const events = gonotify.IN_ATTRIB | + gonotify.IN_CLOSE_WRITE | + gonotify.IN_CREATE | + gonotify.IN_DELETE | + gonotify.IN_MODIFY | + gonotify.IN_MOVE + + if err := in.AddWatch("/etc/", events); err != nil { + m.logf("dns: inotify addwatch: %v", err) + return + } + for { + events, err := in.Read() + if ctx.Err() != nil { + return + } + if err != nil { + m.logf("dns: inotify read: %v", err) + return + } + var match bool + for _, ev := range events { + if ev.Name == resolvConf { + match = true + break + } + } + if !match { + continue + } + m.checkForFileTrample() + } +} + +func (m *directManager) closeInotifyOnDone(ctx context.Context, in *gonotify.Inotify) { + <-ctx.Done() + _ = in.Close() +} diff --git a/internal/dns/direct_notlinux.go b/internal/dns/direct_notlinux.go new file mode 100644 index 0000000..5563586 --- /dev/null +++ b/internal/dns/direct_notlinux.go @@ -0,0 +1,11 @@ +// Copyright (c) 2022 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. + +//go:build !linux + +package dns + +func (m *directManager) runFileWatcher() { + // Not implemented on other platforms. Maybe it could resort to polling. +} diff --git a/internal/dns/direct_test.go b/internal/dns/direct_test.go new file mode 100644 index 0000000..57962dd --- /dev/null +++ b/internal/dns/direct_test.go @@ -0,0 +1,199 @@ +// 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 ( + "errors" + "fmt" + "io/fs" + "net/netip" + "os" + "path/filepath" + "strings" + "syscall" + "testing" + + qt "github.com/frankban/quicktest" + "tailscale.com/util/dnsname" +) + +func TestDirectManager(t *testing.T) { + tmp := t.TempDir() + if err := os.MkdirAll(filepath.Join(tmp, "etc"), 0700); err != nil { + t.Fatal(err) + } + testDirect(t, directFS{prefix: tmp}) +} + +type boundResolvConfFS struct { + directFS +} + +func (fs boundResolvConfFS) Rename(old, new string) error { + if old == "/etc/resolv.conf" || new == "/etc/resolv.conf" { + return errors.New("cannot move to/from /etc/resolv.conf") + } + return fs.directFS.Rename(old, new) +} + +func (fs boundResolvConfFS) Remove(name string) error { + if name == "/etc/resolv.conf" { + return errors.New("cannot remove /etc/resolv.conf") + } + return fs.directFS.Remove(name) +} + +func TestDirectBrokenRename(t *testing.T) { + tmp := t.TempDir() + if err := os.MkdirAll(filepath.Join(tmp, "etc"), 0700); err != nil { + t.Fatal(err) + } + testDirect(t, boundResolvConfFS{directFS{prefix: tmp}}) +} + +func testDirect(t *testing.T, fs wholeFileFS) { + const orig = "nameserver 9.9.9.9 # orig" + resolvPath := "/etc/resolv.conf" + backupPath := "/etc/resolv.pre-ctrld-backup.conf" + + if err := fs.WriteFile(resolvPath, []byte(orig), 0644); err != nil { + t.Fatal(err) + } + + readFile := func(t *testing.T, path string) string { + t.Helper() + b, err := fs.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(b) + } + assertBaseState := func(t *testing.T) { + if got := readFile(t, resolvPath); got != orig { + t.Fatalf("resolv.conf:\n%s, want:\n%s", got, orig) + } + if _, err := fs.Stat(backupPath); !os.IsNotExist(err) { + t.Fatalf("resolv.conf backup: want it to be gone but: %v", err) + } + } + + m := directManager{logf: t.Logf, fs: fs} + if err := m.SetDNS(OSConfig{ + Nameservers: []netip.Addr{netip.MustParseAddr("8.8.8.8"), netip.MustParseAddr("8.8.4.4")}, + SearchDomains: []dnsname.FQDN{"controld.com."}, + MatchDomains: []dnsname.FQDN{"ignored."}, + }); err != nil { + t.Fatal(err) + } + want := `# resolv.conf(5) file generated by ctrld +# DO NOT EDIT THIS FILE BY HAND -- CHANGES WILL BE OVERWRITTEN + +nameserver 8.8.8.8 +nameserver 8.8.4.4 +search controld.com +` + if got := readFile(t, resolvPath); got != want { + t.Fatalf("resolv.conf:\n%s, want:\n%s", got, want) + } + if got := readFile(t, backupPath); got != orig { + t.Fatalf("resolv.conf backup:\n%s, want:\n%s", got, orig) + } + + // Test that a nil OSConfig cleans up resolv.conf. + if err := m.SetDNS(OSConfig{}); err != nil { + t.Fatal(err) + } + assertBaseState(t) + + // Test that Close cleans up resolv.conf. + if err := m.SetDNS(OSConfig{Nameservers: []netip.Addr{netip.MustParseAddr("8.8.8.8")}}); err != nil { + t.Fatal(err) + } + if err := m.Close(); err != nil { + t.Fatal(err) + } + assertBaseState(t) +} + +type brokenRemoveFS struct { + directFS +} + +func (b brokenRemoveFS) Rename(_, _ string) error { + return errors.New("nyaaah I'm a silly container!") +} + +func (b brokenRemoveFS) Remove(name string) error { + if strings.Contains(name, "/etc/resolv.conf") { + return fmt.Errorf("Faking remove failure: %q", &fs.PathError{Err: syscall.EBUSY}) + } + return b.directFS.Remove(name) +} + +func TestDirectBrokenRemove(t *testing.T) { + tmp := t.TempDir() + if err := os.MkdirAll(filepath.Join(tmp, "etc"), 0700); err != nil { + t.Fatal(err) + } + testDirect(t, brokenRemoveFS{directFS{prefix: tmp}}) +} + +func TestReadResolve(t *testing.T) { + c := qt.New(t) + tests := []struct { + in string + want OSConfig + wantErr bool + }{ + {in: `nameserver 192.168.0.100`, + want: OSConfig{ + Nameservers: []netip.Addr{ + netip.MustParseAddr("192.168.0.100"), + }, + }, + }, + {in: `nameserver 192.168.0.100 # comment`, + want: OSConfig{ + Nameservers: []netip.Addr{ + netip.MustParseAddr("192.168.0.100"), + }, + }, + }, + {in: `nameserver 192.168.0.100#`, + want: OSConfig{ + Nameservers: []netip.Addr{ + netip.MustParseAddr("192.168.0.100"), + }, + }, + }, + {in: `nameserver #192.168.0.100`, wantErr: true}, + {in: `nameserver`, wantErr: true}, + {in: `# nameserver 192.168.0.100`, want: OSConfig{}}, + {in: `nameserver192.168.0.100`, wantErr: true}, + + {in: `search controld.com`, + want: OSConfig{ + SearchDomains: []dnsname.FQDN{"controld.com."}, + }, + }, + {in: `search controld.com # typo`, + want: OSConfig{ + SearchDomains: []dnsname.FQDN{"controld.com."}, + }, + }, + {in: `searchcontrold.com`, wantErr: true}, + {in: `search`, wantErr: true}, + } + + for _, test := range tests { + cfg, err := readResolv(strings.NewReader(test.in)) + if test.wantErr { + c.Assert(err, qt.IsNotNil) + } else { + c.Assert(err, qt.IsNil) + } + c.Assert(cfg, qt.DeepEquals, test.want) + } +} diff --git a/internal/dns/manager_linux.go b/internal/dns/manager_linux.go new file mode 100644 index 0000000..20ccf7e --- /dev/null +++ b/internal/dns/manager_linux.go @@ -0,0 +1,387 @@ +// Copyright (c) 2020 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" + "context" + "errors" + "fmt" + "os" + "strings" + "sync" + "time" + + "github.com/godbus/dbus/v5" + "tailscale.com/health" + "tailscale.com/net/netaddr" + "tailscale.com/types/logger" + "tailscale.com/util/clientmetric" + "tailscale.com/util/cmpver" +) + +var _ OSConfigurator = (*directManager)(nil) +var _ OSConfigurator = (*resolvedManager)(nil) +var _ OSConfigurator = (*nmManager)(nil) + +type kv struct { + k, v string +} + +func (kv kv) String() string { + return fmt.Sprintf("%s=%s", kv.k, kv.v) +} + +var publishOnce sync.Once + +func NewOSConfigurator(logf logger.Logf, interfaceName string) (ret OSConfigurator, err error) { + env := newOSConfigEnv{ + fs: directFS{}, + dbusPing: dbusPing, + dbusReadString: dbusReadString, + nmIsUsingResolved: nmIsUsingResolved, + nmVersionBetween: nmVersionBetween, + resolvconfStyle: resolvconfStyle, + } + mode, err := dnsMode(logf, env) + if err != nil { + return nil, err + } + publishOnce.Do(func() { + sanitizedMode := strings.ReplaceAll(mode, "-", "_") + m := clientmetric.NewGauge(fmt.Sprintf("dns_manager_linux_mode_%s", sanitizedMode)) + m.Set(1) + }) + logf("dns: using %q mode", mode) + switch mode { + case "direct": + return newDirectManagerOnFS(logf, env.fs), nil + case "systemd-resolved": + return newResolvedManager(logf, interfaceName) + case "network-manager": + return newNMManager(interfaceName) + default: + logf("[unexpected] detected unknown DNS mode %q, using direct manager as last resort", mode) + return newDirectManagerOnFS(logf, env.fs), nil + } +} + +// newOSConfigEnv are the funcs newOSConfigurator needs, pulled out for testing. +type newOSConfigEnv struct { + fs wholeFileFS + dbusPing func(string, string) error + dbusReadString func(string, string, string, string) (string, error) + nmIsUsingResolved func() error + nmVersionBetween func(v1, v2 string) (safe bool, err error) + resolvconfStyle func() string +} + +func dnsMode(logf logger.Logf, env newOSConfigEnv) (ret string, err error) { + var debug []kv + dbg := func(k, v string) { + debug = append(debug, kv{k, v}) + } + defer func() { + if ret != "" { + dbg("ret", ret) + } + logf("dns: %v", debug) + }() + + // In all cases that we detect systemd-resolved, try asking it what it + // thinks the current resolv.conf mode is so we can add it to our logs. + defer func() { + if ret != "systemd-resolved" { + return + } + + // Try to ask systemd-resolved what it thinks the current + // status of resolv.conf is. This is documented at: + // https://www.freedesktop.org/software/systemd/man/org.freedesktop.resolve1.html + mode, err := env.dbusReadString("org.freedesktop.resolve1", "/org/freedesktop/resolve1", "org.freedesktop.resolve1.Manager", "ResolvConfMode") + if err != nil { + logf("dns: ResolvConfMode error: %v", err) + dbg("resolv-conf-mode", "error") + } else { + dbg("resolv-conf-mode", mode) + } + }() + + // Before we read /etc/resolv.conf (which might be in a broken + // or symlink-dangling state), try to ping the D-Bus service + // for systemd-resolved. If it's active on the machine, this + // will make it start up and write the /etc/resolv.conf file + // before it replies to the ping. (see how systemd's + // src/resolve/resolved.c calls manager_write_resolv_conf + // before the sd_event_loop starts) + resolvedUp := env.dbusPing("org.freedesktop.resolve1", "/org/freedesktop/resolve1") == nil + if resolvedUp { + dbg("resolved-ping", "yes") + } + + bs, err := env.fs.ReadFile(resolvConf) + if os.IsNotExist(err) { + dbg("rc", "missing") + return "direct", nil + } + if err != nil { + return "", fmt.Errorf("reading /etc/resolv.conf: %w", err) + } + + switch resolvOwner(bs) { + case "systemd-resolved": + dbg("rc", "resolved") + + // Some systems, for reasons known only to them, have a + // resolv.conf that has the word "systemd-resolved" in its + // header, but doesn't actually point to resolved. We mustn't + // try to program resolved in that case. + // https://github.com/tailscale/tailscale/issues/2136 + if err := resolvedIsActuallyResolver(bs); err != nil { + logf("dns: resolvedIsActuallyResolver error: %v", err) + dbg("resolved", "not-in-use") + return "direct", nil + } + if err := env.dbusPing("org.freedesktop.NetworkManager", "/org/freedesktop/NetworkManager/DnsManager"); err != nil { + dbg("nm", "no") + return "systemd-resolved", nil + } + dbg("nm", "yes") + if err := env.nmIsUsingResolved(); err != nil { + dbg("nm-resolved", "no") + return "systemd-resolved", nil + } + dbg("nm-resolved", "yes") + + // Version of NetworkManager before 1.26.6 programmed resolved + // incorrectly, such that NM's settings would always take + // precedence over other settings set by other resolved + // clients. + // + // If we're dealing with such a version, we have to set our + // DNS settings through NM to have them take. + // + // However, versions 1.26.6 later both fixed the resolved + // programming issue _and_ started ignoring DNS settings for + // "unmanaged" interfaces - meaning NM 1.26.6 and later + // actively ignore DNS configuration we give it. So, for those + // NM versions, we can and must use resolved directly. + // + // Even more fun, even-older versions of NM won't let us set + // DNS settings if the interface isn't managed by NM, with a + // hard failure on DBus requests. Empirically, NM 1.22 does + // this. Based on the versions popular distros shipped, we + // conservatively decree that only 1.26.0 through 1.26.5 are + // "safe" to use for our purposes. This roughly matches + // distros released in the latter half of 2020. + // + // In a perfect world, we'd avoid this by replacing + // configuration out from under NM entirely (e.g. using + // directManager to overwrite resolv.conf), but in a world + // where resolved runs, we need to get correct configuration + // into resolved regardless of what's in resolv.conf (because + // resolved can also be queried over dbus, or via an NSS + // module that bypasses /etc/resolv.conf). Given that we must + // get correct configuration into resolved, we have no choice + // but to use NM, and accept the loss of IPv6 configuration + // that comes with it (see + // https://github.com/tailscale/tailscale/issues/1699, + // https://github.com/tailscale/tailscale/pull/1945) + safe, err := env.nmVersionBetween("1.26.0", "1.26.5") + if err != nil { + // Failed to figure out NM's version, can't make a correct + // decision. + return "", fmt.Errorf("checking NetworkManager version: %v", err) + } + if safe { + dbg("nm-safe", "yes") + return "network-manager", nil + } + dbg("nm-safe", "no") + return "systemd-resolved", nil + case "resolvconf": + dbg("rc", "resolvconf") + style := env.resolvconfStyle() + switch style { + case "": + dbg("resolvconf", "no") + return "direct", nil + case "debian": + dbg("resolvconf", "debian") + return "debian-resolvconf", nil + case "openresolv": + dbg("resolvconf", "openresolv") + return "openresolv", nil + default: + // Shouldn't happen, that means we updated flavors of + // resolvconf without updating here. + dbg("resolvconf", style) + logf("[unexpected] got unknown flavor of resolvconf %q, falling back to direct manager", env.resolvconfStyle()) + return "direct", nil + } + case "NetworkManager": + dbg("rc", "nm") + // Sometimes, NetworkManager owns the configuration but points + // it at systemd-resolved. + if err := resolvedIsActuallyResolver(bs); err != nil { + logf("dns: resolvedIsActuallyResolver error: %v", err) + dbg("resolved", "not-in-use") + // You'd think we would use newNMManager here. However, as + // explained in + // https://github.com/tailscale/tailscale/issues/1699 , + // using NetworkManager for DNS configuration carries with + // it the cost of losing IPv6 configuration on the + // Tailscale network interface. So, when we can avoid it, + // we bypass NetworkManager by replacing resolv.conf + // directly. + // + // If you ever try to put NMManager back here, keep in mind + // that versions >=1.26.6 will ignore DNS configuration + // anyway, so you still need a fallback path that uses + // directManager. + return "direct", nil + } + dbg("nm-resolved", "yes") + + // See large comment above for reasons we'd use NM rather than + // resolved. systemd-resolved is actually in charge of DNS + // configuration, but in some cases we might need to configure + // it via NetworkManager. All the logic below is probing for + // that case: is NetworkManager running? If so, is it one of + // the versions that requires direct interaction with it? + if err := env.dbusPing("org.freedesktop.NetworkManager", "/org/freedesktop/NetworkManager/DnsManager"); err != nil { + dbg("nm", "no") + return "systemd-resolved", nil + } + safe, err := env.nmVersionBetween("1.26.0", "1.26.5") + if err != nil { + // Failed to figure out NM's version, can't make a correct + // decision. + return "", fmt.Errorf("checking NetworkManager version: %v", err) + } + if safe { + dbg("nm-safe", "yes") + return "network-manager", nil + } + health.SetDNSManagerHealth(errors.New("systemd-resolved and NetworkManager are wired together incorrectly; MagicDNS will probably not work. For more info, see https://tailscale.com/s/resolved-nm")) + dbg("nm-safe", "no") + return "systemd-resolved", nil + default: + dbg("rc", "unknown") + return "direct", nil + } +} + +func nmVersionBetween(first, last string) (bool, error) { + conn, err := dbus.SystemBus() + if err != nil { + // DBus probably not running. + return false, err + } + + nm := conn.Object("org.freedesktop.NetworkManager", dbus.ObjectPath("/org/freedesktop/NetworkManager")) + v, err := nm.GetProperty("org.freedesktop.NetworkManager.Version") + if err != nil { + return false, err + } + + version, ok := v.Value().(string) + if !ok { + return false, fmt.Errorf("unexpected type %T for NM version", v.Value()) + } + + outside := cmpver.Compare(version, first) < 0 || cmpver.Compare(version, last) > 0 + return !outside, nil +} + +func nmIsUsingResolved() error { + conn, err := dbus.SystemBus() + if err != nil { + // DBus probably not running. + return err + } + + nm := conn.Object("org.freedesktop.NetworkManager", dbus.ObjectPath("/org/freedesktop/NetworkManager/DnsManager")) + v, err := nm.GetProperty("org.freedesktop.NetworkManager.DnsManager.Mode") + if err != nil { + return fmt.Errorf("getting NM mode: %w", err) + } + mode, ok := v.Value().(string) + if !ok { + return fmt.Errorf("unexpected type %T for NM DNS mode", v.Value()) + } + if mode != "systemd-resolved" { + return errors.New("NetworkManager is not using systemd-resolved for DNS") + } + return nil +} + +// resolvedIsActuallyResolver reports whether the given resolv.conf +// bytes describe a configuration where systemd-resolved (127.0.0.53) +// is the only configured nameserver. +// +// Returns an error if the configuration is something other than +// exclusively systemd-resolved, or nil if the config is only +// systemd-resolved. +func resolvedIsActuallyResolver(bs []byte) error { + cfg, err := readResolv(bytes.NewBuffer(bs)) + if err != nil { + return err + } + // We've encountered at least one system where the line + // "nameserver 127.0.0.53" appears twice, so we look exhaustively + // through all of them and allow any number of repeated mentions + // of the systemd-resolved stub IP. + if len(cfg.Nameservers) == 0 { + return errors.New("resolv.conf has no nameservers") + } + for _, ns := range cfg.Nameservers { + if ns != netaddr.IPv4(127, 0, 0, 53) { + return fmt.Errorf("resolv.conf doesn't point to systemd-resolved; points to %v", cfg.Nameservers) + } + } + return nil +} + +func dbusPing(name, objectPath string) error { + conn, err := dbus.SystemBus() + if err != nil { + // DBus probably not running. + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + obj := conn.Object(name, dbus.ObjectPath(objectPath)) + call := obj.CallWithContext(ctx, "org.freedesktop.DBus.Peer.Ping", 0) + return call.Err +} + +// dbusReadString reads a string property from the provided name and object +// path. property must be in "interface.member" notation. +func dbusReadString(name, objectPath, iface, member string) (string, error) { + conn, err := dbus.SystemBus() + if err != nil { + // DBus probably not running. + return "", err + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + obj := conn.Object(name, dbus.ObjectPath(objectPath)) + + var result dbus.Variant + err = obj.CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, iface, member).Store(&result) + if err != nil { + return "", err + } + + if s, ok := result.Value().(string); ok { + return s, nil + } + return result.String(), nil +} diff --git a/internal/dns/manager_linux_test.go b/internal/dns/manager_linux_test.go new file mode 100644 index 0000000..70a2be4 --- /dev/null +++ b/internal/dns/manager_linux_test.go @@ -0,0 +1,439 @@ +// 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 ( + "errors" + "io/fs" + "os" + "strings" + "testing" + + "tailscale.com/tstest" + "tailscale.com/util/cmpver" +) + +func TestLinuxDNSMode(t *testing.T) { + tests := []struct { + name string + env newOSConfigEnv + wantLog string + want string + }{ + { + name: "no_obvious_resolv.conf_owner", + env: env(resolvDotConf("nameserver 10.0.0.1")), + wantLog: "dns: [rc=unknown ret=direct]", + want: "direct", + }, + { + name: "network_manager", + env: env( + resolvDotConf( + "# Managed by NetworkManager", + "nameserver 10.0.0.1")), + wantLog: "dns: resolvedIsActuallyResolver error: resolv.conf doesn't point to systemd-resolved; points to [10.0.0.1]\n" + + "dns: [rc=nm resolved=not-in-use ret=direct]", + want: "direct", + }, + { + name: "resolvconf_but_no_resolvconf_binary", + env: env(resolvDotConf("# Managed by resolvconf", "nameserver 10.0.0.1")), + wantLog: "dns: [rc=resolvconf resolvconf=no ret=direct]", + want: "direct", + }, + { + name: "debian_resolvconf", + env: env( + resolvDotConf("# Managed by resolvconf", "nameserver 10.0.0.1"), + resolvconf("debian")), + wantLog: "dns: [rc=resolvconf resolvconf=debian ret=debian-resolvconf]", + want: "debian-resolvconf", + }, + { + name: "openresolv", + env: env( + resolvDotConf("# Managed by resolvconf", "nameserver 10.0.0.1"), + resolvconf("openresolv")), + wantLog: "dns: [rc=resolvconf resolvconf=openresolv ret=openresolv]", + want: "openresolv", + }, + { + name: "unknown_resolvconf_flavor", + env: env( + resolvDotConf("# Managed by resolvconf", "nameserver 10.0.0.1"), + resolvconf("daves-discount-resolvconf")), + wantLog: "[unexpected] got unknown flavor of resolvconf \"daves-discount-resolvconf\", falling back to direct manager\ndns: [rc=resolvconf resolvconf=daves-discount-resolvconf ret=direct]", + want: "direct", + }, + { + name: "resolved_alone_without_ping", + env: env(resolvDotConf("# Managed by systemd-resolved", "nameserver 127.0.0.53")), + wantLog: "dns: ResolvConfMode error: dbus property not found\ndns: [rc=resolved nm=no resolv-conf-mode=error ret=systemd-resolved]", + want: "systemd-resolved", + }, + { + name: "resolved_alone_with_ping", + env: env( + resolvDotConf("# Managed by systemd-resolved", "nameserver 127.0.0.53"), + resolvedRunning()), + wantLog: "dns: [resolved-ping=yes rc=resolved nm=no resolv-conf-mode=fortests ret=systemd-resolved]", + want: "systemd-resolved", + }, + { + name: "resolved_and_networkmanager_not_using_resolved", + env: env( + resolvDotConf("# Managed by systemd-resolved", "nameserver 127.0.0.53"), + resolvedRunning(), + nmRunning("1.2.3", false)), + wantLog: "dns: [resolved-ping=yes rc=resolved nm=yes nm-resolved=no resolv-conf-mode=fortests ret=systemd-resolved]", + want: "systemd-resolved", + }, + { + name: "resolved_and_mid_2020_networkmanager", + env: env( + resolvDotConf("# Managed by systemd-resolved", "nameserver 127.0.0.53"), + resolvedRunning(), + nmRunning("1.26.2", true)), + wantLog: "dns: [resolved-ping=yes rc=resolved nm=yes nm-resolved=yes nm-safe=yes ret=network-manager]", + want: "network-manager", + }, + { + name: "resolved_and_2021_networkmanager", + env: env( + resolvDotConf("# Managed by systemd-resolved", "nameserver 127.0.0.53"), + resolvedRunning(), + nmRunning("1.27.0", true)), + wantLog: "dns: [resolved-ping=yes rc=resolved nm=yes nm-resolved=yes nm-safe=no resolv-conf-mode=fortests ret=systemd-resolved]", + want: "systemd-resolved", + }, + { + name: "resolved_and_ancient_networkmanager", + env: env( + resolvDotConf("# Managed by systemd-resolved", "nameserver 127.0.0.53"), + resolvedRunning(), + nmRunning("1.22.0", true)), + wantLog: "dns: [resolved-ping=yes rc=resolved nm=yes nm-resolved=yes nm-safe=no resolv-conf-mode=fortests ret=systemd-resolved]", + want: "systemd-resolved", + }, + // Regression tests for extreme corner cases below. + { + // One user reported a configuration whose comment string + // alleged that it was managed by systemd-resolved, but it + // was actually a completely static config file pointing + // elsewhere. + name: "allegedly_resolved_but_not_in_resolv.conf", + env: env(resolvDotConf("# Managed by systemd-resolved", "nameserver 10.0.0.1")), + wantLog: "dns: resolvedIsActuallyResolver error: resolv.conf doesn't point to systemd-resolved; points to [10.0.0.1]\n" + + "dns: [rc=resolved resolved=not-in-use ret=direct]", + want: "direct", + }, + { + // We used to incorrectly decide that resolved wasn't in + // charge when handed this (admittedly weird and bugged) + // resolv.conf. + name: "resolved_with_duplicates_in_resolv.conf", + env: env( + resolvDotConf( + "# Managed by systemd-resolved", + "nameserver 127.0.0.53", + "nameserver 127.0.0.53"), + resolvedRunning()), + wantLog: "dns: [resolved-ping=yes rc=resolved nm=no resolv-conf-mode=fortests ret=systemd-resolved]", + want: "systemd-resolved", + }, + { + // More than one user has had resolvconf write a config that points to + // systemd-resolved. We're better off using systemd-resolved. + // regression test for https://github.com/tailscale/tailscale/issues/3026 + name: "allegedly_resolvconf_but_actually_systemd-resolved", + env: env(resolvDotConf( + "# Dynamic resolv.conf(5) file for glibc resolver(3) generated by resolvconf(8)", + "# DO NOT EDIT THIS FILE BY HAND -- YOUR CHANGES WILL BE OVERWRITTEN", + "# 127.0.0.53 is the systemd-resolved stub resolver.", + "# run \"systemd-resolve --status\" to see details about the actual nameservers.", + "nameserver 127.0.0.53"), + resolvedRunning()), + wantLog: "dns: [resolved-ping=yes rc=resolved nm=no resolv-conf-mode=fortests ret=systemd-resolved]", + want: "systemd-resolved", + }, + { + // More than one user has had resolvconf write a config that points to + // systemd-resolved. We're better off using systemd-resolved. + // and assuming that even if the ping doesn't show that env is correct + // regression test for https://github.com/tailscale/tailscale/issues/3026 + name: "allegedly_resolvconf_but_actually_systemd-resolved_but_no_ping", + env: env(resolvDotConf( + "# Dynamic resolv.conf(5) file for glibc resolver(3) generated by resolvconf(8)", + "# DO NOT EDIT THIS FILE BY HAND -- YOUR CHANGES WILL BE OVERWRITTEN", + "# 127.0.0.53 is the systemd-resolved stub resolver.", + "# run \"systemd-resolve --status\" to see details about the actual nameservers.", + "nameserver 127.0.0.53")), + wantLog: "dns: ResolvConfMode error: dbus property not found\ndns: [rc=resolved nm=no resolv-conf-mode=error ret=systemd-resolved]", + want: "systemd-resolved", + }, + { + // regression test for https://github.com/tailscale/tailscale/issues/3304 + name: "networkmanager_but_pointing_at_systemd-resolved", + env: env(resolvDotConf( + "# Generated by NetworkManager", + "nameserver 127.0.0.53", + "options edns0 trust-ad"), + resolvedRunning(), + nmRunning("1.32.12", true)), + wantLog: "dns: [resolved-ping=yes rc=nm nm-resolved=yes nm-safe=no resolv-conf-mode=fortests ret=systemd-resolved]", + want: "systemd-resolved", + }, + { + // regression test for https://github.com/tailscale/tailscale/issues/3304 + name: "networkmanager_but_pointing_at_systemd-resolved_but_no_resolved_ping", + env: env(resolvDotConf( + "# Generated by NetworkManager", + "nameserver 127.0.0.53", + "options edns0 trust-ad"), + nmRunning("1.32.12", true)), + wantLog: "dns: ResolvConfMode error: dbus property not found\ndns: [rc=nm nm-resolved=yes nm-safe=no resolv-conf-mode=error ret=systemd-resolved]", + want: "systemd-resolved", + }, + { + // regression test for https://github.com/tailscale/tailscale/issues/3304 + name: "networkmanager_but_pointing_at_systemd-resolved_and_safe_nm", + env: env(resolvDotConf( + "# Generated by NetworkManager", + "nameserver 127.0.0.53", + "options edns0 trust-ad"), + resolvedRunning(), + nmRunning("1.26.3", true)), + wantLog: "dns: [resolved-ping=yes rc=nm nm-resolved=yes nm-safe=yes ret=network-manager]", + want: "network-manager", + }, + { + // regression test for https://github.com/tailscale/tailscale/issues/3304 + name: "networkmanager_but_pointing_at_systemd-resolved_and_no_networkmanager", + env: env(resolvDotConf( + "# Generated by NetworkManager", + "nameserver 127.0.0.53", + "options edns0 trust-ad"), + resolvedRunning()), + wantLog: "dns: [resolved-ping=yes rc=nm nm-resolved=yes nm=no resolv-conf-mode=fortests ret=systemd-resolved]", + want: "systemd-resolved", + }, + { + // regression test for https://github.com/tailscale/tailscale/issues/3531 + name: "networkmanager_but_systemd-resolved_with_search_domain", + env: env(resolvDotConf( + "# Generated by NetworkManager", + "search lan", + "nameserver 127.0.0.53"), + resolvedRunning()), + wantLog: "dns: [resolved-ping=yes rc=nm nm-resolved=yes nm=no resolv-conf-mode=fortests ret=systemd-resolved]", + want: "systemd-resolved", + }, + { + // Make sure that we ping systemd-resolved to let it start up and write its resolv.conf + // before we read its file. + env: env(resolvedStartOnPingAndThen( + resolvDotConf("# Managed by systemd-resolved", "nameserver 127.0.0.53"), + resolvedDbusProperty(), + )), + wantLog: "dns: [resolved-ping=yes rc=resolved nm=no resolv-conf-mode=fortests ret=systemd-resolved]", + want: "systemd-resolved", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var logBuf tstest.MemLogger + got, err := dnsMode(logBuf.Logf, tt.env) + if err != nil { + t.Fatal(err) + } + if got != tt.want { + t.Errorf("got %s; want %s", got, tt.want) + } + if got := strings.TrimSpace(logBuf.String()); got != tt.wantLog { + t.Errorf("log output mismatch:\n got: %q\nwant: %q\n", got, tt.wantLog) + } + }) + } +} + +type memFS map[string]any // full path => string for regular files + +func (m memFS) Stat(name string) (isRegular bool, err error) { + v, ok := m[name] + if !ok { + return false, fs.ErrNotExist + } + if _, ok := v.(string); ok { + return true, nil + } + return false, nil +} + +func (m memFS) Rename(_, _ string) error { panic("TODO") } +func (m memFS) Remove(_ string) error { panic("TODO") } +func (m memFS) ReadFile(name string) ([]byte, error) { + v, ok := m[name] + if !ok { + return nil, fs.ErrNotExist + } + if s, ok := v.(string); ok { + return []byte(s), nil + } + panic("TODO") +} + +func (m memFS) Truncate(name string) error { + v, ok := m[name] + if !ok { + return fs.ErrNotExist + } + if s, ok := v.(string); ok { + m[name] = s[:0] + } + + return nil +} + +func (m memFS) WriteFile(name string, contents []byte, _ os.FileMode) error { + m[name] = string(contents) + return nil +} + +type dbusService struct { + name, path string + hook func() // if non-nil, run on ping +} + +type dbusProperty struct { + name, path string + iface, member string + hook func() (string, error) // what to return +} + +type envBuilder struct { + fs memFS + dbus []dbusService + dbusProperties []dbusProperty + nmUsingResolved bool + nmVersion string + resolvconfStyle string +} + +type envOption interface { + apply(*envBuilder) +} + +type envOpt func(*envBuilder) + +func (e envOpt) apply(b *envBuilder) { + e(b) +} + +func env(opts ...envOption) newOSConfigEnv { + b := &envBuilder{ + fs: memFS{}, + } + for _, opt := range opts { + opt.apply(b) + } + + return newOSConfigEnv{ + fs: b.fs, + dbusPing: func(name, path string) error { + for _, svc := range b.dbus { + if svc.name == name && svc.path == path { + if svc.hook != nil { + svc.hook() + } + return nil + } + } + return errors.New("dbus service not found") + }, + dbusReadString: func(name, path, iface, member string) (string, error) { + for _, svc := range b.dbusProperties { + if svc.name == name && svc.path == path && svc.iface == iface && svc.member == member { + return svc.hook() + } + } + return "", errors.New("dbus property not found") + }, + nmIsUsingResolved: func() error { + if !b.nmUsingResolved { + return errors.New("networkmanager not using resolved") + } + return nil + }, + nmVersionBetween: func(first, last string) (bool, error) { + outside := cmpver.Compare(b.nmVersion, first) < 0 || cmpver.Compare(b.nmVersion, last) > 0 + return !outside, nil + }, + resolvconfStyle: func() string { return b.resolvconfStyle }, + } +} + +func resolvDotConf(ss ...string) envOption { + return envOpt(func(b *envBuilder) { + b.fs["/etc/resolv.conf"] = strings.Join(ss, "\n") + }) +} + +// resolvedRunning returns an option that makes resolved reply to a dbusPing +// and the ResolvConfMode property. +func resolvedRunning() envOption { + return resolvedStartOnPingAndThen(resolvedDbusProperty()) +} + +// resolvedDbusProperty returns an option that responds to the ResolvConfMode +// property that resolved exposes. +func resolvedDbusProperty() envOption { + return setDbusProperty("org.freedesktop.resolve1", "/org/freedesktop/resolve1", "org.freedesktop.resolve1.Manager", "ResolvConfMode", "fortests") +} + +// resolvedStartOnPingAndThen returns an option that makes resolved be +// active but not yet running. On a dbus ping, it then applies the +// provided options. +func resolvedStartOnPingAndThen(opts ...envOption) envOption { + return envOpt(func(b *envBuilder) { + b.dbus = append(b.dbus, dbusService{ + name: "org.freedesktop.resolve1", + path: "/org/freedesktop/resolve1", + hook: func() { + for _, opt := range opts { + opt.apply(b) + } + }, + }) + }) +} + +func nmRunning(version string, usingResolved bool) envOption { + return envOpt(func(b *envBuilder) { + b.nmUsingResolved = usingResolved + b.nmVersion = version + b.dbus = append(b.dbus, dbusService{name: "org.freedesktop.NetworkManager", path: "/org/freedesktop/NetworkManager/DnsManager"}) + }) +} + +func resolvconf(s string) envOption { + return envOpt(func(b *envBuilder) { + b.resolvconfStyle = s + }) +} + +func setDbusProperty(name, path, iface, member, value string) envOption { + return envOpt(func(b *envBuilder) { + b.dbusProperties = append(b.dbusProperties, dbusProperty{ + name: name, + path: path, + iface: iface, + member: member, + hook: func() (string, error) { + return value, nil + }, + }) + }) +} diff --git a/internal/dns/nm.go b/internal/dns/nm.go new file mode 100644 index 0000000..68ce71b --- /dev/null +++ b/internal/dns/nm.go @@ -0,0 +1,269 @@ +// Copyright (c) 2020 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. + +//go:build linux + +package dns + +import ( + "context" + "fmt" + "net" + "net/netip" + "time" + + "github.com/godbus/dbus/v5" + "tailscale.com/util/dnsname" + "tailscale.com/util/endian" +) + +const ( + highestPriority = int32(-1 << 31) + mediumPriority = int32(1) // Highest priority that doesn't hard-override + lowerPriority = int32(200) // lower than all builtin auto priorities +) + +// nmManager uses the NetworkManager DBus API. +type nmManager struct { + interfaceName string + manager dbus.BusObject + dnsManager dbus.BusObject +} + +func newNMManager(interfaceName string) (*nmManager, error) { + conn, err := dbus.SystemBus() + if err != nil { + return nil, err + } + + return &nmManager{ + interfaceName: interfaceName, + manager: conn.Object("org.freedesktop.NetworkManager", dbus.ObjectPath("/org/freedesktop/NetworkManager")), + dnsManager: conn.Object("org.freedesktop.NetworkManager", dbus.ObjectPath("/org/freedesktop/NetworkManager/DnsManager")), + }, nil +} + +type nmConnectionSettings map[string]map[string]dbus.Variant + +func (m *nmManager) SetDNS(config OSConfig) error { + ctx, cancel := context.WithTimeout(context.Background(), reconfigTimeout) + defer cancel() + + // NetworkManager only lets you set DNS settings on "active" + // connections, which requires an assigned IP address. This got + // configured before the DNS manager was invoked, but it might + // take a little time for the netlink notifications to propagate + // up. So, keep retrying for the duration of the reconfigTimeout. + var err error + for ctx.Err() == nil { + err = m.trySet(ctx, config) + if err == nil { + break + } + time.Sleep(10 * time.Millisecond) + } + + return err +} + +func (m *nmManager) trySet(ctx context.Context, config OSConfig) error { + conn, err := dbus.SystemBus() + if err != nil { + return fmt.Errorf("connecting to system bus: %w", err) + } + + // This is how we get at the DNS settings: + // + // org.freedesktop.NetworkManager + // | + // [GetDeviceByIpIface] + // | + // v + // org.freedesktop.NetworkManager.Device <--------\ + // (describes a network interface) | + // | | + // [GetAppliedConnection] [Reapply] + // | | + // v | + // org.freedesktop.NetworkManager.Connection | + // (connection settings) ------/ + // contains {dns, dns-priority, dns-search} + // + // Ref: https://developer.gnome.org/NetworkManager/stable/settings-ipv4.html. + + nm := conn.Object( + "org.freedesktop.NetworkManager", + dbus.ObjectPath("/org/freedesktop/NetworkManager"), + ) + + var devicePath dbus.ObjectPath + err = nm.CallWithContext( + ctx, "org.freedesktop.NetworkManager.GetDeviceByIpIface", 0, + m.interfaceName, + ).Store(&devicePath) + if err != nil { + return fmt.Errorf("getDeviceByIpIface: %w", err) + } + device := conn.Object("org.freedesktop.NetworkManager", devicePath) + + var ( + settings nmConnectionSettings + version uint64 + ) + err = device.CallWithContext( + ctx, "org.freedesktop.NetworkManager.Device.GetAppliedConnection", 0, + uint32(0), + ).Store(&settings, &version) + if err != nil { + return fmt.Errorf("getAppliedConnection: %w", err) + } + + // Frustratingly, NetworkManager represents IPv4 addresses as uint32s, + // although IPv6 addresses are represented as byte arrays. + // Perform the conversion here. + var ( + dnsv4 []uint32 + dnsv6 [][]byte + ) + for _, ip := range config.Nameservers { + b := ip.As16() + if ip.Is4() { + dnsv4 = append(dnsv4, endian.Native.Uint32(b[12:])) + } else { + dnsv6 = append(dnsv6, b[:]) + } + } + + // NetworkManager wipes out IPv6 address configuration unless we + // tell it explicitly to keep it. Read out the current interface + // settings and mirror them out to NetworkManager. + var addrs6 []map[string]any + if netIface, err := net.InterfaceByName(m.interfaceName); err == nil { + if addrs, err := netIface.Addrs(); err == nil { + for _, a := range addrs { + if ipnet, ok := a.(*net.IPNet); ok { + nip, ok := netip.AddrFromSlice(ipnet.IP) + nip = nip.Unmap() + if ok && nip.Is6() { + addrs6 = append(addrs6, map[string]any{ + "address": nip.String(), + "prefix": uint32(128), + }) + } + } + } + } + } + + seen := map[dnsname.FQDN]bool{} + var search []string + for _, dom := range config.SearchDomains { + if seen[dom] { + continue + } + seen[dom] = true + search = append(search, dom.WithTrailingDot()) + } + for _, dom := range config.MatchDomains { + if seen[dom] { + continue + } + seen[dom] = true + search = append(search, "~"+dom.WithTrailingDot()) + } + if len(config.MatchDomains) == 0 { + // Non-split routing requested, add an all-domains match. + search = append(search, "~.") + } + + // Ideally we would like to disable LLMNR and mdns on the + // interface here, but older NetworkManagers don't understand + // those settings and choke on them, so we don't. Both LLMNR and + // mdns will fail since tailscale0 doesn't do multicast, so it's + // effectively fine. We used to try and enforce LLMNR and mdns + // settings here, but that led to #1870. + + ipv4Map := settings["ipv4"] + ipv4Map["dns"] = dbus.MakeVariant(dnsv4) + ipv4Map["dns-search"] = dbus.MakeVariant(search) + // We should only request priority if we have nameservers to set. + if len(dnsv4) == 0 { + ipv4Map["dns-priority"] = dbus.MakeVariant(lowerPriority) + } else if len(config.MatchDomains) > 0 { + // Set a fairly high priority, but don't override all other + // configs when in split-DNS mode. + ipv4Map["dns-priority"] = dbus.MakeVariant(mediumPriority) + } else { + // Negative priority means only the settings from the most + // negative connection get used. The way this mixes with + // per-domain routing is unclear, but it _seems_ that the + // priority applies after routing has found possible + // candidates for a resolution. + ipv4Map["dns-priority"] = dbus.MakeVariant(highestPriority) + } + + ipv6Map := settings["ipv6"] + // In IPv6 settings, you're only allowed to provide additional + // static DNS settings in "auto" (SLAAC) or "manual" mode. In + // "manual" mode you also have to specify IP addresses, so we use + // "auto". + // + // NM actually documents that to set just DNS servers, you should + // use "auto" mode and then set ignore auto routes and DNS, which + // basically means "autoconfigure but ignore any autoconfiguration + // results you might get". As a safety, we also say that + // NetworkManager should never try to make us the default route + // (none of its business anyway, we handle our own default + // routing). + ipv6Map["method"] = dbus.MakeVariant("auto") + if len(addrs6) > 0 { + ipv6Map["address-data"] = dbus.MakeVariant(addrs6) + } + ipv6Map["ignore-auto-routes"] = dbus.MakeVariant(true) + ipv6Map["ignore-auto-dns"] = dbus.MakeVariant(true) + ipv6Map["never-default"] = dbus.MakeVariant(true) + + ipv6Map["dns"] = dbus.MakeVariant(dnsv6) + ipv6Map["dns-search"] = dbus.MakeVariant(search) + if len(dnsv6) == 0 { + ipv6Map["dns-priority"] = dbus.MakeVariant(lowerPriority) + } else if len(config.MatchDomains) > 0 { + // Set a fairly high priority, but don't override all other + // configs when in split-DNS mode. + ipv6Map["dns-priority"] = dbus.MakeVariant(mediumPriority) + } else { + ipv6Map["dns-priority"] = dbus.MakeVariant(highestPriority) + } + + // deprecatedProperties are the properties in interface settings + // that are deprecated by NetworkManager. + // + // In practice, this means that they are returned for reading, + // but submitting a settings object with them present fails + // with hard-to-diagnose errors. They must be removed. + deprecatedProperties := []string{ + "addresses", "routes", + } + + for _, property := range deprecatedProperties { + delete(ipv4Map, property) + delete(ipv6Map, property) + } + + if call := device.CallWithContext(ctx, "org.freedesktop.NetworkManager.Device.Reapply", 0, settings, version, uint32(0)); call.Err != nil { + return fmt.Errorf("reapply: %w", call.Err) + } + + return nil +} + +func (m *nmManager) Close() error { + // No need to do anything on close, NetworkManager will delete our + // settings when the tailscale interface goes away. + return nil +} + +func (m *nmManager) Mode() string { + return "network-maanger" +} diff --git a/internal/dns/osconfig.go b/internal/dns/osconfig.go new file mode 100644 index 0000000..0f5e91d --- /dev/null +++ b/internal/dns/osconfig.go @@ -0,0 +1,124 @@ +// 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 ( + "bufio" + "fmt" + "net/netip" + + "tailscale.com/types/logger" + "tailscale.com/util/dnsname" +) + +// An OSConfigurator applies DNS settings to the operating system. +type OSConfigurator interface { + // SetDNS updates the OS's DNS configuration to match cfg. + // If cfg is the zero value, all ctrld-related DNS + // configuration is removed. + // SetDNS must not be called after Close. + // SetDNS takes ownership of cfg. + SetDNS(cfg OSConfig) error + + // Close removes ctrld-related DNS configuration from the OS. + Close() error + + Mode() string +} + +// 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 + // single-label name queries. SearchDomains is additive to + // whatever non-Tailscale search domains the OS has. + SearchDomains []dnsname.FQDN + // MatchDomains are the DNS suffixes for which Nameservers should + // be used. If empty, Nameservers is installed as the "primary" resolver. + MatchDomains []dnsname.FQDN +} + +func (o OSConfig) IsZero() bool { + return len(o.Nameservers) == 0 && len(o.SearchDomains) == 0 && len(o.MatchDomains) == 0 +} + +func (a OSConfig) Equal(b OSConfig) bool { + if len(a.Nameservers) != len(b.Nameservers) { + return false + } + if len(a.SearchDomains) != len(b.SearchDomains) { + return false + } + if len(a.MatchDomains) != len(b.MatchDomains) { + return false + } + + for i := range a.Nameservers { + if a.Nameservers[i] != b.Nameservers[i] { + return false + } + } + for i := range a.SearchDomains { + if a.SearchDomains[i] != b.SearchDomains[i] { + return false + } + } + for i := range a.MatchDomains { + if a.MatchDomains[i] != b.MatchDomains[i] { + return false + } + } + + return true +} + +// Format implements the fmt.Formatter interface to ensure that Hosts is +// printed correctly (i.e. not as a bunch of pointers). +// +// Fixes https://github.com/tailscale/tailscale/issues/5669 +func (a OSConfig) Format(f fmt.State, verb rune) { + logger.ArgWriter(func(w *bufio.Writer) { + _, _ = w.WriteString(`{Nameservers:[`) + for i, ns := range a.Nameservers { + if i != 0 { + _, _ = w.WriteString(" ") + } + _, _ = fmt.Fprintf(w, "%+v", ns) + } + _, _ = w.WriteString(`] SearchDomains:[`) + for i, domain := range a.SearchDomains { + if i != 0 { + _, _ = w.WriteString(" ") + } + _, _ = fmt.Fprintf(w, "%+v", domain) + } + _, _ = w.WriteString(`] MatchDomains:[`) + for i, domain := range a.MatchDomains { + if i != 0 { + _, _ = w.WriteString(" ") + } + _, _ = fmt.Fprintf(w, "%+v", domain) + } + _, _ = w.WriteString(`] Hosts:[`) + for i, host := range a.Hosts { + if i != 0 { + _, _ = w.WriteString(" ") + } + _, _ = fmt.Fprintf(w, "%+v", host) + } + _, _ = w.WriteString(`]}`) + }).Format(f, verb) +} diff --git a/internal/dns/osconfig_test.go b/internal/dns/osconfig_test.go new file mode 100644 index 0000000..24ec35b --- /dev/null +++ b/internal/dns/osconfig_test.go @@ -0,0 +1,44 @@ +// Copyright (c) 2022 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 ( + "fmt" + "net/netip" + "testing" + + "tailscale.com/util/dnsname" +) + +func TestOSConfigPrintable(t *testing.T) { + ocfg := OSConfig{ + Hosts: []*HostEntry{ + { + Addr: netip.AddrFrom4([4]byte{100, 1, 2, 3}), + Hosts: []string{"server", "client"}, + }, + { + Addr: netip.AddrFrom4([4]byte{100, 1, 2, 4}), + Hosts: []string{"otherhost"}, + }, + }, + Nameservers: []netip.Addr{ + netip.AddrFrom4([4]byte{8, 8, 8, 8}), + }, + SearchDomains: []dnsname.FQDN{ + dnsname.FQDN("foo.beta.controld.com."), + dnsname.FQDN("bar.beta.controld.com."), + }, + MatchDomains: []dnsname.FQDN{ + dnsname.FQDN("controld.com."), + }, + } + s := fmt.Sprintf("%+v", ocfg) + + const expected = `{Nameservers:[8.8.8.8] SearchDomains:[foo.beta.controld.com. bar.beta.controld.com.] MatchDomains:[controld.com.] Hosts:[&{Addr:100.1.2.3 Hosts:[server client]} &{Addr:100.1.2.4 Hosts:[otherhost]}]}` + if s != expected { + t.Errorf("format mismatch:\n got: %s\n want: %s", s, expected) + } +} diff --git a/internal/dns/resolvconf.go b/internal/dns/resolvconf.go new file mode 100644 index 0000000..b317b3b --- /dev/null +++ b/internal/dns/resolvconf.go @@ -0,0 +1,26 @@ +// 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. + +//go:build linux || freebsd || openbsd + +package dns + +import ( + "os/exec" +) + +func resolvconfStyle() string { + if _, err := exec.LookPath("resolvconf"); err != nil { + return "" + } + if _, err := exec.Command("resolvconf", "--version").CombinedOutput(); err != nil { + // Debian resolvconf doesn't understand --version, and + // exits with a specific error code. + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 99 { + return "debian" + } + } + // Treat everything else as openresolv, by far the more popular implementation. + return "openresolv" +} diff --git a/internal/dns/resolvconffile/resolvconffile.go b/internal/dns/resolvconffile/resolvconffile.go new file mode 100644 index 0000000..5572891 --- /dev/null +++ b/internal/dns/resolvconffile/resolvconffile.go @@ -0,0 +1,119 @@ +// Copyright (c) 2022 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 resolvconffile parses & serializes /etc/resolv.conf-style files. +package resolvconffile + +import ( + "bufio" + "bytes" + "fmt" + "io" + "net/netip" + "os" + "strings" + + "tailscale.com/util/dnsname" + "tailscale.com/util/strs" +) + +// Path is the canonical location of resolv.conf. +const Path = "/etc/resolv.conf" + +// Config represents a resolv.conf(5) file. +type Config struct { + // Nameservers are the IP addresses of the nameservers to use. + Nameservers []netip.Addr + + // SearchDomains are the domain suffixes to use when expanding + // single-label name queries. SearchDomains is additive to + // whatever non-Tailscale search domains the OS has. + SearchDomains []dnsname.FQDN +} + +// Write writes c to w. It does so in one Write call. +func (c *Config) Write(w io.Writer) error { + buf := new(bytes.Buffer) + io.WriteString(buf, "# resolv.conf(5) file generated by ctrld\n") + io.WriteString(buf, "# DO NOT EDIT THIS FILE BY HAND -- CHANGES WILL BE OVERWRITTEN\n\n") + for _, ns := range c.Nameservers { + io.WriteString(buf, "nameserver ") + io.WriteString(buf, ns.String()) + io.WriteString(buf, "\n") + } + if len(c.SearchDomains) > 0 { + io.WriteString(buf, "search") + for _, domain := range c.SearchDomains { + io.WriteString(buf, " ") + io.WriteString(buf, domain.WithoutTrailingDot()) + } + io.WriteString(buf, "\n") + } + _, err := w.Write(buf.Bytes()) + return err +} + +// Parse parses a resolv.conf file from r. +func Parse(r io.Reader) (*Config, error) { + config := new(Config) + scanner := bufio.NewScanner(r) + for scanner.Scan() { + line := scanner.Text() + line, _, _ = strings.Cut(line, "#") // remove any comments + line = strings.TrimSpace(line) + + if s, ok := strs.CutPrefix(line, "nameserver"); ok { + nameserver := strings.TrimSpace(s) + if len(nameserver) == len(s) { + return nil, fmt.Errorf("missing space after \"nameserver\" in %q", line) + } + ip, err := netip.ParseAddr(nameserver) + if err != nil { + return nil, err + } + config.Nameservers = append(config.Nameservers, ip) + continue + } + + if s, ok := strs.CutPrefix(line, "search"); ok { + domains := strings.TrimSpace(s) + if len(domains) == len(s) { + // No leading space?! + return nil, fmt.Errorf("missing space after \"search\" in %q", line) + } + for len(domains) > 0 { + domain := domains + i := strings.IndexAny(domain, " \t") + if i != -1 { + domain = domain[:i] + domains = strings.TrimSpace(domains[i+1:]) + } else { + domains = "" + } + fqdn, err := dnsname.ToFQDN(domain) + if err != nil { + return nil, fmt.Errorf("parsing search domain %q in %q: %w", domain, line, err) + } + config.SearchDomains = append(config.SearchDomains, fqdn) + } + } + } + return config, nil +} + +// ParseFile parses the named resolv.conf file. +func ParseFile(name string) (*Config, error) { + fi, err := os.Stat(name) + if err != nil { + return nil, err + } + if n := fi.Size(); n > 10<<10 { + return nil, fmt.Errorf("unexpectedly large %q file: %d bytes", name, n) + } + all, err := os.ReadFile(name) + if err != nil { + return nil, err + } + return Parse(bytes.NewReader(all)) +} diff --git a/internal/dns/resolvconffile/resolvconffile_test.go b/internal/dns/resolvconffile/resolvconffile_test.go new file mode 100644 index 0000000..e5b5cc6 --- /dev/null +++ b/internal/dns/resolvconffile/resolvconffile_test.go @@ -0,0 +1,104 @@ +// Copyright (c) 2022 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 resolvconffile + +import ( + "net/netip" + "reflect" + "strings" + "testing" + + "tailscale.com/util/dnsname" +) + +func TestParse(t *testing.T) { + tests := []struct { + in string + want *Config + wantErr bool + }{ + {in: `nameserver 192.168.0.100`, + want: &Config{ + Nameservers: []netip.Addr{ + netip.MustParseAddr("192.168.0.100"), + }, + }, + }, + {in: `nameserver 192.168.0.100 # comment`, + want: &Config{ + Nameservers: []netip.Addr{ + netip.MustParseAddr("192.168.0.100"), + }, + }, + }, + {in: `nameserver 192.168.0.100#`, + want: &Config{ + Nameservers: []netip.Addr{ + netip.MustParseAddr("192.168.0.100"), + }, + }, + }, + {in: `nameserver #192.168.0.100`, wantErr: true}, + {in: `nameserver`, wantErr: true}, + {in: `# nameserver 192.168.0.100`, want: &Config{}}, + {in: `nameserver192.168.0.100`, wantErr: true}, + + {in: `search tailsacle.com`, + want: &Config{ + SearchDomains: []dnsname.FQDN{"tailsacle.com."}, + }, + }, + {in: `search tailsacle.com # typo`, + want: &Config{ + SearchDomains: []dnsname.FQDN{"tailsacle.com."}, + }, + }, + {in: `searchtailsacle.com`, wantErr: true}, + {in: `search`, wantErr: true}, + + // Issue 6875: there can be multiple search domains, and even if they're + // over 253 bytes long total. + { + in: "search search-01.example search-02.example search-03.example search-04.example search-05.example search-06.example search-07.example search-08.example search-09.example search-10.example search-11.example search-12.example search-13.example search-14.example search-15.example\n", + want: &Config{ + SearchDomains: []dnsname.FQDN{ + "search-01.example.", + "search-02.example.", + "search-03.example.", + "search-04.example.", + "search-05.example.", + "search-06.example.", + "search-07.example.", + "search-08.example.", + "search-09.example.", + "search-10.example.", + "search-11.example.", + "search-12.example.", + "search-13.example.", + "search-14.example.", + "search-15.example.", + }, + }, + }, + } + + for _, tt := range tests { + cfg, err := Parse(strings.NewReader(tt.in)) + if tt.wantErr { + if err != nil { + continue + } + t.Errorf("missing error for %q", tt.in) + continue + } + if err != nil { + t.Errorf("unexpected error for %q: %v", tt.in, err) + continue + } + if !reflect.DeepEqual(cfg, tt.want) { + t.Errorf("got: %v\nwant: %v\n", cfg, tt.want) + } + } +} diff --git a/internal/dns/resolved.go b/internal/dns/resolved.go new file mode 100644 index 0000000..6c0b1de --- /dev/null +++ b/internal/dns/resolved.go @@ -0,0 +1,389 @@ +// Copyright (c) 2020 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. + +//go:build linux + +package dns + +import ( + "context" + "fmt" + "net" + "strings" + "time" + + "github.com/godbus/dbus/v5" + "golang.org/x/sys/unix" + "tailscale.com/health" + "tailscale.com/logtail/backoff" + "tailscale.com/types/logger" + "tailscale.com/util/dnsname" +) + +const reconfigTimeout = time.Second + +// DBus entities we talk to. +// +// DBus is an RPC bus. In particular, the bus we're talking to is the +// system-wide bus (there is also a per-user session bus for +// user-specific applications). +// +// Daemons connect to the bus, and advertise themselves under a +// well-known object name. That object exposes paths, and each path +// implements one or more interfaces that contain methods, properties, +// and signals. +// +// Clients connect to the bus and walk that same hierarchy to invoke +// RPCs, get/set properties, or listen for signals. +const ( + dbusResolvedObject = "org.freedesktop.resolve1" + dbusResolvedPath dbus.ObjectPath = "/org/freedesktop/resolve1" + dbusResolvedInterface = "org.freedesktop.resolve1.Manager" + dbusPath dbus.ObjectPath = "/org/freedesktop/DBus" + dbusInterface = "org.freedesktop.DBus" + dbusOwnerSignal = "NameOwnerChanged" // broadcast when a well-known name's owning process changes. +) + +type resolvedLinkNameserver struct { + Family int32 + Address []byte +} + +type resolvedLinkDomain struct { + Domain string + RoutingOnly bool +} + +// changeRequest tracks latest OSConfig and related error responses to update. +type changeRequest struct { + config OSConfig // configs OSConfigs, one per each SetDNS call + res chan<- error // response channel +} + +// resolvedManager is an OSConfigurator which uses the systemd-resolved DBus API. +type resolvedManager struct { + ctx context.Context + cancel func() // terminate the context, for close + + logf logger.Logf + ifidx int + + configCR chan changeRequest // tracks OSConfigs changes and error responses +} + +func newResolvedManager(logf logger.Logf, interfaceName string) (*resolvedManager, error) { + iface, err := net.InterfaceByName(interfaceName) + if err != nil { + return nil, err + } + + ctx, cancel := context.WithCancel(context.Background()) + logf = logger.WithPrefix(logf, "dns: ") + + mgr := &resolvedManager{ + ctx: ctx, + cancel: cancel, + + logf: logf, + ifidx: iface.Index, + + configCR: make(chan changeRequest), + } + + go mgr.run(ctx) + + return mgr, nil +} + +func (m *resolvedManager) SetDNS(config OSConfig) error { + errc := make(chan error, 1) + defer close(errc) + + select { + case <-m.ctx.Done(): + return m.ctx.Err() + case m.configCR <- changeRequest{config, errc}: + } + + select { + case <-m.ctx.Done(): + return m.ctx.Err() + case err := <-errc: + if err != nil { + m.logf("failed to configure resolved: %v", err) + } + return err + } +} + +func (m *resolvedManager) run(ctx context.Context) { + var ( + conn *dbus.Conn + signals chan *dbus.Signal + rManager dbus.BusObject // rManager is the Resolved DBus connection + ) + bo := backoff.NewBackoff("resolved-dbus", m.logf, 30*time.Second) + needsReconnect := make(chan bool, 1) + defer func() { + if conn != nil { + _ = conn.Close() + } + }() + + // Reconnect the systemBus if disconnected. + reconnect := func() error { + var err error + signals = make(chan *dbus.Signal, 16) + conn, err = dbus.SystemBus() + if err != nil { + m.logf("dbus connection error: %v", err) + } else { + m.logf("[v1] dbus connected") + } + + if err != nil { + // Backoff increases time between reconnect attempts. + go func() { + bo.BackOff(ctx, err) + needsReconnect <- true + }() + return err + } + + rManager = conn.Object(dbusResolvedObject, dbus.ObjectPath(dbusResolvedPath)) + + // Only receive the DBus signals we need to resync our config on + // resolved restart. Failure to set filters isn't a fatal error, + // we'll just receive all broadcast signals and have to ignore + // them on our end. + if err = conn.AddMatchSignal(dbus.WithMatchObjectPath(dbusPath), dbus.WithMatchInterface(dbusInterface), dbus.WithMatchMember(dbusOwnerSignal), dbus.WithMatchArg(0, dbusResolvedObject)); err != nil { + m.logf("[v1] Setting DBus signal filter failed: %v", err) + } + conn.Signal(signals) + + // Reset backoff and SetNSOSHealth after successful on reconnect. + bo.BackOff(ctx, nil) + health.SetDNSOSHealth(nil) + return nil + } + + // Create initial systemBus connection. + _ = reconnect() + + lastConfig := OSConfig{} + + for { + select { + case <-ctx.Done(): + if rManager == nil { + return + } + // RevertLink resets all per-interface settings on systemd-resolved to defaults. + // When ctx goes away systemd-resolved auto reverts. + // Keeping for potential use in future refactor. + if call := rManager.CallWithContext(ctx, dbusResolvedInterface+".RevertLink", 0, m.ifidx); call.Err != nil { + m.logf("[v1] RevertLink: %v", call.Err) + return + } + return + case configCR := <-m.configCR: + // Track and update sync with latest config change. + lastConfig = configCR.config + + if rManager == nil { + configCR.res <- fmt.Errorf("resolved DBus does not have a connection") + continue + } + err := m.setConfigOverDBus(ctx, rManager, configCR.config) + configCR.res <- err + case <-needsReconnect: + if err := reconnect(); err != nil { + m.logf("[v1] SystemBus reconnect error %T", err) + } + continue + case signal, ok := <-signals: + // If signal ends and is nil then program tries to reconnect. + if !ok { + if err := reconnect(); err != nil { + m.logf("[v1] SystemBus reconnect error %T", err) + } + continue + } + // In theory the signal was filtered by DBus, but if + // AddMatchSignal in the constructor failed, we may be + // getting other spam. + if signal.Path != dbusPath || signal.Name != dbusInterface+"."+dbusOwnerSignal { + continue + } + if lastConfig.IsZero() { + continue + } + // signal.Body is a []any of 3 strings: bus name, previous owner, new owner. + if len(signal.Body) != 3 { + m.logf("[unexpected] DBus NameOwnerChanged len(Body) = %d, want 3") + } + if name, ok := signal.Body[0].(string); !ok || name != dbusResolvedObject { + continue + } + newOwner, ok := signal.Body[2].(string) + if !ok { + m.logf("[unexpected] DBus NameOwnerChanged.new_owner is a %T, not a string", signal.Body[2]) + } + if newOwner == "" { + // systemd-resolved left the bus, no current owner, + // nothing to do. + continue + } + // The resolved bus name has a new owner, meaning resolved + // restarted. Reprogram current config. + m.logf("systemd-resolved restarted, syncing DNS config") + err := m.setConfigOverDBus(ctx, rManager, lastConfig) + // Set health while holding the lock, because this will + // graciously serialize the resync's health outcome with a + // concurrent SetDNS call. + health.SetDNSOSHealth(err) + if err != nil { + m.logf("failed to configure systemd-resolved: %v", err) + } + } + } +} + +// setConfigOverDBus updates resolved DBus config and is only called from the run goroutine. +func (m *resolvedManager) setConfigOverDBus(ctx context.Context, rManager dbus.BusObject, config OSConfig) error { + ctx, cancel := context.WithTimeout(ctx, reconfigTimeout) + defer cancel() + + var linkNameservers = make([]resolvedLinkNameserver, len(config.Nameservers)) + for i, server := range config.Nameservers { + ip := server.As16() + if server.Is4() { + linkNameservers[i] = resolvedLinkNameserver{ + Family: unix.AF_INET, + Address: ip[12:], + } + } else { + linkNameservers[i] = resolvedLinkNameserver{ + Family: unix.AF_INET6, + Address: ip[:], + } + } + } + err := rManager.CallWithContext( + ctx, dbusResolvedInterface+".SetLinkDNS", 0, + m.ifidx, linkNameservers, + ).Store() + if err != nil { + return fmt.Errorf("setLinkDNS: %w", err) + } + linkDomains := make([]resolvedLinkDomain, 0, len(config.SearchDomains)+len(config.MatchDomains)) + seenDomains := map[dnsname.FQDN]bool{} + for _, domain := range config.SearchDomains { + if seenDomains[domain] { + continue + } + seenDomains[domain] = true + linkDomains = append(linkDomains, resolvedLinkDomain{ + Domain: domain.WithTrailingDot(), + RoutingOnly: false, + }) + } + for _, domain := range config.MatchDomains { + if seenDomains[domain] { + // Search domains act as both search and match in + // resolved, so it's correct to skip. + continue + } + seenDomains[domain] = true + linkDomains = append(linkDomains, resolvedLinkDomain{ + Domain: domain.WithTrailingDot(), + RoutingOnly: true, + }) + } + if len(config.MatchDomains) == 0 && len(config.Nameservers) > 0 { + // Caller requested full DNS interception, install a + // routing-only root domain. + linkDomains = append(linkDomains, resolvedLinkDomain{ + Domain: ".", + RoutingOnly: true, + }) + } + + err = rManager.CallWithContext( + ctx, dbusResolvedInterface+".SetLinkDomains", 0, + m.ifidx, linkDomains, + ).Store() + if err != nil && err.Error() == "Argument list too long" { // TODO: better error match + // Issue 3188: older systemd-resolved had argument length limits. + // Trim out the *.arpa. entries and try again. + err = rManager.CallWithContext( + ctx, dbusResolvedInterface+".SetLinkDomains", 0, + m.ifidx, linkDomainsWithoutReverseDNS(linkDomains), + ).Store() + } + if err != nil { + return fmt.Errorf("setLinkDomains: %w", err) + } + + if call := rManager.CallWithContext(ctx, dbusResolvedInterface+".SetLinkDefaultRoute", 0, m.ifidx, len(config.MatchDomains) == 0); call.Err != nil { + if dbusErr, ok := call.Err.(dbus.Error); ok && dbusErr.Name == dbus.ErrMsgUnknownMethod.Name { + // on some older systems like Kubuntu 18.04.6 with systemd 237 method SetLinkDefaultRoute is absent, + // but otherwise it's working good + m.logf("[v1] failed to set SetLinkDefaultRoute: %v", call.Err) + } else { + return fmt.Errorf("setLinkDefaultRoute: %w", call.Err) + } + } + + // Some best-effort setting of things, but resolved should do the + // right thing if these fail (e.g. a really old resolved version + // or something). + + // Disable LLMNR, we don't do multicast. + if call := rManager.CallWithContext(ctx, dbusResolvedInterface+".SetLinkLLMNR", 0, m.ifidx, "no"); call.Err != nil { + m.logf("[v1] failed to disable LLMNR: %v", call.Err) + } + + // Disable mdns. + if call := rManager.CallWithContext(ctx, dbusResolvedInterface+".SetLinkMulticastDNS", 0, m.ifidx, "no"); call.Err != nil { + m.logf("[v1] failed to disable mdns: %v", call.Err) + } + + // We don't support dnssec consistently right now, force it off to + // avoid partial failures when we split DNS internally. + if call := rManager.CallWithContext(ctx, dbusResolvedInterface+".SetLinkDNSSEC", 0, m.ifidx, "no"); call.Err != nil { + m.logf("[v1] failed to disable DNSSEC: %v", call.Err) + } + + if call := rManager.CallWithContext(ctx, dbusResolvedInterface+".SetLinkDNSOverTLS", 0, m.ifidx, "no"); call.Err != nil { + m.logf("[v1] failed to disable DoT: %v", call.Err) + } + + if call := rManager.CallWithContext(ctx, dbusResolvedInterface+".FlushCaches", 0); call.Err != nil { + m.logf("failed to flush resolved DNS cache: %v", call.Err) + } + return nil +} + +func (m *resolvedManager) Close() error { + m.cancel() // stops the 'run' method goroutine + return nil +} + +func (m *resolvedManager) Mode() string { + return "systemd-resolved" +} + +// linkDomainsWithoutReverseDNS returns a copy of v without +// *.arpa. entries. +func linkDomainsWithoutReverseDNS(v []resolvedLinkDomain) (ret []resolvedLinkDomain) { + for _, d := range v { + if strings.HasSuffix(d.Domain, ".arpa.") { + // Oh well. At least the rest will work. + continue + } + ret = append(ret, d) + } + return ret +}