Understanding iroh Relay Servers and NAT Traversal: P2P Connectivity in n0-computer/iroh

Iroh endpoints achieve peer-to-peer connectivity through a three-tier fallback system: direct QUIC connections when possible, relay servers acting as TURN-like intermediaries when behind NAT, and aggressive hole-punching attempts to upgrade relayed connections to direct paths whenever firewall topology permits.

Iroh is a peer-to-peer networking library built on QUIC that powers the n0-computer/iroh ecosystem. When endpoints cannot communicate directly due to NAT or firewall restrictions, the library employs sophisticated relay servers and traversal mechanisms to maintain connectivity. This article examines the implementation details of iroh relay servers and NAT traversal strategies based on the actual source code architecture.

How iroh Relay Servers Function

When constructing an endpoint, developers configure relay behavior through the RelayMode enum defined in iroh/src/endpoint.rs. By default, endpoints use RelayMode::Default, which resolves to the first entry in the RelayMap—a configuration structure mapping RelayUrl values to RelayConfig instances.

The builder API in iroh/src/endpoint.rs (lines 55-71) handles transport configuration:

pub fn relay_mode(mut self, relay_mode: RelayMode) -> Self {
    let transport: Option<_> = relay_mode.into();
    match transport {
        Some(transport) => {
            if let Some(og) = self
                .transports
                .iter_mut()
                .find(|t| matches!(t, TransportConfig::Relay { .. }))
            {
                *og = transport;
            } else {
                self.transports.push(transport);
            }
        }
        None => {
            self.transports.retain(|t| !matches!(t, TransportConfig::Relay { .. }));
        }
    }
    self
}

On the client side, the RelayActor maintains active relay connections in a BTreeMap<RelayUrl, ActiveRelayHandle>. For each configured relay, iroh spawns an ActiveRelayActor that manages the connection lifecycle:

  • Dialing: Attempts connection with exponential backoff and a CONNECT_TIMEOUT of 10 seconds (ActiveRelayActor::run_dialing)
  • Keepalives: Transmits ping frames every PING_INTERVAL of 15 seconds to maintain NAT mappings
  • Datagram forwarding: Handles outbound queuing (run_sending) and inbound distribution (run_connected)

The relay server implementation resides in iroh-relay/src/server/http_server.rs and streams.rs, implementing a QUIC-based protocol defined in iroh-relay/src/protos/relay.rs. The server authenticates clients via signed challenges (handshake.rs) and routes datagrams based on the remote EndpointId.

NAT Classification and Hole-Punching

Iroh classifies NAT behavior using the NatKind enum located in iroh/tests/patchbay/nat.rs, which models real-world firewall behavior:

enum NatKind {
    None,      // Publicly routable
    Easiest,   // Full-cone (endpoint-independent mapping & filtering)
    Easy,      // Port-restricted cone (EIM / APDF)
    Hard,      // Symmetric NAT (EDM / APDF)
}

The hole-punching algorithm implemented in iroh/src/socket/transports/relay/actor.rs operates through three phases:

  1. Address discovery: Endpoints publish their external addresses to the relay via the RelayMap infrastructure
  2. Simultaneous UDP send: Both peers transmit datagrams to the other's externally discovered address, attempting to create matching firewall state
  3. Direct path detection: The Connection::wait_ip method (line 408) monitors incoming packets; when a packet arrives from a direct source address, the connection transitions from relayed to direct mode

When both endpoints sit behind Hard (symmetric) NAT, hole-punching fails because the port mapping depends on the destination address. In these cases, the connection remains relayed indefinitely, which is why corresponding test cases in the NAT matrix are marked #[ignore].

Port Mapping for Direct Connectivity

Iroh optionally supports UPnP, PCP, and NAT-PMP through the portmapper feature gated in iroh/src/portmapper.rs. When enabled via PortmapperConfig::Enabled, the system calls portmapper::Client::procure_mapping to request a public port mapping from the local router.

The client initialization handles feature availability gracefully:

pub(crate) fn create_client(config: &PortmapperConfig) -> Client {
    match config {
        #[cfg(all(not(wasm_browser), feature = "portmapper"))]
        PortmapperConfig::Enabled {} => Client::Enabled(::portmapper::Client::default()),
        _ => {
            let (tx, rx) = watch::channel(None);
            Client::Disabled { _tx: tx, rx }
        }
    }
}

Successful port mapping publishes the resulting public address to the relay infrastructure, allowing peers to establish direct connections without relay assistance. This is particularly effective for home networks where the router supports these protocols, though many corporate firewalls block such requests.

Practical Implementation Example

The following example demonstrates configuring a custom relay, enabling port mapping, and establishing a connection while checking the path type:

use iroh::{Endpoint, endpoint::presets};
use iroh_base::RelayUrl;
use url::Url;

#[tokio::main]
async fn main() -> Result<(), iroh::Error> {
    // Configure a custom relay server
    let custom_relay: RelayUrl = "https://my-relay.example.com".parse().unwrap();
    let relay_cfg = iroh_relay::RelayConfig::new(custom_relay, false);
    let relay_map = iroh_relay::RelayMap::from_iter(
        std::iter::once((relay_cfg.url.clone(), relay_cfg))
    );

    let ep = Endpoint::builder(presets::N0)
        .relay_mode(iroh::RelayMode::Custom(relay_map))
        .bind()
        .await?;

    // Publish external address if port mapping is available
    ep.add_external_addr("203.0.113.42:0".parse().unwrap()).await;

    // Connect to remote peer
    let remote_id = "a1b2c3d4e5f6...".parse().unwrap();
    let remote_addr = iroh_base::EndpointAddr {
        id: remote_id,
        relay_urls: vec![custom_relay.clone()],
        ip_addrs: Vec::new(),
    };
    let conn = ep.connect(remote_addr, b"my-alpn").await?;
    println!("Connected! Direct? {}", conn.is_direct());

    Ok(())
}

This implementation leverages iroh-relay/src/relay_map.rs for URL configuration and demonstrates the fallback behavior where conn.is_direct() returns false when traffic flows through the relay and true when hole-punching succeeds.

Summary

  • Relay servers in iroh act as TURN-like intermediaries, maintaining QUIC connections with PING_INTERVAL keepalives to sustain connectivity through symmetric NATs
  • NAT traversal uses a four-tier classification system (None, Easiest, Easy, Hard) with simultaneous UDP hole-punching attempts monitored via wait_ip in the relay actor
  • Port mapping provides an optional optimization via iroh/src/portmapper.rs, requesting router port openings through UPnP/PCP/NAT-PMP when available
  • The ActiveRelayActor handles connection state machines with 10-second timeouts and automatic reconnection backoff
  • Connections automatically upgrade from relayed to direct paths when Connection::wait_ip detects incoming packets from non-relay sources

Frequently Asked Questions

What happens when both peers are behind symmetric NAT?

When both endpoints are classified as Hard (symmetric) NAT, hole-punching fails because each destination address receives a different port mapping from the router. According to the test matrix in iroh/tests/patchbay/nat.rs, these scenarios fall back to relayed connections indefinitely, maintaining connectivity through the ActiveRelayActor datagram forwarding rather than direct UDP.

How does iroh determine whether to use a relay or direct connection?

The endpoint attempts direct connection first if port mapping or prior address knowledge provides a reachable IP. If direct connection fails or the endpoint is behind NAT, it establishes a relay connection via RelayMode configuration. The system continuously monitors for direct paths through wait_ip in iroh/src/socket/transports/relay/actor.rs, automatically upgrading to direct QUIC when hole-punching succeeds.

Can I deploy my own relay server instead of using the default infrastructure?

Yes. The RelayMap structure in iroh-relay/src/relay_map.rs allows custom relay configuration. Instantiate a RelayConfig with your server URL, create a RelayMap, and pass it to Endpoint::builder().relay_mode(iroh::RelayMode::Custom(relay_map)). The relay server implementation is available in iroh-relay/src/server/ and uses the wire protocol defined in iroh-relay/src/protos/relay.rs.

Is port mapping required for iroh applications to function?

No. Port mapping via iroh/src/portmapper.rs is an optional optimization enabled by the portmapper feature flag. When disabled or unsupported by the router, iroh falls back to relay servers and hole-punching. The create_client function returns a Client::Disabled variant that maintains API compatibility through a watch channel, allowing the rest of the system to operate unchanged.

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 →