From dd77d865b65dbb4dcc8c528ac8c0cbbcaca5ade0 Mon Sep 17 00:00:00 2001 From: Dev Scribe Date: Wed, 2 Sep 2026 09:51:27 +0000 Subject: [PATCH] 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. --- cmd/cli/dns64.go | 446 +++++++++++++++++++++++++++++++++++++ cmd/cli/dns64_test.go | 366 ++++++++++++++++++++++++++++++ cmd/cli/dns_proxy.go | 142 +++++++++--- cmd/cli/dns_proxy_test.go | 46 ++++ cmd/cli/prog.go | 4 + internal/dnscache/cache.go | 9 + 6 files changed, 979 insertions(+), 34 deletions(-) create mode 100644 cmd/cli/dns64.go create mode 100644 cmd/cli/dns64_test.go diff --git a/cmd/cli/dns64.go b/cmd/cli/dns64.go new file mode 100644 index 0000000..aa5af19 --- /dev/null +++ b/cmd/cli/dns64.go @@ -0,0 +1,446 @@ +package cli + +import ( + "context" + "net" + "net/netip" + "sync" + "time" + + "github.com/miekg/dns" + "tailscale.com/net/netmon" + + "github.com/Control-D-Inc/ctrld" + "github.com/Control-D-Inc/ctrld/internal/dnscache" +) + +// DNS64 synthesis for IPv6-only networks WITHOUT client-side 464XLAT (no +// CLAT). On such networks the carrier's DNS64 resolver is load-bearing: it +// synthesizes AAAA records mapping IPv4-only destinations into the NAT64 +// prefix, and there is no CLAT interface to carry real IPv4 traffic. When +// ctrld answers with genuine A records there, IPv4-only destinations become +// unreachable โ€” DNS resolves but connectivity fails (issue companion to +// #533; tethering is unaffected because Apple/Android always provide CLAT). +// +// ctrld therefore performs its own RFC 6147-style synthesis after filtering: +// when the network is IPv6-only with no CLAT and a NAT64 prefix is known, +// an AAAA query whose (policy-approved) answer contains no AAAA records is +// re-resolved as an A query through the same upstream, and the A records are +// mapped into the NAT64 prefix. Blocked answers are never synthesized โ€” +// synthesis runs on the answer the policy engine already approved. +// +// NAT64 prefix discovery uses RFC 7050: resolve AAAA for ipv4only.arpa +// through the network's own resolvers and derive the prefix from the +// embedded well-known IPv4 addresses (192.0.0.170/171). PREF64 router +// advertisements (RFC 8781) are not parsed; RFC 7050 covers the same +// networks without OS-specific RA plumbing. + +const ( + // dns64RecheckInterval bounds how often network state (CLAT presence, + // IPv4 availability, NAT64 prefix) is re-evaluated. + dns64RecheckInterval = 5 * time.Minute + // dns64WellKnownName is the RFC 7050 discovery name. + dns64WellKnownName = "ipv4only.arpa." + // dns64DiscoverTimeout bounds one background discovery attempt. + dns64DiscoverTimeout = 5 * time.Second +) + +// rfc7050WellKnown are the IPv4 addresses embedded in ipv4only.arpa AAAA +// answers on DNS64 networks (RFC 7050). +var rfc7050WellKnown = []netip.Addr{ + netip.AddrFrom4([4]byte{192, 0, 0, 170}), + netip.AddrFrom4([4]byte{192, 0, 0, 171}), +} + +var dns64WellKnownPrefix = netip.MustParsePrefix("64:ff9b::/96") + +// clatPrefix is the RFC 7335 IPv4 service-continuity prefix used by +// client-side translators (CLAT). +var clatPrefix = netip.PrefixFrom(netip.AddrFrom4([4]byte{192, 0, 0, 0}), 29) + +type dns64State struct { + mu sync.Mutex + checkedAt time.Time + active bool // network is v6-only, no CLAT, prefix known + prefix netip.Prefix // discovered NAT64 prefix (/96) + discovering bool + generation uint64 +} + +// dns64NetworkClassFn is a seam for tests. +var dns64NetworkClassFn = currentDNS64NetworkClass + +func addrFromNetAddr(a net.Addr) (netip.Addr, bool) { + var ip net.IP + switch v := a.(type) { + case *net.IPNet: + ip = v.IP + case *net.IPAddr: + ip = v.IP + default: + return netip.Addr{}, false + } + nip, ok := netip.AddrFromSlice(ip) + if !ok { + return netip.Addr{}, false + } + return nip.Unmap(), true +} + +// dns64NetworkClass classifies the host addressing state from interface +// addresses. Only IPv4 on the default-route interface counts as usable, so +// RFC1918 addresses owned by Docker, Parallels, VMware, and similar virtual +// interfaces do not disable DNS64. CLAT is detected across all interfaces. +func dns64NetworkClass(defaultRouteAddrs, allAddrs []net.Addr) (hasUsableIPv4, hasCLAT bool) { + for _, a := range allAddrs { + nip, ok := addrFromNetAddr(a) + if !ok || !nip.Is4() { + continue + } + if clatPrefix.Contains(nip) { + hasCLAT = true + } + } + for _, a := range defaultRouteAddrs { + nip, ok := addrFromNetAddr(a) + if !ok || !nip.Is4() || clatPrefix.Contains(nip) { + continue + } + if nip.IsLoopback() || nip.IsLinkLocalUnicast() || nip.IsUnspecified() { + continue + } + hasUsableIPv4 = true + } + return hasUsableIPv4, hasCLAT +} + +func currentDNS64NetworkClass() (hasUsableIPv4, hasCLAT bool, err error) { + defaultRouteInterface, err := netmon.DefaultRouteInterface() + if err != nil { + return false, false, err + } + iface, err := net.InterfaceByName(defaultRouteInterface) + if err != nil { + return false, false, err + } + defaultRouteAddrs, err := iface.Addrs() + if err != nil { + return false, false, err + } + allAddrs, err := net.InterfaceAddrs() + if err != nil { + return false, false, err + } + hasUsableIPv4, hasCLAT = dns64NetworkClass(defaultRouteAddrs, allAddrs) + return hasUsableIPv4, hasCLAT, nil +} + +// nat64PrefixFromAnswer derives the NAT64 prefix from an ipv4only.arpa AAAA +// answer per RFC 7050: find an AAAA embedding a well-known IPv4 address in +// its last 4 bytes and take the leading /96. +func nat64PrefixFromAnswer(answer *dns.Msg) (netip.Prefix, bool) { + if answer == nil { + return netip.Prefix{}, false + } + for _, rr := range answer.Answer { + aaaa, ok := rr.(*dns.AAAA) + if !ok { + continue + } + v6, ok := netip.AddrFromSlice(aaaa.AAAA.To16()) + if !ok || v6.Is4() { + continue + } + b := v6.As16() + embedded := netip.AddrFrom4([4]byte{b[12], b[13], b[14], b[15]}) + for _, wk := range rfc7050WellKnown { + if embedded == wk { + var p [16]byte + copy(p[:12], b[:12]) + return netip.PrefixFrom(netip.AddrFrom16(p), 96), true + } + } + } + return netip.Prefix{}, false +} + +// synthesizeAAAAFromA returns a copy of aAnswer converted into an AAAA +// answer for the original AAAA request: every A record is mapped into the +// NAT64 prefix; other records (CNAMEs etc.) are preserved. +func synthesizeAAAAFromA(req *dns.Msg, aAnswer *dns.Msg, prefix netip.Prefix) *dns.Msg { + if aAnswer == nil { + return nil + } + out := aAnswer.Copy() + out.SetReply(req) + out.Rcode = aAnswer.Rcode + out.Compress = true + answers := make([]dns.RR, 0, len(aAnswer.Answer)) + pb := prefix.Addr().As16() + for _, rr := range aAnswer.Answer { + a, ok := rr.(*dns.A) + if !ok { + // Preserve CNAME chain records unchanged. + answers = append(answers, dns.Copy(rr)) + continue + } + v4 := a.A.To4() + if v4 == nil { + continue + } + v4Addr, ok := netip.AddrFromSlice(v4) + if !ok || !v4Addr.IsGlobalUnicast() || (prefix == dns64WellKnownPrefix && v4Addr.IsPrivate()) { + continue + } + var b [16]byte + copy(b[:12], pb[:12]) + copy(b[12:], v4) + aaaa := &dns.AAAA{ + Hdr: dns.RR_Header{ + Name: a.Hdr.Name, + Rrtype: dns.TypeAAAA, + Class: a.Hdr.Class, + Ttl: a.Hdr.Ttl, + }, + AAAA: net.IP(b[:]), + } + answers = append(answers, aaaa) + } + out.Answer = answers + return out +} + +// answerHasAAAA reports whether the answer section contains any AAAA record. +func answerHasAAAA(answer *dns.Msg) bool { + if answer == nil { + return false + } + for _, rr := range answer.Answer { + if _, ok := rr.(*dns.AAAA); ok { + return true + } + } + return false +} + +// dns64Eligible reports whether an answer qualifies for DNS64 synthesis: +// an AAAA query answered NOERROR with no AAAA records. NXDOMAIN and error +// rcodes are never synthesized (RFC 6147 ยง5.1.2: the name genuinely does +// not exist or the query failed). +func dns64Eligible(req, answer *dns.Msg) bool { + if req == nil || answer == nil || len(req.Question) == 0 || req.CheckingDisabled { + return false + } + if req.Question[0].Qtype != dns.TypeAAAA { + return false + } + if answer.Rcode != dns.RcodeSuccess { + return false + } + return !answerHasAAAA(answer) +} + +// dns64Active reports whether synthesis should currently run, re-evaluating +// network class and (if needed) kicking off background prefix discovery at +// most every dns64RecheckInterval. +func (p *prog) dns64Active() bool { + s := &p.dns64 + s.mu.Lock() + if time.Since(s.checkedAt) < dns64RecheckInterval { + active := s.active + s.mu.Unlock() + return active + } + s.checkedAt = time.Now() + generation := s.generation + s.mu.Unlock() + + hasV4, hasCLAT, err := dns64NetworkClassFn() + s.mu.Lock() + defer s.mu.Unlock() + if generation != s.generation { + return s.active + } + if err != nil { + s.active = false + return false + } + if hasV4 || hasCLAT { + // Dual-stack or 464XLAT: the OS/CLAT handles IPv4 reachability; + // synthesis would be unnecessary (and on CLAT networks, harmful โ€” + // real A records are preferable so traffic uses the CLAT). + s.generation++ + s.active = false + s.prefix = netip.Prefix{} + s.discovering = false + return false + } + s.active = s.prefix.IsValid() + if !s.discovering { + s.discovering = true + generation := s.generation + go p.discoverNAT64Prefix(generation) + } + return s.active +} + +func (p *prog) activeDNS64Prefix() (netip.Prefix, bool) { + if !p.dns64Active() { + return netip.Prefix{}, false + } + p.dns64.mu.Lock() + defer p.dns64.mu.Unlock() + return p.dns64.prefix, p.dns64.prefix.IsValid() +} + +func dns64CacheVariant(prefix netip.Prefix) string { + return "dns64:" + prefix.String() +} + +func dns64CacheKey(msg *dns.Msg, upstream string, prefix netip.Prefix) dnscache.Key { + return dnscache.NewVariantKey(msg, upstream, dns64CacheVariant(prefix)) +} + +func (p *prog) resetDNS64State() { + p.dns64.mu.Lock() + p.dns64.generation++ + p.dns64.checkedAt = time.Time{} + p.dns64.active = false + p.dns64.prefix = netip.Prefix{} + p.dns64.discovering = false + p.dns64.mu.Unlock() +} + +func dns64RouteStateChanged(delta *netmon.ChangeDelta) bool { + if delta == nil || delta.Old == nil || delta.New == nil { + return true + } + if delta.Old.DefaultRouteInterface != delta.New.DefaultRouteInterface || + delta.Old.HaveV4 != delta.New.HaveV4 || delta.Old.HaveV6 != delta.New.HaveV6 { + return true + } + if dns64StateHasCLAT(delta.Old) != dns64StateHasCLAT(delta.New) { + return true + } + iface := delta.New.DefaultRouteInterface + oldPrefixes := delta.Old.InterfaceIPs[iface] + newPrefixes := delta.New.InterfaceIPs[iface] + if len(oldPrefixes) != len(newPrefixes) { + return true + } + newPrefixSet := make(map[netip.Prefix]struct{}, len(newPrefixes)) + for _, prefix := range newPrefixes { + newPrefixSet[prefix] = struct{}{} + } + for _, prefix := range oldPrefixes { + if _, ok := newPrefixSet[prefix]; !ok { + return true + } + } + return false +} + +func dns64StateHasCLAT(state *netmon.State) bool { + for _, prefixes := range state.InterfaceIPs { + for _, prefix := range prefixes { + if clatPrefix.Contains(prefix.Addr().Unmap()) { + return true + } + } + } + return false +} + +func (p *prog) handleDNS64NetworkChange(delta *netmon.ChangeDelta, major bool) { + if major || dns64RouteStateChanged(delta) { + p.resetDNS64State() + } +} + +func (p *prog) storeDiscoveredNAT64Prefix(generation uint64, prefix netip.Prefix) bool { + p.dns64.mu.Lock() + defer p.dns64.mu.Unlock() + if p.dns64.generation != generation { + return false + } + p.dns64.prefix = prefix + p.dns64.active = true + p.dns64.checkedAt = time.Now() + return true +} + +// discoverNAT64Prefix resolves ipv4only.arpa AAAA through the OS-discovered +// resolvers (the network's own DNS64 resolver) and stores the derived +// prefix. Runs in the background; failures leave synthesis inactive until +// the next recheck window. +func (p *prog) discoverNAT64Prefix(generation uint64) { + defer func() { + p.dns64.mu.Lock() + if p.dns64.generation == generation { + p.dns64.discovering = false + } + p.dns64.mu.Unlock() + }() + ctx, cancel := context.WithTimeout(context.Background(), dns64DiscoverTimeout) + defer cancel() + ctx = ctrld.LoggerCtx(ctx, mainLog.Load()) + + msg := new(dns.Msg) + msg.SetQuestion(dns64WellKnownName, dns.TypeAAAA) + resolver, err := ctrld.NewResolver(ctx, osUpstreamConfig) + if err != nil { + mainLog.Load().Debug().Err(err).Msg("dns64: could not create OS resolver for NAT64 discovery") + return + } + answer, err := resolver.Resolve(ctx, msg) + if err != nil { + mainLog.Load().Debug().Err(err).Msg("dns64: NAT64 prefix discovery query failed") + return + } + prefix, ok := nat64PrefixFromAnswer(answer) + if !ok { + mainLog.Load().Debug().Msg("dns64: no NAT64 prefix present (not a DNS64 network)") + return + } + if !p.storeDiscoveredNAT64Prefix(generation, prefix) { + return + } + mainLog.Load().Info().Msgf("dns64: discovered NAT64 prefix %s; enabling AAAA synthesis for IPv6-only network without CLAT", prefix) +} + +// maybeDNS64 applies DNS64 synthesis to an already-filtered answer when the +// network requires it. resolveA re-resolves the question as an A query +// through the same upstream that produced the answer. +func (p *prog) maybeDNS64(ctx context.Context, req *dns.Msg, answer *dns.Msg, resolveA func(*dns.Msg) *dns.Msg) (*dns.Msg, netip.Prefix) { + if !dns64Eligible(req, answer) || !p.dns64Active() { + return answer, netip.Prefix{} + } + p.dns64.mu.Lock() + prefix := p.dns64.prefix + generation := p.dns64.generation + p.dns64.mu.Unlock() + if !prefix.IsValid() { + return answer, netip.Prefix{} + } + aReq := req.Copy() + aReq.Question[0].Qtype = dns.TypeA + aAnswer := resolveA(aReq) + if aAnswer == nil || aAnswer.Rcode != dns.RcodeSuccess || !sameQuestion(aReq, aAnswer) { + return answer, netip.Prefix{} + } + synth := synthesizeAAAAFromA(req, aAnswer, prefix) + p.dns64.mu.Lock() + current := p.dns64.active && p.dns64.generation == generation && p.dns64.prefix == prefix + p.dns64.mu.Unlock() + if !current { + return answer, netip.Prefix{} + } + if synth == nil || !answerHasAAAA(synth) { + // The companion A lookup completed successfully, so this passthrough + // answer is definitive for the current prefix and may be cached in the + // DNS64 variant to avoid repeating both upstream lookups. + return answer, prefix + } + ctrld.Log(ctx, mainLog.Load().Debug(), "dns64: synthesized AAAA from A records via NAT64 prefix %s", prefix) + return synth, prefix +} diff --git a/cmd/cli/dns64_test.go b/cmd/cli/dns64_test.go new file mode 100644 index 0000000..292450e --- /dev/null +++ b/cmd/cli/dns64_test.go @@ -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") + } +} diff --git a/cmd/cli/dns_proxy.go b/cmd/cli/dns_proxy.go index ee08527..ff55343 100644 --- a/cmd/cli/dns_proxy.go +++ b/cmd/cli/dns_proxy.go @@ -98,6 +98,52 @@ type upstreamForResult struct { srcAddr string } +func (p *prog) addCachedResponse(key dnscache.Key, answer *dns.Msg) { + ttl := ttlFromMsg(answer) + now := time.Now() + expired := now.Add(time.Duration(ttl) * time.Second) + if cachedTTL := p.cfg.Service.CacheTTLOverride; cachedTTL > 0 { + expired = now.Add(time.Duration(cachedTTL) * time.Second) + } + setCachedAnswerTTL(answer, now, expired) + p.cache.Add(key, dnscache.NewValue(answer, expired)) +} + +func (p *prog) cachedResponse(req *dns.Msg, upstream string, dns64Prefix netip.Prefix, dns64Active bool, now time.Time) (answer, stale *dns.Msg, hit, dns64Hit, dns64Bypass bool) { + if dns64Active { + if cachedValue := p.cache.Get(dns64CacheKey(req, upstream, dns64Prefix)); cachedValue != nil { + answer = cachedValue.Msg.Copy() + ctrld.SetCacheReply(answer, req, answer.Rcode) + if cachedValue.Expire.After(now) { + setCachedAnswerTTL(answer, now, cachedValue.Expire) + return answer, nil, true, true, false + } + stale = answer + } + } + + cachedValue := p.cache.Get(dnscache.NewKey(req, upstream)) + if cachedValue == nil { + return nil, stale, false, false, false + } + answer = cachedValue.Msg.Copy() + ctrld.SetCacheReply(answer, req, answer.Rcode) + if cachedValue.Expire.After(now) { + if dns64Eligible(req, answer) && dns64Active { + if stale == nil { + stale = answer + } + return nil, stale, false, false, true + } + setCachedAnswerTTL(answer, now, cachedValue.Expire) + return answer, stale, true, false, false + } + if stale == nil { + stale = answer + } + return nil, stale, false, false, false +} + // serveDNS sets up and starts a DNS server on the specified listener, handling DNS queries and network monitoring. // This is the main entry point for DNS server functionality func (p *prog) serveDNS(ctx context.Context, listenerNum string) error { @@ -785,48 +831,45 @@ func (p *prog) tryCache(ctx context.Context, req *proxyRequest, upstreams []stri // checkCache checks if a cached DNS response exists for the given request and upstream. // Returns a proxyResponse with the cached response if found and valid, or nil otherwise. func (p *prog) checkCache(ctx context.Context, req *proxyRequest, upstream string) *proxyResponse { - cachedValue := p.cache.Get(dnscache.NewKey(req.msg, upstream)) - if cachedValue == nil { - ctrld.Log(ctx, p.Debug(), "No cached value found for upstream: %s", upstream) + dns64Prefix, dns64Active := netip.Prefix{}, false + if req.msg.Question[0].Qtype == dns.TypeAAAA { + dns64Prefix, dns64Active = p.activeDNS64Prefix() + } + + answer, stale, hit, dns64Hit, dns64Bypass := p.cachedResponse(req.msg, upstream, dns64Prefix, dns64Active, time.Now()) + if stale != nil { + req.staleAnswer = stale + } + if dns64Bypass { + ctrld.Log(ctx, p.Debug(), "DNS64: bypassing cached empty-AAAA answer for synthesis") + } + if !hit { + ctrld.Log(ctx, p.Debug(), "No usable cached value found for upstream: %s", upstream) return nil } - answer := cachedValue.Msg.Copy() - ctrld.SetCacheReply(answer, req.msg, answer.Rcode) - now := time.Now() - - if cachedValue.Expire.After(now) { + if dns64Hit { + ctrld.Log(ctx, p.Debug(), "DNS64: hit cached response variant") + } else { ctrld.Log(ctx, p.Debug(), "Hit cached response") - setCachedAnswerTTL(answer, now, cachedValue.Expire) - - // Firewall mode: refresh allowlist entries from cached responses. - // Even though these IPs were already added when the response was first - // resolved, the allowlist entries may have expired (TTL-based reaper) - // while the DNS cache entry is still valid. Refreshing here ensures - // the allowlist stays populated for as long as the cached DNS entry is served. - if p.firewallModeEnabled() { - domain := canonicalName(req.msg.Question[0].Name) - p.firewallRecordResolvedIPs(answer, domain) - } - - return &proxyResponse{answer: answer, cached: true} } - ctrld.Log(ctx, p.Debug(), "Cached response expired, storing as stale") - req.staleAnswer = answer - return nil + // Firewall mode: refresh allowlist entries from cached responses. + // Even though these IPs were already added when the response was first + // resolved, the allowlist entries may have expired (TTL-based reaper) + // while the DNS cache entry is still valid. Refreshing here ensures + // the allowlist stays populated for as long as the cached DNS entry is served. + if p.firewallModeEnabled() { + domain := canonicalName(req.msg.Question[0].Name) + p.firewallRecordResolvedIPs(answer, domain) + } + + return &proxyResponse{answer: answer, cached: true} } // updateCache updates the DNS response cache with the given request, response, TTL, and upstream information. func (p *prog) updateCache(ctx context.Context, req *proxyRequest, answer *dns.Msg, upstream string) { - ttl := ttlFromMsg(answer) - now := time.Now() - expired := now.Add(time.Duration(ttl) * time.Second) - if cachedTTL := p.cfg.Service.CacheTTLOverride; cachedTTL > 0 { - expired = now.Add(time.Duration(cachedTTL) * time.Second) - } - setCachedAnswerTTL(answer, now, expired) - p.cache.Add(dnscache.NewKey(req.msg, upstream), dnscache.NewValue(answer, expired)) + p.addCachedResponse(dnscache.NewKey(req.msg, upstream), answer) ctrld.Log(ctx, p.Debug(), "Added cached response") } @@ -900,6 +943,36 @@ func (p *prog) prepareSuccessResponse(ctx context.Context, req *proxyRequest, an p.updateCache(ctx, req, answer, upstream) } + // Apply DNS64 only after policy processing, and resolve the companion A + // question through the same upstream that produced the approved answer. + var synthesizedPrefix netip.Prefix + answer, synthesizedPrefix = p.maybeDNS64(ctx, req.msg, answer, func(aReq *dns.Msg) *dns.Msg { + key := dnscache.NewKey(aReq, upstream) + if p.cache != nil { + if cachedValue := p.cache.Get(key); cachedValue != nil { + now := time.Now() + if cachedValue.Expire.After(now) { + cached := cachedValue.Msg.Copy() + ctrld.SetCacheReply(cached, aReq, cached.Rcode) + setCachedAnswerTTL(cached, now, cachedValue.Expire) + return cached + } + } + } + + aProxyReq := *req + aProxyReq.msg = aReq + resolved := p.queryUpstream(ctx, &aProxyReq, upstream, upstreamConfig) + if p.cache != nil && resolved != nil && sameQuestion(aReq, resolved) { + p.addCachedResponse(key, resolved) + } + return resolved + }) + if p.cache != nil && synthesizedPrefix.IsValid() { + p.addCachedResponse(dns64CacheKey(req.msg, upstream, synthesizedPrefix), answer) + ctrld.Log(ctx, p.Debug(), "DNS64: added cached response variant") + } + hostname := "" if req.ci != nil { hostname = req.ci.Hostname @@ -1673,11 +1746,12 @@ func (p *prog) monitorNetworkChanges(ctx context.Context) error { } mon.RegisterChangeCallback(func(delta *netmon.ChangeDelta) { + isMajorChange := mon.IsMajorChangeFrom(delta.Old, delta.New) + p.handleDNS64NetworkChange(delta, isMajorChange) + // Get map of valid interfaces validIfaces := ctrld.ValidInterfaces(ctrld.LoggerCtx(ctx, p.logger.Load())) - isMajorChange := mon.IsMajorChangeFrom(delta.Old, delta.New) - p.Debug(). Interface("old_state", delta.Old). Interface("new_state", delta.New). diff --git a/cmd/cli/dns_proxy_test.go b/cmd/cli/dns_proxy_test.go index 34b0d7a..4867150 100644 --- a/cmd/cli/dns_proxy_test.go +++ b/cmd/cli/dns_proxy_test.go @@ -286,6 +286,52 @@ func TestCache(t *testing.T) { assert.Equal(t, answer2.Rcode, got2.answer.Rcode) } +func TestDNS64CacheLookup(t *testing.T) { + cfg := testhelper.SampleConfig(t) + p := &prog{cfg: cfg} + cache, err := dnscache.NewLRUCache(16) + require.NoError(t, err) + p.cache = cache + + now := time.Now() + prefix := dns64WellKnownPrefix + req := mkAAAAReq("legacy.example") + upstream := "upstream.0" + empty := new(dns.Msg) + empty.SetReply(req) + synthesized := new(dns.Msg) + synthesized.SetReply(req) + synthesized.Answer = []dns.RR{&dns.AAAA{Hdr: dns.RR_Header{Name: req.Question[0].Name, Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: 60}, AAAA: net.ParseIP("64:ff9b::c000:201")}} + + t.Run("fresh variant hit", func(t *testing.T) { + p.cache.Purge() + p.cache.Add(dns64CacheKey(req, upstream, prefix), dnscache.NewValue(synthesized, now.Add(time.Minute))) + answer, stale, hit, dns64Hit, bypass := p.cachedResponse(req, upstream, prefix, true, now) + if answer == nil || !answerHasAAAA(answer) || stale != nil || !hit || !dns64Hit || bypass { + t.Fatalf("unexpected lookup result: answer=%v stale=%v hit=%v dns64Hit=%v bypass=%v", answer, stale, hit, dns64Hit, bypass) + } + }) + + t.Run("fresh empty normal answer is retained as stale while bypassed", func(t *testing.T) { + p.cache.Purge() + p.cache.Add(dnscache.NewKey(req, upstream), dnscache.NewValue(empty, now.Add(time.Minute))) + answer, stale, hit, dns64Hit, bypass := p.cachedResponse(req, upstream, prefix, true, now) + if answer != nil || stale == nil || hit || dns64Hit || !bypass { + t.Fatalf("unexpected lookup result: answer=%v stale=%v hit=%v dns64Hit=%v bypass=%v", answer, stale, hit, dns64Hit, bypass) + } + }) + + t.Run("expired variant is preferred as stale", func(t *testing.T) { + p.cache.Purge() + p.cache.Add(dns64CacheKey(req, upstream, prefix), dnscache.NewValue(synthesized, now.Add(-time.Minute))) + p.cache.Add(dnscache.NewKey(req, upstream), dnscache.NewValue(empty, now.Add(-time.Minute))) + answer, stale, hit, dns64Hit, bypass := p.cachedResponse(req, upstream, prefix, true, now) + if answer != nil || stale == nil || !answerHasAAAA(stale) || hit || dns64Hit || bypass { + t.Fatalf("unexpected lookup result: answer=%v stale=%v hit=%v dns64Hit=%v bypass=%v", answer, stale, hit, dns64Hit, bypass) + } + }) +} + func Test_ipAndMacFromMsg(t *testing.T) { tests := []struct { name string diff --git a/cmd/cli/prog.go b/cmd/cli/prog.go index cd589f0..c457f2a 100644 --- a/cmd/cli/prog.go +++ b/cmd/cli/prog.go @@ -174,6 +174,10 @@ type prog struct { // authentication without tearing down WFP/pf filters. recoveryBypass atomic.Bool + // dns64 tracks DNS64/NAT64 synthesis state for IPv6-only networks + // without client-side 464XLAT. See cmd/cli/dns64.go. + dns64 dns64State + // interceptDNSTargetService names the macOS network service on which // ctrld set a temporary DNS target; interceptDNSTargetSetValue records the // exact value. Both are guarded by interceptDNSTargetMu. diff --git a/internal/dnscache/cache.go b/internal/dnscache/cache.go index 1437370..90f6bb9 100644 --- a/internal/dnscache/cache.go +++ b/internal/dnscache/cache.go @@ -29,6 +29,7 @@ type Key struct { Name string Upstream string ECS string + Variant string } type Value struct { @@ -71,6 +72,14 @@ func NewKey(msg *dns.Msg, upstream string) Key { return Key{Qtype: q.Qtype, Qclass: q.Qclass, Name: normalizeQname(q.Name), Upstream: upstream, ECS: CanonicalECS(msg)} } +// NewVariantKey creates a cache key in a named result variant. Variants keep +// derived answers separate from the upstream's answer to the same question. +func NewVariantKey(msg *dns.Msg, upstream, variant string) Key { + key := NewKey(msg, upstream) + key.Variant = variant + return key +} + // CanonicalECS returns a canonical string form of the EDNS Client Subnet (ECS, // EDNS option 8) carried by msg, suitable for partitioning cache and // singleflight keys. A request with no ECS option returns "", so all ECS-less