How Iroh Hole-Punching Works for Direct Peer Connections

Iroh establishes direct peer-to-peer connections by using a remote-state actor to monitor network address changes, selecting the lowest-ID client connection, and delegating UDP NAT traversal to the underlying noq library, while tracking successful paths as PathStatus::Open in the path state manager.

Iroh is a peer-to-peer networking library that establishes direct QUIC connections through NATs and firewalls using automatic hole-punching. The implementation relies on a remote-state actor in iroh/src/socket/remote_map/remote_state.rs to coordinate when and how to punch holes, leveraging the noq crate for low-level UDP traversal. This article examines the source code to explain how iroh hole-punching detects candidate addresses, triggers traversal attempts, and manages successful direct paths.

The Remote State Actor Architecture

The core of iroh's hole-punching logic lives in the RemoteStateActor, which runs inside iroh/src/socket/remote_map/remote_state.rs. This actor watches for changes in local and remote candidate addresses, decides when a new hole-punch attempt is required, and asks the underlying noq connection to perform the actual NAT traversal.

The actor maintains the state of all connections to a given remote endpoint, tracking which paths are open, which are candidates for hole-punching, and when the last attempt occurred. It ensures that hole-punching only happens when necessary, preventing unnecessary network traffic when the topology is stable.

When and How Hole-Punching is Triggered

The trigger_holepunching method (lines 504–517 in remote_state.rs) implements a five-step decision process to determine whether to initiate NAT traversal.

The Decision Logic

The method first checks if any connections exist, returning early if the set is empty. It then selects the client connection with the lowest ConnId, as only client-side connections can initiate NAT traversal, and the deterministic selection prevents both peers from acting simultaneously when both are clients.

Next, it gathers candidate addresses by calling conn.get_remote_nat_traversal_addresses(), which returns IP:port pairs discovered via STUN-like probes. These are compared against the previous attempt's candidates; if the sets are unchanged and the interval HOLEPUNCH_ATTEMPTS_INTERVAL (5 seconds) has not elapsed, the attempt is deferred to save bandwidth.

/// Triggers hole‑punching to the remote endpoint.
fn trigger_holepunching(&mut self) {
    // 1️⃣ No connections → nothing to do.
    if self.connections.is_empty() {
        trace!("not holepunching: no connections");
        return;
    }

    // 2️⃣ Pick the *client* connection 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;
    };

    // 3️⃣ Gather candidate addresses from the remote side.
    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();

    // 4️⃣ If the address set changed since the last attempt ⇒ hole‑punch now,
    //    otherwise schedule the next attempt after a fixed interval.
    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 !new_candidates {
        if let Some(last_hp) = &self.state.last_holepunch {
            let next_hp = last_hp.when + HOLEPUNCH_ATTEMPTS_INTERVAL;
            if Instant::now() < next_hp {
                trace!(scheduled_in = ?(next_hp - Instant::now()),
                       "not holepunching: no new addresses");
                self.state.scheduled_holepunch = Some(next_hp);
                return;
            }
        }
    }

    // 5️⃣ Finally, delegate the actual NAT‑traversal to the connection.
    self.state.do_holepunching(conn);
}

Key implementation details:

  • Client selection: The filter conn.side().is_client() ensures only clients initiate, preventing redundant attempts from both sides.
  • Candidate deduplication: The new_candidates check uses set subset operations to detect topology changes.
  • Scheduling: Failed or unchanged attempts are deferred by HOLEPUNCH_ATTEMPTS_INTERVAL, defaulting to 5 seconds.

Executing NAT Traversal

Once triggered, the actor calls do_holepunching (lines 560–575 in remote_state.rs), which records the attempt and delegates to the noq library.

impl State {
    fn do_holepunching(&mut self, conn: &noq::Connection) {
        // Record the attempt for future comparison.
        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(),
        });

        // The *noq* connection knows how to craft and send the required
        // STUN‑like packets to punch a hole through symmetric NATs.
        conn.holepunch();
    }
}

The noq crate (noq_proto::n0_nat_traversal) handles the low-level UDP hole-punch algorithm, sending packets from both sides to candidate address pairs until a path is established. This separation of concerns allows iroh to focus on state management while noq handles protocol specifics.

Path Management and Selection

After successful hole-punching, the resulting path is tracked in path_state.rs (lines 84–98). The actor inserts the path as PathStatus::Open, marks its source as Source::HolePunch, and invokes the path selector.

self.state.paths.insert_open_path(addr, Source::HolePunch);
self.select_path();               // picks the fastest, most reliable path
self.apply_selected_path();       // tells every connection which path to use

The path selector evaluates all open paths (including relayed and direct) and chooses the optimal one based on latency and reliability. If a hole-punched path later fails, the actor prunes it from the state and schedules a new hole-punch attempt after the interval expires.

Practical Implementation Examples

Connecting to a Remote Peer

The connect.rs example demonstrates how iroh automatically triggers hole-punching when calling endpoint.connect():

use iroh::{Endpoint, EndpointAddr, RelayMode, TransportAddr, endpoint::presets};

const EXAMPLE_ALPN: &[u8] = b"n0/iroh/examples/0";

#[tokio::main]
async fn main() -> iroh::Result<()> {
    // 1️⃣ Build an endpoint that uses the default relay servers.
    let endpoint = Endpoint::builder(presets::N0)
        .secret_key(iroh::SecretKey::generate())
        .alpns(vec![EXAMPLE_ALPN.to_vec()])
        .relay_mode(RelayMode::Default)          // <‑‑ enables hole‑punching & relaying
        .bind()
        .await?;

    // 2️⃣ Build the remote address (public key + UDP candidates + relay URL).
    let remote = EndpointAddr::from_parts(
        /* remote endpoint id */ remote_id,
        vec![
            TransportAddr::Ip("203.0.113.42:4567".parse()?),
            TransportAddr::Relay("https://relay.iroh.example/".parse()?),
        ],
    );

    // 3️⃣ Connect – iroh will trigger hole‑punching behind the scenes.
    let conn = endpoint.connect(remote, EXAMPLE_ALPN).await?;
    println!("🔗 Direct connection established (or relayed if NAT prevented it)");
    // … use `conn.open_bi()` etc.
    Ok(())
}

Source: iroh/examples/connect.rs (lines 41–53 for builder, 71–78 for address creation, 80–83 for connect).

Accepting Inbound Connections

The listen.rs example shows how an endpoint advertises its UDP ports and accepts inbound hole-punches:

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

#[tokio::main]
async fn main() -> iroh::Result<()> {
    // Listen with the same preset – the library will advertise its UDP ports
    // and accept inbound hole‑punches from peers.
    let endpoint = Endpoint::builder(presets::N0)
        .secret_key(iroh::SecretKey::generate())
        .relay_mode(RelayMode::Default)
        .bind()
        .await?;

    // Wait for an inbound connection.
    let inbound = endpoint.accept().await?.await?;
    println!("🛜 Received direct (or relayed) connection from {}", inbound.remote_id());
    Ok(())
}

Source: iroh/examples/listen.rs.

Summary

  • Iroh hole-punching is coordinated by the RemoteStateActor in iroh/src/socket/remote_map/remote_state.rs, which monitors address changes and decides when to initiate NAT traversal.
  • Client selection uses the connection with the lowest ConnId to ensure deterministic behavior when both peers are clients.
  • Candidate deduplication prevents redundant attempts by comparing current local and remote addresses against the last HolepunchAttempt, deferring retries by HOLEPUNCH_ATTEMPTS_INTERVAL (5 seconds).
  • Execution delegates to the noq library via conn.holepunch(), which implements the low-level UDP packet exchange.
  • Path management stores successful traversals as PathStatus::Open in path_state.rs, with the path selector choosing the best available route for subsequent traffic.

Frequently Asked Questions

When does iroh trigger hole-punching?

Iroh triggers hole-punching when the RemoteStateActor detects changes in local or remote candidate addresses, or when the HOLEPUNCH_ATTEMPTS_INTERVAL has elapsed since the last attempt. The trigger_holepunching method checks if new addresses are subsets of previous candidates, initiating traversal only when the network topology has changed or the retry timer expires.

Why does iroh select the client connection with the lowest ID?

The actor filters for client-side connections using conn.side().is_client() and selects the one with the minimum ConnId to ensure deterministic behavior. This prevents both peers from simultaneously initiating hole-punching when both happen to be clients, reducing race conditions and unnecessary network traffic.

What happens if hole-punching fails?

If NAT traversal fails, the connection falls back to relay servers specified in the EndpointAddr. The actor continues to monitor for address changes and will retry hole-punching after HOLEPUNCH_ATTEMPTS_INTERVAL (5 seconds) if new candidates become available, ensuring the system eventually establishes a direct path if the network topology permits.

How does iroh handle multiple candidate addresses?

The actor collects all candidate addresses from get_remote_nat_traversal_addresses() and stores them in a BTreeSet. It compares these sets against previous attempts to detect changes. When executing do_holepunching, the noq library attempts to establish connectivity with each candidate pair, and the first successful path is marked as PathStatus::Open in the path state manager.

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 →