mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-02-03 22:18:39 +00:00
all: fork tailscale Linux dns manager package
With modification to fit our use case.
This commit is contained in:
committed by
Cuong Manh Le
parent
b8772d7b4a
commit
851f9b9742
2
internal/dns/README.md
Normal file
2
internal/dns/README.md
Normal file
@@ -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.
|
||||
533
internal/dns/direct.go
Normal file
533
internal/dns/direct.go
Normal file
@@ -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") != ""
|
||||
}
|
||||
62
internal/dns/direct_linux.go
Normal file
62
internal/dns/direct_linux.go
Normal file
@@ -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()
|
||||
}
|
||||
11
internal/dns/direct_notlinux.go
Normal file
11
internal/dns/direct_notlinux.go
Normal file
@@ -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.
|
||||
}
|
||||
199
internal/dns/direct_test.go
Normal file
199
internal/dns/direct_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
387
internal/dns/manager_linux.go
Normal file
387
internal/dns/manager_linux.go
Normal file
@@ -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
|
||||
}
|
||||
439
internal/dns/manager_linux_test.go
Normal file
439
internal/dns/manager_linux_test.go
Normal file
@@ -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
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
269
internal/dns/nm.go
Normal file
269
internal/dns/nm.go
Normal file
@@ -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"
|
||||
}
|
||||
124
internal/dns/osconfig.go
Normal file
124
internal/dns/osconfig.go
Normal file
@@ -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)
|
||||
}
|
||||
44
internal/dns/osconfig_test.go
Normal file
44
internal/dns/osconfig_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
26
internal/dns/resolvconf.go
Normal file
26
internal/dns/resolvconf.go
Normal file
@@ -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"
|
||||
}
|
||||
119
internal/dns/resolvconffile/resolvconffile.go
Normal file
119
internal/dns/resolvconffile/resolvconffile.go
Normal file
@@ -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))
|
||||
}
|
||||
104
internal/dns/resolvconffile/resolvconffile_test.go
Normal file
104
internal/dns/resolvconffile/resolvconffile_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
389
internal/dns/resolved.go
Normal file
389
internal/dns/resolved.go
Normal file
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user