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,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
|
||||
}
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user