Files
ctrld/cmd/cli/dns64.go
T
Dev Scribe dd77d865b6 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.
2026-09-03 13:58:24 +07:00

447 lines
13 KiB
Go

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
}