This commit is contained in:
bluepython508 committed 2026-10-22 12:27:19 +01:00
1 parent 3530afb854
commit 6903fcff27
2146 files changed
+112256 -190703

No files matched your search

+71 -8
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:generate go run tailscale.com/cmd/viewer --type=Config --clonefunc
@@ -21,10 +21,15 @@ import (
"tailscale.com/net/tsaddr"
"tailscale.com/types/dnstype"
"tailscale.com/util/dnsname"
"tailscale.com/util/set"
)
// Config is a DNS configuration.
type Config struct {
// AcceptDNS true if [Prefs.CorpDNS] is enabled (or --accept-dns=true).
// This should be used for error handling and health reporting
// purposes only.
AcceptDNS bool
// DefaultResolvers are the DNS resolvers to use for DNS names
// which aren't covered by more specific per-domain routes below.
// If empty, the OS's default resolvers (the ones that predate
@@ -48,9 +53,22 @@ type Config struct {
// it to resolve, you also need to add appropriate routes to
// Routes.
Hosts map[dnsname.FQDN][]netip.Addr
// SubdomainHosts is a set of FQDNs from Hosts that should also
// resolve subdomain queries to the same IPs. For example, if
// "node.tailnet.ts.net" is in SubdomainHosts, then queries for
// "anything.node.tailnet.ts.net" will resolve to node's IPs.
SubdomainHosts set.Set[dnsname.FQDN]
// OnlyIPv6, if true, uses the IPv6 service IP (for MagicDNS)
// instead of the IPv4 version (100.100.100.100).
OnlyIPv6 bool
// MagicDNSHostsUnrouted is whether MagicDNS host records are
// served on demand via [resolver.MagicDNSHosts] (so are not
// listed in Hosts) without being covered by any Routes entry;
// that is, MagicDNS domain routing is off. It preserves the
// effect the node records had when they were listed in Hosts:
// hasHostsWithoutSplitDNSRoutes reports true, keeping quad-100
// in the OS resolver path so the names still resolve.
MagicDNSHostsUnrouted bool
}
var magicDNSDualStack = envknob.RegisterBool("TS_DEBUG_MAGIC_DNS_DUAL_STACK")
@@ -63,11 +81,9 @@ func (c *Config) serviceIPs(knobs *controlknobs.Knobs) []netip.Addr {
return []netip.Addr{tsaddr.TailscaleServiceIPv6()}
}
// TODO(bradfitz,mikeodr,raggi): include IPv6 here too; tailscale/tailscale#15404
// And add a controlknobs knob to disable dual stack.
//
// For now, opt-in for testing.
if magicDNSDualStack() {
// See https://github.com/tailscale/tailscale/issues/15404 for the background
// on the opt-in debug knob and the controlknob opt-out.
if magicDNSDualStack() || !knobs.ShouldForceRegisterMagicDNSIPv4Only() {
return []netip.Addr{
tsaddr.TailscaleServiceIP(),
tsaddr.TailscaleServiceIPv6(),
@@ -119,8 +135,13 @@ func (c Config) hasDefaultIPResolversOnly() bool {
// hasHostsWithoutSplitDNSRoutes reports whether c contains any Host entries
// that aren't covered by a SplitDNS route suffix.
func (c Config) hasHostsWithoutSplitDNSRoutes() bool {
// TODO(bradfitz): this could be more efficient, but we imagine
// the number of SplitDNS routes and/or hosts will be small.
if c.MagicDNSHostsUnrouted {
return true
}
// Hosts is small here on most platforms: per-node records are
// served via [resolver.MagicDNSHosts] (accounted for above), so
// only control's DNS.ExtraRecords remain. On Windows it still
// carries every node's records for the hosts-file path.
for host := range c.Hosts {
if !c.hasSplitDNSRouteForHost(host) {
return true
@@ -129,6 +150,19 @@ func (c Config) hasHostsWithoutSplitDNSRoutes() bool {
return false
}
// requiresPrimaryResolver reports whether c can only be served correctly with
// quad-100 installed as the OS's primary (catch-all) resolver, rather than
// scoped to a set of match domains.
//
// That's the case when c has names quad-100 must answer that no route suffix
// covers, so there is no suffix to scope to. dnsConfigForNetmap pairs every
// ExtraRecord it emits with a route, so in practice this is the
// MagicDNS-names-present but MagicDNS-domain-routing-off case
// (MagicDNSHostsUnrouted).
func (c Config) requiresPrimaryResolver() bool {
return c.hasHostsWithoutSplitDNSRoutes()
}
// hasSplitDNSRouteForHost reports whether c contains a SplitDNS route
// that contains hosts.
func (c Config) hasSplitDNSRouteForHost(host dnsname.FQDN) bool {
@@ -140,6 +174,35 @@ func (c Config) hasSplitDNSRouteForHost(host dnsname.FQDN) bool {
return false
}
// hasHostsWithoutReverseRoutes reports whether any PTR record synthesized by
// quad-100 from Hosts is outside the configured routes. Apple scoping must
// preserve these answers as well as forward records.
func (c Config) hasHostsWithoutReverseRoutes() bool {
for _, ips := range c.Hosts {
for _, ip := range ips {
if !ip.IsValid() || !c.hasSplitDNSRouteForHost(reverseDNSName(ip)) {
return true
}
}
}
return false
}
// reverseDNSName returns the PTR query name for a valid IP address.
func reverseDNSName(ip netip.Addr) dnsname.FQDN {
if ip.Is4() {
b := ip.As4()
return dnsname.FQDN(fmt.Sprintf("%d.%d.%d.%d.in-addr.arpa.", b[3], b[2], b[1], b[0]))
}
b := ip.As16()
const hex = "0123456789abcdef"
name := make([]byte, 0, 64+len("ip6.arpa."))
for i := len(b) - 1; i >= 0; i-- {
name = append(name, hex[b[i]&0xf], '.', hex[b[i]>>4], '.')
}
return dnsname.FQDN(append(name, "ip6.arpa."...))
}
func (c Config) hasDefaultResolvers() bool {
return len(c.DefaultResolvers) > 0
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux && !android && !ts_omit_dbus
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build (linux && !android) || freebsd || openbsd
+70 -2
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !android && !ios
@@ -26,8 +26,10 @@ import (
"tailscale.com/feature"
"tailscale.com/health"
"tailscale.com/hostinfo"
"tailscale.com/net/dns/resolvconffile"
"tailscale.com/net/tsaddr"
"tailscale.com/types/lazy"
"tailscale.com/types/logger"
"tailscale.com/util/dnsname"
"tailscale.com/util/eventbus"
@@ -277,6 +279,9 @@ func (m *directManager) rename(old, new string) error {
if !m.renameBroken {
err := m.fs.Rename(old, new)
if err == nil {
if new == resolvConf {
m.relabelResolvConf()
}
return nil
}
if runtime.GOOS == "linux" && distro.Get() == distro.Synology {
@@ -309,9 +314,55 @@ func (m *directManager) rename(old, new string) error {
return fmt.Errorf("remove of %q failed (%w) and so did truncate: %v", old, err, err2)
}
}
if new == resolvConf {
m.relabelResolvConf()
}
return nil
}
var restoreconPath lazy.SyncValue[string] // path to restorecon, or "" if absent
// relabelResolvConf restores the policy-default SELinux context on
// /etc/resolv.conf. Best effort: only runs when SELinux is enforcing. See:
//
// https://github.com/tailscale/tailscale/issues/20149.
func (m *directManager) relabelResolvConf() {
if runtime.GOOS != "linux" {
return
}
restorecon := restoreconPath.Get(func() string {
p, _ := exec.LookPath("restorecon")
return p
})
if restorecon == "" {
return
}
if !hostinfo.IsSELinuxEnforcing() {
// Clear any prior warning, e.g. if SELinux was turned off at runtime
// with setenforce(8) since the last failure.
m.health.SetHealthy(selinuxRelabelWarnable)
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// m.fs may be rooted at a test prefix; restorecon needs the real path.
actualPath := m.fs.ActualPath(resolvConf)
if out, err := exec.CommandContext(ctx, restorecon, actualPath).CombinedOutput(); err != nil {
m.logf("dns: restorecon of %q failed: %v, output: %s", actualPath, err, bytes.TrimSpace(out))
m.health.SetUnhealthy(selinuxRelabelWarnable, nil)
} else {
m.health.SetHealthy(selinuxRelabelWarnable)
}
}
var selinuxRelabelWarnable = health.Register(&health.Warnable{
Code: "resolv-conf-relabel-failed",
Severity: health.SeverityMedium,
Title: "DNS configuration issue",
Text: health.StaticMessage("Failed to restore the SELinux label on /etc/resolv.conf; DNS may break when other services manage the file. See https://github.com/tailscale/tailscale/issues/20149"),
})
// setWant sets the expected contents of /etc/resolv.conf, if any.
//
// A value of nil means no particular value is expected.
@@ -442,7 +493,9 @@ func (m *directManager) runFileWatcher() {
if !ok {
return
}
if err := watchFile(m.ctx, "/etc/", resolvConf, m.checkForFileTrample); err != nil {
dir := m.fs.ActualPath(filepath.Dir(resolvConf))
file := m.fs.ActualPath(resolvConf)
if err := watchFile(m.ctx, dir, file, m.checkForFileTrample); err != nil {
// This is all best effort for now, so surface warnings to users.
m.logf("dns: inotify: %s", err)
}
@@ -597,6 +650,19 @@ type wholeFileFS interface {
ReadFile(name string) ([]byte, error)
Remove(name string) error
Rename(oldName, newName string) error
// ActualPath returns the real filesystem path for the given absolute
// logical path. All other methods in this interface accept logical
// paths (like "/etc/resolv.conf") and translate them internally;
// ActualPath exposes that same translation for callers that need
// the real path for use outside the interface (e.g. setting up an
// inotify watch on the correct directory).
//
// For directFS with an empty prefix (production), the input is
// returned unchanged ("/etc" → "/etc"). For directFS with a test
// prefix like "/tmp/test123", the prefix is joined
// ("/etc" → "/tmp/test123/etc"). For wslFS the input is returned
// unchanged, since paths are passed through to wsl.exe as-is.
ActualPath(name string) string
Stat(name string) (isRegular bool, err error)
Truncate(name string) error
WriteFile(name string, contents []byte, perm os.FileMode) error
@@ -613,6 +679,8 @@ type directFS struct {
func (fs directFS) path(name string) string { return filepath.Join(fs.prefix, name) }
func (fs directFS) ActualPath(name string) string { return fs.path(name) }
func (fs directFS) Stat(name string) (isRegular bool, err error) {
fi, err := os.Stat(fs.path(name))
if err != nil {
+24 -8
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by tailscale.com/cmd/cloner; DO NOT EDIT.
@@ -10,6 +10,7 @@ import (
"tailscale.com/types/dnstype"
"tailscale.com/util/dnsname"
"tailscale.com/util/set"
)
// Clone makes a deep copy of Config.
@@ -32,8 +33,19 @@ func (src *Config) Clone() *Config {
}
if dst.Routes != nil {
dst.Routes = map[dnsname.FQDN][]*dnstype.Resolver{}
for k := range src.Routes {
dst.Routes[k] = append([]*dnstype.Resolver{}, src.Routes[k]...)
for k, sv := range src.Routes {
if sv == nil {
dst.Routes[k] = nil
continue
}
dst.Routes[k] = make([]*dnstype.Resolver, len(sv))
for i := range sv {
if sv[i] == nil {
dst.Routes[k][i] = nil
} else {
dst.Routes[k][i] = sv[i].Clone()
}
}
}
}
dst.SearchDomains = append(src.SearchDomains[:0:0], src.SearchDomains...)
@@ -43,16 +55,20 @@ func (src *Config) Clone() *Config {
dst.Hosts[k] = append([]netip.Addr{}, src.Hosts[k]...)
}
}
dst.SubdomainHosts = src.SubdomainHosts.Clone()
return dst
}
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _ConfigCloneNeedsRegeneration = Config(struct {
DefaultResolvers []*dnstype.Resolver
Routes map[dnsname.FQDN][]*dnstype.Resolver
SearchDomains []dnsname.FQDN
Hosts map[dnsname.FQDN][]netip.Addr
OnlyIPv6 bool
AcceptDNS bool
DefaultResolvers []*dnstype.Resolver
Routes map[dnsname.FQDN][]*dnstype.Resolver
SearchDomains []dnsname.FQDN
Hosts map[dnsname.FQDN][]netip.Addr
SubdomainHosts set.Set[dnsname.FQDN]
OnlyIPv6 bool
MagicDNSHostsUnrouted bool
}{})
// Clone duplicates src into dst and reports whether it succeeded.
+34 -8
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by tailscale/cmd/viewer; DO NOT EDIT.
@@ -15,6 +15,7 @@ import (
"tailscale.com/types/dnstype"
"tailscale.com/types/views"
"tailscale.com/util/dnsname"
"tailscale.com/util/set"
)
//go:generate go run tailscale.com/cmd/cloner -clonefunc=true -type=Config
@@ -86,6 +87,11 @@ func (v *ConfigView) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
return nil
}
// AcceptDNS true if [Prefs.CorpDNS] is enabled (or --accept-dns=true).
// This should be used for error handling and health reporting
// purposes only.
func (v ConfigView) AcceptDNS() bool { return v.ж.AcceptDNS }
// DefaultResolvers are the DNS resolvers to use for DNS names
// which aren't covered by more specific per-domain routes below.
// If empty, the OS's default resolvers (the ones that predate
@@ -123,16 +129,36 @@ func (v ConfigView) Hosts() views.MapSlice[dnsname.FQDN, netip.Addr] {
return views.MapSliceOf(v.ж.Hosts)
}
// SubdomainHosts is a set of FQDNs from Hosts that should also
// resolve subdomain queries to the same IPs. For example, if
// "node.tailnet.ts.net" is in SubdomainHosts, then queries for
// "anything.node.tailnet.ts.net" will resolve to node's IPs.
func (v ConfigView) SubdomainHosts() views.Map[dnsname.FQDN, struct{}] {
return views.MapOf(v.ж.SubdomainHosts)
}
// OnlyIPv6, if true, uses the IPv6 service IP (for MagicDNS)
// instead of the IPv4 version (100.100.100.100).
func (v ConfigView) OnlyIPv6() bool { return v.ж.OnlyIPv6 }
func (v ConfigView) Equal(v2 ConfigView) bool { return v.ж.Equal(v2.ж) }
func (v ConfigView) OnlyIPv6() bool { return v.ж.OnlyIPv6 }
// MagicDNSHostsUnrouted is whether MagicDNS host records are
// served on demand via [resolver.MagicDNSHosts] (so are not
// listed in Hosts) without being covered by any Routes entry;
// that is, MagicDNS domain routing is off. It preserves the
// effect the node records had when they were listed in Hosts:
// hasHostsWithoutSplitDNSRoutes reports true, keeping quad-100
// in the OS resolver path so the names still resolve.
func (v ConfigView) MagicDNSHostsUnrouted() bool { return v.ж.MagicDNSHostsUnrouted }
func (v ConfigView) Equal(v2 ConfigView) bool { return v.ж.Equal(v2.ж) }
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _ConfigViewNeedsRegeneration = Config(struct {
DefaultResolvers []*dnstype.Resolver
Routes map[dnsname.FQDN][]*dnstype.Resolver
SearchDomains []dnsname.FQDN
Hosts map[dnsname.FQDN][]netip.Addr
OnlyIPv6 bool
AcceptDNS bool
DefaultResolvers []*dnstype.Resolver
Routes map[dnsname.FQDN][]*dnstype.Resolver
SearchDomains []dnsname.FQDN
Hosts map[dnsname.FQDN][]netip.Addr
SubdomainHosts set.Set[dnsname.FQDN]
OnlyIPv6 bool
MagicDNSHostsUnrouted bool
}{})
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !windows
+57 -11
View File
@@ -1,34 +1,80 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package dns
import (
"fmt"
"log"
"os/exec"
"syscall"
"time"
"golang.org/x/sys/windows"
"tailscale.com/util/coalescedop"
)
func flushCaches() error {
var (
flushDNSOp = coalescedop.New(doFlushDNS)
registerDNSOp = coalescedop.New(doRegisterDNS)
)
func doFlushDNS() {
t0 := time.Now()
cmd := exec.Command("ipconfig", "/flushdns")
cmd.SysProcAttr = &syscall.SysProcAttr{
CreationFlags: windows.DETACHED_PROCESS,
}
out, err := cmd.CombinedOutput()
err := cmd.Run()
d := time.Since(t0).Round(time.Millisecond)
if err != nil {
return fmt.Errorf("%v (output: %s)", err, out)
log.Printf("error running ipconfig /flushdns after %v: %v", d, err)
} else {
log.Printf("ran ipconfig /flushdns in %v", d)
}
}
func doRegisterDNS() {
t0 := time.Now()
cmd := exec.Command("ipconfig", "/registerdns")
cmd.SysProcAttr = &syscall.SysProcAttr{
CreationFlags: windows.DETACHED_PROCESS,
}
err := cmd.Run()
d := time.Since(t0).Round(time.Millisecond)
if err != nil {
log.Printf("error running ipconfig /registerdns after %v: %v", d, err)
} else {
log.Printf("ran ipconfig /registerdns in %v", d)
}
}
func flushCaches() error {
flushDNSOp.Do()
return nil
}
// Flush clears the local resolver cache.
//
// Only Windows has a public dns.Flush, needed in router_windows.go. Other
// platforms like Linux need a different flush implementation depending on
// the DNS manager. There is a FlushCaches method on the manager which
// can be used on all platforms.
func Flush() error {
return flushCaches()
// Only Windows has a public dns.Flush, needed in tailscaled_windows.go.
// Other platforms like Linux need a different flush implementation
// depending on the DNS manager. There is a FlushCaches method on the manager
// which can be used on all platforms.
//
// Flush is non-blocking; it coalesces concurrent and rapid successive
// calls so that at most one ipconfig /flushdns is in flight at a time,
// with at most one additional execution queued.
func Flush() {
flushDNSOp.Do()
}
// registerDNS forces DNS re-registration in Active Directory. This
// invokes an undocumented hidden function that forces Windows to
// notice that adapter settings have changed, which makes the DNS
// settings actually take effect.
//
// registerDNS is non-blocking; it coalesces concurrent and rapid
// successive calls so that at most one ipconfig /registerdns is in
// flight at a time, with at most one additional execution queued.
func registerDNS() {
registerDNSOp.Do()
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build windows
+275 -62
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package dns
@@ -20,6 +20,7 @@ import (
"time"
"tailscale.com/control/controlknobs"
"tailscale.com/envknob"
"tailscale.com/feature/buildfeatures"
"tailscale.com/health"
"tailscale.com/net/dns/resolver"
@@ -33,6 +34,7 @@ import (
"tailscale.com/util/eventbus"
"tailscale.com/util/slicesx"
"tailscale.com/util/syspolicy/policyclient"
"tailscale.com/version"
)
var (
@@ -40,12 +42,28 @@ var (
// ErrNoDNSConfig is returned by RecompileDNSConfig when the Manager
// has no existing DNS configuration.
ErrNoDNSConfig = errors.New("no DNS configuration")
// errEmptyBaseConfig is returned by compileConfig when the OS has no
// upstream resolvers to forward the default route to. See
// https://github.com/tailscale/tailscale/issues/20341
errEmptyBaseConfig = errors.New("no upstream resolvers in OS base config")
)
// Bounds for [Manager.retryEmptyBaseConfig]. The delay doubles each attempt,
// so these give up after roughly a minute.
const (
baseConfigRetryInterval = 1 * time.Second
baseConfigRetryAttempts = 6
)
// maxActiveQueries returns the maximal number of DNS requests that can
// be running.
const maxActiveQueries = 256
// ResponseMapper is a function that accepts the bytes representing
// a DNS response and returns bytes representing a DNS response.
// Used to observe and/or mutate DNS responses managed by this manager.
type ResponseMapper func([]byte) []byte
// We use file-ignore below instead of ignore because on some platforms,
// the lint exception is necessary and on others it is not,
// and plain ignore complains if the exception is unnecessary.
@@ -67,8 +85,10 @@ type Manager struct {
knobs *controlknobs.Knobs // or nil
goos string // if empty, gets set to runtime.GOOS
mu sync.Mutex // guards following
config *Config // Tracks the last viable DNS configuration set by Set. nil on failures other than compilation failures or if set has never been called.
mu sync.Mutex // guards following
config *Config // Tracks the last viable DNS configuration set by Set. nil on failures other than compilation failures or if set has never been called.
queryResponseMapper ResponseMapper
waitingForBaseCfg bool // a retry goroutine is waiting for OS upstream resolvers
}
// NewManager created a new manager from the given config.
@@ -125,6 +145,16 @@ func (m *Manager) Resolver() *resolver.Resolver {
return m.resolver
}
// ProbeLocks acquires and releases the manager's internal mutexes.
func (m *Manager) ProbeLocks() {
m.mu.Lock()
m.mu.Unlock()
if r := m.Resolver(); r != nil {
r.ProbeLocks()
}
}
// RecompileDNSConfig recompiles the last attempted DNS configuration, which has
// the side effect of re-querying the OS's interface nameservers. This should be used
// on platforms where the interface nameservers can change. Darwin, for example,
@@ -199,6 +229,7 @@ func (m *Manager) setLocked(cfg Config) error {
}
m.health.SetHealthy(osConfigurationSetWarnable)
m.health.SetHealthy(EmptyBaseConfigWarnable)
m.config = &cfg
return nil
@@ -264,7 +295,10 @@ func compileHostEntries(cfg Config) (hosts []*HostEntry) {
return hosts
}
var osConfigurationReadWarnable = health.Register(&health.Warnable{
// OSConfigurationReadWarnable is a Warnable set when Tailscale cannot read the
// DNS configuration the OS was using before Tailscale took over. It is
// exported so that a test can name it rather than repeat its wording.
var OSConfigurationReadWarnable = health.Register(&health.Warnable{
Code: "dns-read-os-config-failed",
Title: "Failed to read system DNS configuration",
Text: func(args health.Args) string {
@@ -274,6 +308,17 @@ var osConfigurationReadWarnable = health.Register(&health.Warnable{
DependsOn: []*health.Warnable{health.NetworkStatusWarnable},
})
// EmptyBaseConfigWarnable warns that MagicDNS is inactive because the OS has no
// upstream resolvers to forward the default route to. It is exported so that a
// test can name it rather than repeat its wording.
var EmptyBaseConfigWarnable = health.Register(&health.Warnable{
Code: "dns-empty-base-config",
Title: "Waiting for system DNS configuration",
Text: health.StaticMessage("Your device has no system DNS servers for Tailscale to forward queries to, so Tailscale has left DNS alone. MagicDNS stays inactive until the system has DNS servers of its own."),
Severity: health.SeverityMedium,
DependsOn: []*health.Warnable{health.NetworkStatusWarnable},
})
var osConfigurationSetWarnable = health.Register(&health.Warnable{
Code: "dns-set-os-config-failed",
Title: "Failed to set system DNS configuration",
@@ -291,6 +336,8 @@ func (m *Manager) compileConfig(cfg Config) (rcfg resolver.Config, ocfg OSConfig
// authoritative suffixes, even if we don't propagate MagicDNS to
// the OS.
rcfg.Hosts = cfg.Hosts
rcfg.SubdomainHosts = cfg.SubdomainHosts
rcfg.AcceptDNS = cfg.AcceptDNS
routes := map[dnsname.FQDN][]*dnstype.Resolver{} // assigned conditionally to rcfg.Routes below.
var propagateHostsToOS bool
for suffix, resolvers := range cfg.Routes {
@@ -301,8 +348,21 @@ func (m *Manager) compileConfig(cfg Config) (rcfg resolver.Config, ocfg OSConfig
routes[suffix] = resolvers
}
}
// LocalDomains is an unordered suffix set, but it comes out of map
// iteration; sort it so equal configs compare and log equal.
slices.Sort(rcfg.LocalDomains)
// Similarly, the OS always gets search paths.
isWindows := m.goos == "windows"
isIOS := m.goos == "ios"
isSandboxedMac := m.goos == "darwin" && isSandboxedMacOS()
supportsSplitDNS := m.os.SupportsSplitDNS()
isSandboxedApple := isIOS || isSandboxedMac
// Preserve configured search domains in control's order (tailnet first).
// Do not add split-DNS suffixes: restricted resolvers are match-only.
// LAN-provided search domains are appended below, at lowest priority.
// The Apple extension only installs this list in primary resolver mode;
// scoped mode uses the match domains as its global search list.
ocfg.SearchDomains = cfg.SearchDomains
if propagateHostsToOS && m.goos == "windows" {
ocfg.Hosts = compileHostEntries(cfg)
@@ -355,16 +415,17 @@ func (m *Manager) compileConfig(cfg Config) (rcfg resolver.Config, ocfg OSConfig
// quad-100 will still have the full split configuration as well,
// and so can service WSL requests correctly.
//
// This bool is used in a couple of places below to implement this
// workaround.
isWindows := m.goos == "windows"
isApple := (m.goos == "darwin" || m.goos == "ios")
if len(cfg.singleResolverSet()) > 0 && m.os.SupportsSplitDNS() && !isWindows && !isApple {
// Split DNS configuration requested, where all split domains
// go to the same resolvers. We can let the OS do it.
ocfg.Nameservers = toIPsOnly(cfg.singleResolverSet())
ocfg.MatchDomains = cfg.matchDomains()
return rcfg, ocfg, nil
// Apple platforms keep split-domain traffic pointed at quad-100 rather than
// handing the upstream resolvers to the OS directly, because those resolvers
// may only be reachable through the tunnel.
if supportsSplitDNS && !isWindows && !isSandboxedApple {
if srs := toIPsOnly(cfg.singleResolverSet()); len(srs) > 0 {
// Split DNS configuration requested, where all split domains
// go to the same resolvers. We can let the OS do it.
ocfg.Nameservers = srs
ocfg.MatchDomains = cfg.matchDomains()
return rcfg, ocfg, nil
}
}
// Split DNS configuration with either multiple upstream routes,
@@ -373,66 +434,203 @@ func (m *Manager) compileConfig(cfg Config) (rcfg resolver.Config, ocfg OSConfig
rcfg.Routes = routes
ocfg.Nameservers = cfg.serviceIPs(m.knobs)
var baseCfg *OSConfig // base config; non-nil if/when known
// Apple tunnels can contribute global search domains only through
// matchDomains with matchDomainsNoSearch=false; NEDNSSettings.searchDomains
// is stripped by configd. Scope only simple configs (Mode A), otherwise
// install quad-100 as primary (Mode B) and keep split suffixes internal.
// This prevents custom split suffixes from becoming search domains while
// keeping bare tailnet names reachable. See tailscale/corp#48693.
//
// Both forward records and the PTR records synthesized from Hosts must
// be covered. Scoping is opt-out on iOS and opt-in on sandboxed macOS.
appleScopeEnabled := (isIOS && !m.disableSplitDNSOptimization()) ||
(isSandboxedMac && m.scopeQuad100OnMacOS())
scopeApple := appleScopeEnabled && rcfg.RoutesRequireNoCustomResolvers() &&
!cfg.requiresPrimaryResolver() && !cfg.hasHostsWithoutReverseRoutes()
// Even though Apple devices can do split DNS, they don't provide a way to
// selectively answer ExtraRecords, and ignore other DNS traffic. As a
// workaround, we read the existing default resolver configuration and use
// that as the forwarder for all DNS traffic that quad-100 doesn't handle.
if isApple || !m.os.SupportsSplitDNS() {
// If the OS can't do native split-dns, read out the underlying
// resolver config and blend it into our config. On apple platforms, [OSConfigurator.GetBaseConfig]
// has a tendency to temporarily fail if called immediately following
// an interface change. These failures should be retried if/when the OS
// indicates that the DNS configuration has changed via [RecompileDNSConfig].
cfg, err := m.os.GetBaseConfig()
if err == nil {
baseCfg = &cfg
} else if isApple && err == ErrGetBaseConfigNotSupported {
// This is currently (2022-10-13) expected on certain iOS and macOS
// builds.
} else {
m.health.SetUnhealthy(osConfigurationReadWarnable, health.Args{health.ArgError: err.Error()})
return resolver.Config{}, OSConfig{}, err
}
m.health.SetHealthy(osConfigurationReadWarnable)
// iOS still reads the base config below, even when scoped, so direct
// queries to quad-100 can be forwarded to the underlying resolver.
if supportsSplitDNS && !isIOS && (!isSandboxedApple || scopeApple) && !cfg.requiresPrimaryResolver() {
ocfg.MatchDomains = cfg.matchDomains()
return rcfg, ocfg, nil
}
if baseCfg == nil {
// If there was no base config, then we need to fallback to SplitDNS mode.
ocfg.MatchDomains = cfg.matchDomains()
} else {
// On iOS only (for now), check if all route names point to resources inside the tailnet.
// If so, we can set those names as MatchDomains to enable a split DNS configuration
// which will help preserve battery life.
// Because on iOS MatchDomains must equal SearchDomains, we cannot do this when
// we have any Routes outside the tailnet. Otherwise when app connectors are enabled,
// a query for 'work-laptop' might lead to search domain expansion, resolving
// as 'work-laptop.aws.com' for example.
if m.goos == "ios" && rcfg.RoutesRequireNoCustomResolvers() {
if !m.disableSplitDNSOptimization() {
for r := range rcfg.Routes {
ocfg.MatchDomains = append(ocfg.MatchDomains, r)
}
} else {
m.logf("iOS split DNS is disabled by nodeattr")
// When quad-100 is primary (or the OS cannot do split DNS), use the
// underlying resolver for queries quad-100 cannot answer locally. On iOS,
// [OSConfigurator.GetBaseConfig] can temporarily fail immediately after an
// interface change. These failures should be retried if/when the OS indicates
// that the DNS configuration has changed via [RecompileDNSConfig].
base, err := m.os.GetBaseConfig()
if err != nil {
if errors.Is(err, ErrGetBaseConfigNoResolvers) {
m.health.SetHealthy(OSConfigurationReadWarnable)
m.health.SetUnhealthy(EmptyBaseConfigWarnable, nil)
if !isSandboxedApple {
m.retryEmptyBaseConfig()
}
return resolver.Config{}, OSConfig{}, err
}
var defaultRoutes []*dnstype.Resolver
for _, ip := range baseCfg.Nameservers {
defaultRoutes = append(defaultRoutes, &dnstype.Resolver{Addr: ip.String()})
canScopeWithoutBase := !isSandboxedApple && (isNoopManager(m.os) || supportsSplitDNS) ||
isIOS && supportsSplitDNS && scopeApple
if canScopeWithoutBase && err == ErrGetBaseConfigNotSupported {
// Some managers have no base config by construction. Apple Mode B
// cannot fall back to scoping: it would lose unrouted records or
// expose split suffixes as search domains. Nor can it install a
// catch-all without forwarding upstreams; report the read error.
// Sandboxed macOS has /etc/resolv.conf, so an unsupported read
// remains an error there, even for an otherwise simple config.
m.health.SetHealthy(OSConfigurationReadWarnable)
ocfg.MatchDomains = cfg.matchDomains()
return rcfg, ocfg, nil
}
m.health.SetUnhealthy(OSConfigurationReadWarnable, health.Args{health.ArgError: err.Error()})
return resolver.Config{}, OSConfig{}, err
}
m.health.SetHealthy(OSConfigurationReadWarnable)
defaultRoutes := underlyingResolvers(base)
if len(defaultRoutes) == 0 && (!isSandboxedApple || !scopeApple) {
// Taking over here would point the OS at quad-100 with an empty "."
// route, failing every non-Tailscale name. Leave the OS config alone
// and retry until resolvers appear. Check the recovered resolvers,
// which may carry endpoints not representable in base.Nameservers.
// Apple extensions trigger recompilation on DNS changes instead of
// using the retry goroutine. Apple scoped mode does not need upstreams
// for queries left to the OS, so it tolerates a successful empty read.
m.logf("no upstream resolvers in OS base config; not taking over DNS")
m.health.SetUnhealthy(EmptyBaseConfigWarnable, nil)
if !isSandboxedApple {
m.retryEmptyBaseConfig()
}
return resolver.Config{}, OSConfig{}, errEmptyBaseConfig
} else if len(defaultRoutes) == 0 {
m.logf("dns: base config has no resolvers; quad-100 has no upstream for non-tailnet queries")
}
if isIOS && supportsSplitDNS && scopeApple {
// Include the authoritative MagicDNS roots, not just upstream routes.
// Do not union search-only domains: that would capture their queries.
ocfg.MatchDomains = cfg.matchDomains()
}
rcfg.Routes["."] = defaultRoutes
// Append base config search domains, but only if not already present.
// This prevents duplicates when GetBaseConfig() reads back domains that
// Tailscale itself previously wrote to resolv.conf.
for _, domain := range base.SearchDomains {
if !slices.Contains(ocfg.SearchDomains, domain) {
ocfg.SearchDomains = append(ocfg.SearchDomains, domain)
}
rcfg.Routes["."] = defaultRoutes
ocfg.SearchDomains = append(ocfg.SearchDomains, baseCfg.SearchDomains...)
}
return rcfg, ocfg, nil
}
// underlyingResolvers returns the resolvers that quad-100 should forward
// non-tailnet queries to, derived from the OS's base configuration.
//
// Platforms that can recover the underlying configuration with more detail
// than IP addresses (non-standard ports, DoH/DoT endpoints) populate
// [OSConfig.Resolvers], which preserves that detail end to end. Otherwise we
// fall back to plain IP:53 resolvers built from Nameservers. Note that the
// result can be empty: callers that cannot serve queries without a catch-all
// forwarder must check and handle that case.
func underlyingResolvers(base OSConfig) []*dnstype.Resolver {
if len(base.Resolvers) > 0 {
out := make([]*dnstype.Resolver, 0, len(base.Resolvers))
for _, r := range base.Resolvers {
if r == nil || r.Addr == "" {
continue
}
out = append(out, r.Clone())
}
if len(out) > 0 {
return out
}
}
var out []*dnstype.Resolver
for _, ip := range base.Nameservers {
out = append(out, &dnstype.Resolver{Addr: ip.String()})
}
return out
}
func (m *Manager) disableSplitDNSOptimization() bool {
return m.knobs != nil && m.knobs.DisableSplitDNSWhenNoCustomResolvers.Load()
}
var scopeQuad100OnMacOSEnv = envknob.RegisterOptBool("TS_DEBUG_SCOPE_QUAD100_MACOS")
// scopeQuad100OnMacOS reports whether sandboxed macOS should scope quad-100 to
// its match domains for eligible simple configs, rather than installing it
// as the OS's primary resolver. Off (false) unless control sets
// NodeAttrScopeQuad100OnMacOS, or TS_DEBUG_SCOPE_QUAD100_MACOS is set.
// See tailscale/corp#45534.
func (m *Manager) scopeQuad100OnMacOS() bool {
if v, ok := scopeQuad100OnMacOSEnv().Get(); ok {
return v
}
return m.knobs != nil && m.knobs.ScopeQuad100OnMacOS.Load()
}
// retryEmptyBaseConfig starts a goroutine that reapplies the last config until
// the OS has upstream resolvers, giving up after baseConfigRetryAttempts. A
// later [Manager.Set] or link change starts it again.
//
// Only one runs at a time: each attempt re-enters compileConfig, which calls
// back here while the base config is still empty.
//
// m.mu must be held.
func (m *Manager) retryEmptyBaseConfig() {
syncs.AssertLocked(&m.mu)
if m.waitingForBaseCfg {
return
}
m.waitingForBaseCfg = true
go func() {
defer func() {
m.mu.Lock()
defer m.mu.Unlock()
m.waitingForBaseCfg = false
}()
d := baseConfigRetryInterval
for range baseConfigRetryAttempts {
select {
case <-m.ctx.Done():
return
case <-time.After(d):
}
d *= 2
switch err := m.reapplyConfig(); {
case err == nil:
m.logf("OS upstream resolvers appeared; DNS configured")
return
case errors.Is(err, errEmptyBaseConfig):
// Keep waiting.
case errors.Is(err, net.ErrClosed):
return
default:
// Could be transient, e.g. a failed OS config read, so keep
// waiting rather than giving up early.
m.logf("error reapplying DNS config: %v", err)
}
}
m.logf("gave up waiting for OS upstream resolvers")
}()
}
// reapplyConfig reapplies the last config, returning [net.ErrClosed] if
// [Manager.Down] has run or there is no config to apply.
func (m *Manager) reapplyConfig() error {
m.mu.Lock()
defer m.mu.Unlock()
if m.ctx.Err() != nil || m.config == nil {
return net.ErrClosed
}
return m.setLocked(*m.config)
}
var isSandboxedMacOS = version.IsSandboxedMacOS
// toIPsOnly returns only the IP portion of dnstype.Resolver.
// Only safe to use if the resolvers slice has been cleared of
// DoH or custom-port entries with something like hasDefaultIPResolversOnly.
@@ -465,7 +663,16 @@ func (m *Manager) Query(ctx context.Context, bs []byte, family string, from neti
return nil, errFullQueue
}
defer atomic.AddInt32(&m.activeQueriesAtomic, -1)
return m.resolver.Query(ctx, bs, family, from)
outbs, err := m.resolver.Query(ctx, bs, family, from)
if err != nil {
return outbs, err
}
m.mu.Lock()
defer m.mu.Unlock()
if m.queryResponseMapper != nil {
outbs = m.queryResponseMapper(outbs)
}
return outbs, err
}
const (
@@ -480,7 +687,7 @@ const (
maxReqSizeTCP = 4096
)
// TrampleDNS is an an event indicating we detected that DNS config was
// TrampleDNS is an event indicating we detected that DNS config was
// overwritten by another process.
type TrampleDNS struct {
LastTrample time.Time
@@ -651,3 +858,9 @@ func CleanUp(logf logger.Logf, netMon *netmon.Monitor, bus *eventbus.Bus, health
}
var metricDNSQueryErrorQueue = clientmetric.NewCounter("dns_query_local_error_queue")
func (m *Manager) SetQueryResponseMapper(fx ResponseMapper) {
m.mu.Lock()
defer m.mu.Unlock()
m.queryResponseMapper = fx
}
+156 -17
View File
@@ -1,11 +1,16 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package dns
import (
"bytes"
"fmt"
"io/fs"
"os"
"os/exec"
"strconv"
"strings"
"go4.org/mem"
"tailscale.com/control/controlknobs"
@@ -22,20 +27,31 @@ import (
//
// The health tracker, bus and the knobs may be nil and are ignored on this platform.
func NewOSConfigurator(logf logger.Logf, _ *health.Tracker, _ *eventbus.Bus, _ policyclient.Client, _ *controlknobs.Knobs, ifName string) (OSConfigurator, error) {
return &darwinConfigurator{logf: logf, ifName: ifName}, nil
return &darwinConfigurator{
logf: logf,
ifName: ifName,
resolverDir: "/etc/resolver",
resolvConfPath: "/etc/resolv.conf",
runScutil: runScutil,
}, nil
}
// darwinConfigurator is the tailscaled-on-macOS DNS OS configurator that
// maintains the Split DNS nameserver entries pointing MagicDNS DNS suffixes
// to 100.100.100.100 using the macOS /etc/resolver/$SUFFIX files.
type darwinConfigurator struct {
logf logger.Logf
ifName string
logf logger.Logf
ifName string
resolverDir string // default "/etc/resolver"
resolvConfPath string // default "/etc/resolv.conf"
runScutil func(script string) (output string, err error)
}
func (c *darwinConfigurator) Close() error {
c.removeResolverFiles(func(domain string) bool { return true })
return nil
if err := c.removeGlobalDNS(); err != nil {
return err
}
return c.removeResolverFiles(func(domain string) bool { return true })
}
func (c *darwinConfigurator) SupportsSplitDNS() bool {
@@ -43,6 +59,16 @@ func (c *darwinConfigurator) SupportsSplitDNS() bool {
}
func (c *darwinConfigurator) SetDNS(cfg OSConfig) error {
if len(cfg.Nameservers) > 0 && len(cfg.MatchDomains) == 0 {
if err := c.setGlobalDNS(cfg); err != nil {
return err
}
return c.removeResolverFiles(func(domain string) bool { return true })
}
if err := c.removeGlobalDNS(); err != nil {
return err
}
var buf bytes.Buffer
buf.WriteString(macResolverFileHeader)
for _, ip := range cfg.Nameservers {
@@ -51,10 +77,16 @@ func (c *darwinConfigurator) SetDNS(cfg OSConfig) error {
buf.WriteString("\n")
}
if err := os.MkdirAll("/etc/resolver", 0755); err != nil {
if err := os.MkdirAll(c.resolverDir, 0755); err != nil {
return err
}
root, err := os.OpenRoot(c.resolverDir)
if err != nil {
return err
}
defer root.Close()
var keep map[string]bool
// Add a dummy file to /etc/resolver with a "search ..." directive if we have
@@ -70,7 +102,7 @@ func (c *darwinConfigurator) SetDNS(cfg OSConfig) error {
sbuf.WriteString(string(d.WithoutTrailingDot()))
}
sbuf.WriteString("\n")
if err := os.WriteFile("/etc/resolver/"+searchFile, sbuf.Bytes(), 0644); err != nil {
if err := root.WriteFile(searchFile, sbuf.Bytes(), 0644); err != nil {
return err
}
}
@@ -78,15 +110,117 @@ func (c *darwinConfigurator) SetDNS(cfg OSConfig) error {
for _, d := range cfg.MatchDomains {
fileBase := string(d.WithoutTrailingDot())
mak.Set(&keep, fileBase, true)
fullPath := "/etc/resolver/" + fileBase
if err := os.WriteFile(fullPath, buf.Bytes(), 0644); err != nil {
if !isValidResolverFileName(fileBase) {
c.logf("[unexpected] invalid resolver domain %q with slashes or colons", fileBase)
return fmt.Errorf("invalid resolver domain %q: must not contain slashes or colons", fileBase)
}
if err := root.WriteFile(fileBase, buf.Bytes(), 0644); err != nil {
return err
}
}
return c.removeResolverFiles(func(domain string) bool { return !keep[domain] })
}
// macOSGlobalDNSKey is a stable synthetic SystemConfiguration service key for
// tailscaled's global DNS resolver. The UUID does not identify a real network
// service; it just gives tailscaled one well-known dynamic-store location to
// set and later remove.
const macOSGlobalDNSKey = "State:/Network/Service/FF457792-79C0-4A25-8392-D875BBEACCA6/DNS"
// setGlobalDNS installs cfg.Nameservers as a default resolver using
// SystemConfiguration's dynamic store. /etc/resolver is only a split-DNS
// mechanism; it cannot express a primary resolver.
func (c *darwinConfigurator) setGlobalDNS(cfg OSConfig) error {
var script strings.Builder
script.WriteString("d.init\n")
script.WriteString("d.add SearchOrder # 100000\n")
script.WriteString("d.add ServerAddresses *")
for _, ip := range cfg.Nameservers {
script.WriteByte(' ')
script.WriteString(ip.String())
}
script.WriteByte('\n')
// An empty SupplementalMatchDomains entry makes this a default resolver.
script.WriteString("d.add SupplementalMatchDomains * \"\"\n")
if len(cfg.SearchDomains) > 0 {
script.WriteString("d.add SearchDomains *")
for _, fqdn := range cfg.SearchDomains {
script.WriteByte(' ')
writeScutilString(&script, fqdn.WithoutTrailingDot())
}
script.WriteByte('\n')
}
script.WriteString("set ")
script.WriteString(macOSGlobalDNSKey)
script.WriteString("\nquit\n")
out, err := c.runScutilScript(script.String())
if err != nil {
return err
}
out = strings.TrimSpace(out)
if out != "" {
return fmt.Errorf("scutil set %s: %s", macOSGlobalDNSKey, out)
}
return nil
}
func (c *darwinConfigurator) removeGlobalDNS() error {
script := "remove " + macOSGlobalDNSKey + "\nquit\n"
out, err := c.runScutilScript(script)
if err != nil {
return err
}
out = strings.TrimSpace(out)
if out == "" || out == "No such key" {
return nil
}
return fmt.Errorf("scutil remove %s: %s", macOSGlobalDNSKey, out)
}
func (c *darwinConfigurator) runScutilScript(script string) (string, error) {
run := c.runScutil
if run == nil {
run = runScutil
}
return run(script)
}
func runScutil(script string) (string, error) {
cmd := exec.Command("/usr/sbin/scutil")
cmd.Stdin = strings.NewReader(script)
out, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("scutil: %w: %s", err, strings.TrimSpace(string(out)))
}
return string(out), nil
}
func writeScutilString(b *strings.Builder, v string) {
if v == "" || strings.ContainsAny(v, " \t\n\"") {
b.WriteString(strconv.Quote(v))
return
}
b.WriteString(v)
}
func isValidResolverFileName(name string) bool {
// Verify that the filename doesn't contain any characters that
// might cause issues when used as a filename; os.Root is a
// defense against path traversal, but prefer a nice error here
// if we can. These aren't valid for domain names anyway.
if strings.Contains(name, "/") || strings.Contains(name, "\\") {
return false
}
if strings.Contains(name, ":") {
return false
}
return true
}
// GetBaseConfig returns the current OS DNS configuration, extracting it from /etc/resolv.conf.
// We should really be using the SystemConfiguration framework to get this information, as this
// is not a stable public API, and is provided mostly as a compatibility effort with Unix
@@ -95,9 +229,9 @@ func (c *darwinConfigurator) SetDNS(cfg OSConfig) error {
func (c *darwinConfigurator) GetBaseConfig() (OSConfig, error) {
cfg := OSConfig{}
resolvConf, err := resolvconffile.ParseFile("/etc/resolv.conf")
resolvConf, err := resolvconffile.ParseFile(c.resolvConfPath)
if err != nil {
c.logf("failed to parse /etc/resolv.conf: %v", err)
c.logf("failed to parse %s: %v", c.resolvConfPath, err)
return cfg, ErrGetBaseConfigNotSupported
}
@@ -113,7 +247,7 @@ func (c *darwinConfigurator) GetBaseConfig() (OSConfig, error) {
if len(cfg.Nameservers) == 0 {
// Log a warning in case we couldn't find any nameservers in /etc/resolv.conf.
c.logf("no nameservers found in /etc/resolv.conf, DNS resolution might fail")
c.logf("no nameservers found in %s, DNS resolution might fail", c.resolvConfPath)
}
return cfg, nil
@@ -124,13 +258,19 @@ const macResolverFileHeader = "# Added by tailscaled\n"
// removeResolverFiles deletes all files in /etc/resolver for which the shouldDelete
// func returns true.
func (c *darwinConfigurator) removeResolverFiles(shouldDelete func(domain string) bool) error {
dents, err := os.ReadDir("/etc/resolver")
root, err := os.OpenRoot(c.resolverDir)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
defer root.Close()
dents, err := fs.ReadDir(root.FS(), ".")
if err != nil {
return err
}
for _, de := range dents {
if !de.Type().IsRegular() {
continue
@@ -139,8 +279,7 @@ func (c *darwinConfigurator) removeResolverFiles(shouldDelete func(domain string
if !shouldDelete(name) {
continue
}
fullPath := "/etc/resolver/" + name
contents, err := os.ReadFile(fullPath)
contents, err := root.ReadFile(name)
if err != nil {
if os.IsNotExist(err) { // race?
continue
@@ -150,7 +289,7 @@ func (c *darwinConfigurator) removeResolverFiles(shouldDelete func(domain string
if !mem.HasPrefix(mem.B(contents), mem.S(macResolverFileHeader)) {
continue
}
if err := os.Remove(fullPath); err != nil {
if err := root.Remove(name); err != nil {
return err
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build (!linux || android) && !freebsd && !openbsd && !windows && !darwin && !illumos && !solaris && !plan9
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package dns
+2 -2
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux && !android
@@ -380,7 +380,7 @@ func isLibnssResolveUsed(env newOSConfigEnv) error {
if err != nil {
return fmt.Errorf("reading /etc/resolv.conf: %w", err)
}
for _, line := range strings.Split(string(bs), "\n") {
for line := range strings.SplitSeq(string(bs), "\n") {
fields := strings.Fields(line)
if len(fields) < 2 || fields[0] != "hosts:" {
continue
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package dns
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// TODO: man 6 ndb | grep -e 'suffix.*same line'
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package dns
+137 -100
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package dns
@@ -8,15 +8,14 @@ import (
"bytes"
"errors"
"fmt"
"io/fs"
"maps"
"net/netip"
"os"
"os/exec"
"path/filepath"
"slices"
"sort"
"strings"
"syscall"
"time"
"golang.org/x/sys/windows"
@@ -34,6 +33,7 @@ import (
"tailscale.com/util/syspolicy/policyclient"
"tailscale.com/util/syspolicy/ptype"
"tailscale.com/util/winutil"
"tailscale.com/util/winutil/winenv"
)
const (
@@ -49,6 +49,9 @@ type windowsManager struct {
nrptDB *nrptRuleDatabase
wslManager *wslManager
polc policyclient.Client
doV4 bool
doV6 bool
doNetBIOS bool
unregisterPolicyChangeCb func() // called when the manager is closing
@@ -71,12 +74,16 @@ func NewOSConfigurator(logf logger.Logf, health *health.Tracker, bus *eventbus.B
wslManager: newWSLManager(logf, health),
}
if err := ret.checkInterfaces(); err != nil {
return nil, err
}
if isWindows10OrBetter() {
ret.nrptDB = newNRPTRuleDatabase(logf)
}
var err error
if ret.unregisterPolicyChangeCb, err = polc.RegisterChangeCallback(ret.sysPolicyChanged); err != nil {
if ret.unregisterPolicyChangeCb, err = polc.RegisterChangeCallback("", ret.sysPolicyChanged); err != nil {
logf("error registering policy change callback: %v", err) // non-fatal
}
@@ -92,22 +99,42 @@ func NewOSConfigurator(logf logger.Logf, health *health.Tracker, bus *eventbus.B
return ret, nil
}
func (m *windowsManager) openInterfaceKey(pfx winutil.RegistryPathPrefix) (registry.Key, error) {
var key registry.Key
var err error
path := pfx.WithSuffix(m.guid)
m.mu.Lock()
closing := m.closing
m.mu.Unlock()
if closing {
// Do not wait for the interface key to appear if the manager is being closed.
// If it's being closed due to the removal of the wintun adapter,
// the key would already be gone by now and will not reappear until tailscaled is restarted.
key, err = registry.OpenKey(registry.LOCAL_MACHINE, string(path), registry.SET_VALUE)
} else {
key, err = winutil.OpenKeyWait(registry.LOCAL_MACHINE, path, registry.SET_VALUE)
func (m *windowsManager) checkInterfaces() error {
guid, err := windows.GUIDFromString(m.guid)
if err != nil {
return err
}
luid, err := winipcfg.LUIDFromGUID(&guid)
if err != nil {
return err
}
_, err = luid.IPInterface(windows.AF_INET)
if err != nil && !errors.Is(err, windows.ERROR_NOT_FOUND) {
return fmt.Errorf("getting AF_INET interface: %w", err)
}
m.doV4 = err == nil
_, err = luid.IPInterface(windows.AF_INET6)
if err != nil && !errors.Is(err, windows.ERROR_NOT_FOUND) {
return fmt.Errorf("getting AF_INET6 interface: %w", err)
}
m.doV6 = err == nil
if !m.doV4 && !m.doV6 {
return errors.New("no IP interfaces available for dns configuration")
}
// NetBIOS is non-fatal
_, err = luid.IPInterface(windows.AF_NETBIOS)
m.doNetBIOS = err == nil
return nil
}
func (m *windowsManager) openInterfaceKey(pfx winutil.RegistryPathPrefix) (registry.Key, error) {
path := pfx.WithSuffix(m.guid)
key, err := registry.OpenKey(registry.LOCAL_MACHINE, string(path), registry.SET_VALUE)
if err != nil {
return 0, fmt.Errorf("opening %s: %w", path, err)
}
@@ -145,7 +172,7 @@ func (m *windowsManager) setSplitDNS(resolvers []netip.Addr, domains []dnsname.F
return fmt.Errorf("Split DNS unsupported on this Windows version")
}
defer m.nrptDB.Refresh()
defer m.nrptDB.NotifyPolicyChanged()
if len(resolvers) == 0 {
return m.nrptDB.DelAllRuleKeys()
}
@@ -245,7 +272,13 @@ func (m *windowsManager) setHosts(hosts []*HostEntry) error {
}
hostsFile := filepath.Join(systemDir, "drivers", "etc", "hosts")
b, err := os.ReadFile(hostsFile)
if err != nil {
switch {
case err == nil:
// Continue.
case errors.Is(err, fs.ErrNotExist):
// Non-fatal, we'll just create a new hosts file.
m.logf("failed to read the hosts file: %v", err)
default:
return err
}
outB, err := setTailscaleHosts(m.logf, b, hosts)
@@ -293,58 +326,66 @@ func (m *windowsManager) setPrimaryDNS(resolvers []netip.Addr, domains []dnsname
domStrs = append(domStrs, dom.WithoutTrailingDot())
}
key4, err := m.openInterfaceKey(winutil.IPv4TCPIPInterfacePrefix)
if err != nil {
return m.muteKeyNotFoundIfClosing(err)
}
defer key4.Close()
if m.doV4 {
key4, err := m.openInterfaceKey(winutil.IPv4TCPIPInterfacePrefix)
if err != nil {
return m.muteKeyNotFoundIfClosing(err)
}
defer key4.Close()
if len(ipsv4) == 0 {
if err := delValue(key4, "NameServer"); err != nil {
if len(ipsv4) == 0 {
if err := delValue(key4, "NameServer"); err != nil {
return err
}
} else if err := key4.SetStringValue("NameServer", strings.Join(ipsv4, ",")); err != nil {
return err
}
} else if err := key4.SetStringValue("NameServer", strings.Join(ipsv4, ",")); err != nil {
return err
}
if len(domains) == 0 {
if err := delValue(key4, "SearchList"); err != nil {
if len(domains) == 0 {
if err := delValue(key4, "SearchList"); err != nil {
return err
}
} else if err := key4.SetStringValue("SearchList", strings.Join(domStrs, ",")); err != nil {
return err
}
} else if err := key4.SetStringValue("SearchList", strings.Join(domStrs, ",")); err != nil {
return err
}
key6, err := m.openInterfaceKey(winutil.IPv6TCPIPInterfacePrefix)
if err != nil {
return m.muteKeyNotFoundIfClosing(err)
}
defer key6.Close()
if len(ipsv6) == 0 {
if err := delValue(key6, "NameServer"); err != nil {
// Disable LLMNR on the Tailscale interface. We don't do multicast, and we
// certainly don't do LLMNR, so it's pointless to make Windows try it. It is
// being deprecated.
if err := key4.SetDWordValue("EnableMulticast", 0); err != nil {
return err
}
} else if err := key6.SetStringValue("NameServer", strings.Join(ipsv6, ",")); err != nil {
return err
}
if len(domains) == 0 {
if err := delValue(key6, "SearchList"); err != nil {
if m.doV6 {
key6, err := m.openInterfaceKey(winutil.IPv6TCPIPInterfacePrefix)
if err != nil {
return m.muteKeyNotFoundIfClosing(err)
}
defer key6.Close()
if len(ipsv6) == 0 {
if err := delValue(key6, "NameServer"); err != nil {
return err
}
} else if err := key6.SetStringValue("NameServer", strings.Join(ipsv6, ",")); err != nil {
return err
}
} else if err := key6.SetStringValue("SearchList", strings.Join(domStrs, ",")); err != nil {
return err
}
// Disable LLMNR on the Tailscale interface. We don't do multicast, and we
// certainly don't do LLMNR, so it's pointless to make Windows try it. It is
// being deprecated.
if err := key4.SetDWordValue("EnableMulticast", 0); err != nil {
return err
}
if err := key6.SetDWordValue("EnableMulticast", 0); err != nil {
return err
if len(domains) == 0 {
if err := delValue(key6, "SearchList"); err != nil {
return err
}
} else if err := key6.SetStringValue("SearchList", strings.Join(domStrs, ",")); err != nil {
return err
}
// Disable LLMNR on the Tailscale interface. We don't do multicast, and we
// certainly don't do LLMNR, so it's pointless to make Windows try it. It is
// being deprecated.
if err := key6.SetDWordValue("EnableMulticast", 0); err != nil {
return err
}
}
return nil
@@ -354,6 +395,10 @@ func (m *windowsManager) disableLocalDNSOverrideViaNRPT() bool {
return m.knobs != nil && m.knobs.DisableLocalDNSOverrideViaNRPT.Load()
}
func (m *windowsManager) disableHostsFileUpdates() bool {
return m.knobs != nil && m.knobs.DisableHostsFileUpdates.Load()
}
func (m *windowsManager) SetDNS(cfg OSConfig) error {
// We can configure Windows DNS in one of two ways:
//
@@ -399,7 +444,15 @@ func (m *windowsManager) SetDNS(cfg OSConfig) error {
if err := m.setSplitDNS(resolvers, domains); err != nil {
return err
}
if err := m.setHosts(nil); err != nil {
var hosts []*HostEntry
if !m.disableHostsFileUpdates() && winenv.IsDomainJoined() {
// On domain-joined Windows devices the primary search domain (the one the device is joined to)
// always takes precedence over other search domains. This breaks MagicDNS when we are the primary
// resolver on the device (see #18712). To work around this Windows behavior, we should write MagicDNS
// host names the hosts file just as we do when we're not the primary resolver.
hosts = cfg.Hosts
}
if err := m.setHosts(hosts); err != nil {
return err
}
if err := m.setPrimaryDNS(cfg.Nameservers, cfg.SearchDomains); err != nil {
@@ -421,12 +474,14 @@ func (m *windowsManager) SetDNS(cfg OSConfig) error {
return err
}
// As we are not the primary resolver in this setup, we need to
// explicitly set some single name hosts to ensure that we can resolve
// them quickly and get around the 2.3s delay that otherwise occurs due
// to multicast timeouts.
if err := m.setHosts(cfg.Hosts); err != nil {
return err
if !m.disableHostsFileUpdates() {
// As we are not the primary resolver in this setup, we need to
// explicitly set some single name hosts to ensure that we can resolve
// them quickly and get around the 2.3s delay that otherwise occurs due
// to multicast timeouts.
if err := m.setHosts(cfg.Hosts); err != nil {
return err
}
}
}
@@ -436,40 +491,15 @@ func (m *windowsManager) SetDNS(cfg OSConfig) error {
// have changed, which makes the DNS settings actually take
// effect.
//
// This command can take a few seconds to run, so run it async, best effort.
//
// After re-registering DNS, also flush the DNS cache to clear out
// any cached split-horizon queries that are no longer the correct
// answer.
go func() {
t0 := time.Now()
m.logf("running ipconfig /registerdns ...")
cmd := exec.Command("ipconfig", "/registerdns")
cmd.SysProcAttr = &syscall.SysProcAttr{
CreationFlags: windows.DETACHED_PROCESS,
}
err := cmd.Run()
d := time.Since(t0).Round(time.Millisecond)
if err != nil {
m.logf("error running ipconfig /registerdns after %v: %v", d, err)
} else {
m.logf("ran ipconfig /registerdns in %v", d)
}
t0 = time.Now()
m.logf("running ipconfig /flushdns ...")
cmd = exec.Command("ipconfig", "/flushdns")
cmd.SysProcAttr = &syscall.SysProcAttr{
CreationFlags: windows.DETACHED_PROCESS,
}
err = cmd.Run()
d = time.Since(t0).Round(time.Millisecond)
if err != nil {
m.logf("error running ipconfig /flushdns after %v: %v", d, err)
} else {
m.logf("ran ipconfig /flushdns in %v", d)
}
}()
//
// Both operations are non-blocking and coalesced: at most one of
// each is in flight at a time, with at most one more queued.
m.logf("Re-registering DNS and flushing cache")
registerDNS()
Flush()
// On initial setup of WSL, the restart caused by --shutdown is slow,
// so we do it out-of-line.
@@ -551,9 +581,12 @@ func (m *windowsManager) reconfigureDNSRegistration() {
// the Windows DHCP client from registering Tailscale IP addresses with DNS
// and sending dynamic updates for our interface to AD domain controllers.
func (m *windowsManager) configureDNSRegistration(enabled bool) error {
prefixen := []winutil.RegistryPathPrefix{
winutil.IPv4TCPIPInterfacePrefix,
winutil.IPv6TCPIPInterfacePrefix,
prefixen := make([]winutil.RegistryPathPrefix, 0, 2)
if m.doV4 {
prefixen = append(prefixen, winutil.IPv4TCPIPInterfacePrefix)
}
if m.doV6 {
prefixen = append(prefixen, winutil.IPv6TCPIPInterfacePrefix)
}
var (
@@ -607,6 +640,10 @@ func (m *windowsManager) setSingleDWORD(prefix winutil.RegistryPathPrefix, value
// Further, LLMNR and NetBIOS are being deprecated anyway in favor of MDNS.
// https://techcommunity.microsoft.com/t5/networking-blog/aligning-on-mdns-ramping-down-netbios-name-resolution-and-llmnr/ba-p/3290816
func (m *windowsManager) disableNetBIOS() error {
if !m.doNetBIOS {
return nil
}
return m.setSingleDWORD(winutil.NetBTInterfacePrefix, "NetbiosOptions", 2)
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux && !android && !ts_omit_networkmanager
+6 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package dns
@@ -15,3 +15,8 @@ func (m noopManager) GetBaseConfig() (OSConfig, error) {
func NewNoopManager() (noopManager, error) {
return noopManager{}, nil
}
func isNoopManager(c OSConfigurator) bool {
_, ok := c.(noopManager)
return ok
}
+28 -34
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package dns
@@ -7,7 +7,6 @@ import (
"fmt"
"strings"
"sync"
"sync/atomic"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
@@ -19,7 +18,6 @@ import (
)
const (
dnsBaseGP = `SOFTWARE\Policies\Microsoft\Windows NT\DNSClient`
nrptBaseLocal = `SYSTEM\CurrentControlSet\Services\Dnscache\Parameters\DnsPolicyConfig`
nrptBaseGP = `SOFTWARE\Policies\Microsoft\Windows NT\DNSClient\DnsPolicyConfig`
@@ -53,13 +51,12 @@ const (
// nrptRuleDatabase encapsulates access to the Windows Name Resolution Policy
// Table (NRPT).
type nrptRuleDatabase struct {
logf logger.Logf
watcher *gp.ChangeWatcher
isGPRefreshPending atomic.Bool
mu sync.Mutex // protects the fields below
ruleIDs []string
isGPDirty bool
writeAsGP bool
logf logger.Logf
watcher *gp.ChangeWatcher
mu sync.Mutex // protects the fields below
ruleIDs []string
isGPDirty bool
writeAsGP bool
}
func newNRPTRuleDatabase(logf logger.Logf) *nrptRuleDatabase {
@@ -141,6 +138,12 @@ func (db *nrptRuleDatabase) detectWriteAsGP() {
writeAsGP = len(gpSubkeyMap) > 0
}
func regKeyHasNoValues(ki *registry.KeyInfo) bool {
// Either the key has no values, or there is one value: an empty default value.
return ki.ValueCount == 0 ||
ki.ValueCount == 1 && ki.MaxValueNameLen == 0 && ki.MaxValueLen == 0
}
// DelAllRuleKeys removes any and all NRPT rules that are owned by Tailscale.
func (db *nrptRuleDatabase) DelAllRuleKeys() error {
db.mu.Lock()
@@ -171,7 +174,7 @@ func (db *nrptRuleDatabase) delRuleKeys(nrptRuleIDs []string) error {
keyNameGP := nrptBaseGP + `\` + rid
err := registry.DeleteKey(registry.LOCAL_MACHINE, keyNameGP)
if err == nil {
// If this deleted subkey existed under the GP key, we will need to refresh.
// If this deleted subkey existed under the GP key, we will need to notify.
db.isGPDirty = true
} else if err != registry.ErrNotExist {
db.logf("Error deleting NRPT rule key %q: %v", keyNameGP, err)
@@ -193,8 +196,8 @@ func (db *nrptRuleDatabase) delRuleKeys(nrptRuleIDs []string) error {
return registry.DeleteKey(registry.LOCAL_MACHINE, nrptBaseGP)
}
// isPolicyConfigSubkeyEmpty returns true if and only if the nrptBaseGP exists
// and does not contain any values or subkeys.
// isPolicyConfigSubkeyEmpty returns whether the nrptBaseGP exists and does not
// contain any values or subkeys.
func isPolicyConfigSubkeyEmpty() (bool, error) {
subKey, err := registry.OpenKey(registry.LOCAL_MACHINE, nrptBaseGP, registry.READ)
if err != nil {
@@ -210,7 +213,7 @@ func isPolicyConfigSubkeyEmpty() (bool, error) {
return false, err
}
return (ki.ValueCount == 0 && ki.SubKeyCount == 0), nil
return regKeyHasNoValues(ki) && ki.SubKeyCount == 0, nil
}
func (db *nrptRuleDatabase) WriteSplitDNSConfig(servers []string, domains []dnsname.FQDN) error {
@@ -277,26 +280,22 @@ func (db *nrptRuleDatabase) WriteSplitDNSConfig(servers []string, domains []dnsn
return nil
}
// Refresh notifies the Windows group policy engine when policies have changed.
func (db *nrptRuleDatabase) Refresh() {
// NotifyPolicyChanged notifies the Windows group policy engine when policies have changed.
func (db *nrptRuleDatabase) NotifyPolicyChanged() {
db.mu.Lock()
defer db.mu.Unlock()
db.refreshLocked()
db.notifyPolicyChangedLocked()
}
func (db *nrptRuleDatabase) refreshLocked() {
func (db *nrptRuleDatabase) notifyPolicyChangedLocked() {
if !db.isGPDirty {
return
}
// Record that we are about to initiate a refresh.
// (*nrptRuleDatabase).watchForGPChanges() checks this value to avoid false
// positives.
db.isGPRefreshPending.Store(true)
if err := gp.RefreshMachinePolicy(true); err != nil {
db.logf("RefreshMachinePolicy failed: %v", err)
db.logf("Calling NotifyMachinePolicyChange")
if err := gp.NotifyMachinePolicyChange(); err != nil {
db.logf("NotifyMachinePolicyChange failed: %v", err)
return
}
@@ -318,7 +317,7 @@ func (db *nrptRuleDatabase) writeNRPTRule(ruleID string, servers, doms []string)
}
defer key.Close()
if err := writeNRPTValues(key, strings.Join(servers, "; "), doms); err != nil {
if err := writeNRPTValues(key, strings.Join(servers, ";"), doms); err != nil {
return err
}
}
@@ -355,12 +354,7 @@ func writeNRPTValues(key registry.Key, servers string, doms []string) error {
func (db *nrptRuleDatabase) watchForGPChanges() {
watchHandler := func() {
// Do not invoke detectWriteAsGP when we ourselves were responsible for
// initiating the group policy refresh.
if db.isGPRefreshPending.CompareAndSwap(true, false) {
return
}
db.logf("Computer group policies refreshed, reconfiguring NRPT rule database.")
db.logf("Received machine group policy change notification. Reconfiguring NRPT rule database.")
db.detectWriteAsGP()
}
@@ -380,8 +374,8 @@ func (db *nrptRuleDatabase) watchForGPChanges() {
// db.mu must already be locked.
func (db *nrptRuleDatabase) updateGroupPoliciesLocked(writeAsGP bool) {
// Since we're updating the group policy NRPT table, we need
// to refresh once this updateGroupPoliciesLocked is done.
defer db.refreshLocked()
// to notify once this updateGroupPoliciesLocked is done.
defer db.notifyPolicyChangedLocked()
for _, id := range db.ruleIDs {
if writeAsGP {
+70 -15
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build (linux && !android) || freebsd || openbsd
@@ -7,10 +7,14 @@ package dns
import (
"bytes"
"errors"
"fmt"
"net/netip"
"os/exec"
"slices"
"strings"
"tailscale.com/net/tsaddr"
"tailscale.com/types/logger"
)
@@ -39,6 +43,38 @@ func (m openresolvManager) logCmdErr(cmd *exec.Cmd, err error) {
m.logf("error running command %s stderr=%q exitCode=%d: %v", commandStr, exerr.Stderr, exerr.ExitCode(), err)
}
// openresolvNoSnippetsExitCode is the exit status resolvconf returns when a
// requested config snippet does not exist. Asking for all snippets when none
// are registered returns this status too, because openresolv treats the empty
// result as a missing snippet rather than as an empty list.
const openresolvNoSnippetsExitCode = 2
// readSnippets runs resolvconf with the given arguments and returns its stdout.
// An exit status of openresolvNoSnippetsExitCode is not an error: it returns no
// output and a nil error. Other failures are logged and returned.
//
// Callers must pass either "-i" with no arguments or "-l" with explicit snippet
// names. Only those forms produce empty stdout alongside
// openresolvNoSnippetsExitCode; "-i" with snippet names prints the ones that do
// exist and still exits 2, so its output would be silently dropped.
//
// Stderr is excluded from the returned bytes so that diagnostics like "No
// resolv.conf for key foo" are never parsed as snippet names or resolv.conf
// lines. logCmdErr still logs stderr when a command fails.
func (m openresolvManager) readSnippets(args ...string) ([]byte, error) {
cmd := exec.Command("resolvconf", args...)
out, err := cmd.Output()
if err != nil {
if ee, ok := errors.AsType[*exec.ExitError](err); ok && ee.ExitCode() == openresolvNoSnippetsExitCode {
m.logf("[v1] resolvconf %q found no matching config snippets", args)
return nil, nil
}
m.logCmdErr(cmd, err)
return nil, err
}
return out, nil
}
func (m openresolvManager) deleteTailscaleConfig() error {
cmd := exec.Command("resolvconf", "-f", "-d", "tailscale")
out, err := cmd.CombinedOutput()
@@ -75,18 +111,21 @@ func (m openresolvManager) GetBaseConfig() (OSConfig, error) {
// List the names of all config snippets openresolv is aware
// of. Snippets get listed in priority order (most to least),
// which we'll exploit later.
bs, err := exec.Command("resolvconf", "-i").CombinedOutput()
bs, err := m.readSnippets("-i")
if err != nil {
return OSConfig{}, err
}
// Remove the "tailscale" snippet from the list.
args := []string{"-l"}
for _, f := range strings.Split(strings.TrimSpace(string(bs)), " ") {
if f == "tailscale" {
continue
}
args = append(args, f)
others := slices.DeleteFunc(strings.Fields(string(bs)), func(f string) bool {
return f == "tailscale"
})
if len(others) == 0 {
// There are no other snippets, so there is no base config to read.
// Returning early is required, not merely an optimization: a
// "resolvconf -l" with no snippet names lists every snippet,
// including Tailscale's own, which would make quad-100 its own
// upstream. See tailscale/tailscale#20825.
return OSConfig{}, nil
}
// List all resolvconf snippets except our own, and parse that as
@@ -100,14 +139,30 @@ func (m openresolvManager) GetBaseConfig() (OSConfig, error) {
// practice, openresolv uses are generally quite limited, and boil
// down to 1-2 DHCP leases, for which the correct outcome is a
// blended config like the one we produce here.
var buf bytes.Buffer
cmd := exec.Command("resolvconf", args...)
cmd.Stdout = &buf
if err := cmd.Run(); err != nil {
m.logCmdErr(cmd, err)
out, err := m.readSnippets(append([]string{"-l"}, others...)...)
if err != nil {
return OSConfig{}, err
}
return readResolv(&buf)
cfg, err := readResolv(bytes.NewReader(out))
if err != nil {
return OSConfig{}, err
}
// Forwarding to the Tailscale service IPs would make quad-100 send
// queries to itself, in an infinite loop, so drop them if another
// snippet names them. See tailscale/tailscale#7816.
var removed bool
cfg.Nameservers = slices.DeleteFunc(cfg.Nameservers, func(ip netip.Addr) bool {
if ip == tsaddr.TailscaleServiceIP() || ip == tsaddr.TailscaleServiceIPv6() {
removed = true
return true
}
return false
})
if removed {
m.logf("[v1] dropped Tailscale service IP from openresolv base config")
}
return cfg, nil
}
func (m openresolvManager) Close() error {
+21 -3
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package dns
@@ -12,6 +12,7 @@ import (
"strings"
"tailscale.com/feature/buildfeatures"
"tailscale.com/types/dnstype"
"tailscale.com/types/logger"
"tailscale.com/util/dnsname"
)
@@ -36,7 +37,9 @@ type OSConfigurator interface {
// Only works when SupportsSplitDNS=false.
// Implementations that don't support getting the base config must
// return ErrGetBaseConfigNotSupported.
// return ErrGetBaseConfigNotSupported. Implementations that can read the
// configuration but cannot recover any underlying resolvers may return
// ErrGetBaseConfigNoResolvers; other read failures should return their error.
GetBaseConfig() (OSConfig, error)
// Close removes Tailscale-related DNS configuration from the OS.
Close() error
@@ -66,6 +69,15 @@ type OSConfig struct {
// from the OS, which will only work with OSConfigurators that
// report SupportsSplitDNS()=true.
MatchDomains []dnsname.FQDN
// Resolvers is the full-fidelity form of Nameservers, populated only by
// OSConfigurators whose [OSConfigurator.GetBaseConfig] can recover more
// of the underlying resolver configuration than plain IP:53 addresses
// (non-standard ports, DoH/DoT endpoints). It is input to the DNS
// manager only: the manager blends it into quad-100's catch-all route
// when the tunnel resolver must serve as the OS primary. It is never
// applied to the OS itself, and [OSConfig.Equal] deliberately ignores
// it, because it does not affect the configuration installed on the OS.
Resolvers []*dnstype.Resolver `json:",omitempty"`
}
func (o *OSConfig) WriteToBufioWriter(w *bufio.Writer) {
@@ -108,7 +120,8 @@ func (o OSConfig) IsZero() bool {
return len(o.Hosts) == 0 &&
len(o.Nameservers) == 0 &&
len(o.SearchDomains) == 0 &&
len(o.MatchDomains) == 0
len(o.MatchDomains) == 0 &&
len(o.Resolvers) == 0
}
func (a OSConfig) Equal(b OSConfig) bool {
@@ -199,3 +212,8 @@ func (a OSConfig) Format(f fmt.State, verb rune) {
// OSConfigurator.GetBaseConfig returns when the OSConfigurator
// doesn't support reading the underlying configuration out of the OS.
var ErrGetBaseConfigNotSupported = errors.New("getting OS base config is not supported")
// ErrGetBaseConfigNoResolvers is returned by [OSConfigurator.GetBaseConfig]
// when neither a fresh read nor a retained configuration supplies an underlying
// resolver. It produces the same health warning as a successful but empty read.
var ErrGetBaseConfigNoResolvers = errEmptyBaseConfig
+73 -34
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package publicdns contains mapping and helpers for working with
@@ -7,18 +7,16 @@ package publicdns
import (
"bytes"
"encoding/binary"
"encoding/hex"
"fmt"
"log"
"math/big"
"net/netip"
"slices"
"sort"
"strconv"
"strings"
"sync"
"tailscale.com/feature/buildfeatures"
"tailscale.com/util/testenv"
)
// dohOfIP maps from public DNS IPs to their DoH base URL.
@@ -58,12 +56,15 @@ func DoHEndpointFromIP(ip netip.Addr) (dohBase string, dohOnly bool, ok bool) {
return sb.String(), true, true
}
// Control D DoH URLs are of the form "https://dns.controld.com/8yezwenugs"
// where the path component is represented by 8 bytes (7-14) of the IPv6 address in base36
if controlDv6RangeA.Contains(ip) || controlDv6RangeB.Contains(ip) {
path := big.NewInt(0).SetBytes(ip.AsSlice()[6:14]).Text(36)
return controlDBase + path, true, true
}
// Control D's free anycast resolvers (freedns.controld.com/pN) are the only
// Control D addresses that serve DoH; they're registered as exact entries in
// dohOfIP (see populate) and are handled by the lookup above.
//
// The ID-encoded addresses in the 2606:1a40::/48 ranges (customer resolver ID
// base36-encoded into the address) are legacy plaintext-DNS (port 53) endpoints
// that refuse DoH on :443, so we deliberately don't upgrade them to DoH here:
// they fall through and are used as ordinary port-53 resolvers. Premium DoH is
// only reachable via the dns.controld.com/<id> URL path (see DoHIPsOfBase).
return "", false, false
}
@@ -124,15 +125,12 @@ func DoHIPsOfBase(dohBase string) []netip.Addr {
}
}
}
if pathStr, ok := strings.CutPrefix(dohBase, controlDBase); ok {
if i := strings.IndexFunc(pathStr, isSlashOrQuestionMark); i != -1 {
pathStr = pathStr[:i]
}
if strings.HasPrefix(dohBase, controlDBase) {
return []netip.Addr{
controlDv4One,
controlDv4Two,
controlDv6Gen(controlDv6RangeA.Addr(), pathStr),
controlDv6Gen(controlDv6RangeB.Addr(), pathStr),
controlDv6One,
controlDv6Two,
}
}
return nil
@@ -275,6 +273,26 @@ func populate() {
addDoH("76.76.10.4", "https://freedns.controld.com/family")
addDoH("2606:1a40::4", "https://freedns.controld.com/family")
addDoH("2606:1a40:1::4", "https://freedns.controld.com/family")
// CIRA Canadian Shield: https://www.cira.ca/en/canadian-shield/configure/summary-cira-canadian-shield-dns-resolver-addresses/
// CIRA Canadian Shield Private (DNS resolution only)
addDoH("149.112.121.10", "https://private.canadianshield.cira.ca/dns-query")
addDoH("149.112.122.10", "https://private.canadianshield.cira.ca/dns-query")
addDoH("2620:10a:80bb::10", "https://private.canadianshield.cira.ca/dns-query")
addDoH("2620:10a:80bc::10", "https://private.canadianshield.cira.ca/dns-query")
// CIRA Canadian Shield Protected (Malware and phishing protection)
addDoH("149.112.121.20", "https://protected.canadianshield.cira.ca/dns-query")
addDoH("149.112.122.20", "https://protected.canadianshield.cira.ca/dns-query")
addDoH("2620:10a:80bb::20", "https://protected.canadianshield.cira.ca/dns-query")
addDoH("2620:10a:80bc::20", "https://protected.canadianshield.cira.ca/dns-query")
// CIRA Canadian Shield Family (Protected + blocking adult content)
addDoH("149.112.121.30", "https://family.canadianshield.cira.ca/dns-query")
addDoH("149.112.122.30", "https://family.canadianshield.cira.ca/dns-query")
addDoH("2620:10a:80bb::30", "https://family.canadianshield.cira.ca/dns-query")
addDoH("2620:10a:80bc::30", "https://family.canadianshield.cira.ca/dns-query")
}
var (
@@ -308,6 +326,8 @@ var (
controlDv6RangeB = netip.MustParsePrefix("2606:1a40:1::/48")
controlDv4One = netip.MustParseAddr("76.76.2.22")
controlDv4Two = netip.MustParseAddr("76.76.10.22")
controlDv6One = netip.MustParseAddr("2606:1a40::22")
controlDv6Two = netip.MustParseAddr("2606:1a40:1::22")
)
// nextDNSv6Gen generates a NextDNS IPv6 address from the upper 8 bytes in the
@@ -321,23 +341,6 @@ func nextDNSv6Gen(ip netip.Addr, id []byte) netip.Addr {
return netip.AddrFrom16(a)
}
// controlDv6Gen generates a Control D IPv6 address from provided ip and id.
//
// The id is taken from the DoH query path component and represents a unique resolver configuration.
// e.g. https://dns.controld.com/hyq3ipr2ct
func controlDv6Gen(ip netip.Addr, id string) netip.Addr {
b := make([]byte, 8)
decoded, err := strconv.ParseUint(id, 36, 64)
if err != nil {
log.Printf("controlDv6Gen: failed to parse id %q: %v", id, err)
}
binary.BigEndian.PutUint64(b, decoded)
a := ip.AsSlice()
copy(a[6:14], b)
addr, _ := netip.AddrFromSlice(a)
return addr
}
// IPIsDoHOnlyServer reports whether ip is a DNS server that should only use
// DNS-over-HTTPS (not regular port 53 DNS).
func IPIsDoHOnlyServer(ip netip.Addr) bool {
@@ -347,3 +350,39 @@ func IPIsDoHOnlyServer(ip netip.Addr) bool {
controlDv6RangeA.Contains(ip) || controlDv6RangeB.Contains(ip) ||
ip == controlDv4One || ip == controlDv4Two
}
var testMu sync.Mutex
// RegisterTestDoHEndpoint registers a test DoH endpoint mapping for use in tests.
// It maps the given IP to the DoH base URL, and the URL back to the IP.
//
// This function panics if called outside of tests, and cannot be called
// concurrently with any usage of this package (i.e. before any DNS forwarders
// are created). It is safe to call concurrently with itself.
//
// It returns a cleanup function that removes the registration.
func RegisterTestDoHEndpoint(ip netip.Addr, dohBase string) func() {
if !testenv.InTest() {
panic("RegisterTestDoHEndpoint called outside of tests")
}
populateOnce.Do(populate)
testMu.Lock()
defer testMu.Unlock()
dohOfIP[ip] = dohBase
dohIPsOfBase[dohBase] = append(dohIPsOfBase[dohBase], ip)
return func() {
testMu.Lock()
defer testMu.Unlock()
delete(dohOfIP, ip)
dohIPsOfBase[dohBase] = slices.DeleteFunc(dohIPsOfBase[dohBase], func(addr netip.Addr) bool {
return addr == ip
})
if len(dohIPsOfBase[dohBase]) == 0 {
delete(dohIPsOfBase, dohBase)
}
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
#!/bin/sh
# Copyright (c) Tailscale Inc & AUTHORS
# Copyright (c) Tailscale Inc & contributors
# SPDX-License-Identifier: BSD-3-Clause
#
# This script is a workaround for a vpn-unfriendly behavior of the
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux || freebsd || openbsd
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package resolvconffile parses & serializes /etc/resolv.conf-style files.
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !gokrazy
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build gokrazy
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build openbsd
+31 -21
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux && !android && !ts_omit_resolved
@@ -215,29 +215,10 @@ func (m *resolvedManager) run(ctx context.Context) {
}
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.
if !m.isResolvedRestartSignal(signal) {
continue
}
// The resolved bus name has a new owner, meaning resolved
@@ -258,6 +239,35 @@ func (m *resolvedManager) run(ctx context.Context) {
}
}
// isResolvedRestartSignal reports whether signal says that systemd-resolved
// has acquired a new D-Bus owner.
func (m *resolvedManager) isResolvedRestartSignal(signal *dbus.Signal) bool {
// In theory the signal was filtered by DBus, but if AddMatchSignal in the
// constructor failed, we may be getting other spam.
if signal == nil {
m.logf("[unexpected] received nil DBus signal")
return false
}
if signal.Path != dbusPath || signal.Name != dbusInterface+"."+dbusOwnerSignal {
return false
}
// 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", len(signal.Body))
return false
}
if name, ok := signal.Body[0].(string); !ok || name != dbusResolvedObject {
return false
}
newOwner, ok := signal.Body[2].(string)
if !ok {
m.logf("[unexpected] DBus NameOwnerChanged.new_owner is a %T, not a string", signal.Body[2])
return false
}
return newOwner != ""
}
// 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)
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package resolver
+497 -116
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package resolver
@@ -13,6 +13,7 @@ import (
"errors"
"fmt"
"io"
"maps"
"net"
"net/http"
"net/netip"
@@ -41,8 +42,10 @@ import (
"tailscale.com/types/dnstype"
"tailscale.com/types/logger"
"tailscale.com/types/nettype"
"tailscale.com/types/views"
"tailscale.com/util/cloudenv"
"tailscale.com/util/dnsname"
"tailscale.com/util/mak"
"tailscale.com/util/race"
"tailscale.com/version"
)
@@ -63,6 +66,17 @@ func truncatedFlagSet(pkt []byte) bool {
return (binary.BigEndian.Uint16(pkt[2:4]) & dnsFlagTruncated) != 0
}
// setTCFlag sets the TC (truncated) flag in the DNS packet header.
// The packet must be at least headerBytes in length.
func setTCFlag(packet []byte) {
if len(packet) < headerBytes {
return
}
flags := binary.BigEndian.Uint16(packet[2:4])
flags |= dnsFlagTruncated
binary.BigEndian.PutUint16(packet[2:4], flags)
}
const (
// dohIdleConnTimeout is how long to keep idle HTTP connections
// open to DNS-over-HTTPS servers. 10 seconds is a sensible
@@ -131,47 +145,59 @@ func getRCode(packet []byte) dns.RCode {
return dns.RCode(packet[3] & 0x0F)
}
// findOPTRecord finds and validates the EDNS OPT record at the end of a DNS packet.
// Returns the requested buffer size and a pointer to the OPT record bytes if valid,
// or (0, nil) if no valid OPT record is found.
// The OPT record must be at the very end of the packet with no option codes.
func findOPTRecord(packet []byte) (requestedSize uint16, opt []byte) {
const optFixedBytes = 11 // size of an OPT record with no option codes
const edns0Version = 0 // EDNS version number (currently only version 0 is defined)
if len(packet) < headerBytes+optFixedBytes {
return 0, nil
}
arCount := binary.BigEndian.Uint16(packet[10:12])
if arCount == 0 {
// OPT shows up in an AR, so there must be no OPT
return 0, nil
}
// https://datatracker.ietf.org/doc/html/rfc6891#section-6.1.2
opt = packet[len(packet)-optFixedBytes:]
if opt[0] != 0 {
// OPT NAME must be 0 (root domain)
return 0, nil
}
if dns.Type(binary.BigEndian.Uint16(opt[1:3])) != dns.TypeOPT {
// Not an OPT record
return 0, nil
}
requestedSize = binary.BigEndian.Uint16(opt[3:5])
// Ignore extended RCODE in opt[5]
if opt[6] != edns0Version {
// Be conservative and don't touch unknown versions.
return 0, nil
}
// Ignore flags in opt[6:9]
if binary.BigEndian.Uint16(opt[9:11]) != 0 {
// RDLEN must be 0 (no variable length data). We're at the end of the
// packet so this should be 0 anyway.
return 0, nil
}
return requestedSize, opt
}
// clampEDNSSize attempts to limit the maximum EDNS response size. This is not
// an exhaustive solution, instead only easy cases are currently handled in the
// interest of speed and reduced complexity. Only OPT records at the very end of
// the message with no option codes are addressed.
// TODO: handle more situations if we discover that they happen often
func clampEDNSSize(packet []byte, maxSize uint16) {
// optFixedBytes is the size of an OPT record with no option codes.
const optFixedBytes = 11
const edns0Version = 0
if len(packet) < headerBytes+optFixedBytes {
return
}
arCount := binary.BigEndian.Uint16(packet[10:12])
if arCount == 0 {
// OPT shows up in an AR, so there must be no OPT
return
}
// https://datatracker.ietf.org/doc/html/rfc6891#section-6.1.2
opt := packet[len(packet)-optFixedBytes:]
if opt[0] != 0 {
// OPT NAME must be 0 (root domain)
return
}
if dns.Type(binary.BigEndian.Uint16(opt[1:3])) != dns.TypeOPT {
// Not an OPT record
return
}
requestedSize := binary.BigEndian.Uint16(opt[3:5])
// Ignore extended RCODE in opt[5]
if opt[6] != edns0Version {
// Be conservative and don't touch unknown versions.
return
}
// Ignore flags in opt[6:9]
if binary.BigEndian.Uint16(opt[9:11]) != 0 {
// RDLEN must be 0 (no variable length data). We're at the end of the
// packet so this should be 0 anyway)..
requestedSize, opt := findOPTRecord(packet)
if opt == nil {
return
}
@@ -183,6 +209,57 @@ func clampEDNSSize(packet []byte, maxSize uint16) {
binary.BigEndian.PutUint16(opt[3:5], maxSize)
}
// getEDNSBufferSize extracts the EDNS buffer size from a DNS request packet.
// Returns (bufferSize, true) if a valid EDNS OPT record is found,
// or (0, false) if no EDNS OPT record is found or if there's an error.
func getEDNSBufferSize(packet []byte) (uint16, bool) {
requestedSize, opt := findOPTRecord(packet)
return requestedSize, opt != nil
}
// checkResponseSizeAndSetTC sets the TC (truncated) flag in the DNS header when
// the response exceeds the maximum UDP size. If no EDNS OPT record is present
// in the request, it sets the TC flag when the response is bigger than 512 bytes
// per RFC 1035. If an EDNS OPT record is present, it sets the TC flag when the
// response is bigger than the EDNS buffer size. The response buffer is not
// truncated; only the TC flag is set. Returns the response unchanged except for
// the TC flag being set if needed.
func checkResponseSizeAndSetTC(response []byte, request []byte, family string, logf logger.Logf) []byte {
const defaultUDPSize = 512 // default maximum UDP DNS packet size per RFC 1035
// Only check for UDP queries; TCP can handle larger responses
if family != "udp" {
return response
}
// Check if TC flag is already set
if len(response) < headerBytes {
return response
}
if truncatedFlagSet(response) {
// TC flag already set, nothing to do
return response
}
ednsSize, hasEDNS := getEDNSBufferSize(request)
// Determine maximum allowed size
var maxSize int
if hasEDNS {
maxSize = int(ednsSize)
} else {
// No EDNS: enforce default UDP size limit per RFC 1035
maxSize = defaultUDPSize
}
// Check if response exceeds maximum size
if len(response) > maxSize {
setTCFlag(response)
}
return response
}
// dnsForwarderFailing should be raised when the forwarder is unable to reach the
// upstream resolvers. This is a high severity warning as it results in "no internet".
// This warning must be cleared when the forwarder is working again.
@@ -249,6 +326,30 @@ type forwarder struct {
// /etc/resolv.conf is missing/corrupt, and the peerapi ExitDNS stub
// resolver lookup.
cloudHostFallback []resolverAndDelay
// schemes are the collection of registered URI scheme names that
// dynamically decide which resolver to use at the time of each query. The
// key is the scheme (the portion before the first `:`) and the value is a
// handler that determines where the current query should be sent.
// Use schemeCacheLocked() to get the current contents that can continue to
// be accessed once mu is released. This allows the (much more common)
// resolver code path to avoid repeated locking and unlocking.
// When modified, call invalidateSchemeCacheLocked() before unlocking mu.
schemes map[string]CustomSchemeHandler
// schemeCache is an immutable copy of schemes. Do not read directly,
// use schemeCacheLocked() which will regenerate its contents as needed.
schemeCache views.Map[string, CustomSchemeHandler]
// acceptDNS tracks the CorpDNS pref (--accept-dns)
// This lets us skip health warnings if the forwarder receives inbound
// queries directly - but we didn't configure it with any upstream resolvers.
// That's an error, but not a health error if the user has disabled CorpDNS.
acceptDNS bool
}
func (f *forwarder) probeLocks() {
f.mu.Lock()
f.mu.Unlock()
}
func newForwarder(logf logger.Logf, netMon *netmon.Monitor, linkSel ForwardLinkSelector, dialer *tsdial.Dialer, health *health.Tracker, knobs *controlknobs.Knobs) *forwarder {
@@ -360,7 +461,7 @@ func cloudResolvers() []resolverAndDelay {
// Resolver.SetConfig on reconfig.
//
// The memory referenced by routesBySuffix should not be modified.
func (f *forwarder) setRoutes(routesBySuffix map[dnsname.FQDN][]*dnstype.Resolver) {
func (f *forwarder) setRoutes(routesBySuffix map[dnsname.FQDN][]*dnstype.Resolver, acceptDNS bool) {
routes := make([]route, 0, len(routesBySuffix))
cloudHostFallback := cloudResolvers()
@@ -394,6 +495,7 @@ func (f *forwarder) setRoutes(routesBySuffix map[dnsname.FQDN][]*dnstype.Resolve
f.mu.Lock()
defer f.mu.Unlock()
f.acceptDNS = acceptDNS
f.routes = routes
f.cloudHostFallback = cloudHostFallback
}
@@ -430,6 +532,62 @@ func (f *forwarder) getKnownDoHClientForProvider(urlBase string) (c *http.Client
if len(allIPs) == 0 {
return nil, false
}
return f.newDoHClientLocked(urlBase, urlBase, allIPs)
}
// getDoHClientForResolver returns an HTTP client for the DoH server described
// by r. Known public providers ([publicdns.DoHIPsOfBase]) work as before. For
// arbitrary providers — typically enterprise resolvers recovered from the
// OS's base configuration — the resolver must either carry a
// BootstrapResolution, or its URL host must be an IP literal (which needs no
// bootstrap): we cannot resolve the DoH server's own name through DNS without
// recursing through ourselves.
func (f *forwarder) getDoHClientForResolver(r *dnstype.Resolver) (c *http.Client, ok bool) {
urlBase := r.Addr
f.mu.Lock()
defer f.mu.Unlock()
key := dohClientCacheKey(urlBase, r.BootstrapResolution)
if c, ok := f.dohClient[key]; ok {
return c, true
}
allIPs := publicdns.DoHIPsOfBase(urlBase)
if len(allIPs) == 0 {
if len(r.BootstrapResolution) > 0 {
allIPs = r.BootstrapResolution
} else {
// A URL whose host is an IP literal dials itself; no bootstrap
// resolution is needed.
u, err := url.Parse(urlBase)
if err != nil {
return nil, false
}
ip, err := netip.ParseAddr(u.Hostname())
if err != nil {
return nil, false
}
allIPs = []netip.Addr{ip}
}
}
return f.newDoHClientLocked(key, urlBase, allIPs)
}
// dohClientCacheKey distinguishes cached clients: bootstrap results can change
// with the underlying network, unlike known public provider IPs.
func dohClientCacheKey(urlBase string, bootstrap []netip.Addr) string {
if len(bootstrap) == 0 || len(publicdns.DoHIPsOfBase(urlBase)) > 0 {
return urlBase
}
ips := make([]string, len(bootstrap))
for i, ip := range bootstrap {
ips[i] = ip.String()
}
return urlBase + "\x00" + strings.Join(ips, ",")
}
// newDoHClientLocked builds a DoH HTTP client that dials urlBase's host at
// the given IPs, caching the client under cacheKey (which differs from
// urlBase when bootstrap results vary). f.mu must be held.
func (f *forwarder) newDoHClientLocked(cacheKey, urlBase string, allIPs []netip.Addr) (c *http.Client, ok bool) {
dohURL, err := url.Parse(urlBase)
if err != nil {
return nil, false
@@ -465,7 +623,7 @@ func (f *forwarder) getKnownDoHClientForProvider(urlBase string) (c *http.Client
if f.dohClient == nil {
f.dohClient = map[string]*http.Client{}
}
f.dohClient[urlBase] = c
f.dohClient[cacheKey] = c
return c, true
}
@@ -535,21 +693,32 @@ func (f *forwarder) send(ctx context.Context, fq *forwardQuery, rr resolverAndDe
if !buildfeatures.HasPeerAPIClient {
return nil, feature.ErrUnavailable
}
return f.sendDoH(ctx, rr.name.Addr, f.dialer.PeerAPIHTTPClient(), fq.packet)
res, err := f.sendDoH(ctx, rr.name.Addr, f.dialer.PeerAPIHTTPClient(), fq.packet)
if err != nil {
return nil, err
}
// Check response size and set TC flag if needed (only for UDP queries)
res = checkResponseSizeAndSetTC(res, fq.packet, fq.family, f.logf)
return res, nil
}
if strings.HasPrefix(rr.name.Addr, "https://") {
// Only known DoH providers are supported currently. Specifically, we
// only support DoH providers where we can TCP connect to them on port
// 443 at the same IP address they serve normal UDP DNS from (1.1.1.1,
// 8.8.8.8, 9.9.9.9, etc.) That's why OpenDNS and custom DoH providers
// aren't currently supported. There's no backup DNS resolution path for
// them.
urlBase := rr.name.Addr
if hc, ok := f.getKnownDoHClientForProvider(urlBase); ok {
return f.sendDoH(ctx, urlBase, hc, fq.packet)
// Known DoH providers (see tailscale.com/net/dns/publicdns) are dialed
// at their well-known IPs. Arbitrary providers — typically enterprise
// resolvers recovered from the OS's base DNS configuration — are only
// usable when the resolver carries a bootstrap resolution, or when the
// URL's host is an IP literal, since we cannot resolve the DoH
// server's own name through DNS without recursing through ourselves.
if hc, ok := f.getDoHClientForResolver(rr.name); ok {
res, err := f.sendDoH(ctx, rr.name.Addr, hc, fq.packet)
if err != nil {
return nil, err
}
// Check response size and set TC flag if needed (only for UDP queries)
res = checkResponseSizeAndSetTC(res, fq.packet, fq.family, f.logf)
return res, nil
}
metricDNSFwdErrorType.Add(1)
return nil, fmt.Errorf("arbitrary https:// resolvers not supported yet")
return nil, fmt.Errorf("cannot dial https:// resolver %q: no known IPs or bootstrap resolution", rr.name.Addr)
}
if strings.HasPrefix(rr.name.Addr, "tls://") {
metricDNSFwdErrorType.Add(1)
@@ -634,8 +803,7 @@ func (f *forwarder) send(ctx context.Context, fq *forwardQuery, rr resolverAndDe
}
// If we got a truncated UDP response, return that instead of an error.
var trErr truncatedResponseError
if errors.As(err, &trErr) {
if trErr, ok := errors.AsType[truncatedResponseError](err); ok {
return trErr.res, nil
}
return nil, err
@@ -647,6 +815,27 @@ type truncatedResponseError struct {
func (tr truncatedResponseError) Error() string { return "response truncated" }
// rcodeResponseError is returned when an upstream DNS server responds with an
// rcode that is treated as a soft error (currently REFUSED and SERVFAIL). The
// response bytes are preserved so they can be returned to the client rather
// than synthesizing a new response.
type rcodeResponseError struct {
rcode dns.RCode
res []byte
}
func (r rcodeResponseError) Error() string { return r.Unwrap().Error() }
func (r rcodeResponseError) Unwrap() error {
switch r.rcode {
case dns.RCodeRefused:
return errRefused
case dns.RCodeServerFailure:
return errServerFailure
}
return nil
}
var errRefused = errors.New("response code indicates refusal")
var errServerFailure = errors.New("response code indicates server issue")
var errTxIDMismatch = errors.New("txid doesn't match")
@@ -659,21 +848,10 @@ func (f *forwarder) sendUDP(ctx context.Context, fq *forwardQuery, rr resolverAn
metricDNSFwdUDP.Add(1)
ctx = sockstats.WithSockStats(ctx, sockstats.LabelDNSForwarderUDP, f.logf)
ln, err := f.packetListener(ipp.Addr())
conn, err := f.dialUDP(ctx, ipp)
if err != nil {
return nil, err
}
// Specify the exact UDP family to work around https://github.com/golang/go/issues/52264
udpFam := "udp4"
if ipp.Addr().Is6() {
udpFam = "udp6"
}
conn, err := ln.ListenPacket(ctx, udpFam, ":0")
if err != nil {
f.logf("ListenPacket failed: %v", err)
return nil, err
}
defer conn.Close()
fq.closeOnCtxDone.Add(conn)
@@ -690,44 +868,72 @@ func (f *forwarder) sendUDP(ctx context.Context, fq *forwardQuery, rr resolverAn
// The 1 extra byte is to detect packet truncation.
out := make([]byte, maxResponseBytes+1)
n, _, err := conn.ReadFromUDPAddrPort(out)
if err != nil {
if err := ctx.Err(); err != nil {
return nil, err
// The conn is unconnected (see dialUDP), so datagrams can arrive from
// any address, not just the resolver we queried. A reply must come
// from the resolver's address and carry the transaction ID we sent.
// Datagrams that are neither are dropped rather than acted on, so
// neither a spoofed reply nor a single stray datagram can decide the
// query. The loop ends when the conn is closed, which the query's
// context cancellation does via fq.closeOnCtxDone.
var n int
for {
var src netip.AddrPort
var err error
n, src, err = conn.ReadFromUDPAddrPort(out)
if err != nil {
if err := ctx.Err(); err != nil {
return nil, err
}
if !neterror.PacketWasTruncated(err) {
metricDNSFwdUDPErrorRead.Add(1)
return nil, err
}
// Windows reports a datagram larger than out as a
// truncation error, returning the bytes that fit in
// out but no source address. Fall through and let the
// txid check decide, since the source can't be checked.
} else if src != ipp {
// Not from the resolver we asked, so not a reply to
// this query.
metricDNSFwdUDPDropSrc.Add(1)
continue
}
if neterror.PacketWasTruncated(err) {
err = nil
} else {
metricDNSFwdUDPErrorRead.Add(1)
return nil, err
if n < headerBytes {
f.logf("recv: packet too small (%d bytes)", n)
continue
}
if getTxID(out[:n]) != fq.txid {
metricDNSFwdUDPErrorTxID.Add(1)
continue
}
break
}
truncated := n > maxResponseBytes
if truncated {
n = maxResponseBytes
}
if n < headerBytes {
f.logf("recv: packet too small (%d bytes)", n)
}
out = out[:n]
txid := getTxID(out)
if txid != fq.txid {
metricDNSFwdUDPErrorTxID.Add(1)
return nil, errTxIDMismatch
}
tcFlagAlreadySet := truncatedFlagSet(out)
rcode := getRCode(out)
// don't forward transient errors back to the client when the server fails
if rcode == dns.RCodeServerFailure {
f.logf("recv: response code indicating server failure: %d", rcode)
switch rcode {
case dns.RCodeServerFailure:
f.logf("sendUDP: response code indicating server failure: %d", rcode)
metricDNSFwdUDPErrorServer.Add(1)
return nil, errServerFailure
return nil, rcodeResponseError{dns.RCodeServerFailure, out}
case dns.RCodeRefused:
// treat REFUSED as a soft error so other resolvers in the race can respond
f.logf("sendUDP: response code indicating refusal: %d", rcode)
metricDNSFwdUDPErrorRefused.Add(1)
return nil, rcodeResponseError{dns.RCodeRefused, out}
}
if truncated {
// Set the truncated bit if it wasn't already.
flags := binary.BigEndian.Uint16(out[2:4])
flags |= dnsFlagTruncated
binary.BigEndian.PutUint16(out[2:4], flags)
// Set the truncated bit if buffer was truncated during read and the flag isn't already set
if truncated && !tcFlagAlreadySet {
setTCFlag(out)
// TODO(#2067): Remove any incomplete records? RFC 1035 section 6.2
// states that truncation should head drop so that the authority
@@ -736,6 +942,8 @@ func (f *forwarder) sendUDP(ctx context.Context, fq *forwardQuery, rr resolverAn
// best we can do.
}
out = checkResponseSizeAndSetTC(out, fq.packet, fq.family, f.logf)
if truncatedFlagSet(out) {
metricDNSFwdTruncated.Add(1)
}
@@ -745,6 +953,58 @@ func (f *forwarder) sendUDP(ctx context.Context, fq *forwardQuery, rr resolverAn
return out, nil
}
// dialUDP returns a UDP conn to ipp, over netstack if that's the only way to
// reach it. Same dispatch as [tsdial.Dialer.dialOneUser].
func (f *forwarder) dialUDP(ctx context.Context, ipp netip.AddrPort) (nettype.PacketConn, error) {
if f.dialer.UseNetstackForIP != nil && f.dialer.UseNetstackForIP(ipp.Addr()) {
if f.dialer.NetstackDialUDP == nil {
return nil, errors.New("dialer not initialized correctly: no NetstackDialUDP")
}
conn, err := f.dialer.NetstackDialUDP(ctx, ipp)
if err != nil {
return nil, err
}
return &netstackPacketConn{Conn: conn, peer: ipp}, nil
}
ln, err := f.packetListener(ipp.Addr())
if err != nil {
return nil, err
}
// Name the family explicitly: netns looks for a "6" in this string to
// choose between IP_BOUND_IF and IPV6_BOUND_IF on macOS, and "udp" would
// give a v6 socket bound with the v4 option.
udpFam := "udp4"
if ipp.Addr().Is6() {
udpFam = "udp6"
}
conn, err := ln.ListenPacket(ctx, udpFam, ":0")
if err != nil {
f.logf("ListenPacket failed: %v", err)
return nil, err
}
return conn, nil
}
// netstackPacketConn presents a conn already connected to peer as a
// [nettype.PacketConn].
type netstackPacketConn struct {
net.Conn
peer netip.AddrPort
}
func (c *netstackPacketConn) WriteToUDPAddrPort(b []byte, _ netip.AddrPort) (int, error) {
return c.Write(b)
}
// ReadFromUDPAddrPort returns how much of the datagram fit in b; gVisor drops
// the rest without erroring, as a kernel socket does.
func (c *netstackPacketConn) ReadFromUDPAddrPort(b []byte) (int, netip.AddrPort, error) {
n, err := c.Read(b)
return n, c.peer, err
}
var optDNSForwardUseRoutes = envknob.RegisterOptBool("TS_DEBUG_DNS_FORWARD_USE_ROUTES")
// ShouldUseRoutes reports whether the DNS resolver should consider routes when dialing
@@ -765,7 +1025,7 @@ func ShouldUseRoutes(knobs *controlknobs.Knobs) bool {
switch runtime.GOOS {
case "android", "ios":
// On mobile platforms with lower memory limits (e.g., 50MB on iOS),
// this behavior is still gated by the "user-dial-routes" nodeAttr.
// this behavior is still gated by the "user-dial-routes" nodecap.
return knobs != nil && knobs.UserDialUseRoutes.Load()
default:
// On all other platforms, it is the default behavior,
@@ -856,10 +1116,16 @@ func (f *forwarder) sendTCP(ctx context.Context, fq *forwardQuery, rr resolverAn
rcode := getRCode(out)
// don't forward transient errors back to the client when the server fails
if rcode == dns.RCodeServerFailure {
switch rcode {
case dns.RCodeServerFailure:
f.logf("sendTCP: response code indicating server failure: %d", rcode)
metricDNSFwdTCPErrorServer.Add(1)
return nil, errServerFailure
return nil, rcodeResponseError{dns.RCodeServerFailure, out}
case dns.RCodeRefused:
// treat REFUSED as a soft error so other resolvers in the race can respond
f.logf("sendTCP: response code indicating refusal: %d", rcode)
metricDNSFwdTCPErrorRefused.Add(1)
return nil, rcodeResponseError{dns.RCodeRefused, out}
}
// TODO(andrew): do we need to do this?
@@ -868,15 +1134,66 @@ func (f *forwarder) sendTCP(ctx context.Context, fq *forwardQuery, rr resolverAn
return out, nil
}
// applySchemes resolves any custom-scheme entries in rrs using the provided
// scheme handlers, returning the resulting slice. Entries whose handler returns
// an error or empty string are dropped. Entries with no registered scheme pass
// through unchanged. If schemes is nil, rrs is returned as-is.
func applySchemes(logf logger.Logf, rrs []resolverAndDelay, schemes views.Map[string, CustomSchemeHandler]) []resolverAndDelay {
if schemes.IsNil() {
return rrs
}
var result []resolverAndDelay
for i, rr := range rrs {
scheme, _, hasColon := strings.Cut(rr.name.Addr, ":")
handler, isCustom := schemes.GetOk(scheme)
if !hasColon || !isCustom {
if result != nil {
result = append(result, rr)
}
continue
}
// Avoid making a results slice in the common case where there
// are no custom scheme resolvers.
if result == nil {
result = make([]resolverAndDelay, i, len(rrs))
copy(result, rrs)
}
newAddr, err := handler(rr.name.Addr)
if err != nil {
logf("error from custom scheme handler, skipping resolver : %v", err)
}
if err != nil || newAddr == "" {
continue
}
newResolver := *rr.name
newResolver.Addr = newAddr
result = append(result, resolverAndDelay{name: &newResolver, startDelay: rr.startDelay})
}
// If we didn't have any custom schemes, return the original rrs.
if result == nil {
return rrs
}
return result
}
// resolvers returns the resolvers to use for domain.
func (f *forwarder) resolvers(domain dnsname.FQDN) []resolverAndDelay {
f.mu.Lock()
routes := f.routes
cloudHostFallback := f.cloudHostFallback
schemes := f.schemeCacheLocked()
f.mu.Unlock()
for _, route := range routes {
if route.Suffix == "." || route.Suffix.Contains(domain) {
return route.Resolvers
if route.Suffix != "." && !route.Suffix.Contains(domain) {
continue
}
resolved := applySchemes(f.logf, route.Resolvers, schemes)
// If scheme resolution filtered out all resolvers from a non-empty
// route, fall through to the next matching route. If the resolvers
// were configured to be empty allow resolved to be empty.
if len(resolved) > 0 || len(route.Resolvers) == 0 {
return resolved
}
}
return cloudHostFallback // or nil if no fallback
@@ -893,6 +1210,39 @@ func (f *forwarder) GetUpstreamResolvers(name dnsname.FQDN) []*dnstype.Resolver
return upstreamResolvers
}
// RegisterCustomScheme adds a [CustomSchemeHandler] that is called to provide
// an updated address when a [dnstype.Resolver.Addr] uses that scheme.
func (f *forwarder) RegisterCustomScheme(scheme string, h CustomSchemeHandler) error {
f.mu.Lock()
defer f.mu.Unlock()
if _, ok := f.schemes[scheme]; ok {
return fmt.Errorf("scheme %q already registered", scheme)
}
f.invalidateSchemeCacheLocked()
mak.Set(&f.schemes, scheme, h)
return nil
}
// invalidateSchemeCacheLocked clears f.schemeCache so that it will be rebuilt
// on the next call to f.schemeCacheLocked().
func (f *forwarder) invalidateSchemeCacheLocked() {
f.schemeCache = views.Map[string, CustomSchemeHandler]{}
}
// schemeCacheLocked returns an immutable copy of f.schemes that can be used
// after mu is unlocked.
func (f *forwarder) schemeCacheLocked() views.Map[string, CustomSchemeHandler] {
if !f.schemeCache.IsNil() {
return f.schemeCache
}
if f.schemes == nil {
return f.schemeCache // returns a nil view
}
// Regenerate the cache
f.schemeCache = views.MapOf(maps.Clone(f.schemes))
return f.schemeCache
}
// forwardQuery is information and state about a forwarded DNS query that's
// being sent to 1 or more upstreams.
//
@@ -966,8 +1316,11 @@ func (f *forwarder) forwardWithDestChan(ctx context.Context, query packet, respo
if len(resolvers) == 0 {
resolvers = f.resolvers(domain)
if len(resolvers) == 0 {
// No upstream resolver for this name isn't a forwarder failure:
// it's split DNS / a name we weren't asked to handle. Count it
// rather than raising dnsForwarderFailing, which is reserved for
// resolvers we found but couldn't reach. See tailscale/tailscale#19931.
metricDNSFwdErrorNoUpstream.Add(1)
f.health.SetUnhealthy(dnsForwarderFailing, health.Args{health.ArgDNSServers: ""})
f.logf("no upstream resolvers set, returning SERVFAIL")
res, err := servfailResponse(query)
@@ -1031,6 +1384,7 @@ func (f *forwarder) forwardWithDestChan(ctx context.Context, query packet, respo
var firstErr error
var numErr int
var sawNonRefused bool
for {
select {
case v := <-resc:
@@ -1050,30 +1404,56 @@ func (f *forwarder) forwardWithDestChan(ctx context.Context, query packet, respo
if firstErr == nil {
firstErr = err
}
if !errors.Is(err, errRefused) {
sawNonRefused = true
}
numErr++
if numErr == len(resolvers) {
if errors.Is(firstErr, errServerFailure) {
res, err := servfailResponse(query)
if err != nil {
f.logf("building servfail response: %v", err)
var res packet
if sawNonRefused {
// At least one server failed with SERVFAIL or a transport error
// (e.g. network failure, TxID mismatch, unsupported resolver type).
// All such errors map to SERVFAIL at the client level.
// Prefer returning the upstream SERVFAIL bytes from firstErr if
// available; otherwise synthesize a SERVFAIL response. Note the
// rcode guard: firstErr may be a REFUSED rcodeResponseError if it
// arrived before the SERVFAIL that set sawNonRefused.
if rcodeErr, ok := errors.AsType[rcodeResponseError](firstErr); ok && rcodeErr.rcode == dns.RCodeServerFailure {
res = packet{rcodeErr.res, query.family, query.addr}
} else {
r, err := servfailResponse(query)
if err != nil {
f.logf("building servfail response: %v", err)
return firstErr
}
res = r
}
} else {
// !sawNonRefused means every error was an rcodeResponseError with rcode REFUSED,
// so firstErr is guaranteed to wrap one.
rcodeErr, ok := errors.AsType[rcodeResponseError](firstErr)
if !ok {
f.logf("unexpected: all errors were REFUSED but firstErr is not rcodeResponseError: %v", firstErr)
return firstErr
}
select {
case <-ctx.Done():
metricDNSFwdErrorContext.Add(1)
metricDNSFwdErrorContextGotError.Add(1)
var resolverAddrs []string
for _, rr := range resolvers {
resolverAddrs = append(resolverAddrs, rr.name.Addr)
}
f.health.SetUnhealthy(dnsForwarderFailing, health.Args{health.ArgDNSServers: strings.Join(resolverAddrs, ",")})
case responseChan <- res:
if f.verboseFwd {
f.logf("forwarder response(%d, %v, %d) = %d, %v", fq.txid, typ, len(domain), len(res.bs), firstErr)
}
return nil
res = packet{rcodeErr.res, query.family, query.addr}
}
select {
case <-ctx.Done():
metricDNSFwdErrorContext.Add(1)
metricDNSFwdErrorContextGotError.Add(1)
var resolverAddrs []string
for _, rr := range resolvers {
resolverAddrs = append(resolverAddrs, rr.name.Addr)
}
if f.acceptDNS {
f.health.SetUnhealthy(dnsForwarderFailing, health.Args{health.ArgDNSServers: strings.Join(resolverAddrs, ",")})
}
case responseChan <- res:
if f.verboseFwd {
f.logf("forwarder response(%d, %v, %d) = %d, %v", fq.txid, typ, len(domain), len(res.bs), firstErr)
}
return nil
}
return firstErr
}
@@ -1086,13 +1466,14 @@ func (f *forwarder) forwardWithDestChan(ctx context.Context, query packet, respo
// If we haven't got an error or a successful response,
// include all resolvers in the error message so we can
// at least see what what servers we're trying to
// query.
// at least see what servers we're trying to query.
var resolverAddrs []string
for _, rr := range resolvers {
resolverAddrs = append(resolverAddrs, rr.name.Addr)
}
f.health.SetUnhealthy(dnsForwarderFailing, health.Args{health.ArgDNSServers: strings.Join(resolverAddrs, ",")})
if f.acceptDNS {
f.health.SetUnhealthy(dnsForwarderFailing, health.Args{health.ArgDNSServers: strings.Join(resolverAddrs, ",")})
}
return fmt.Errorf("waiting for response or error from %v: %w", resolverAddrs, ctx.Err())
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build ts_macext && (darwin || ios)
+253 -71
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package resolver implements a stub DNS resolver that can also serve
@@ -16,7 +16,7 @@ import (
"net/netip"
"os"
"runtime"
"sort"
"slices"
"strconv"
"strings"
"sync"
@@ -39,6 +39,7 @@ import (
"tailscale.com/util/clientmetric"
"tailscale.com/util/cloudenv"
"tailscale.com/util/dnsname"
"tailscale.com/util/set"
)
const dnsSymbolicFQDN = "magicdns.localhost-tailscale-daemon."
@@ -48,8 +49,26 @@ const dnsSymbolicFQDN = "magicdns.localhost-tailscale-daemon."
// truncation in a platform-agnostic way.
const maxResponseBytes = 4095
// defaultTTL is the TTL of all responses from Resolver.
const defaultTTL = 600 * time.Second
// defaultTTL is the TTL of positive responses from Resolver.
//
// It's short because the source of truth (the netmap-fed host maps)
// is local and in-memory, so re-queries are nearly free, while
// anything cached downstream (e.g. mDNSResponder on macOS) delays
// clients noticing node renames for the full TTL (tailscale/corp#45631).
const defaultTTL = 5 * time.Second
// negativeTTL is how long resolvers may cache the nonexistence of a
// name (or of records of the queried type) for domains we're
// authoritative for. It's advertised via the SOA record attached to
// the authority section of NXDOMAIN and no-data responses, per RFC
// 2308. Without it, some resolvers (notably mDNSResponder) seem to
// cache negative entries for a really long time, so a name queried
// shortly before a node rename doesn't start resolving for a while
// (tailscale/corp#45631).
const negativeTTL = 10 * time.Second
// timeNow is time.Now, except in tests.
var timeNow = time.Now
var (
errNotQuery = errors.New("not a DNS query")
@@ -69,6 +88,9 @@ type packet struct {
// Else forward the query to the most specific matching entry in Routes.
// Else return SERVFAIL.
type Config struct {
// True if [Prefs.CorpDNS] is true or --accept-dns=true was specified.
// This should only be used for error handling and health reporting.
AcceptDNS bool
// Routes is a map of DNS name suffix to the resolvers to use for
// queries within that suffix.
// Queries only match the most specific suffix.
@@ -79,6 +101,12 @@ type Config struct {
// LocalDomains is a list of DNS name suffixes that should not be
// routed to upstream resolvers.
LocalDomains []dnsname.FQDN
// SubdomainHosts is a set of FQDNs from Hosts that should also
// resolve subdomain queries to the same IPs. If a query like
// "sub.node.tailnet.ts.net" doesn't match Hosts directly, and
// "node.tailnet.ts.net" is in SubdomainHosts, the query resolves
// to the IPs for "node.tailnet.ts.net".
SubdomainHosts set.Set[dnsname.FQDN]
}
// WriteToBufioWriter write a debug version of c for logs to w, omitting
@@ -162,7 +190,7 @@ func WriteRoutes(w *bufio.Writer, routes map[dnsname.FQDN][]*dnstype.Resolver) {
}
kk = append(kk, k)
}
sort.Slice(kk, func(i, j int) bool { return kk[i] < kk[j] })
slices.Sort(kk)
w.WriteByte('{')
for i, k := range kk {
if i > 0 {
@@ -214,10 +242,61 @@ type Resolver struct {
closed chan struct{}
// mu guards the following fields from being updated while used.
mu syncs.Mutex
localDomains []dnsname.FQDN
hostToIP map[dnsname.FQDN][]netip.Addr
ipToHost map[netip.Addr]dnsname.FQDN
mu syncs.Mutex
localDomains []dnsname.FQDN
hostToIP map[dnsname.FQDN][]netip.Addr
ipToHost map[netip.Addr]dnsname.FQDN
subdomainHosts set.Set[dnsname.FQDN]
magicHosts MagicDNSHosts // or nil if none installed
}
// MagicDNSHosts is a live source of MagicDNS host records, installed
// via [Resolver.SetMagicDNSHosts].
//
// It replaces the per-node entries of [Config.Hosts]: instead of the
// caller pushing a full snapshot of every node's name and addresses
// into the resolver on every (possibly incremental) netmap change,
// the resolver pulls the answer for one name on demand from the
// caller's live indexes. [Config.Hosts] remains for control's
// DNS.ExtraRecords entries, which are few, and is consulted first.
//
// Implementations must be safe for concurrent use and cheap: the
// methods are called on the DNS query serving path. Name lookups are
// case-insensitive: the resolver passes lowercase names, but
// implementations must not rely on that.
//
// Short names ("foo") are only resolved if MagicDNS is enabled
// for the current tailnet.
type MagicDNSHosts interface {
// LookupHost returns the IPs to answer for the node with the
// given MagicDNS FQDN, and whether the name is known. It returns
// all answerable IPs regardless of record type; the resolver
// filters them by the query's type, and a known name with no IPs
// of the query's family is "name exists, no records", not
// NXDOMAIN.
LookupHost(dnsname.FQDN) (ips []netip.Addr, ok bool)
// LookupPTR returns the MagicDNS FQDN of the node that owns
// the given Tailscale IP, and whether the IP is known.
LookupPTR(netip.Addr) (_ dnsname.FQDN, ok bool)
// SubdomainHost reports whether fqdn names a node with the
// [tailcfg.NodeAttrDNSSubdomainResolve] attribute, whose
// subdomains all resolve to the node's own addresses.
SubdomainHost(dnsname.FQDN) bool
}
// SetMagicDNSHosts installs the live MagicDNS host source consulted
// by forward and reverse MagicDNS lookups that miss [Config.Hosts].
// It is expected to be called once, before the resolver serves
// queries.
func (r *Resolver) SetMagicDNSHosts(h MagicDNSHosts) {
if !buildfeatures.HasDNS {
return
}
r.mu.Lock()
defer r.mu.Unlock()
r.magicHosts = h
}
type ForwardLinkSelector interface {
@@ -255,6 +334,16 @@ func New(logf logger.Logf, linkSel ForwardLinkSelector, dialer *tsdial.Dialer, h
func (r *Resolver) TestOnlySetHook(hook func(Config)) { r.saveConfigForTests = hook }
// ProbeLocks acquires and releases the resolver's internal mutexes.
func (r *Resolver) ProbeLocks() {
r.mu.Lock()
r.mu.Unlock()
if r.forwarder != nil {
r.forwarder.probeLocks()
}
}
func (r *Resolver) SetConfig(cfg Config) error {
if !buildfeatures.HasDNS {
return nil
@@ -271,16 +360,29 @@ func (r *Resolver) SetConfig(cfg Config) error {
}
}
r.forwarder.setRoutes(cfg.Routes)
r.forwarder.setRoutes(cfg.Routes, cfg.AcceptDNS)
r.mu.Lock()
defer r.mu.Unlock()
r.localDomains = cfg.LocalDomains
r.hostToIP = cfg.Hosts
r.ipToHost = reverse
r.subdomainHosts = cfg.SubdomainHosts
return nil
}
// CustomSchemeHandler takes a URI (retrieved from [dnstype.Resolver.Addr]) and
// returns an updated URI to use for the current query. The result is only valid
// for right now and may change over time.
type CustomSchemeHandler func(addr string) (newAddr string, err error)
// RegisterCustomScheme adds a [CustomSchemaHandler] that is called to provide
// an updated address to the forwarder when a [dnstype.Resolver.Addr] uses that
// scheme.
func (r *Resolver) RegisterCustomScheme(scheme string, h CustomSchemeHandler) error {
return r.forwarder.RegisterCustomScheme(scheme, h)
}
// Close shuts down the resolver and ensures poll goroutines have exited.
// The Resolver cannot be used again after Close is called.
func (r *Resolver) Close() {
@@ -328,7 +430,12 @@ func (r *Resolver) Query(ctx context.Context, bs []byte, family string, from net
return (<-responses).bs, nil
}
return out, err
if err != nil {
return out, err
}
out = checkResponseSizeAndSetTC(out, bs, family, r.logf)
return out, nil
}
// GetUpstreamResolvers returns the resolvers that would be used to resolve
@@ -642,9 +749,26 @@ func (r *Resolver) resolveLocal(domain dnsname.FQDN, typ dns.Type) (netip.Addr,
r.mu.Lock()
hosts := r.hostToIP
localDomains := r.localDomains
subdomainHosts := r.subdomainHosts
magicHosts := r.magicHosts
r.mu.Unlock()
addrs, found := hosts[domain]
if !found && magicHosts != nil {
addrs, found = magicHosts.LookupHost(domain)
}
if !found {
for parent := domain.Parent(); parent != ""; parent = parent.Parent() {
if subdomainHosts.Contains(parent) {
addrs, found = hosts[parent]
break
}
if magicHosts != nil && magicHosts.SubdomainHost(parent) {
addrs, found = magicHosts.LookupHost(parent)
break
}
}
}
if !found {
for _, suffix := range localDomains {
if suffix.Contains(domain) {
@@ -715,23 +839,16 @@ func (r *Resolver) resolveLocal(domain dnsname.FQDN, typ dns.Type) (netip.Addr,
}
// resolveViaDomain synthesizes an IP address for quad-A DNS requests of the form
// `<IPv4-address-with-hypens-instead-of-dots>-via-<siteid>[.*]`. Two prior formats that
// didn't pan out (due to a Chrome issue and DNS search ndots issues) were
// `<IPv4-address>.via-<X>` and the older `via-<X>.<IPv4-address>`,
// where X is a decimal, or hex-encoded number with a '0x' prefix.
// `<IPv4-address-with-hypens-instead-of-dots>-via-<siteid>[.*]`.
// For example: "192-168-1-2-via-7" or "192-168-1-2-via-7.foo.ts.net."
//
// This exists as a convenient mapping into Tailscales 'Via Range'.
//
// It returns a zero netip.Addr and true to indicate a successful response with
// an empty answers section if the specified domain is a valid Tailscale 4via6
// domain, but the request type is neither quad-A nor ALL.
//
// TODO(maisem/bradfitz/tom): `<IPv4-address>.via-<X>` was introduced
// (2022-06-02) to work around an issue in Chrome where it would treat
// "http://via-1.1.2.3.4" as a search string instead of a URL. We should rip out
// the old format in early 2023.
func (r *Resolver) resolveViaDomain(domain dnsname.FQDN, typ dns.Type) (netip.Addr, bool) {
fqdn := string(domain.WithoutTrailingDot())
func (r *Resolver) resolveViaDomain(dnsName dnsname.FQDN, typ dns.Type) (netip.Addr, bool) {
fqdn := string(dnsName.WithoutTrailingDot())
switch typ {
case dns.TypeA, dns.TypeAAAA, dns.TypeALL:
// For Type A requests, we should return a successful response
@@ -745,45 +862,23 @@ func (r *Resolver) resolveViaDomain(domain dnsname.FQDN, typ dns.Type) (netip.Ad
default:
return netip.Addr{}, false
}
if len(fqdn) < len("via-X.0.0.0.0") {
if len(fqdn) < len("0-0-0-0-via-0") {
return netip.Addr{}, false // too short to be valid
}
var siteID string
var ip4Str string
switch {
case strings.Contains(fqdn, "-via-"):
// Format number 3: "192-168-1-2-via-7" or "192-168-1-2-via-7.foo.ts.net."
// Third time's a charm. The earlier two formats follow after this block.
firstLabel, domain, _ := strings.Cut(fqdn, ".") // "192-168-1-2-via-7"
if !(domain == "" || dnsname.HasSuffix(domain, "ts.net") || dnsname.HasSuffix(domain, "tailscale.net")) {
return netip.Addr{}, false
}
v4hyphens, suffix, ok := strings.Cut(firstLabel, "-via-")
if !ok {
return netip.Addr{}, false
}
siteID = suffix
ip4Str = strings.ReplaceAll(v4hyphens, "-", ".")
case strings.HasPrefix(fqdn, "via-"):
firstDot := strings.Index(fqdn, ".")
if firstDot < 0 {
return netip.Addr{}, false // missing dot delimiters
}
siteID = fqdn[len("via-"):firstDot]
ip4Str = fqdn[firstDot+1:]
default:
lastDot := strings.LastIndex(fqdn, ".")
if lastDot < 0 {
return netip.Addr{}, false // missing dot delimiters
}
suffix := fqdn[lastDot+1:]
if !strings.HasPrefix(suffix, "via-") {
return netip.Addr{}, false
}
siteID = suffix[len("via-"):]
ip4Str = fqdn[:lastDot]
if !strings.Contains(fqdn, "-via-") {
return netip.Addr{}, false // not a 4via6 domain
}
firstLabel, domain, _ := strings.Cut(fqdn, ".") // "192-168-1-2-via-7"
if !(domain == "" || dnsname.HasSuffix(domain, "ts.net") || dnsname.HasSuffix(domain, "tailscale.net")) {
return netip.Addr{}, false
}
v4hyphens, suffix, ok := strings.Cut(firstLabel, "-via-")
if !ok {
return netip.Addr{}, false
}
siteID := suffix
ip4Str := strings.ReplaceAll(v4hyphens, "-", ".")
ip4, err := netip.ParseAddr(ip4Str)
if err != nil {
@@ -846,6 +941,9 @@ func (r *Resolver) fqdnForIPLocked(ip netip.Addr, name dnsname.FQDN) (dnsname.FQ
}
ret, ok := r.ipToHost[ip]
if !ok && r.magicHosts != nil {
ret, ok = r.magicHosts.LookupPTR(ip)
}
if !ok {
for _, suffix := range r.localDomains {
if suffix.Contains(name) {
@@ -883,6 +981,14 @@ type response struct {
// NSs are the responses to an NS query.
NSs []*net.NS
// SOAZone, if non-empty, is the zone we're authoritative for
// that contains Question's name. marshalResponse attaches its
// SOA record to the authority section so that resolvers bound
// their negative caching to negativeTTL, per RFC 2308. It must
// only be set on negative responses: NXDOMAIN, or success with
// no records of the queried type.
SOAZone dnsname.FQDN
}
var dnsParserPool = &sync.Pool{
@@ -1064,6 +1170,35 @@ func marshalSRV(queryName dns.Name, srvs []*net.SRV, builder *dns.Builder) error
return nil
}
// marshalSOA serializes zone's SOA record into the authority section
// of an active builder, which must have had StartAuthorities called.
// Its only purpose is communicating negativeTTL to caching resolvers
// (RFC 2308), so all fields other than the TTLs are placeholders.
func marshalSOA(zone dnsname.FQDN, builder *dns.Builder) error {
name, err := dns.NewName(zone.WithTrailingDot())
if err != nil {
return err
}
return builder.SOAResource(dns.ResourceHeader{
Name: name,
Type: dns.TypeSOA,
Class: dns.ClassINET,
TTL: uint32(negativeTTL / time.Second),
}, dns.SOAResource{
NS: name,
MBox: name,
// A serial should only change when the zone data does,
// but nothing consumes ours (no secondaries, no zone
// transfers), and the current time is at least monotonic
// and cheap. Unix seconds fit in the uint32 until 2106.
Serial: uint32(timeNow().Unix()),
Refresh: uint32(negativeTTL / time.Second),
Retry: uint32(negativeTTL / time.Second),
Expire: uint32(negativeTTL / time.Second),
MinTTL: uint32(negativeTTL / time.Second),
})
}
// marshalResponse serializes the DNS response into a new buffer.
func marshalResponse(resp *response) ([]byte, error) {
resp.Header.Response = true
@@ -1098,6 +1233,14 @@ func marshalResponse(resp *response) ([]byte, error) {
// Only successful responses contain answers.
if !isSuccess {
if resp.SOAZone != "" {
if err := builder.StartAuthorities(); err != nil {
return nil, err
}
if err := marshalSOA(resp.SOAZone, &builder); err != nil {
return nil, err
}
}
return builder.Finish()
}
@@ -1131,6 +1274,15 @@ func marshalResponse(resp *response) ([]byte, error) {
return nil, err
}
if resp.SOAZone != "" {
if err := builder.StartAuthorities(); err != nil {
return nil, err
}
if err := marshalSOA(resp.SOAZone, &builder); err != nil {
return nil, err
}
}
return builder.Finish()
}
@@ -1248,6 +1400,19 @@ func rdnsNameToIPv6(name dnsname.FQDN) (ip netip.Addr, ok bool) {
// respondReverse returns a DNS response to a PTR query.
// It is assumed that resp.Question is populated by respond before this is called.
// authoritativeZoneFor returns the zone from the configured local
// domains that contains name, or "" if none does.
func (r *Resolver) authoritativeZoneFor(name dnsname.FQDN) dnsname.FQDN {
r.mu.Lock()
defer r.mu.Unlock()
for _, suffix := range r.localDomains {
if suffix.Contains(name) {
return suffix
}
}
return ""
}
func (r *Resolver) respondReverse(query []byte, name dnsname.FQDN, resp *response) ([]byte, error) {
if hasRDNSBonjourPrefix(name) {
metricDNSReverseMissBonjour.Add(1)
@@ -1259,6 +1424,9 @@ func (r *Resolver) respondReverse(query []byte, name dnsname.FQDN, resp *respons
metricDNSReverseMissOther.Add(1)
return nil, errNotOurName
}
if resp.Header.RCode == dns.RCodeNameError {
resp.SOAZone = r.authoritativeZoneFor(name)
}
metricDNSMagicDNSSuccessReverse.Add(1)
return marshalResponse(resp)
@@ -1315,6 +1483,17 @@ func (r *Resolver) respond(query []byte) ([]byte, error) {
resp := parser.response()
resp.Header.RCode = rcode
resp.IP = ip
switch {
case rcode == dns.RCodeNameError:
resp.SOAZone = r.authoritativeZoneFor(name)
case rcode == dns.RCodeSuccess && !ip.IsValid():
switch parser.Question.Type {
case dns.TypeA, dns.TypeAAAA, dns.TypeALL:
// The name exists but has no records of the queried
// type (e.g. an AAAA query for an IPv4-only node).
resp.SOAZone = r.authoritativeZoneFor(name)
}
}
metricDNSMagicDNSSuccessName.Add(1)
return marshalResponse(resp)
}
@@ -1376,21 +1555,24 @@ var (
metricDNSFwdErrorType = clientmetric.NewCounter("dns_query_fwd_error_type")
metricDNSFwdTruncated = clientmetric.NewCounter("dns_query_fwd_truncated")
metricDNSFwdUDP = clientmetric.NewCounter("dns_query_fwd_udp") // on entry
metricDNSFwdUDPWrote = clientmetric.NewCounter("dns_query_fwd_udp_wrote") // sent UDP packet
metricDNSFwdUDPErrorWrite = clientmetric.NewCounter("dns_query_fwd_udp_error_write")
metricDNSFwdUDPErrorServer = clientmetric.NewCounter("dns_query_fwd_udp_error_server")
metricDNSFwdUDPErrorTxID = clientmetric.NewCounter("dns_query_fwd_udp_error_txid")
metricDNSFwdUDPErrorRead = clientmetric.NewCounter("dns_query_fwd_udp_error_read")
metricDNSFwdUDPSuccess = clientmetric.NewCounter("dns_query_fwd_udp_success")
metricDNSFwdUDP = clientmetric.NewCounter("dns_query_fwd_udp") // on entry
metricDNSFwdUDPWrote = clientmetric.NewCounter("dns_query_fwd_udp_wrote") // sent UDP packet
metricDNSFwdUDPDropSrc = clientmetric.NewCounter("dns_query_fwd_udp_drop_src")
metricDNSFwdUDPErrorWrite = clientmetric.NewCounter("dns_query_fwd_udp_error_write")
metricDNSFwdUDPErrorServer = clientmetric.NewCounter("dns_query_fwd_udp_error_server")
metricDNSFwdUDPErrorRefused = clientmetric.NewCounter("dns_query_fwd_udp_error_refused")
metricDNSFwdUDPErrorTxID = clientmetric.NewCounter("dns_query_fwd_udp_error_txid")
metricDNSFwdUDPErrorRead = clientmetric.NewCounter("dns_query_fwd_udp_error_read")
metricDNSFwdUDPSuccess = clientmetric.NewCounter("dns_query_fwd_udp_success")
metricDNSFwdTCP = clientmetric.NewCounter("dns_query_fwd_tcp") // on entry
metricDNSFwdTCPWrote = clientmetric.NewCounter("dns_query_fwd_tcp_wrote") // sent TCP packet
metricDNSFwdTCPErrorWrite = clientmetric.NewCounter("dns_query_fwd_tcp_error_write")
metricDNSFwdTCPErrorServer = clientmetric.NewCounter("dns_query_fwd_tcp_error_server")
metricDNSFwdTCPErrorTxID = clientmetric.NewCounter("dns_query_fwd_tcp_error_txid")
metricDNSFwdTCPErrorRead = clientmetric.NewCounter("dns_query_fwd_tcp_error_read")
metricDNSFwdTCPSuccess = clientmetric.NewCounter("dns_query_fwd_tcp_success")
metricDNSFwdTCP = clientmetric.NewCounter("dns_query_fwd_tcp") // on entry
metricDNSFwdTCPWrote = clientmetric.NewCounter("dns_query_fwd_tcp_wrote") // sent TCP packet
metricDNSFwdTCPErrorWrite = clientmetric.NewCounter("dns_query_fwd_tcp_error_write")
metricDNSFwdTCPErrorServer = clientmetric.NewCounter("dns_query_fwd_tcp_error_server")
metricDNSFwdTCPErrorRefused = clientmetric.NewCounter("dns_query_fwd_tcp_error_refused")
metricDNSFwdTCPErrorTxID = clientmetric.NewCounter("dns_query_fwd_tcp_error_txid")
metricDNSFwdTCPErrorRead = clientmetric.NewCounter("dns_query_fwd_tcp_error_read")
metricDNSFwdTCPSuccess = clientmetric.NewCounter("dns_query_fwd_tcp_success")
metricDNSFwdDoH = clientmetric.NewCounter("dns_query_fwd_doh")
metricDNSFwdDoHErrorStatus = clientmetric.NewCounter("dns_query_fwd_doh_error_status")
+2 -5
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package dns
@@ -28,10 +28,7 @@ func maybeUnUTF16(bs []byte) []byte {
// Can't be complete UTF-16.
return bs
}
checkLen := 20
if len(bs) < checkLen {
checkLen = len(bs)
}
checkLen := min(len(bs), 20)
zeroOff := bytes.IndexByte(bs[:checkLen], 0)
if zeroOff == -1 {
return bs
+4 -3
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package dns
@@ -148,6 +148,8 @@ type wslFS struct {
distro string
}
func (fs wslFS) ActualPath(name string) string { return name }
func (fs wslFS) Stat(name string) (isRegular bool, err error) {
err = wslRun(fs.cmd("test", "-f", name))
if ee, _ := err.(*exec.ExitError); ee != nil {
@@ -172,8 +174,7 @@ func (fs wslFS) Truncate(name string) error { return fs.WriteFile(name, nil, 064
func (fs wslFS) ReadFile(name string) ([]byte, error) {
b, err := wslCombinedOutput(fs.cmd("cat", "--", name))
var ee *exec.ExitError
if errors.As(err, &ee) && ee.ExitCode() == 1 {
if ee, ok := errors.AsType[*exec.ExitError](err); ok && ee.ExitCode() == 1 {
return nil, os.ErrNotExist
}
return b, err