feature/linuxdnsfight: move inotify watching of /etc/resolv.conf out to a feature

tsnet apps in particular never use the Linux DNS OSManagers, so they don't need
DBus, etc. I started to pull that all out into separate features so tsnet doesn't
need to bring in DBus, but hit this first.

Here you can see that tsnet (and the k8s-operator) no longer pulls in inotify.

Updates #17206

Change-Id: I7af0f391f60c5e7dbeed7a080346f83262346591
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
Brad Fitzpatrick
2025-09-19 17:15:04 -07:00
committed by Brad Fitzpatrick
parent f9c699812a
commit 798fddbe5c
13 changed files with 159 additions and 122 deletions

View File

@@ -145,8 +145,6 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
L github.com/google/nftables/xt from github.com/google/nftables/expr+ L github.com/google/nftables/xt from github.com/google/nftables/expr+
github.com/google/uuid from github.com/prometheus-community/pro-bing+ github.com/google/uuid from github.com/prometheus-community/pro-bing+
github.com/hdevalence/ed25519consensus from tailscale.com/clientupdate/distsign+ github.com/hdevalence/ed25519consensus from tailscale.com/clientupdate/distsign+
L 💣 github.com/illarion/gonotify/v3 from tailscale.com/net/dns
L github.com/illarion/gonotify/v3/syscallf from github.com/illarion/gonotify/v3
L github.com/jmespath/go-jmespath from github.com/aws/aws-sdk-go-v2/service/ssm L github.com/jmespath/go-jmespath from github.com/aws/aws-sdk-go-v2/service/ssm
github.com/josharian/intern from github.com/mailru/easyjson/jlexer github.com/josharian/intern from github.com/mailru/easyjson/jlexer
L 💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/netmon L 💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/netmon

View File

@@ -124,7 +124,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
L github.com/google/nftables/xt from github.com/google/nftables/expr+ L github.com/google/nftables/xt from github.com/google/nftables/expr+
DW github.com/google/uuid from tailscale.com/clientupdate+ DW github.com/google/uuid from tailscale.com/clientupdate+
github.com/hdevalence/ed25519consensus from tailscale.com/clientupdate/distsign+ github.com/hdevalence/ed25519consensus from tailscale.com/clientupdate/distsign+
L 💣 github.com/illarion/gonotify/v3 from tailscale.com/net/dns L 💣 github.com/illarion/gonotify/v3 from tailscale.com/feature/linuxdnsfight
L github.com/illarion/gonotify/v3/syscallf from github.com/illarion/gonotify/v3 L github.com/illarion/gonotify/v3/syscallf from github.com/illarion/gonotify/v3
L github.com/insomniacslk/dhcp/dhcpv4 from tailscale.com/feature/tap L github.com/insomniacslk/dhcp/dhcpv4 from tailscale.com/feature/tap
L github.com/insomniacslk/dhcp/iana from github.com/insomniacslk/dhcp/dhcpv4 L github.com/insomniacslk/dhcp/iana from github.com/insomniacslk/dhcp/dhcpv4
@@ -277,6 +277,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/feature/condregister/portmapper from tailscale.com/feature/condregister tailscale.com/feature/condregister/portmapper from tailscale.com/feature/condregister
tailscale.com/feature/debugportmapper from tailscale.com/feature/condregister tailscale.com/feature/debugportmapper from tailscale.com/feature/condregister
tailscale.com/feature/drive from tailscale.com/feature/condregister tailscale.com/feature/drive from tailscale.com/feature/condregister
L tailscale.com/feature/linuxdnsfight from tailscale.com/feature/condregister
tailscale.com/feature/portmapper from tailscale.com/feature/condregister/portmapper tailscale.com/feature/portmapper from tailscale.com/feature/condregister/portmapper
tailscale.com/feature/relayserver from tailscale.com/feature/condregister tailscale.com/feature/relayserver from tailscale.com/feature/condregister
tailscale.com/feature/syspolicy from tailscale.com/feature/condregister+ tailscale.com/feature/syspolicy from tailscale.com/feature/condregister+

View File

@@ -114,8 +114,6 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
L github.com/google/nftables/xt from github.com/google/nftables/expr+ L github.com/google/nftables/xt from github.com/google/nftables/expr+
DW github.com/google/uuid from github.com/prometheus-community/pro-bing+ DW github.com/google/uuid from github.com/prometheus-community/pro-bing+
github.com/hdevalence/ed25519consensus from tailscale.com/clientupdate/distsign+ github.com/hdevalence/ed25519consensus from tailscale.com/clientupdate/distsign+
L 💣 github.com/illarion/gonotify/v3 from tailscale.com/net/dns
L github.com/illarion/gonotify/v3/syscallf from github.com/illarion/gonotify/v3
L github.com/jmespath/go-jmespath from github.com/aws/aws-sdk-go-v2/service/ssm L github.com/jmespath/go-jmespath from github.com/aws/aws-sdk-go-v2/service/ssm
L 💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/netmon L 💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/netmon
L github.com/jsimonetti/rtnetlink/internal/unix from github.com/jsimonetti/rtnetlink L github.com/jsimonetti/rtnetlink/internal/unix from github.com/jsimonetti/rtnetlink

View File

@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build ts_omit_linuxdnsfight
package buildfeatures
// HasLinuxDNSFight is whether the binary was built with support for modular feature "Linux support for detecting DNS fights (inotify watching of /etc/resolv.conf)".
// Specifically, it's whether the binary was NOT built with the "ts_omit_linuxdnsfight" build tag.
// It's a const so it can be used for dead code elimination.
const HasLinuxDNSFight = false

View File

@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build !ts_omit_linuxdnsfight
package buildfeatures
// HasLinuxDNSFight is whether the binary was built with support for modular feature "Linux support for detecting DNS fights (inotify watching of /etc/resolv.conf)".
// Specifically, it's whether the binary was NOT built with the "ts_omit_linuxdnsfight" build tag.
// It's a const so it can be used for dead code elimination.
const HasLinuxDNSFight = true

View File

@@ -0,0 +1,8 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux && !android && !ts_omit_linuxdnsfight
package condregister
import _ "tailscale.com/feature/linuxdnsfight"

View File

@@ -105,6 +105,7 @@ var Features = map[FeatureTag]FeatureMeta{
"desktop_sessions": {"DesktopSessions", "Desktop sessions support", nil}, "desktop_sessions": {"DesktopSessions", "Desktop sessions support", nil},
"drive": {"Drive", "Tailscale Drive (file server) support", nil}, "drive": {"Drive", "Tailscale Drive (file server) support", nil},
"kube": {"Kube", "Kubernetes integration", nil}, "kube": {"Kube", "Kubernetes integration", nil},
"linuxdnsfight": {"LinuxDNSFight", "Linux support for detecting DNS fights (inotify watching of /etc/resolv.conf)", nil},
"oauthkey": {"OAuthKey", "OAuth secret-to-authkey resolution support", nil}, "oauthkey": {"OAuthKey", "OAuth secret-to-authkey resolution support", nil},
"outboundproxy": { "outboundproxy": {
Sym: "OutboundProxy", Sym: "OutboundProxy",

View File

@@ -0,0 +1,51 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux && !android
// Package linuxdnsfight provides Linux support for detecting DNS fights
// (inotify watching of /etc/resolv.conf).
package linuxdnsfight
import (
"context"
"fmt"
"github.com/illarion/gonotify/v3"
"tailscale.com/net/dns"
)
func init() {
dns.HookWatchFile.Set(watchFile)
}
// watchFile sets up an inotify watch for a given directory and
// calls the callback function every time a particular file is changed.
// The filename should be located in the provided directory.
func watchFile(ctx context.Context, dir, filename string, cb func()) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
const events = gonotify.IN_ATTRIB |
gonotify.IN_CLOSE_WRITE |
gonotify.IN_CREATE |
gonotify.IN_DELETE |
gonotify.IN_MODIFY |
gonotify.IN_MOVE
watcher, err := gonotify.NewDirWatcher(ctx, events, dir)
if err != nil {
return fmt.Errorf("NewDirWatcher: %w", err)
}
for {
select {
case event := <-watcher.C:
if event.Name == filename {
cb()
}
case <-ctx.Done():
return ctx.Err()
}
}
}

View File

@@ -1,7 +1,9 @@
// Copyright (c) Tailscale Inc & AUTHORS // Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause // SPDX-License-Identifier: BSD-3-Clause
package dns //go:build linux && !android
package linuxdnsfight
import ( import (
"context" "context"

View File

@@ -23,6 +23,7 @@ import (
"sync" "sync"
"time" "time"
"tailscale.com/feature"
"tailscale.com/health" "tailscale.com/health"
"tailscale.com/net/dns/resolvconffile" "tailscale.com/net/dns/resolvconffile"
"tailscale.com/net/tsaddr" "tailscale.com/net/tsaddr"
@@ -415,6 +416,73 @@ func (m *directManager) GetBaseConfig() (OSConfig, error) {
return oscfg, nil return oscfg, nil
} }
// HookWatchFile is a hook for watching file changes, for platforms that support it.
// The function is called with a directory and filename to watch, and a callback
// to call when the file changes. It returns an error if the watch could not be set up.
var HookWatchFile feature.Hook[func(ctx context.Context, dir, filename string, cb func()) error]
func (m *directManager) runFileWatcher() {
watchFile, ok := HookWatchFile.GetOk()
if !ok {
return
}
if err := watchFile(m.ctx, "/etc/", resolvConf, m.checkForFileTrample); err != nil {
// This is all best effort for now, so surface warnings to users.
m.logf("dns: inotify: %s", err)
}
}
var resolvTrampleWarnable = health.Register(&health.Warnable{
Code: "resolv-conf-overwritten",
Severity: health.SeverityMedium,
Title: "DNS configuration issue",
Text: health.StaticMessage("System DNS config not ideal. /etc/resolv.conf overwritten. See https://tailscale.com/s/dns-fight"),
})
// checkForFileTrample checks whether /etc/resolv.conf has been trampled
// by another program on the system. (e.g. a DHCP client)
func (m *directManager) checkForFileTrample() {
m.mu.Lock()
want := m.wantResolvConf
lastWarn := m.lastWarnContents
m.mu.Unlock()
if want == nil {
return
}
cur, err := m.fs.ReadFile(resolvConf)
if err != nil {
m.logf("trample: read error: %v", err)
return
}
if bytes.Equal(cur, want) {
m.health.SetHealthy(resolvTrampleWarnable)
if lastWarn != nil {
m.mu.Lock()
m.lastWarnContents = nil
m.mu.Unlock()
m.logf("trample: resolv.conf again matches expected content")
}
return
}
if bytes.Equal(cur, lastWarn) {
// We already logged about this, so not worth doing it again.
return
}
m.mu.Lock()
m.lastWarnContents = cur
m.mu.Unlock()
show := cur
if len(show) > 1024 {
show = show[:1024]
}
m.logf("trample: resolv.conf changed from what we expected. did some other program interfere? current contents: %q", show)
m.health.SetUnhealthy(resolvTrampleWarnable, nil)
}
func (m *directManager) Close() error { func (m *directManager) Close() error {
m.ctxClose() m.ctxClose()

View File

@@ -1,104 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux && !android
package dns
import (
"bytes"
"context"
"fmt"
"github.com/illarion/gonotify/v3"
"tailscale.com/health"
)
func (m *directManager) runFileWatcher() {
if err := watchFile(m.ctx, "/etc/", resolvConf, m.checkForFileTrample); err != nil {
// This is all best effort for now, so surface warnings to users.
m.logf("dns: inotify: %s", err)
}
}
// watchFile sets up an inotify watch for a given directory and
// calls the callback function every time a particular file is changed.
// The filename should be located in the provided directory.
func watchFile(ctx context.Context, dir, filename string, cb func()) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
const events = gonotify.IN_ATTRIB |
gonotify.IN_CLOSE_WRITE |
gonotify.IN_CREATE |
gonotify.IN_DELETE |
gonotify.IN_MODIFY |
gonotify.IN_MOVE
watcher, err := gonotify.NewDirWatcher(ctx, events, dir)
if err != nil {
return fmt.Errorf("NewDirWatcher: %w", err)
}
for {
select {
case event := <-watcher.C:
if event.Name == filename {
cb()
}
case <-ctx.Done():
return ctx.Err()
}
}
}
var resolvTrampleWarnable = health.Register(&health.Warnable{
Code: "resolv-conf-overwritten",
Severity: health.SeverityMedium,
Title: "Linux DNS configuration issue",
Text: health.StaticMessage("Linux DNS config not ideal. /etc/resolv.conf overwritten. See https://tailscale.com/s/dns-fight"),
})
// checkForFileTrample checks whether /etc/resolv.conf has been trampled
// by another program on the system. (e.g. a DHCP client)
func (m *directManager) checkForFileTrample() {
m.mu.Lock()
want := m.wantResolvConf
lastWarn := m.lastWarnContents
m.mu.Unlock()
if want == nil {
return
}
cur, err := m.fs.ReadFile(resolvConf)
if err != nil {
m.logf("trample: read error: %v", err)
return
}
if bytes.Equal(cur, want) {
m.health.SetHealthy(resolvTrampleWarnable)
if lastWarn != nil {
m.mu.Lock()
m.lastWarnContents = nil
m.mu.Unlock()
m.logf("trample: resolv.conf again matches expected content")
}
return
}
if bytes.Equal(cur, lastWarn) {
// We already logged about this, so not worth doing it again.
return
}
m.mu.Lock()
m.lastWarnContents = cur
m.mu.Unlock()
show := cur
if len(show) > 1024 {
show = show[:1024]
}
m.logf("trample: resolv.conf changed from what we expected. did some other program interfere? current contents: %q", show)
m.health.SetUnhealthy(resolvTrampleWarnable, nil)
}

View File

@@ -1,10 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !linux && !android && !ios
package dns
func (m *directManager) runFileWatcher() {
// Not implemented on other platforms. Maybe it could resort to polling.
}

View File

@@ -114,8 +114,6 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware)
L github.com/google/nftables/xt from github.com/google/nftables/expr+ L github.com/google/nftables/xt from github.com/google/nftables/expr+
DWI github.com/google/uuid from github.com/prometheus-community/pro-bing+ DWI github.com/google/uuid from github.com/prometheus-community/pro-bing+
github.com/hdevalence/ed25519consensus from tailscale.com/clientupdate/distsign+ github.com/hdevalence/ed25519consensus from tailscale.com/clientupdate/distsign+
L 💣 github.com/illarion/gonotify/v3 from tailscale.com/net/dns
L github.com/illarion/gonotify/v3/syscallf from github.com/illarion/gonotify/v3
L github.com/jmespath/go-jmespath from github.com/aws/aws-sdk-go-v2/service/ssm L github.com/jmespath/go-jmespath from github.com/aws/aws-sdk-go-v2/service/ssm
L 💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/netmon L 💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/netmon
L github.com/jsimonetti/rtnetlink/internal/unix from github.com/jsimonetti/rtnetlink L github.com/jsimonetti/rtnetlink/internal/unix from github.com/jsimonetti/rtnetlink