control/controlknobs,net/dns,tailcfg: add a control knob that disables hosts file updates on Windows

In the absence of a better mechanism, writing unqualified hostnames to the hosts file may be required
for MagicDNS to work on some Windows environments, such as domain-joined machines. It can also
improve MagicDNS performance on non-domain joined devices when we are not the device's primary
DNS resolver.

At the same time, updating the hosts file can be slow and expensive, especially when it already contains
many entries, as was previously reported in #14327. It may also have negative side effects, such as interfering
with the system's DNS resolution policies.

Additionally, to fix #18712, we had to extend hosts file usage to domain-joined machines when we are not
the primary DNS resolver. For the reasons above, this change may introduce risk.

To allow customers to disable hosts file updates remotely without disabling MagicDNS entirely, whether on
domain-joined machines or not, this PR introduces the `disable-hosts-file-updates` node attribute.

Updates #18712
Updates #14327

Signed-off-by: Nick Khyl <nickk@tailscale.com>
main
Nick Khyl 2 months ago committed by Nick Khyl
parent afb065fb68
commit 9741c1e846
  1. 8
      control/controlknobs/controlknobs.go
  2. 21
      net/dns/manager_windows.go
  3. 10
      tailcfg/tailcfg.go

@ -107,6 +107,12 @@ type Knobs struct {
// of queued netmap.NetworkMap between the controlclient and LocalBackend. // of queued netmap.NetworkMap between the controlclient and LocalBackend.
// See tailscale/tailscale#14768. // See tailscale/tailscale#14768.
DisableSkipStatusQueue atomic.Bool DisableSkipStatusQueue atomic.Bool
// DisableHostsFileUpdates indicates that the node's DNS manager should not create
// hosts file entries when it normally would, such as when we're not the primary
// resolver on Windows or when the host is domain-joined and its primary domain
// takes precedence over MagicDNS. As of 2026-02-13, it is only used on Windows.
DisableHostsFileUpdates atomic.Bool
} }
// UpdateFromNodeAttributes updates k (if non-nil) based on the provided self // UpdateFromNodeAttributes updates k (if non-nil) based on the provided self
@ -137,6 +143,7 @@ func (k *Knobs) UpdateFromNodeAttributes(capMap tailcfg.NodeCapMap) {
disableLocalDNSOverrideViaNRPT = has(tailcfg.NodeAttrDisableLocalDNSOverrideViaNRPT) disableLocalDNSOverrideViaNRPT = has(tailcfg.NodeAttrDisableLocalDNSOverrideViaNRPT)
disableCaptivePortalDetection = has(tailcfg.NodeAttrDisableCaptivePortalDetection) disableCaptivePortalDetection = has(tailcfg.NodeAttrDisableCaptivePortalDetection)
disableSkipStatusQueue = has(tailcfg.NodeAttrDisableSkipStatusQueue) disableSkipStatusQueue = has(tailcfg.NodeAttrDisableSkipStatusQueue)
disableHostsFileUpdates = has(tailcfg.NodeAttrDisableHostsFileUpdates)
) )
if has(tailcfg.NodeAttrOneCGNATEnable) { if has(tailcfg.NodeAttrOneCGNATEnable) {
@ -163,6 +170,7 @@ func (k *Knobs) UpdateFromNodeAttributes(capMap tailcfg.NodeCapMap) {
k.DisableLocalDNSOverrideViaNRPT.Store(disableLocalDNSOverrideViaNRPT) k.DisableLocalDNSOverrideViaNRPT.Store(disableLocalDNSOverrideViaNRPT)
k.DisableCaptivePortalDetection.Store(disableCaptivePortalDetection) k.DisableCaptivePortalDetection.Store(disableCaptivePortalDetection)
k.DisableSkipStatusQueue.Store(disableSkipStatusQueue) k.DisableSkipStatusQueue.Store(disableSkipStatusQueue)
k.DisableHostsFileUpdates.Store(disableHostsFileUpdates)
// If both attributes are present, then "enable" should win. This reflects // If both attributes are present, then "enable" should win. This reflects
// the history of seamless key renewal. // the history of seamless key renewal.

@ -34,6 +34,7 @@ import (
"tailscale.com/util/syspolicy/policyclient" "tailscale.com/util/syspolicy/policyclient"
"tailscale.com/util/syspolicy/ptype" "tailscale.com/util/syspolicy/ptype"
"tailscale.com/util/winutil" "tailscale.com/util/winutil"
"tailscale.com/util/winutil/winenv"
) )
const ( const (
@ -354,6 +355,10 @@ func (m *windowsManager) disableLocalDNSOverrideViaNRPT() bool {
return m.knobs != nil && m.knobs.DisableLocalDNSOverrideViaNRPT.Load() 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 { func (m *windowsManager) SetDNS(cfg OSConfig) error {
// We can configure Windows DNS in one of two ways: // We can configure Windows DNS in one of two ways:
// //
@ -400,7 +405,7 @@ func (m *windowsManager) SetDNS(cfg OSConfig) error {
return err return err
} }
var hosts []*HostEntry var hosts []*HostEntry
if winenv.IsDomainJoined() { if !m.disableHostsFileUpdates() && winenv.IsDomainJoined() {
// On domain-joined Windows devices the primary search domain (the one the device is joined to) // 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 // 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 // resolver on the device (see #18712). To work around this Windows behavior, we should write MagicDNS
@ -429,12 +434,14 @@ func (m *windowsManager) SetDNS(cfg OSConfig) error {
return err return err
} }
// As we are not the primary resolver in this setup, we need to if !m.disableHostsFileUpdates() {
// explicitly set some single name hosts to ensure that we can resolve // As we are not the primary resolver in this setup, we need to
// them quickly and get around the 2.3s delay that otherwise occurs due // explicitly set some single name hosts to ensure that we can resolve
// to multicast timeouts. // them quickly and get around the 2.3s delay that otherwise occurs due
if err := m.setHosts(cfg.Hosts); err != nil { // to multicast timeouts.
return err if err := m.setHosts(cfg.Hosts); err != nil {
return err
}
} }
} }

@ -178,7 +178,8 @@ type CapabilityVersion int
// - 129: 2025-10-04: Fixed sleep/wake deadlock in magicsock when using peer relay (PR #17449) // - 129: 2025-10-04: Fixed sleep/wake deadlock in magicsock when using peer relay (PR #17449)
// - 130: 2025-10-06: client can send key.HardwareAttestationPublic and key.HardwareAttestationKeySignature in MapRequest // - 130: 2025-10-06: client can send key.HardwareAttestationPublic and key.HardwareAttestationKeySignature in MapRequest
// - 131: 2025-11-25: client respects [NodeAttrDefaultAutoUpdate] // - 131: 2025-11-25: client respects [NodeAttrDefaultAutoUpdate]
const CurrentCapabilityVersion CapabilityVersion = 131 // - 132: 2026-02-13: client respects [NodeAttrDisableHostsFileUpdates]
const CurrentCapabilityVersion CapabilityVersion = 132
// ID is an integer ID for a user, node, or login allocated by the // ID is an integer ID for a user, node, or login allocated by the
// control plane. // control plane.
@ -2740,6 +2741,13 @@ const (
// //
// The value of the key in [NodeCapMap] is a JSON boolean. // The value of the key in [NodeCapMap] is a JSON boolean.
NodeAttrDefaultAutoUpdate NodeCapability = "default-auto-update" NodeAttrDefaultAutoUpdate NodeCapability = "default-auto-update"
// NodeAttrDisableHostsFileUpdates indicates that the node's DNS manager should
// not create hosts file entries when it normally would, such as when we're not
// the primary resolver on Windows or when the host is domain-joined and its
// primary domain takes precedence over MagicDNS. As of 2026-02-12, it is only
// used on Windows.
NodeAttrDisableHostsFileUpdates NodeCapability = "disable-hosts-file-updates"
) )
// SetDNSRequest is a request to add a DNS record. // SetDNSRequest is a request to add a DNS record.

Loading…
Cancel
Save