Technology Guide
How DNS46 and NAT46 work under the hood
DNS46 / NAT46 Technology Guide
This guide explains the technology behind IPv6 support in the NPA Publisher, covering the DNS translation layer (DNS46), the packet translation layer (NAT46 via Jool SIIT), and how they work together to enable IPv4 clients to reach IPv6-only applications.
The Problem
NPA clients operate in IPv4 space. When a private application is only reachable via IPv6 (no A records, only AAAA), the standard publisher flow breaks — the client expects an IPv4 address, and the publisher's SNAT engine works with IPv4 connections.
The Solution: DNS46
DNS46 solves this by translating at two layers:
- DNS layer: Synthesize IPv4 addresses for IPv6-only destinations
- Packet layer: Translate IPv4 packets to IPv6 using those synthesized addresses
Component Overview
Publisher Container
+--------------------------------+
| |
Client --> Stitcher --> tun0 --> SNAT --> CoreDNS |
| | (nat46 |
| | plugin) |
| v |
| IPv4 240.x.x.x |
+-----------|--------------------+
|
Host Kernel (Jool SIIT)
|
IPv4 src 191.1.0.1 --> IPv6 host addr (EAMT)
IPv4 dst 240.x.x.x --> IPv6 real addr (EAMT)
|
IPv6 destinationDNS Translation (CoreDNS nat46 Plugin)
The CoreDNS nat46 plugin intercepts DNS queries and performs address synthesis.
A Query Flow (Normal IPv4 Host)
1. Client asks for: example.com A
2. nat46 plugin queries upstream for: example.com A
3. Upstream returns: 93.184.216.34 (real A record)
4. nat46 passes through: 93.184.216.34No translation needed — the host has a real IPv4 address.
A Query Flow (IPv6-Only Host)
1. Client asks for: v6app.internal A
2. nat46 plugin queries upstream for: v6app.internal A
3. Upstream returns: NXDOMAIN (no A record)
4. nat46 queries upstream for: v6app.internal AAAA
5. Upstream returns: 2001:db8::1 (real AAAA record)
6. nat46 synthesizes: 240.0.0.1 -> 2001:db8::1 mapping
7. nat46 creates EAMT entry in Jool via netlink
8. nat46 returns to client: 240.0.0.1 (synthesized A record)The synthesized IPv4 address comes from the configured ipv4_range (default 240.0.0.0/4). Each unique IPv6 address gets a unique IPv4 mapping.
AAAA Query Handling
1. Client asks for: v6app.internal AAAA
2. nat46 returns: NODATA (empty answer, RCODE=NOERROR)The plugin returns an empty response for all AAAA queries. This is intentional:
- Not passthrough: If real AAAA records were returned,
getaddrinfowould prefer IPv6 (RFC 6724 default policy), and the publisher's IPv4-only SNAT engine would reject it. - Not NXDOMAIN: That would tell the client the domain doesn't exist, potentially interfering with the valid A response.
- NODATA: "The domain exists but has no AAAA records" — correct semantics that doesn't interfere with the A query result.
EAMT (Explicit Address Mapping Table)
The nat46 plugin communicates with the Jool SIIT kernel module via netlink to manage address mappings:
| Synthesized IPv4 | Real IPv6 | TTL |
|---|---|---|
| 240.0.0.1 | 2001:db8::1 | 300s |
| 240.0.0.2 | 2001:db8::2 | 300s |
Mappings are created when DNS queries are answered and removed after the TTL plus a grace period expires, provided no active connections exist (checked via conntrack).
Packet Translation (Jool SIIT)
Jool SIIT is a Linux kernel module that performs Stateless IP/ICMP Translation (RFC 7915). It translates packets by re-injecting them directly into the network stack, bypassing all netfilter hooks (including conntrack and NAT).
Translation Flow
1. Publisher SNAT sends IPv4 packet:
src: 191.1.0.1 (fixed SNAT address for NAT46 destinations)
dst: 240.0.0.1 (synthesized)
2. Jool SIIT translates both src and dst via EAMT:
src: 191.1.0.1 --> <host's real IPv6> (source EAMT entry)
dst: 240.0.0.1 --> 2001:db8::1 (destination EAMT entry)
3. Packet leaves host as IPv6:
src: <host's real IPv6 address>
dst: 2001:db8::1
4. Packet reaches IPv6 destination
5. Response follows reverse path:
dst: <host's real IPv6> --> Jool EAMT --> 191.1.0.1 (IPv4)
Publisher SNAT maps back to clientKey Concepts
EAMT Source Translation
Jool SIIT bypasses all netfilter hooks — ip6tables MASQUERADE and conntrack cannot work on Jool-translated packets. Instead, source translation uses a Jool EAMT (Explicit Address Mapping Table) entry that maps the publisher's fixed SNAT address to the host's real IPv6:
191.1.0.1/32 <-> <host_ipv6>/128This entry is created by the wizard when NAT46 is enabled and restored by the CoreDNS nat46 plugin on startup (after its EAMT flush).
Destination-Aware SNAT
When NAT46 is active, the publisher uses port-based SNAT with a single fixed address (191.1.0.1) for all traffic destined to the NAT46 IPv4 range (e.g., 240.0.0.0/4). This allows the single EAMT source entry to handle all NAT46 connections. Non-NAT46 traffic continues to use the normal address-based SNAT across the 191.1.0.0/16 range.
pool6 Prefix
A /96 ULA (Unique Local Address) prefix generated by the wizard (e.g., fd8f:c742:6e17::/96). Used as the default IPv6 source prefix for addresses without an explicit EAMT entry. With the EAMT source entry in place, the publisher's SNAT address (191.1.0.1) is translated to the host's real IPv6 instead of a pool6 address.
Stateless Translation
Jool SIIT is stateless — it doesn't track connections. Each packet is translated independently based on the EAMT entries. Connection state is tracked by the publisher's SNAT engine on the IPv4 side.
Full Traffic Flow
Here is the complete end-to-end flow for an IPv4 client accessing an IPv6-only application through NAT46:
Step 1: DNS Resolution
Client intercepts DNS for "v6app.internal"
Client synthesizes 100.64.0.1 (CGNAT) for steering
Client sends traffic for 100.64.0.1:443 to stitcher
Step 2: Stitcher to Publisher
Stitcher forwards connection to publisher
Packet arrives on tun0 with dst=<private app IP>
Step 3: Publisher SNAT DNS
Publisher resolves "v6app.internal" via CoreDNS at 127.0.0.1
getaddrinfo(AF_UNSPEC) sends A and AAAA queries
A query: nat46 synthesizes 240.0.0.1, creates EAMT entry
AAAA query: nat46 returns NODATA
Publisher gets 240.0.0.1 as the resolved address
Step 4: Publisher SNAT Connection
Publisher detects dst 240.0.0.1 is in NAT46 range
Uses fixed SNAT address with port-based NAT:
original: client_ip:port -> 240.0.0.1:443
translated: 191.1.0.1:snat_port -> 240.0.0.1:443
Step 5: Kernel Translation (Jool SIIT via EAMT)
IPv4 packet hits Jool SIIT
EAMT source: 191.1.0.1 -> <host IPv6>
EAMT destination: 240.0.0.1 -> 2001:db8::1
Packet leaves as IPv6: src=<host IPv6>, dst=2001:db8::1
Step 6: Application Response
IPv6 app responds to host's IPv6 address
Jool reverse-translates IPv6 -> IPv4:
dst: <host IPv6> -> 191.1.0.1
src: 2001:db8::1 -> 240.0.0.1
Publisher SNAT maps 191.1.0.1:snat_port back to client
Response flows back through stitcher to clientDesign Decisions
Why 240.0.0.0/4?
This is an IANA reserved "future use" block that is never routed on the public internet. Using it avoids conflicts with any real private or public IPv4 addresses. Some deployments may use 10.0.0.0/8 if 240/4 is not supported by their network stack.
Why SIIT (Stateless) Instead of NAT64?
NAT64 is stateful and typically sits at the network edge translating IPv6 clients to IPv4 servers. Our use case is the reverse — IPv4 clients need to reach IPv6 servers. SIIT with explicit address mappings (EAMT) gives us precise control over which addresses are translated, and the mappings are driven by DNS (created/removed by the CoreDNS plugin).
Why CoreDNS Plugin Instead of External DNS64?
DNS64 (RFC 6147) synthesizes AAAA records from A records — the opposite of what we need. We need to synthesize A records from AAAA records. A CoreDNS plugin gives us tight integration with the publisher's DNS server and direct netlink access to manage Jool EAMT entries.
Why NODATA for AAAA Queries?
The publisher's getaddrinfo uses AF_UNSPEC, which sends both A and AAAA queries in parallel. If AAAA queries returned real IPv6 records, glibc would prefer them (RFC 6724 default address selection), and the publisher's SNAT engine would reject the IPv6 address. NODATA tells the resolver "this name has no AAAA records" without negating the name itself.