Iroh Hole Punching for Direct Connections: How NAT Traversal Works in n0-computer/iroh

Iroh hole punching automatically upgrades relay-mediated QUIC connections into direct, end-to-end encrypted paths between peers behind NATs by coordinating NAT traversal address discovery and UDP path establishment.

Iroh is a peer-to-peer networking stack designed to prioritize direct connectivity even when both peers reside behind restrictive firewalls or NAT devices. When two endpoints first connect, they typically communicate through a home relay to guarantee reachability, but iroh hole punching for direct connections works in the background to eliminate that relay hop as soon as a direct path becomes available. This process reduces latency, minimizes bandwidth costs, and ensures optimal performance for real-time applications.

Why Iroh Implements Hole Punching for Direct Connections

When an iroh endpoint starts, it first establishes a connection to a home relay to ensure it remains reachable from anywhere on the internet. While this relay guarantees connectivity, it introduces extra latency and bandwidth overhead compared to a direct path.

Hole punching solves this by allowing two peers to discover a direct UDP path through their respective NATs. According to the iroh documentation, the relay is essential for initial connectivity, but the system continuously attempts to upgrade to a direct connection to improve performance. If NAT conditions prevent a direct path, traffic continues over the relay transparently without dropping the connection.

The Architecture Behind Iroh Hole Punching

All connection management and hole punching logic resides in the RemoteStateActor, found in iroh/src/socket/remote_map/remote_state.rs. This actor monitors QUIC connections, gathers candidate addresses, and decides when to initiate hole punching attempts.

RemoteStateActor and Connection Management

The RemoteStateActor manages the lifecycle of every peer connection. It periodically gathers candidate NAT traversal addresses from two sources:

  • The QUIC connection itself via conn.get_remote_nat_traversal_addresses()
  • The local watcher of direct addresses

When new candidates appear on either side, the actor evaluates whether to schedule a hole punch. This design ensures that endpoint mobility—such as switching from Wi-Fi to cellular—triggers new attempts to find optimal paths.

The Hole Punching Trigger Mechanism

The core decision logic lives in RemoteStateActor::trigger_holepunching (lines 504–518). This method filters active connections to find the most suitable client-side connection, collects candidate addresses, and determines whether a new hole punch is necessary.

// Simplified view of the trigger logic
fn trigger_holepunching(&mut self) {
    // Pick a client-side connection with the lowest ConnId
    let conn = self.connections.iter()
        .filter_map(|(id, s)| s.handle.upgrade().map(|c| (*id, c)))
        .filter(|(_, c)| c.side().is_client())
        .min_by_key(|(id, _)| *id)
        .map(|(_, c)| c)?;
    
    // Collect remote and local NAT candidates
    let remote_candidates = conn.get_remote_nat_traversal_addresses()?.into_iter().collect();
    let local_candidates = self.state.local_candidates();

    // Decide whether a new hole-punch is needed
    let new_candidates = self.state.last_holepunch
        .as_ref()
        .map(|last| !remote_candidates.is_subset(&last.remote_candidates)
                 || !local_candidates.is_subset(&last.local_candidates))
        .unwrap_or(true);

    // Schedule or execute the punch
    if new_candidates { self.state.do_holepunching(conn); }
    else { /* schedule next attempt */ }
}

The logic compares current candidates against the previous attempt (last_holepunch). If either local or remote candidates have changed, it triggers do_holepunching, which instructs the underlying QUIC library (noq) to exchange NAT traversal information and open a direct UDP path.

Path Selection and Failover Strategy

Once hole punching succeeds, RemoteStateActor::apply_selected_path (lines 682–702) updates the selected_path to the direct route and closes the less efficient relay-based paths. This atomic switch ensures minimal interruption to active data streams.

If hole punching fails, the actor retains the relay path as a fallback and retries after a configurable interval. The system prunes paths that repeatedly fail from the candidate set, as implemented in remote_state/path_state.rs (lines 243–254), preventing futile reattempts against incompatible NAT configurations.

Code Examples: Establishing Direct Connections in Iroh

Iroh handles hole punching automatically once you bind endpoints using the standard presets. The following example demonstrates creating two endpoints and establishing a connection that will automatically upgrade to a direct path.

Creating Endpoints with Automatic Hole Punching

use iroh::{Endpoint, endpoint::presets};
use iroh::protocol::Router;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // Both peers use the same preset (includes DNS & relay config)
    let a = Endpoint::bind(presets::N0).await?;
    let b = Endpoint::bind(presets::N0).await?;

    // Router on `a` just echoes data
    let router = Router::builder(a.clone())
        .accept(b"example/echo/0", Echo)
        .spawn();

    // Make `b` dial `a` by its public key (the endpoint Id)
    let conn = b.connect(a.addr(), b"example/echo/0").await?;
    let (mut send, mut recv) = conn.open_bi().await?;
    send.write_all(b"hello").await?;
    send.finish()?;
    let reply = recv.read_to_end(64).await?;
    assert_eq!(&reply, b"hello");

    // `a` and `b` will have performed hole punching behind the scenes.
    Ok(())
}

Verifying the Selected Path

To confirm whether a direct connection has been established, inspect the RemoteInfo after connection setup:

use iroh::endpoint::Endpoint;
use iroh::endpoint::presets::N0;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let ep = Endpoint::bind(N0).await?;
    // After a connection is established...
    let info = ep.remote_info().await?; // Returns RemoteInfo (see remote_state.rs)
    println!("selected path: {:?}", info.selected_path);
    Ok(())
}

The selected_path field indicates whether the connection is using a direct UDP path or still routing through the relay. When hole punching succeeds, this value updates to reflect the direct endpoint addresses.

Summary

  • Iroh hole punching transforms relay-mediated connections into direct paths between NATed peers, reducing latency and bandwidth costs.
  • The RemoteStateActor in iroh/src/socket/remote_map/remote_state.rs orchestrates the process by gathering NAT traversal candidates and triggering punches via trigger_holepunching.
  • Successful punches update the selected_path and close relay connections, while failures retain the relay as a fallback with automatic retry every 5 seconds (HOLEPUNCH_ATTEMPTS_INTERVAL).
  • Failed paths are automatically pruned from consideration to prevent repeated unsuccessful attempts.
  • Applications using the standard Endpoint API with presets like N0 receive automatic hole punching without manual configuration.

Frequently Asked Questions

What is iroh hole punching?

Iroh hole punching is a NAT traversal mechanism that allows two peers behind firewalls or NAT devices to establish a direct UDP connection. The system uses a relay server for initial connectivity, then coordinates address discovery and UDP NAT traversal to open a direct path, upgrading the connection transparently without application intervention.

How long does hole punching take in iroh?

Hole punching occurs asynchronously after the initial connection is established. The RemoteStateActor evaluates candidates continuously and attempts hole punching whenever new addresses are discovered. If a direct path is possible, it typically establishes within seconds, though the exact timing depends on NAT types and network conditions. The system retries every 5 seconds (HOLEPUNCH_ATTEMPTS_INTERVAL) if the first attempt fails.

What happens if hole punching fails in iroh?

If hole punching fails, iroh maintains the connection over the relay path without dropping packets or interrupting the application. The RemoteStateActor continues to monitor for new candidate addresses and will retry periodically. Paths that consistently fail are pruned from the candidate set to optimize future attempts, ensuring the system only tries viable routes.

Does iroh require a relay server for hole punching to work?

Yes, iroh requires at least one home relay to facilitate hole punching. The relay serves as the initial meeting point where peers exchange their NAT traversal addresses (via get_remote_nat_traversal_addresses). Without the relay, peers cannot discover each other's candidates to coordinate the hole punch. Once the direct path is established, the relay is no longer used for data transmission, but it remains available for fallback connectivity.

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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →