How Iroh Hole Punching Works for Direct Peer-to-Peer Connections
Iroh uses a remote-state actor to monitor network changes, selects the lowest-ID client connection, and delegates UDP hole punching to the underlying noq library to establish direct P2P paths while falling back to relays when NAT traversal fails.
Iroh is a Rust-based peer-to-peer networking library built on QUIC that automatically traverses NATs and firewalls to create direct connections. The hole punching mechanism is orchestrated by the RemoteStateActor in iroh/src/socket/remote_map/remote_state.rs, which coordinates with the noq library to perform the actual UDP NAT traversal. This architecture allows applications to establish direct peer-to-peer connections without manual network configuration.
When Hole Punching Triggers: The Remote-State Actor
The RemoteStateActor monitors local and remote address changes to determine when to initiate hole punching. The trigger_holepunching method (lines 504–517 in remote_state.rs) implements a sophisticated decision tree that prevents unnecessary attempts while ensuring connectivity.
Selecting the Client Connection
Before punching, the actor must identify which connection should initiate the traversal. According to the source code, the logic filters for client-side connections and selects the one with the lowest ConnId:
let Some(conn) = self.connections.iter()
.filter_map(|(id, state)| state.handle.upgrade().map(|c| (*id, c)))
.filter(|(_, conn)| conn.side().is_client())
.min_by_key(|(id, _)| *id)
.map(|(_, conn)| conn) else {
trace!("not holepunching: no client connection");
return;
};
This selection strategy matters because only a client can initiate NAT traversal, and using the lowest ID provides deterministic behavior when both peers are clients.
Gathering NAT Candidates
Once a connection is selected, the actor gathers potential paths through NATs. It retrieves remote candidates via conn.get_remote_nat_traversal_addresses() and combines them with local candidates from self.state.local_candidates():
let remote_candidates = match conn.get_remote_nat_traversal_addresses() {
Ok(addrs) => BTreeSet::from_iter(addrs),
Err(err) => {
warn!("failed to get nat candidate addresses: {err:#}");
return;
}
};
let local_candidates = self.state.local_candidates();
The noq library probes the remote peer using STUN-like packets to discover these candidate IP:port pairs that may be reachable through NATs.
The Decision Logic: Deduplication and Scheduling
Iroh avoids redundant hole punching attempts by comparing current candidates against the previous attempt. The trigger_holepunching method checks if the new remote candidates are a subset of the last attempt's candidates:
let new_candidates = self.state.last_holepunch.as_ref().map(|last_hp| {
!remote_candidates.is_subset(&last_hp.remote_candidates)
|| !local_candidates.is_subset(&last_hp.local_candidates)
}).unwrap_or(true);
If the address sets match, the actor schedules the next attempt using HOLEPUNCH_ATTEMPTS_INTERVAL (5 seconds by default) to conserve bandwidth and CPU:
if let Some(last_hp) = &self.state.last_holepunch {
let next_hp = last_hp.when + HOLEPUNCH_ATTEMPTS_INTERVAL;
if Instant::now() < next_hp {
self.state.scheduled_holepunch = Some(next_hp);
return;
}
}
Executing the Hole Punch via Noq
When new candidates are detected, do_holepunching (lines 560–575 in remote_state.rs) records the attempt and delegates the heavy lifting to the noq library. This method updates the state machine before calling the underlying connection:
impl State {
fn do_holepunching(&mut self, conn: &noq::Connection) {
self.last_holepunch = Some(HolepunchAttempt {
when: Instant::now(),
local_candidates: self.local_candidates(),
remote_candidates: conn
.get_remote_nat_traversal_addresses()
.unwrap_or_default()
.into_iter()
.collect(),
});
conn.holepunch();
}
}
The conn.holepunch() call triggers noq's implementation in noq_proto/src/n0_nat_traversal.rs, which crafts and sends UDP packets to candidate address pairs from both sides until a direct path is established.
Path Management and Selection
After successful hole punching, iroh updates the path state in iroh/src/socket/remote_map/remote_state/path_state.rs. Successful paths are recorded as PathStatus::Open with the source tagged as Source::HolePunch (lines 84–98):
self.state.paths.insert_open_path(addr, Source::HolePunch);
self.select_path();
self.apply_selected_path();
The select_path() method chooses the optimal path based on latency and reliability, while apply_selected_path() propagates this decision to all active connections. If a path later fails, the actor prunes it from the state and retries hole punching after the configured interval.
Practical Usage Examples
Hole punching in iroh is automatic, but understanding the API helps developers optimize their applications.
Connecting to a Remote Endpoint
When initiating a connection, iroh automatically triggers hole punching if direct UDP candidates are available:
use iroh::{Endpoint, EndpointAddr, RelayMode, TransportAddr, endpoint::presets};
const ALPN: &[u8] = b"n0/iroh/examples/0";
#[tokio::main]
async fn main() -> iroh::Result<()> {
let endpoint = Endpoint::builder(presets::N0)
.secret_key(iroh::SecretKey::generate())
.alpns(vec![ALPN.to_vec()])
.relay_mode(RelayMode::Default)
.bind()
.await?;
let remote = EndpointAddr::from_parts(
remote_id,
vec![
TransportAddr::Ip("203.0.113.42:4567".parse()?),
TransportAddr::Relay("https://relay.iroh.example/".parse()?),
],
);
let conn = endpoint.connect(remote, ALPN).await?;
println!("Direct connection established (or relayed if NAT prevented it)");
Ok(())
}
This example from iroh/examples/connect.rs demonstrates how the Endpoint::connect call implicitly handles NAT traversal by providing both UDP and relay addresses.
Accepting Hole-Punched Connections
Listening endpoints automatically handle incoming hole punch attempts:
use iroh::{Endpoint, endpoint::presets};
#[tokio::main]
async fn main() -> iroh::Result<()> {
let endpoint = Endpoint::builder(presets::N0)
.secret_key(iroh::SecretKey::generate())
.relay_mode(RelayMode::Default)
.bind()
.await?;
let inbound = endpoint.accept().await?.await?;
println!("Received direct connection from {}", inbound.remote_id());
Ok(())
}
As shown in iroh/examples/listen.rs, the endpoint advertises its UDP ports and processes inbound hole punch requests without requiring application-level code.
Summary
- Automatic Triggering: The
RemoteStateActorinremote_state.rsmonitors address changes and triggerstrigger_holepunchingonly when new NAT candidates appear or afterHOLEPUNCH_ATTEMPTS_INTERVAL(5 seconds). - Client Selection: The system selects the client-side connection with the lowest
ConnIdto ensure deterministic NAT traversal initiation. - Noq Delegation: Actual UDP hole punching is delegated to
conn.holepunch()in the noq library, which performs the STUN-like packet exchange. - Path Lifecycle: Successful attempts create
PathStatus::Openentries inpath_state.rs, which the path selector uses to route traffic over the fastest direct connection. - Fallback Support: If hole punching fails, iroh automatically falls back to relay servers specified in the
EndpointAddr.
Frequently Asked Questions
How does iroh decide when to attempt hole punching?
Iroh's RemoteStateActor compares current local and remote NAT candidates against the previous attempt stored in last_holepunch. If the candidate sets have changed or HOLEPUNCH_ATTEMPTS_INTERVAL (5 seconds) has elapsed since the last attempt, it triggers a new hole punch via trigger_holepunching. This prevents redundant traffic when network topology is stable.
What happens if hole punching fails?
If the noq library cannot establish a direct UDP path after attempting all candidate pairs, iroh falls back to using the relay server specified in the EndpointAddr. The connection remains functional over the relay while the remote-state actor continues to monitor for new candidates and retry hole punching in the background.
Why does iroh select the connection with the lowest ConnId?
The hole punching logic requires a client-side connection to initiate NAT traversal. When multiple connections exist, selecting the one with the smallest ConnId provides deterministic behavior, particularly in scenarios where both peers are clients. This ensures both sides don't simultaneously initiate redundant hole punch attempts.
Can I disable hole punching in iroh?
While you cannot explicitly disable hole punching in the public API, you can force relay-only connections by providing only TransportAddr::Relay addresses in the EndpointAddr. However, the default RelayMode::Default configuration automatically enables hole punching alongside relay support to prioritize direct P2P connections when possible.
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 →