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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+98
-24
@@ -98,6 +98,52 @@ type upstreamForResult struct {
|
|||||||
srcAddr string
|
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.
|
// 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
|
// This is the main entry point for DNS server functionality
|
||||||
func (p *prog) serveDNS(ctx context.Context, listenerNum string) error {
|
func (p *prog) serveDNS(ctx context.Context, listenerNum string) error {
|
||||||
@@ -785,19 +831,28 @@ 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.
|
// 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.
|
// 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 {
|
func (p *prog) checkCache(ctx context.Context, req *proxyRequest, upstream string) *proxyResponse {
|
||||||
cachedValue := p.cache.Get(dnscache.NewKey(req.msg, upstream))
|
dns64Prefix, dns64Active := netip.Prefix{}, false
|
||||||
if cachedValue == nil {
|
if req.msg.Question[0].Qtype == dns.TypeAAAA {
|
||||||
ctrld.Log(ctx, p.Debug(), "No cached value found for upstream: %s", upstream)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
answer := cachedValue.Msg.Copy()
|
if dns64Hit {
|
||||||
ctrld.SetCacheReply(answer, req.msg, answer.Rcode)
|
ctrld.Log(ctx, p.Debug(), "DNS64: hit cached response variant")
|
||||||
now := time.Now()
|
} else {
|
||||||
|
|
||||||
if cachedValue.Expire.After(now) {
|
|
||||||
ctrld.Log(ctx, p.Debug(), "Hit cached response")
|
ctrld.Log(ctx, p.Debug(), "Hit cached response")
|
||||||
setCachedAnswerTTL(answer, now, cachedValue.Expire)
|
}
|
||||||
|
|
||||||
// Firewall mode: refresh allowlist entries from cached responses.
|
// Firewall mode: refresh allowlist entries from cached responses.
|
||||||
// Even though these IPs were already added when the response was first
|
// Even though these IPs were already added when the response was first
|
||||||
@@ -810,23 +865,11 @@ func (p *prog) checkCache(ctx context.Context, req *proxyRequest, upstream strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &proxyResponse{answer: answer, cached: true}
|
return &proxyResponse{answer: answer, cached: true}
|
||||||
}
|
|
||||||
|
|
||||||
ctrld.Log(ctx, p.Debug(), "Cached response expired, storing as stale")
|
|
||||||
req.staleAnswer = answer
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateCache updates the DNS response cache with the given request, response, TTL, and upstream information.
|
// 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) {
|
func (p *prog) updateCache(ctx context.Context, req *proxyRequest, answer *dns.Msg, upstream string) {
|
||||||
ttl := ttlFromMsg(answer)
|
p.addCachedResponse(dnscache.NewKey(req.msg, upstream), 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))
|
|
||||||
ctrld.Log(ctx, p.Debug(), "Added cached response")
|
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)
|
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 := ""
|
hostname := ""
|
||||||
if req.ci != nil {
|
if req.ci != nil {
|
||||||
hostname = req.ci.Hostname
|
hostname = req.ci.Hostname
|
||||||
@@ -1673,11 +1746,12 @@ func (p *prog) monitorNetworkChanges(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mon.RegisterChangeCallback(func(delta *netmon.ChangeDelta) {
|
mon.RegisterChangeCallback(func(delta *netmon.ChangeDelta) {
|
||||||
|
isMajorChange := mon.IsMajorChangeFrom(delta.Old, delta.New)
|
||||||
|
p.handleDNS64NetworkChange(delta, isMajorChange)
|
||||||
|
|
||||||
// Get map of valid interfaces
|
// Get map of valid interfaces
|
||||||
validIfaces := ctrld.ValidInterfaces(ctrld.LoggerCtx(ctx, p.logger.Load()))
|
validIfaces := ctrld.ValidInterfaces(ctrld.LoggerCtx(ctx, p.logger.Load()))
|
||||||
|
|
||||||
isMajorChange := mon.IsMajorChangeFrom(delta.Old, delta.New)
|
|
||||||
|
|
||||||
p.Debug().
|
p.Debug().
|
||||||
Interface("old_state", delta.Old).
|
Interface("old_state", delta.Old).
|
||||||
Interface("new_state", delta.New).
|
Interface("new_state", delta.New).
|
||||||
|
|||||||
@@ -286,6 +286,52 @@ func TestCache(t *testing.T) {
|
|||||||
assert.Equal(t, answer2.Rcode, got2.answer.Rcode)
|
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) {
|
func Test_ipAndMacFromMsg(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -174,6 +174,10 @@ type prog struct {
|
|||||||
// authentication without tearing down WFP/pf filters.
|
// authentication without tearing down WFP/pf filters.
|
||||||
recoveryBypass atomic.Bool
|
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
|
// interceptDNSTargetService names the macOS network service on which
|
||||||
// ctrld set a temporary DNS target; interceptDNSTargetSetValue records the
|
// ctrld set a temporary DNS target; interceptDNSTargetSetValue records the
|
||||||
// exact value. Both are guarded by interceptDNSTargetMu.
|
// exact value. Both are guarded by interceptDNSTargetMu.
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ type Key struct {
|
|||||||
Name string
|
Name string
|
||||||
Upstream string
|
Upstream string
|
||||||
ECS string
|
ECS string
|
||||||
|
Variant string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Value struct {
|
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)}
|
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,
|
// CanonicalECS returns a canonical string form of the EDNS Client Subnet (ECS,
|
||||||
// EDNS option 8) carried by msg, suitable for partitioning cache and
|
// EDNS option 8) carried by msg, suitable for partitioning cache and
|
||||||
// singleflight keys. A request with no ECS option returns "", so all ECS-less
|
// singleflight keys. A request with no ECS option returns "", so all ECS-less
|
||||||
|
|||||||
Reference in New Issue
Block a user