13.3 Dual Stack, Happy Eyeballs, and IPv6-Only Transition
The network is transitioning from IPv4 to IPv6, and when old and new addresses coexist, the same domain might resolve to two completely different-quality paths.
A host having an IPv6 address doesn't mean the application will definitely use IPv6; even if a domain has both A and AAAA records, it doesn't mean clients will mechanically try IPv6 first, then fall back to IPv4 after three seconds. Actual connection establishment also involves the system resolver, address prioritization, concurrent attempts, proxies, and network quality.
DNS Returns records, without replacing client path selection
- A record saves an IPv4 address;
- AAAA record saves IPv6 address;
- IPv6 nibble usage in reverse PTR records uses
ip6.arpa; - Ordinary authoritative DNS servers typically respond based on query type and won't automatically return only AAAA records just because the client "supports IPv6."
dig example.com A
dig example.com AAAA
getent ahosts example.comObserve DNS protocol responses; closer to typical applications using system name service configuration. Differences may arise from dig, NSS, caching, split DNS, search domains, or local resolver. getent /etc/hosts
Use getaddrinfo() in Python, don't concatenate DNS answers:
import socket
def stream_candidates(host: str, port: int):
seen = set()
for family, socktype, proto, _, sockaddr in socket.getaddrinfo(
host,
port,
family=socket.AF_UNSPEC,
type=socket.SOCK_STREAM,
proto=socket.IPPROTO_TCP,
):
key = (family, sockaddr)
if key not in seen:
seen.add(key)
yield family, socktype, proto, sockaddrThe order of returned addresses is influenced by RFC 6724 address selection rules, platform policies, and network configuration. Applications must still handle multiple candidate addresses.
Happy Eyeballs isn't serial fallback
The simplest client tries addresses in sequence. If the first IPv6 path is a black hole, a few seconds of connect timeout will make users mistakenly think the entire service is slow.
The core of Happy Eyeballs v2 is: sorting DNS results and initiating connection attempts to different address families at short intervals; the first successful connection wins, and the rest are canceled. It also includes details on DNS queries, connection attempt order, and historical preferences.
t=0 ms start first preferred-family attempt
t=250 ms if no winner, start next suitable attempt
... continue paced attempts
winner use connection; cancel/close others250 ms is merely a suggested default in the RFC and should not be hardcoded as the optimal value for all networks. Excessive concurrency also wastes socket, port, and server resources.
The Python standard library's socket.create_connection() attempts multiple addresses but does not assume that all versions and platforms fully implement the RFC 8305 race condition. Production clients should prefer mature HTTP/network libraries that explicitly support Happy Eyeballs and test scenarios such as IPv6 black holes, slow AAAA records, and single-stack environments.
Verify IPv4 and IPv6 separately
curl -4 --verbose --connect-timeout 3 https://example.com/
curl -6 --verbose --connect-timeout 3 https://example.com/
ip -6 route get 2001:db8::10
ping -6 -c 3 2001:db8::10
traceroute -6 -n 2001:db8::10curl -4/-6 is a diagnostic check and does not imply that a particular address family should be permanently enforced in production applications. If IPv6 fails while IPv4 succeeds, continue verifying AAAA records, source address selection, default routes, NDP, ICMPv6, MTU, firewalls, and return path routing.
How does the server listen on a dual stack?
Whether IPv6 wildcard :: accepts IPv4-mapped connections depends on the platform and IPV6_V6ONLY. Do not assume the default behavior on a Linux host is portable.
Python can explicitly request dual-stack sockets on supported platforms:
import socket
if not socket.has_dualstack_ipv6():
raise RuntimeError("this platform does not expose a dual-stack IPv6 socket")
with socket.create_server(
("::", 8080),
family=socket.AF_INET6,
dualstack_ipv6=True,
) as server:
connection, peer = server.accept()
with connection:
connection.sendall(
b"HTTP/1.1 200 OK\r\n"
b"Content-Length: 3\r\n"
b"Connection: close\r\n"
b"\r\n"
b"ok\n"
)Real services also need request parsing, concurrency, deadlines, limits, logging, and graceful shutdown. A more explicit approach is to separately create IPv4 and IPv6 listeners, managed by the service framework.
Three Types of Transition Deployments
Dual Stack
Hosts and networks run both IPv4 and IPv6 simultaneously, with applications using address selection and Happy Eyeballs to choose available paths. The advantage is native support for both protocols; the cost is the need to operate two separate routing systems, security policies, monitoring, and capacity.
Dual stacking itself doesn't require NAT64. NAT64 only comes into play when an IPv6-only client needs to access an IPv4-only service.
IPv6-in-IPv4 Tunnel
Tunnels encapsulate IPv6 packets within IPv4 to traverse IPv4-only regions. They are still used in controlled networks and specific products, but automatic 6to4 and related relay anycast have been deprecated by RFC 7526 and should not be used as default solutions for new deployments.
Tunnels introduce additional headers, MTU, observability, and security boundaries. The tunnel endpoints, effective MTU, routing, and filtering policies must be clearly defined.
IPv6-only + DNS64/NAT64
When the client has only IPv6 and the destination has only IPv4:
- DNS64 synthesizes AAAA records with a NAT64 prefix based on A records;
- The client sends IPv6 to the synthetic address;
- The NAT64 translator extracts the IPv4 destination and maintains the necessary translation state;
- IPv4 services see the IPv4 traffic on the translator side.
IPv6-only client
-> synthesized AAAA under Pref64
-> NAT64 translator
-> IPv4-only serverThe IETF well-known prefix is 64:ff9b::/96, and operators may also use a network-specific prefix. RFC 6052 defines other allowed prefix lengths, and it is not permissible to simply append a 32-bit IPv4 string arbitrarily.
Here's only a demonstration of /96:
import ipaddress
def embed_ipv4_in_96(prefix: str, ipv4: str) -> ipaddress.IPv6Address:
network = ipaddress.IPv6Network(prefix)
if network.prefixlen != 96:
raise ValueError("this example only implements RFC 6052 /96 embedding")
value = int(network.network_address) | int(ipaddress.IPv4Address(ipv4))
return ipaddress.IPv6Address(value)
assert str(embed_ipv4_in_96("64:ff9b::/96", "192.0.2.33")) == \
"64:ff9b::c000:221"NAT64 is not transparent to all applications: IPv4 literals, embedded addresses in the payload, certain non-TCP/UDP protocols, address-family-specific APIs, and DNSSEC validation boundaries all may require additional handling. Mobile networks often combine 464XLAT to allow IPv4-only applications to traverse IPv6-only access networks.
The Trust Boundaries of DNSSEC and DNS64
The AAAA records synthesized by DNS64 are not originally signed in the authoritative zone. If a client performs DNSSEC validation locally, it may detect a mismatch between the synthesized result and the original signature. Deployment must clearly define where validation occurs and adhere to the DNS64/DNSSEC specification when designing trust boundaries, simply disabling validation to "fix the resolution" is not an acceptable solution.
IPv6 Doesn't Need NAT, But That Doesn't Mean It Doesn't Need Firewalls
A global unicast address is merely a routable address type. Whether it truly reaches the public internet is determined jointly by routing, stateful firewall, ACL, host-bound address listening, cloud security groups, and application authentication.
Security baselines should cover at least:
- Inbound/outbound policies equivalent to IPv4 and IPv6;
- Necessary ICMPv6, rather than completely blocking it;
- link-local, ULA, global, etc., different scopes;
- rogue RA/NDP risk;
- Impact of temporary addresses on logging, assets, and access control;
- Does IPv6 tunneling bypass existing surveillance for unmanaged addresses?
The vast /64 makes sequential enumeration inefficient, but attackers can discover active addresses through DNS, certificate transparency logs, service scans, fixed IID, and application leaks. A large address space is not equivalent to access control.
Launch Before Validation Matrix
| Scenario | Mandatory Tests |
|---|---|
| IPv4-only client | A record, IPv4 listener, TLS/HTTP, return path |
| IPv6-only client | AAAA or DNS64, IPv6 route, NDP, PMTUD, TLS/HTTP |
| dual-stack client | address sorting, Happy Eyeballs, fallback delay during single-stack failure |
| IPv6-only to IPv4-only | Pref64, DNS64 synthesis, NAT64 sessions, literals, and DNSSEC boundaries |
| load balancer/CDN | A/AAAA backend consistency, health check protocols, true client addresses in logs |
Fault drills must at least include: failed AAAA records, IPv6 silent packet loss, misblocked ICMPv6 Packet Too Big messages, IPv4 functioning while IPv6 backend services are not published, and exhausted NAT64 capacity.
Lesson Summary
The challenge of dual stacking isn't having an additional address type; it's two potentially independent paths that could both fail. Happy Eyeballs reduces user wait time by racing paths in a controlled manner to mitigate single-path failures; IPv6-only networks can access IPv4-only services via DNS64/NAT64, but the translation boundary must be clearly tested and monitored.
Standard Entry Point
- RFC 6724: Default Address Selection for IPv6;
- RFC 8305: Happy Eyeballs Version 2;
- RFC 6146: Stateful NAT64;
- RFC 6147: DNS64;
- RFC 6052: IPv6 Addressing of IPv4/IPv6 Translators;
- RFC 6877: 464XLAT;
- RFC 7526: Deprecating Anycast Prefix for 6to4 Relay Routers.