Files
ctrld/cmd/cli/dns64_test.go
T
Anthony Wong 8ae09ac98e feat(cli): put the network state of the host in the log journal
The journal records the network state of the host and each change of
that state. The support team reads one file to see the interfaces, the
routes, the resolvers, and the recoveries around an incident. The same
file shows the health of the query path. A change that touches only
AirDrop or virtual interfaces runs no DNS work.
2026-09-28 16:49:05 +07:00

425 lines
15 KiB
Go

package cli
import (
"net"
"net/netip"
"testing"
"time"
"github.com/miekg/dns"
"tailscale.com/net/netmon"
"github.com/Control-D-Inc/ctrld/internal/dnscache"
)
func mkAAAAReq(name string) *dns.Msg {
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(name), dns.TypeAAAA)
return m
}
func mkNetAddr(cidr string) net.Addr {
ip, n, err := net.ParseCIDR(cidr)
if err != nil {
panic(err)
}
n.IP = ip
return n
}
func TestDNS64NetworkClass(t *testing.T) {
tests := []struct {
name string
defaultRouteAddrs []net.Addr
allAddrs []net.Addr
wantV4 bool
wantCLAT bool
}{
{"dual stack", []net.Addr{mkNetAddr("10.0.11.61/23"), mkNetAddr("2605:8d80::1/64")}, []net.Addr{mkNetAddr("10.0.11.61/23"), mkNetAddr("2605:8d80::1/64")}, true, false},
{"virtual rfc1918 does not imply ipv4 connectivity", []net.Addr{mkNetAddr("2605:8d80::1/64")}, []net.Addr{mkNetAddr("2605:8d80::1/64"), mkNetAddr("192.168.65.1/24")}, false, false},
{"464xlat tether (customer case)", []net.Addr{mkNetAddr("2605:8d80:6b41:122::1/64")}, []net.Addr{mkNetAddr("192.0.0.2/32"), mkNetAddr("2605:8d80:6b41:122::1/64")}, false, true},
{"v6 only no clat (dns64 network)", []net.Addr{mkNetAddr("2001:db8::1/64")}, []net.Addr{mkNetAddr("2001:db8::1/64")}, false, false},
{"loopback only", []net.Addr{mkNetAddr("127.0.0.1/8"), mkNetAddr("::1/128")}, []net.Addr{mkNetAddr("127.0.0.1/8"), mkNetAddr("::1/128")}, false, false},
{"link local v4 ignored", []net.Addr{mkNetAddr("169.254.10.1/16"), mkNetAddr("2001:db8::1/64")}, []net.Addr{mkNetAddr("169.254.10.1/16"), mkNetAddr("2001:db8::1/64")}, false, false},
{"clat plus real v4", []net.Addr{mkNetAddr("10.0.0.5/24")}, []net.Addr{mkNetAddr("192.0.0.2/32"), mkNetAddr("10.0.0.5/24")}, true, true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
v4, clat := dns64NetworkClass(tc.defaultRouteAddrs, tc.allAddrs)
if v4 != tc.wantV4 || clat != tc.wantCLAT {
t.Errorf("dns64NetworkClass() = (v4=%v, clat=%v), want (v4=%v, clat=%v)", v4, clat, tc.wantV4, tc.wantCLAT)
}
})
}
}
func TestNAT64PrefixFromAnswer(t *testing.T) {
mkAnswer := func(v6 string) *dns.Msg {
m := new(dns.Msg)
m.SetQuestion(dns64WellKnownName, dns.TypeAAAA)
r := new(dns.Msg)
r.SetReply(m)
if v6 != "" {
r.Answer = append(r.Answer, &dns.AAAA{
Hdr: dns.RR_Header{Name: dns64WellKnownName, Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: 300},
AAAA: net.ParseIP(v6),
})
}
return r
}
tests := []struct {
name string
answer *dns.Msg
wantPrefix string
wantOK bool
}{
{"well-known prefix + 192.0.0.170", mkAnswer("64:ff9b::c000:aa"), "64:ff9b::/96", true},
{"well-known prefix + 192.0.0.171", mkAnswer("64:ff9b::c000:ab"), "64:ff9b::/96", true},
{"carrier-specific prefix", mkAnswer("2001:db8:64::c000:aa"), "2001:db8:64::/96", true},
{"non-dns64 answer (real aaaa)", mkAnswer("2001:db8::1"), "", false},
{"empty answer", mkAnswer(""), "", false},
{"nil answer", nil, "", false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
p, ok := nat64PrefixFromAnswer(tc.answer)
if ok != tc.wantOK {
t.Fatalf("nat64PrefixFromAnswer() ok = %v, want %v", ok, tc.wantOK)
}
if ok && p != netip.MustParsePrefix(tc.wantPrefix) {
t.Errorf("nat64PrefixFromAnswer() = %s, want %s", p, tc.wantPrefix)
}
})
}
}
func TestSynthesizeAAAAFromA(t *testing.T) {
req := mkAAAAReq("legacy.example.com")
aReq := req.Copy()
aReq.Question[0].Qtype = dns.TypeA
aAns := new(dns.Msg)
aAns.SetReply(aReq)
aAns.Answer = []dns.RR{
&dns.CNAME{Hdr: dns.RR_Header{Name: "legacy.example.com.", Rrtype: dns.TypeCNAME, Class: dns.ClassINET, Ttl: 60}, Target: "cdn.example.net."},
&dns.A{Hdr: dns.RR_Header{Name: "cdn.example.net.", Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60}, A: net.ParseIP("198.51.100.7")},
}
prefix := netip.MustParsePrefix("64:ff9b::/96")
out := synthesizeAAAAFromA(req, aAns, prefix)
if out == nil {
t.Fatal("synthesizeAAAAFromA returned nil")
}
var gotAAAA *dns.AAAA
var gotCNAME *dns.CNAME
for _, rr := range out.Answer {
switch v := rr.(type) {
case *dns.AAAA:
gotAAAA = v
case *dns.CNAME:
gotCNAME = v
case *dns.A:
t.Error("synthesized answer still contains an A record")
}
}
if gotCNAME == nil {
t.Error("CNAME chain record not preserved")
}
if gotAAAA == nil {
t.Fatal("no synthesized AAAA record")
}
want := net.ParseIP("64:ff9b::c633:6407") // 198.51.100.7 embedded
if !gotAAAA.AAAA.Equal(want) {
t.Errorf("synthesized AAAA = %s, want %s", gotAAAA.AAAA, want)
}
if gotAAAA.Hdr.Ttl != 60 {
t.Errorf("TTL not preserved: got %d", gotAAAA.Hdr.Ttl)
}
if out.Question[0].Qtype != dns.TypeAAAA {
t.Errorf("reply question type = %d, want AAAA", out.Question[0].Qtype)
}
}
func TestSynthesizeAAAAFromAIPv4Eligibility(t *testing.T) {
req := mkAAAAReq("blocked.example")
tests := []struct {
name string
prefix netip.Prefix
ip string
want bool
}{
{"unspecified with well-known prefix", dns64WellKnownPrefix, "0.0.0.0", false},
{"loopback with well-known prefix", dns64WellKnownPrefix, "127.0.0.1", false},
{"link-local with well-known prefix", dns64WellKnownPrefix, "169.254.1.1", false},
{"private with well-known prefix", dns64WellKnownPrefix, "10.0.0.1", false},
{"private with network-specific prefix", netip.MustParsePrefix("2001:db8:64::/96"), "10.0.0.1", true},
{"unspecified with network-specific prefix", netip.MustParsePrefix("2001:db8:64::/96"), "0.0.0.0", false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
aReq := req.Copy()
aReq.Question[0].Qtype = dns.TypeA
aAns := new(dns.Msg)
aAns.SetReply(aReq)
aAns.Answer = []dns.RR{&dns.A{Hdr: dns.RR_Header{Name: req.Question[0].Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60}, A: net.ParseIP(tc.ip)}}
if got := answerHasAAAA(synthesizeAAAAFromA(req, aAns, tc.prefix)); got != tc.want {
t.Fatalf("answerHasAAAA() = %v, want %v", got, tc.want)
}
})
}
}
func TestDNS64Eligible(t *testing.T) {
emptyReply := func(req *dns.Msg, rcode int) *dns.Msg {
r := new(dns.Msg)
r.SetReply(req)
r.Rcode = rcode
return r
}
aaaaReq := mkAAAAReq("x.example.")
cdReq := aaaaReq.Copy()
cdReq.CheckingDisabled = true
withAAAA := emptyReply(aaaaReq, dns.RcodeSuccess)
withAAAA.Answer = []dns.RR{&dns.AAAA{Hdr: dns.RR_Header{Name: "x.example.", Rrtype: dns.TypeAAAA, Class: dns.ClassINET}, AAAA: net.ParseIP("2001:db8::1")}}
aReq := new(dns.Msg)
aReq.SetQuestion("x.example.", dns.TypeA)
tests := []struct {
name string
req *dns.Msg
answer *dns.Msg
want bool
}{
{"AAAA empty NOERROR -> eligible", aaaaReq, emptyReply(aaaaReq, dns.RcodeSuccess), true},
{"AAAA with records -> not eligible", aaaaReq, withAAAA, false},
{"CD query is not synthesized", cdReq, emptyReply(cdReq, dns.RcodeSuccess), false},
{"NXDOMAIN never synthesized", aaaaReq, emptyReply(aaaaReq, dns.RcodeNameError), false},
{"SERVFAIL never synthesized", aaaaReq, emptyReply(aaaaReq, dns.RcodeServerFailure), false},
{"A query not eligible", aReq, emptyReply(aReq, dns.RcodeSuccess), false},
{"nil answer", aaaaReq, nil, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := dns64Eligible(tc.req, tc.answer); got != tc.want {
t.Errorf("dns64Eligible() = %v, want %v", got, tc.want)
}
})
}
}
func TestDNS64ActiveGating(t *testing.T) {
cases := []struct {
name string
hasV4 bool
hasCLAT bool
prefix string
want bool
}{
{"dual stack, prefix known", true, false, "64:ff9b::/96", false},
{"clat network, prefix known", false, true, "64:ff9b::/96", false},
{"v6-only no clat, prefix known", false, false, "64:ff9b::/96", true},
{"v6-only no clat, no prefix yet", false, false, "", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
old := dns64NetworkClassFn
dns64NetworkClassFn = func() (bool, bool, error) { return tc.hasV4, tc.hasCLAT, nil }
t.Cleanup(func() { dns64NetworkClassFn = old })
p := &prog{}
p.dns64.discovering = true // block background discovery in tests
if tc.prefix != "" {
p.dns64.prefix = netip.MustParsePrefix(tc.prefix)
}
if got := p.dns64Active(); got != tc.want {
t.Errorf("dns64Active() = %v, want %v", got, tc.want)
}
})
}
}
func TestStoreDiscoveredNAT64PrefixActivatesImmediately(t *testing.T) {
p := &prog{}
p.dns64.generation = 4
p.dns64.checkedAt = time.Now()
prefix := netip.MustParsePrefix("64:ff9b::/96")
if !p.storeDiscoveredNAT64Prefix(4, prefix) {
t.Fatal("current discovery result was rejected")
}
if !p.dns64.active || p.dns64.prefix != prefix || p.dns64.checkedAt.IsZero() {
t.Fatalf("discovery did not immediately activate DNS64: active=%v prefix=%s checkedAt=%s", p.dns64.active, p.dns64.prefix, p.dns64.checkedAt)
}
if p.storeDiscoveredNAT64Prefix(3, netip.MustParsePrefix("2001:db8:64::/96")) {
t.Fatal("stale discovery result was accepted")
}
if p.dns64.prefix != prefix {
t.Fatalf("stale discovery replaced prefix: %s", p.dns64.prefix)
}
}
func TestDNS64NetworkChangeInvalidatesPrefix(t *testing.T) {
p := &prog{}
p.dns64.prefix = netip.MustParsePrefix("64:ff9b::/96")
p.dns64.active = true
p.dns64.checkedAt = time.Now()
p.dns64.discovering = true
delta := &netmon.ChangeDelta{
Old: &netmon.State{DefaultRouteInterface: "en0", HaveV6: true, InterfaceIPs: map[string][]netip.Prefix{"en0": {netip.MustParsePrefix("2001:db8:1::1/64")}}},
New: &netmon.State{DefaultRouteInterface: "en0", HaveV6: true, InterfaceIPs: map[string][]netip.Prefix{"en0": {netip.MustParsePrefix("2001:db8:2::1/64")}}},
}
p.handleDNS64NetworkChange(delta, false)
if p.dns64.active || p.dns64.prefix.IsValid() || !p.dns64.checkedAt.IsZero() || p.dns64.discovering {
t.Fatalf("network change did not invalidate DNS64 state: active=%v prefix=%s checkedAt=%s discovering=%v", p.dns64.active, p.dns64.prefix, p.dns64.checkedAt, p.dns64.discovering)
}
if p.dns64.generation != 1 {
t.Fatalf("generation = %d, want 1", p.dns64.generation)
}
}
func TestDNS64CacheKeyPartitionsByPrefix(t *testing.T) {
req := mkAAAAReq("legacy.example")
normal := dnscache.NewKey(req, "upstream.0")
wellKnown := dns64CacheKey(req, "upstream.0", netip.MustParsePrefix("64:ff9b::/96"))
carrier := dns64CacheKey(req, "upstream.0", netip.MustParsePrefix("2001:db8:64::/96"))
if normal == wellKnown || wellKnown == carrier {
t.Fatalf("normal and per-prefix synthesized cache keys must be distinct: normal=%+v well-known=%+v carrier=%+v", normal, wellKnown, carrier)
}
cache, err := dnscache.NewLRUCache(4)
if err != nil {
t.Fatal(err)
}
answer := new(dns.Msg)
answer.SetReply(req)
cache.Add(wellKnown, dnscache.NewValue(answer, time.Now().Add(time.Minute)))
if cache.Get(wellKnown) == nil || cache.Get(normal) != nil || cache.Get(carrier) != nil {
t.Fatal("synthesized cache entry crossed the normal or carrier-prefix partition")
}
}
func TestMaybeDNS64EndToEnd(t *testing.T) {
old := dns64NetworkClassFn
dns64NetworkClassFn = func() (bool, bool, error) { return false, false, nil }
t.Cleanup(func() { dns64NetworkClassFn = old })
p := &prog{}
p.dns64.discovering = true
p.dns64.prefix = netip.MustParsePrefix("64:ff9b::/96")
req := mkAAAAReq("legacy.example.com")
empty := new(dns.Msg)
empty.SetReply(req)
resolveA := func(aReq *dns.Msg) *dns.Msg {
if aReq.Question[0].Qtype != dns.TypeA {
t.Fatalf("resolveA called with qtype %d", aReq.Question[0].Qtype)
}
r := new(dns.Msg)
r.SetReply(aReq)
r.Answer = []dns.RR{&dns.A{Hdr: dns.RR_Header{Name: aReq.Question[0].Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 30}, A: net.ParseIP("203.0.113.9")}}
return r
}
out, usedPrefix := p.maybeDNS64(t.Context(), req, empty, resolveA)
if !answerHasAAAA(out) {
t.Fatal("expected synthesized AAAA answer")
}
if usedPrefix != p.dns64.prefix {
t.Fatalf("used prefix = %s, want %s", usedPrefix, p.dns64.prefix)
}
blocked := new(dns.Msg)
blocked.SetReply(req)
blocked.Rcode = dns.RcodeNameError
if got, _ := p.maybeDNS64(t.Context(), req, blocked, resolveA); got != blocked {
t.Error("NXDOMAIN answer must pass through unsynthesized")
}
blockedA := func(aReq *dns.Msg) *dns.Msg {
r := new(dns.Msg)
r.SetReply(aReq)
r.Answer = []dns.RR{&dns.A{Hdr: dns.RR_Header{Name: aReq.Question[0].Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 30}, A: net.IPv4zero}}
return r
}
if got, prefix := p.maybeDNS64(t.Context(), req, empty, blockedA); got != empty || prefix != p.dns64.prefix {
t.Error("NODATA plus 0.0.0.0 block answer must pass through unsynthesized and be cacheable for the current prefix")
}
}
func TestMaybeDNS64DropsStaleInFlightPrefix(t *testing.T) {
p := &prog{}
p.dns64.active = true
p.dns64.checkedAt = time.Now()
p.dns64.prefix = netip.MustParsePrefix("64:ff9b::/96")
req := mkAAAAReq("legacy.example")
empty := new(dns.Msg)
empty.SetReply(req)
got, prefix := p.maybeDNS64(t.Context(), req, empty, func(aReq *dns.Msg) *dns.Msg {
p.resetDNS64State()
r := new(dns.Msg)
r.SetReply(aReq)
r.Answer = []dns.RR{&dns.A{Hdr: dns.RR_Header{Name: aReq.Question[0].Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 30}, A: net.ParseIP("203.0.113.9")}}
return r
})
if got != empty || prefix.IsValid() {
t.Fatal("in-flight synthesis used a prefix invalidated by a network change")
}
}
// TestDNS64PrefixSnapshotReadsTheStoredStateOnly covers the read that a report
// of the network uses. The active read reclassifies the network and can start
// a discovery query, and a report must change nothing.
func TestDNS64PrefixSnapshotReadsTheStoredStateOnly(t *testing.T) {
classReads := 0
old := dns64NetworkClassFn
dns64NetworkClassFn = func() (bool, bool, error) {
classReads++
return false, false, nil
}
t.Cleanup(func() { dns64NetworkClassFn = old })
p := &prog{}
p.dns64.prefix = netip.MustParsePrefix("64:ff9b::/96")
p.dns64.active = true
prefix, active := p.dns64PrefixSnapshot()
if !active || prefix != p.dns64.prefix {
t.Fatalf("snapshot = %v, %v, want %v, true", prefix, active, p.dns64.prefix)
}
if classReads != 0 {
t.Fatalf("the snapshot classified the network %d times, want 0", classReads)
}
if p.dns64.discovering {
t.Fatal("the snapshot started a NAT64 prefix discovery")
}
}
// TestDNS64ResultLinesReachTheJournalAtInfo covers the level of the two NAT64
// result lines. Both report a change, and a debug line never leaves a host
// that runs at the normal level, so the journal lost the result of a network
// that has no prefix.
func TestDNS64ResultLinesReachTheJournalAtInfo(t *testing.T) {
logs := captureDebugMainLog(t)
p := &prog{}
// The first result of a run reports nothing when the network has no
// prefix, so the prefix has to arrive before it goes away.
p.logNAT64Result("64:ff9b::/96")
p.logNAT64Result("64:ff9b::/96")
p.logNAT64Result("")
absent := jsonLogEvents(t, logs, "dns64: no NAT64 prefix present (not a DNS64 network)")
if len(absent) != 1 {
t.Fatalf("got %d no-prefix lines, want 1", len(absent))
}
wantField(t, absent[0], "level", "info")
wantField(t, absent[0], "journal", true)
found := jsonLogEvents(t, logs, "dns64: discovered NAT64 prefix 64:ff9b::/96; enabling AAAA synthesis for IPv6-only network without CLAT")
if len(found) != 1 {
t.Fatalf("got %d discovered lines for two identical results, want 1", len(found))
}
wantField(t, found[0], "level", "info")
wantField(t, found[0], "journal", true)
}