How Iroh's Hole-Punching Mechanism Works for NAT Traversal
Iroh performs NAT traversal through a coordinated three-phase process involving port mapping for external address discovery, candidate exchange between peers, and a remote state actor that orchestrates UDP hole-punching attempts whenever new network paths become available.
Iroh is a peer-to-peer networking stack built by n0-computer that enables direct connections between nodes behind restrictive NATs. Its hole-punching mechanism automatically discovers public endpoints, exchanges network candidates with remote peers, and attempts to establish direct UDP paths before falling back to relay connections.
Port Mapper Integration: Discovering Public Endpoints
When an Iroh node initializes with PortmapperConfig::Enabled (the default configuration), it spawns a background client in iroh/src/portmapper.rs that continuously monitors the network for external address changes. This client invokes procure_mapping() periodically to communicate with local network gateways via UPnP, PCP, or NAT-PMP protocols.
The port mapper returns the external SocketAddrV4 representing the node's public-facing endpoint. This address is exposed through a watch::Receiver channel, allowing the rest of the stack to react immediately when the router assigns a new port mapping. According to the source code in portmapper.rs (lines 58-65), this discovery happens asynchronously and updates the node's view of its own network topology without blocking the main event loop.
Candidate Exchange Protocol
Once the external address is known, the RemoteStateActor in iroh/src/socket/remote_map/remote_state.rs manages the lifecycle of connection candidates. When the external address changes, the actor calls update_local_direct_address (lines 92-100) to recompute the set of local DirectAddr candidates and push these updates to all active connections.
Each connection can request the remote peer's NAT traversal addresses via conn.get_remote_nat_traversal_addresses(). The response— a vector of SocketAddrV4 addresses—is converted into a BTreeSet for efficient comparison and deduplication. This exchange happens over the relay protocol, ensuring that even before a direct connection exists, both nodes can share their potential contact endpoints.
The Remote State Actor: Orchestrating Punch Attempts
The core decision logic for when to initiate hole-punching resides in RemoteStateActor::trigger_holepunching (lines 44-65). The actor maintains the previous attempt's candidate sets in last_holepunch and compares them against current local and remote candidates. If either set has grown—meaning new public addresses have appeared since the last attempt—the actor immediately schedules a new hole-punch.
If the candidate sets remain unchanged, the actor respects the HOLEPUNCH_ATTEMPTS_INTERVAL constant and schedules a retry for later. This prevents aggressive polling while ensuring that transient network conditions or delayed STUN responses don't permanently block direct connectivity.
Performing the Hole-Punch
When the actor decides to attempt a connection, it invokes do_holepunching in remote_state.rs (lines 660-695). This function creates an asynchronous task that:
- Sends a UDP packet from each local candidate address to each remote candidate address
- Waits for a matching inbound packet to cross the NAT boundary
- Upon successful packet exchange, establishes a direct QUIC (NOQ) path between the nodes
The mechanism relies on the simultaneous open principle: by sending outbound packets to the remote candidate, the local NAT creates a temporary mapping that allows the remote peer's return traffic to pass through.
Path State Management and Relay Fallback
Iroh tracks the outcome of each attempt in PathState structures. Successful hole-punches are marked as HolepunchSucceeded, while failures are recorded as HolepunchFailed and pruned after a timeout (lines 240-275) to keep the candidate set tidy and prevent repeated attempts on dead paths.
If hole-punching fails—such as when both peers sit behind symmetric NATs that perform strict endpoint filtering—Iroh automatically falls back to a relay connection. This fallback logic lives in iroh/src/socket/transports.rs (line 1080) and guarantees connectivity even in the worst NAT scenarios by routing traffic through a trusted intermediary.
Implementation Example
To enable hole-punching in your Iroh application, ensure the port mapper is enabled (default) and spawn an endpoint:
// 1. Enable the port-mapper (default) when building a node
let builder = iroh::endpoint::Builder::default()
.portmapper_config(iroh::endpoint::PortmapperConfig::Enabled {});
// 2. Start the endpoint – a background task will keep the external address up-to-date
let endpoint = builder.spawn().await?;
// 3. Connect to a remote peer (remote's public key known)
// The call returns immediately; the endpoint will try a direct hole-punch behind the scenes.
let conn = endpoint.connect(remote_peer_id).await?;
// 4. Optionally watch the external address for debugging
let mut ext_addr_rx = endpoint.watch_external_address();
while let Some(Some(addr)) = ext_addr_rx.recv().await {
println!("Our public address is {addr}");
}
Summary
- Port mapping in
iroh/src/portmapper.rscontinuously discovers the node's externalSocketAddrV4via UPnP/PCP/NAT-PMP and broadcasts changes through a watch channel. - Candidate management in
iroh/src/socket/remote_map/remote_state.rstracks local and remote addresses, triggering hole-punch attempts only when new candidates appear. - UDP hole-punching sends simultaneous probe packets to create NAT mappings, establishing direct QUIC paths when packets successfully cross both firewalls.
- Automatic fallback to relay connections occurs when
PathStaterecords persistentHolepunchFailedstates, ensuring connectivity regardless of NAT topology.
Frequently Asked Questions
What happens if both peers are behind symmetric NATs?
When both peers use symmetric NATs—which map each internal destination to a unique external port—standard hole-punching typically fails because the predicted ports never align. In this scenario, Iroh's transport selector in iroh/src/socket/transports.rs detects the failure and transparently falls back to a relay connection, maintaining connectivity without application-level intervention.
How does Iroh detect its public IP address?
Iroh uses the procure_mapping() function in iroh/src/portmapper.rs to query local network gateways via UPnP, PCP, or NAT-PMP protocols. These protocols ask the router to report the external port mapping created for the internal socket, revealing the public SocketAddrV4 that remote peers can use to initiate connections.
What is the difference between a direct candidate and a relay candidate?
A direct candidate represents a public IP address and port obtained through the port mapper or STUN that could allow a peer-to-peer UDP connection. A relay candidate represents the address of a relay server that can forward traffic when direct paths fail. The RemoteStateActor prioritizes direct candidates but maintains relay candidates as guaranteed fallback options.
How long does Iroh wait between hole-punching attempts?
Iroh respects the HOLEPUNCH_ATTEMPTS_INTERVAL constant when scheduling retries. The exact duration is implementation-defined in the source, but the mechanism ensures that attempts are spaced sufficiently to allow NAT mappings to propagate and to avoid overwhelming the network with probe packets, while still attempting new punches immediately when fresh candidates are discovered.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →