Files
tailscale/feature/dnsresolvecache/dnsresolvecache_test.go
Brad Fitzpatrick aa2681ac5f net/dnscache, feature/dnsresolvecache: persist DNS resolutions to disk
Add a new modular dnsresolvecache feature that records every successful
DNS resolution from net/dnscache as a JSON file per hostname in
$statedir/dns-cache/, so a later boot with misconfigured DNS can still
find last-known-good IPs for critical hostnames like the control plane.
Files are rewritten only when their contents change, so a file's
modification time records when the answer last changed.

When regular DNS resolution fails, the disk cache is now consulted
before the DERP-based bootstrap DNS in net/dnsfallback. This is the
first step toward removing the DERP-based mechanism: new clientmetrics
(dnscache_disk_fallback_hit, dnscache_disk_fallback_miss,
dnscache_derp_fallback_ok, dnscache_derp_fallback_dial_ok) will tell us
when the DERP path no longer fires in the fleet and can be deleted.

The feature is linked into tailscaled by default (omittable with
ts_omit_dnsresolvecache) and is not included in tsnet.

Updates #21028

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I59228cbf68e3b48dfb1215cdd12bd8166ab14034
2026-09-08 16:11:02 -07:00

178 lines
4.4 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package dnsresolvecache
import (
"net/netip"
"os"
"path/filepath"
"slices"
"testing"
"time"
)
func setDirForTest(t *testing.T) string {
t.Helper()
dir := t.TempDir()
mu.Lock()
cacheDir = dir
logf = t.Logf
lastWritten = nil
mu.Unlock()
t.Cleanup(func() {
mu.Lock()
cacheDir = ""
logf = nil
lastWritten = nil
mu.Unlock()
})
return dir
}
func mustIPs(ss ...string) (ips []netip.Addr) {
for _, s := range ss {
ips = append(ips, netip.MustParseAddr(s))
}
return ips
}
func mtime(t *testing.T, path string) time.Time {
t.Helper()
fi, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
return fi.ModTime()
}
func TestPersistAndLookup(t *testing.T) {
dir := setDirForTest(t)
persist("Ctrl.Example.COM", "forward", mustIPs("4.4.4.4", "2600::2", "1.1.1.1", "2600::1", "1.1.1.1"))
path := filepath.Join(dir, "dns-ctrl.example.com.json")
got, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
const want = `{"Resolver":"forward","A":["1.1.1.1","4.4.4.4"],"AAAA":["2600::1","2600::2"]}`
if string(got) != want {
t.Errorf("file contents = %q; want %q", got, want)
}
ips, ok := lookup("ctrl.example.com")
if !ok {
t.Fatal("lookup failed")
}
if want := mustIPs("1.1.1.1", "4.4.4.4", "2600::1", "2600::2"); !slices.Equal(ips, want) {
t.Errorf("lookup = %v; want %v", ips, want)
}
if _, ok := lookup("other.example.com"); ok {
t.Error("lookup of unknown host unexpectedly succeeded")
}
}
func TestNoRewriteWhenUnchanged(t *testing.T) {
dir := setDirForTest(t)
path := filepath.Join(dir, "dns-ctrl.example.com.json")
old := time.Now().Add(-time.Hour).Round(time.Second)
persist("ctrl.example.com", "forward", mustIPs("1.1.1.1"))
if err := os.Chtimes(path, old, old); err != nil {
t.Fatal(err)
}
persist("ctrl.example.com", "forward", mustIPs("1.1.1.1"))
if got := mtime(t, path); !got.Equal(old) {
t.Errorf("unchanged persist rewrote file; mtime = %v, want %v", got, old)
}
// A fresh process (no in-memory digests) should also not rewrite
// an identical file.
mu.Lock()
lastWritten = nil
mu.Unlock()
persist("ctrl.example.com", "forward", mustIPs("1.1.1.1"))
if got := mtime(t, path); !got.Equal(old) {
t.Errorf("unchanged persist after restart rewrote file; mtime = %v, want %v", got, old)
}
persist("ctrl.example.com", "forward", mustIPs("2.2.2.2"))
if got := mtime(t, path); got.Equal(old) {
t.Error("changed persist did not rewrite file")
}
if ips, ok := lookup("ctrl.example.com"); !ok || !slices.Equal(ips, mustIPs("2.2.2.2")) {
t.Errorf("lookup after change = %v, %v; want [2.2.2.2], true", ips, ok)
}
}
func TestInvalidHostnames(t *testing.T) {
dir := setDirForTest(t)
for _, host := range []string{
"",
".",
"..",
"../evil",
"foo/bar.example.com",
"foo..example.com",
".example.com",
"example.com.", // trailing dot: valid DNS, but not a name we store
"bad*char.example.com",
} {
persist(host, "forward", mustIPs("1.1.1.1"))
if _, ok := lookup(host); ok {
t.Errorf("lookup(%q) unexpectedly succeeded", host)
}
}
des, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
for _, de := range des {
t.Errorf("unexpected file %q written for invalid hostname", de.Name())
}
}
func TestLookupCorruptFile(t *testing.T) {
dir := setDirForTest(t)
if err := os.WriteFile(filepath.Join(dir, "dns-ctrl.example.com.json"), []byte("not json"), 0600); err != nil {
t.Fatal(err)
}
if _, ok := lookup("ctrl.example.com"); ok {
t.Error("lookup of corrupt file unexpectedly succeeded")
}
}
func TestSetCacheDirFirstCallWins(t *testing.T) {
t.Cleanup(func() {
mu.Lock()
cacheDir = ""
logf = nil
lastWritten = nil
mu.Unlock()
})
dir1 := t.TempDir()
dir2 := t.TempDir()
setCacheDir(dir1, t.Logf)
// A second call is a no-op; the first directory stays in use.
setCacheDir(dir2, t.Logf)
persist("ctrl.example.com", "forward", mustIPs("1.1.1.1"))
if _, err := os.Stat(filepath.Join(dir1, "dns-ctrl.example.com.json")); err != nil {
t.Errorf("file not written to first dir: %v", err)
}
if _, err := os.Stat(filepath.Join(dir2, "dns-ctrl.example.com.json")); err == nil {
t.Error("file unexpectedly written to second dir")
}
}
func TestDisabled(t *testing.T) {
// No setCacheDir call: both directions are no-ops.
persist("ctrl.example.com", "forward", mustIPs("1.1.1.1"))
if _, ok := lookup("ctrl.example.com"); ok {
t.Error("lookup unexpectedly succeeded with no cache dir set")
}
}