Files
tailscale/net/dns/resolver/negative_cache_apple_test.go
Jonathan Nobels 5cc8aa7f07 net/dns: flush stale MagicDNS NXDOMAINs on Apple platforms (#21584)
Updates tailscale/corp#49775

Track authoritative NXDOMAINs for at least 60 seconds on macOS and iOS (where our short SOA ttls are not properly respected), and flush when a cached name becomes resolvable. Compile a no-op tracker elsewhere and avoid duplicate VPN reconfiguration on link changes.
2026-10-13 15:48:31 -04:00

301 lines
9.3 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build darwin || ios
package resolver
import (
"fmt"
"net/netip"
"sync"
"testing"
"time"
dns "golang.org/x/net/dns/dnsmessage"
"tailscale.com/util/dnsname"
"tailscale.com/util/set"
)
// TestTrackNegativeAnswers checks that only authoritative NXDOMAIN answers
// enter the tracker, with case-normalized names and reverse queries included.
func TestTrackNegativeAnswers(t *testing.T) {
for _, tt := range []struct {
name dnsname.FQDN
typ dns.Type
want dnsname.FQDN
}{
{"TEST3.IPN.DEV.", dns.TypeA, "test3.ipn.dev."},
{"test3.ipn.dev.", dns.TypeAAAA, "test3.ipn.dev."},
{"test3.ipn.dev.", dns.TypeMX, "test3.ipn.dev."},
{"test1.ipn.dev.", dns.TypeA, ""},
{"test1.ipn.dev.", dns.TypeAAAA, ""},
{"9.3.2.1.in-addr.arpa.", dns.TypePTR, "9.3.2.1.in-addr.arpa."},
{"foo.onion.", dns.TypeA, ""},
{"outside.example.", dns.TypeA, ""},
} {
t.Run(string(tt.name)+tt.typ.String(), func(t *testing.T) {
r := newResolver(t)
defer r.Close()
if err := r.SetConfig(dnsCfg); err != nil {
t.Fatal(err)
}
_, err := r.respond(dnspacket(tt.name, tt.typ, noEdns))
if err != nil && err != errNotOurName {
t.Fatal(err)
}
if tt.want == "" {
if r.negativeCache.entries.Len() != 0 {
t.Fatal("non-authoritative-NXDOMAIN was tracked")
}
} else if _, ok := r.negativeCache.entries.PeekOk(tt.want); !ok {
t.Fatalf("missing negative %s", tt.want)
}
})
}
}
// negativeCacheUpdatingHosts runs an update after a lookup has observed a miss,
// but before the resolver can record the resulting NXDOMAIN.
type negativeCacheUpdatingHosts struct {
fakeMagicDNSHosts
afterMiss func()
}
func (h *negativeCacheUpdatingHosts) missed() {
if f := h.afterMiss; f != nil {
h.afterMiss = nil
f()
}
}
func (h *negativeCacheUpdatingHosts) LookupHost(name dnsname.FQDN) ([]netip.Addr, bool) {
ips, ok := h.fakeMagicDNSHosts.LookupHost(name)
if !ok {
h.missed()
}
return ips, ok
}
func (h *negativeCacheUpdatingHosts) LookupPTR(ip netip.Addr) (dnsname.FQDN, bool) {
name, ok := h.fakeMagicDNSHosts.LookupPTR(ip)
if !ok {
h.missed()
}
return name, ok
}
// TestNegativeCacheUpdateDuringQuery covers a query that observes a missing peer
// just before a peer update checks the still-empty negative-answer history.
// Returning NXDOMAIN for the newly available peer would leave the OS's stale
// negative cached until expiry or another update. The afterMiss callback forces
// this ordering without timing or goroutines.
//
// A, AAAA, and PTR queries must retry and answer from the new peer; a cache reset
// during resolution must likewise invalidate the pending negative. An unrelated
// update must leave a retried NXDOMAIN tracked for a later matching update, while
// a discarded negative must not cause a subsequent cache flush.
func TestNegativeCacheUpdateDuringQuery(t *testing.T) {
for _, tt := range []struct {
name string
typ dns.Type
stillMissing bool
reset bool
}{
{name: "A", typ: dns.TypeA},
{name: "AAAA", typ: dns.TypeAAAA},
{name: "PTR", typ: dns.TypePTR},
{name: "unrelated update", typ: dns.TypeA, stillMissing: true},
{name: "reset", typ: dns.TypeA, reset: true},
} {
t.Run(tt.name, func(t *testing.T) {
r := newResolver(t)
defer r.Close()
if err := r.SetConfig(Config{LocalDomains: []dnsname.FQDN{"test.net.", "64.100.in-addr.arpa."}}); err != nil {
t.Fatal(err)
}
hosts := &negativeCacheUpdatingHosts{}
r.SetMagicDNSHosts(hosts)
const name dnsname.FQDN = "server.test.net."
ip4 := netip.MustParseAddr("100.64.0.2")
ip6 := netip.MustParseAddr("fd7a:115c:a1e0::2")
addPeer := func() {
hosts.hosts = map[dnsname.FQDN][]netip.Addr{name: {ip4, ip6}}
hosts.ptr = map[netip.Addr]dnsname.FQDN{ip4: name}
}
hosts.afterMiss = func() {
if !tt.stillMissing {
addPeer()
}
if tt.reset {
r.ClearNegativeCache()
} else if r.CheckCachedDNS() {
t.Fatal("empty history requested a flush")
}
}
queryName := name
if tt.typ == dns.TypePTR {
queryName = "2.0.64.100.in-addr.arpa."
}
packet, err := r.respond(dnspacket(queryName, tt.typ, noEdns))
if err != nil {
t.Fatal(err)
}
var msg dns.Message
if err := msg.Unpack(packet); err != nil {
t.Fatal(err)
}
want := dns.RCodeSuccess
if tt.stillMissing {
want = dns.RCodeNameError
}
if msg.RCode != want {
t.Fatalf("rcode = %v, want %v", msg.RCode, want)
}
if tt.stillMissing {
// A retried query that is still negative must remain tracked
// so a subsequent matching peer update can flush it.
addPeer()
if !r.CheckCachedDNS() {
t.Fatal("retried NXDOMAIN was not tracked")
}
} else if len(msg.Answers) != 1 {
t.Fatalf("answers = %v, want one answer from the new peer", msg.Answers)
}
if r.CheckCachedDNS() {
t.Fatal("unsent NXDOMAIN requested a flush")
}
})
}
}
// TestCheckCachedDNS checks name matching, expiry, subdomain capabilities,
// and history clearing when deciding whether a cached NXDOMAIN needs a flush.
func TestCheckCachedDNS(t *testing.T) {
for _, tt := range []struct {
name string
query dnsname.FQDN
subdomains, reset, want bool
age time.Duration
}{
{name: "exact", query: "server.test.net.", want: true},
{name: "unqueried"},
{name: "unrelated", query: "other.test.net."},
{name: "recent", query: "server.test.net.", age: negativeCacheLifetime - time.Nanosecond, want: true},
{name: "expired", query: "server.test.net.", age: negativeCacheLifetime},
{name: "subdomain", query: "foo.server.test.net.", subdomains: true, want: true},
{name: "nested subdomain", query: "foo.bar.server.test.net.", subdomains: true, want: true},
{name: "no subdomain capability", query: "foo.server.test.net."},
{name: "label boundary", query: "foo.otherserver.test.net.", subdomains: true},
{name: "profile reset", query: "server.test.net.", reset: true},
} {
t.Run(tt.name, func(t *testing.T) {
r := newResolver(t)
defer r.Close()
now := time.Unix(1000, 0)
r.negativeCache.now = func() time.Time { return now }
cfg := Config{LocalDomains: []dnsname.FQDN{"test.net."}}
if err := r.SetConfig(cfg); err != nil {
t.Fatal(err)
}
if tt.query != "" {
if _, err := r.respond(dnspacket(tt.query, dns.TypeA, noEdns)); err != nil {
t.Fatal(err)
}
}
if r.CheckCachedDNS() {
t.Fatal("still missing name triggered flush")
}
now = now.Add(tt.age)
if tt.reset {
r.ClearNegativeCache()
}
cfg.Hosts = map[dnsname.FQDN][]netip.Addr{"server.test.net.": {netip.MustParseAddr("100.64.0.1")}}
if tt.subdomains {
cfg.SubdomainHosts = set.Of[dnsname.FQDN]("server.test.net.")
}
if err := r.SetConfig(cfg); err != nil {
t.Fatal(err)
}
if got := r.CheckCachedDNS(); got != tt.want {
t.Fatalf("flush = %v, want %v", got, tt.want)
}
if r.CheckCachedDNS() {
t.Fatal("repeated update requested another flush")
}
})
}
}
// TestNegativeCacheBoundedAndConcurrent checks the LRU capacity under serial
// and concurrent inserts, and verifies recording resumes after clearing.
func TestNegativeCacheBoundedAndConcurrent(t *testing.T) {
for _, tt := range []struct {
name string
workers, perWorker, want int
}{
{"below capacity", 1, 10, 10},
{"at capacity", 1, maxRecentNXDomains, maxRecentNXDomains},
{"concurrent overflow", 100, 50, maxRecentNXDomains},
} {
t.Run(tt.name, func(t *testing.T) {
r := newResolver(t)
defer r.Close()
var wg sync.WaitGroup
for i := range tt.workers {
wg.Go(func() {
for j := range tt.perWorker {
r.negativeCache.record(dnsname.FQDN(fmt.Sprintf("peer%d-%d.test.net.", i, j)), r.negativeCache.queryGeneration())
}
})
}
wg.Wait()
if got := r.negativeCache.entries.Len(); got != tt.want {
t.Fatalf("entries = %d, want %d", got, tt.want)
}
r.ClearNegativeCache()
if r.negativeCache.entries.Len() != 0 {
t.Fatal("global flush did not clear the LRU")
}
r.negativeCache.record("fresh.test.net.", r.negativeCache.queryGeneration())
if r.negativeCache.entries.Len() != 1 {
t.Fatal("negative after flush was lost")
}
})
}
}
// TestNegativeCacheRepeatedAnswerRefreshesAge checks that repeated negative
// answers restart the tracking lifetime and eventually expire.
func TestNegativeCacheRepeatedAnswerRefreshesAge(t *testing.T) {
for _, tt := range []struct {
name string
repeat bool
elapsed time.Duration
want bool
}{
{"without refresh", false, 2 * time.Second, false},
{"refreshed", true, 2 * time.Second, true},
{"refresh expired", true, negativeCacheLifetime, false},
} {
t.Run(tt.name, func(t *testing.T) {
r := newResolver(t)
defer r.Close()
now := time.Unix(1000, 0)
r.negativeCache.now = func() time.Time { return now }
r.negativeCache.record("server.test.net.", r.negativeCache.queryGeneration())
now = now.Add(negativeCacheLifetime - time.Second)
if tt.repeat {
r.negativeCache.record("server.test.net.", r.negativeCache.queryGeneration())
}
now = now.Add(tt.elapsed)
if err := r.SetConfig(Config{Hosts: map[dnsname.FQDN][]netip.Addr{"server.test.net.": {netip.MustParseAddr("100.64.0.1")}}}); err != nil {
t.Fatal(err)
}
if got := r.CheckCachedDNS(); got != tt.want {
t.Fatalf("flush = %v, want %v", got, tt.want)
}
})
}
}