net/dns: retrample resolve.conf when another process has trampled it (#18069)

When using the resolve.conf file for setting DNS, it is possible that
some other services will trample the file and overwrite our set DNS
server. Experiments has shown this to be a racy error depending on how
quickly processes start.

Make an attempt to trample back the file a limited number of times if
the file is changed.

Updates #16635

Signed-off-by: Claus Lensbøl <claus@tailscale.com>
This commit is contained in:
Claus Lensbøl
2025-12-09 14:55:26 -05:00
committed by GitHub
parent a9b37c510c
commit 1dfdee8521
17 changed files with 261 additions and 45 deletions

View File

@@ -0,0 +1,109 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux
package dns
import (
"context"
"fmt"
"net/netip"
"os"
"path/filepath"
"testing"
"testing/synctest"
"github.com/illarion/gonotify/v3"
"tailscale.com/util/dnsname"
"tailscale.com/util/eventbus/eventbustest"
)
func TestDNSTrampleRecovery(t *testing.T) {
HookWatchFile.Set(watchFile)
synctest.Test(t, func(t *testing.T) {
tmp := t.TempDir()
if err := os.MkdirAll(filepath.Join(tmp, "etc"), 0700); err != nil {
t.Fatal(err)
}
const resolvPath = "/etc/resolv.conf"
fs := directFS{prefix: tmp}
readFile := func(t *testing.T, path string) string {
t.Helper()
b, err := fs.ReadFile(path)
if err != nil {
t.Errorf("Reading DNS config: %v", err)
}
return string(b)
}
bus := eventbustest.NewBus(t)
eventbustest.LogAllEvents(t, bus)
m := newDirectManagerOnFS(t.Logf, nil, bus, fs)
defer m.Close()
if err := m.SetDNS(OSConfig{
Nameservers: []netip.Addr{netip.MustParseAddr("8.8.8.8"), netip.MustParseAddr("8.8.4.4")},
SearchDomains: []dnsname.FQDN{"ts.net.", "ts-dns.test."},
MatchDomains: []dnsname.FQDN{"ignored."},
}); err != nil {
t.Fatal(err)
}
const want = `# resolv.conf(5) file generated by tailscale
# For more info, see https://tailscale.com/s/resolvconf-overwrite
# DO NOT EDIT THIS FILE BY HAND -- CHANGES WILL BE OVERWRITTEN
nameserver 8.8.8.8
nameserver 8.8.4.4
search ts.net ts-dns.test
`
if got := readFile(t, resolvPath); got != want {
t.Fatalf("resolv.conf:\n%s, want:\n%s", got, want)
}
tw := eventbustest.NewWatcher(t, bus)
const trample = "Hvem er det som tramper på min bro?"
if err := fs.WriteFile(resolvPath, []byte(trample), 0644); err != nil {
t.Fatal(err)
}
synctest.Wait()
if err := eventbustest.Expect(tw, eventbustest.Type[TrampleDNS]()); err != nil {
t.Errorf("did not see trample event: %s", err)
}
})
}
// watchFile is generally copied from linuxtrample, but cancels the context
// after the first call to cb() after the first trample to end the test.
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()
cancel()
}
case <-ctx.Done():
return ctx.Err()
}
}
}