Skip to content

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."
bash
dig example.com A
dig example.com AAAA
getent ahosts example.com

Observe 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:

python
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, sockaddr

The 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.

text
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 others

250 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

bash
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::10

curl -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:

python
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:

  1. DNS64 synthesizes AAAA records with a NAT64 prefix based on A records;
  2. The client sends IPv6 to the synthetic address;
  3. The NAT64 translator extracts the IPv4 destination and maintains the necessary translation state;
  4. IPv4 services see the IPv4 traffic on the translator side.
text
IPv6-only client
  -> synthesized AAAA under Pref64
  -> NAT64 translator
  -> IPv4-only server

The 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:

python
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

ScenarioMandatory Tests
IPv4-only clientA record, IPv4 listener, TLS/HTTP, return path
IPv6-only clientAAAA or DNS64, IPv6 route, NDP, PMTUD, TLS/HTTP
dual-stack clientaddress sorting, Happy Eyeballs, fallback delay during single-stack failure
IPv6-only to IPv4-onlyPref64, DNS64 synthesis, NAT64 sessions, literals, and DNSSEC boundaries
load balancer/CDNA/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.

Built with VitePress | Software Systems Atlas