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:
Dev Scribe
2026-09-03 13:58:24 +07:00
committed by Cuong Manh Le
parent d8994cc7d8
commit dd77d865b6
6 changed files with 979 additions and 34 deletions
+446
View File
@@ -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
}
+366
View File
@@ -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")
}
}
+108 -34
View File
@@ -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).
+46
View File
@@ -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
+4
View File
@@ -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.
+9
View File
@@ -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