How the Portmapper in Iroh Assists with NAT Traversal: UPnP, PCP, and NAT-PMP Explained

The portmapper in iroh automates NAT traversal by negotiating temporary port mappings via UPnP, PCP, or NAT-PMP protocols, exposing the internal socket's public address to remote peers while ensuring mappings are cleaned up on shutdown.

When running peer-to-peer QUIC connections in the n0-computer/iroh ecosystem, both endpoints often sit behind routers performing Network Address Translation (NAT). Inbound packets are blocked unless the router maintains a specific forwarding rule. The portmapper component solves this by dynamically requesting port mappings from the local gateway and broadcasting the resulting public address to the network.

Configuring the Portmapper in the Endpoint Builder

The portmapper is controlled through PortmapperConfig, which is passed to the endpoint builder during initialization. This configuration determines whether the system attempts to negotiate mappings with the local router.

In iroh/src/endpoint.rs, the builder exposes the portmapper_config method. The default value is PortmapperConfig::Enabled, which activates the mapping logic:

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

let endpoint = Endpoint::builder()
    .portmapper_config(PortmapperConfig::Enabled)   // Activates UPnP/PCP/NAT-PMP
    .build()
    .await?;

Set the configuration to PortmapperConfig::Disabled to skip mapping entirely. This is useful when running in environments where gateway protocols are unavailable or when operating behind symmetric NATs that resist port mapping.

Client Initialization and Feature Gating

When the endpoint builds, the create_client function in iroh/src/portmapper.rs converts the configuration into a concrete portmapper::Client. The implementation uses conditional compilation to determine behavior:

  • Native targets (non-WebAssembly): Returns a live client that communicates with the OS's UPnP/PCP service via ::portmapper::Client::default().
  • WebAssembly or disabled feature: Returns a no-op client that retains only a watch channel, providing the same API surface without network operations.

This abstraction allows the rest of the codebase to remain agnostic to whether port mapping is actually available.

The Portmapping Lifecycle

Procuring Mappings During Address Updates

The DirectAddrUpdateState struct in iroh/src/socket.rs owns the port_mapper: portmapper::Client instance. Before each network report probe, it invokes procure_mapping() to request or refresh the NAT binding:

// Inside DirectAddrUpdateState::run (iroh/src/socket.rs)
self.port_mapper.procure_mapping()

This call initiates the negotiation with the local gateway to open a public port and forward traffic to the internal socket.

Cleanup and Deactivation

When the socket shuts down or the endpoint reconfigures, the system calls deactivate() on the portmapper client. This removes the port mapping from the gateway, preventing stale NAT entries that could block future connection attempts or create security vulnerabilities.

The lifecycle hooks are located at lines 1005-1008 in iroh/src/socket.rs, ensuring deterministic cleanup regardless of how the connection terminates.

Broadcasting Public Addresses

Once a mapping is established, the portmapper client exposes the external address through a watch_external_address() receiver. The endpoint's address-lookup service subscribes to this receiver in iroh/src/portmapper.rs (lines 83-89).

When the mapping resolves to a SocketAddrV4, the service publishes this address to the distributed address-lookup system. Remote peers can then discover and connect directly to this public endpoint, bypassing the need for relay servers and achieving direct, low-latency communication.

Integration with Network Reporting

The net-report component, responsible for probing relays and detecting captive portals, coordinates with the portmapper to ensure mappings stay fresh. In iroh/src/net_report/reportgen.rs, the system creates a portmapper future that runs in parallel with network probes.

This concurrent execution ensures that while the endpoint determines its public visibility and relay capabilities, the port mapping is simultaneously refreshed or renegotiated, minimizing the time between network changes and address publication.

Practical Implementation Example

The following example demonstrates enabling the portmapper, monitoring the external address, and disabling the feature for specific network conditions:

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

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Build endpoint with portmapper enabled (default)
    let ep = Endpoint::builder()
        .portmapper_config(PortmapperConfig::Enabled)
        .build()
        .await?;

    // Monitor external address assignment
    let mut watcher = ep.portmapper().watch_external_address();
    if let Some(addr) = *watcher.borrow() {
        println!("Public address: {}", addr);
    } else {
        watcher.changed().await?;
        println!("Public address: {}", watcher.borrow().as_ref().unwrap());
    }

    // Disable for symmetric NAT scenarios or testing
    let ep_no_pm = Endpoint::builder()
        .portmapper_config(PortmapperConfig::Disabled)
        .build()
        .await?;

    Ok(())
}

The watch_external_address() method forwards to portmapper::Client::watch_external_address, providing real-time updates as the NAT mapping changes.

Summary

  • Protocol Support: The portmapper negotiates with gateways using UPnP, PCP, or NAT-PMP to open temporary public ports.
  • Automatic Lifecycle: Mappings are procured before network probes and deactivated on shutdown via DirectAddrUpdateState in iroh/src/socket.rs.
  • Address Propagation: The watch_external_address() receiver publishes discovered SocketAddrV4 addresses to the distributed lookup service.
  • Feature Gating: Native targets use live clients; WebAssembly falls back to no-op implementations without code changes.
  • Parallel Execution: The net-report component spawns portmapper futures alongside connectivity probes to ensure timely mapping updates.

Frequently Asked Questions

What protocols does the iroh portmapper support?

The portmapper supports UPnP, PCP (Port Control Protocol), and NAT-PMP (NAT Port Mapping Protocol). These are implemented in the underlying portmapper crate and wrapped by the client in iroh/src/portmapper.rs.

How do I disable the portmapper if I'm behind a symmetric NAT?

Pass PortmapperConfig::Disabled to the endpoint builder. Symmetric NATs typically randomize source ports for each outbound connection, making static port mappings ineffective. Disabling the feature prevents unnecessary network traffic and failed negotiation attempts.

Why does the portmapper use a watch channel for external addresses?

The watch_external_address() method returns a tokio::sync::watch receiver that updates when the gateway assigns or changes the public port. This allows the address-lookup service to react asynchronously to network changes without polling, ensuring remote peers always receive the current valid address.

Does the portmapper work on WebAssembly targets?

No. When compiling for WebAssembly, create_client in iroh/src/portmapper.rs returns a no-op client that maintains the API surface but performs no network operations. This ensures the iroh crate compiles for WASM environments where raw socket access and gateway protocols are unavailable.

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 →