How Iroh Performs NAT Traversal and Hole-Punching Internally
Iroh establishes direct peer-to-peer connections through NATs by coordinating external address discovery, candidate exchange, and stateful hole-punching attempts, automatically falling back to relay connections when direct paths fail.
Iroh's approach to NAT traversal (hole-punching) is implemented in the n0-computer/iroh repository as a coordinated system of address discovery, candidate management, and connection state tracking. Unlike simple STUN-based approaches, Iroh continuously monitors network conditions and dynamically attempts hole-punching whenever new public addresses become available. This internal machinery ensures that even nodes behind restrictive NATs can achieve direct QUIC connections when possible.
The Three Core Components of Iroh's NAT Traversal
Iroh's hole-punching system relies on three coordinated subsystems that work together to discover and exploit direct network paths.
Port-Mapper Integration and External Address Discovery
When PortmapperConfig::Enabled is set (the default in iroh/src/portmapper.rs), Iroh creates a real portmapper::Client that continuously probes for external addresses. This client periodically invokes procure_mapping(), which negotiates with UPnP, PCP, or NAT-PMP devices to obtain the external SocketAddrV4. The result is exposed through a watch::Receiver channel, allowing the rest of the stack to react immediately when the public address changes.
Candidate Exchange Through the Relay Protocol
Each node maintains a set of local candidates (DirectAddrs) representing potentially reachable addresses. When connecting to a peer, Iroh exchanges these candidates through the relay protocol. The RemoteStateActor manages this exchange by calling update_local_direct_address whenever the external address changes, pushing updated candidate sets to all active connections.
Remote-State Actor and Hole-Punching Decisions
The RemoteStateActor in iroh/src/socket/remote_map/remote_state.rs serves as the decision engine for hole-punching. It tracks previous attempt states (last_holepunch) and compares current candidate sets against historical data. When either the local or remote candidate set grows—indicating new public addresses have appeared—the actor triggers a new hole-punching attempt.
Step-by-Step Hole-Punching Flow
Understanding the internal flow reveals how Iroh moves from address discovery to established direct connections.
Discovering Public Addresses with Portmapper
When the endpoint starts with PortmapperConfig::Enabled, the system creates a client that runs in the background. The procure_mapping function (lines 58-65 in portmapper.rs) handles the protocol negotiation with network gateways. This external address discovery happens continuously, not just at startup, ensuring that mobile nodes or nodes with changing public IPs remain reachable.
Updating Local Candidates
Upon detecting an external address change, RemoteStateActor::update_local_direct_address (lines 92-100 in remote_state.rs) recomputes the set of local candidates. These candidates include both the discovered public address and any local interface addresses. The actor then pushes these updates to all active connections, ensuring that remote peers always have the latest reachability information.
Exchanging Remote Candidates
Each connection can request the remote peer's NAT-traversal addresses via conn.get_remote_nat_traversal_addresses(). Inside RemoteStateActor::trigger_holepunching (lines 32-38), the response—a vector of SocketAddrV4—is converted into a BTreeSet for deterministic comparison. This candidate exchange happens over the existing relay connection, ensuring it works even before direct connectivity is established.
Deciding When to Hole-Punch
The decision logic (lines 44-65 in remote_state.rs) compares the current candidate sets against last_holepunch. If new addresses have appeared in either the local or remote set, the actor immediately initiates a hole-punch attempt. If the sets are unchanged, the actor respects HOLEPUNCH_ATTEMPTS_INTERVAL to avoid excessive probing while still retrying periodically to handle temporary NAT table timeouts.
Performing the Hole-Punch
When a new attempt is required, self.state.do_holepunching(conn) is invoked (around line 660 in remote_state.rs). This creates an asynchronous task that sends UDP packets from each local candidate address to each remote candidate address simultaneously. The implementation waits for matching inbound packets; when a packet crosses both NATs and reaches the peer, it validates the path and triggers QUIC path migration to the direct route.
Handling Success and Failure
Successful hole-punches are recorded in PathState as HolepunchSucceeded (lines 240-275 in remote_state.rs), marking the path as preferred for future traffic. Failed attempts are marked HolepunchFailed and pruned after a timeout to prevent candidate set pollution. If all hole-punching attempts fail—such as with symmetric NATs—Iroh automatically falls back to relay connections via the transport selector in src/socket/transports.rs (line 1080), ensuring connectivity is maintained even in the worst-case NAT scenario.
Configuration and Implementation
Enabling and observing hole-punching requires minimal configuration. The portmapper is enabled by default, but you can explicitly configure it when building a node:
// 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}");
}
Key source files for this implementation include:
iroh/src/portmapper.rs– External address discovery and watch channel managementiroh/src/socket/remote_map/remote_state.rs– Core actor logic for candidate management and hole-punching decisionsiroh/src/socket/transports.rs– Transport selection and relay fallbackiroh/tests/patchbay/nat.rs– End-to-end tests covering various NAT configurations
Summary
- Continuous discovery: Iroh uses
procure_mapping()inportmapper.rsto continuously discover external addresses via UPnP/PCP/NAT-PMP, not just at startup. - Stateful coordination: The
RemoteStateActortracks candidate sets and previous attempts, only triggering new hole-punches when new addresses appear. - Aggressive probing:
do_holepunchingsends UDP packets from all local candidates to all remote candidates simultaneously to maximize success chances. - Graceful degradation: Failed attempts are tracked in
PathStateand pruned automatically, with automatic fallback to relay connections via the transport selector. - Zero configuration: The portmapper is enabled by default, requiring no manual STUN server configuration to achieve direct connections.
Frequently Asked Questions
How does Iroh discover its public IP address without manual configuration?
Iroh uses the portmapper client (iroh/src/portmapper.rs) to automatically discover external addresses by communicating with the local network gateway via UPnP, PCP, or NAT-PMP protocols. When PortmapperConfig::Enabled is set (the default), the procure_mapping() function runs continuously in the background, updating a watch::Receiver that notifies the rest of the stack whenever the public address changes.
What triggers a hole-punching attempt in Iroh?
The RemoteStateActor compares the current set of local and remote candidates against the last_holepunch state. If either set has grown—meaning new public addresses have been discovered—the actor immediately triggers do_holepunching(). If the candidate sets remain unchanged, the actor respects HOLEPUNCH_ATTEMPTS_INTERVAL to avoid excessive probing while still retrying periodically.
What happens when hole-punching fails in Iroh?
When hole-punching fails, such as with symmetric NATs that prevent direct UDP communication, Iroh marks the attempt as HolepunchFailed in PathState and prunes these failed candidates after a timeout. Simultaneously, the transport selector in src/socket/transports.rs (around line 1080) automatically falls back to using the relay connection, ensuring that connectivity is maintained even when direct paths cannot be established.
How does Iroh prevent infinite hole-punching loops?
Iroh prevents infinite loops through the HOLEPUNCH_ATTEMPTS_INTERVAL mechanism and state tracking in RemoteStateActor. The actor only initiates new attempts when the candidate sets change (indicating new potential paths) or when the retry interval has elapsed. Failed candidates are tracked and pruned, ensuring the system does not repeatedly attempt the same unreachable addresses.
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 →