mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-09-04 13:36:35 +02:00
dns proxy: port DNS64 synthesis to master
Port the reviewed DNS64 behavior while preserving master's context-aware resolver, cache, logging, and Firewall Mode flow.
This commit is contained in:
@@ -0,0 +1,366 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user