How to Implement Custom Transport Protocols with Iroh's unstable-custom-transports Feature

Enable the unstable-custom-transports Cargo feature, implement the CustomTransport, CustomEndpoint, and CustomSender traits, and register your transport via Builder::add_custom_transport to route packets through your custom protocol.

Iroh's networking stack extends beyond QUIC to support user-defined packet transport mechanisms. By enabling the unstable-custom-transports feature in the iroh crate, you can inject bespoke protocols—such as specialized radio links, VPN tunnels, or simulation layers—directly into the connection lifecycle.

Enabling the Feature Flag

To compile with custom transport support, add the feature to your Cargo.toml:

iroh = { version = "...", features = ["unstable-custom-transports"] }

This activates the trait definitions in iroh/src/socket/transports/custom.rs and exposes the Builder::add_custom_transport method in iroh/src/endpoint.rs at lines 800-814.

Understanding the Custom Transport Architecture

The architecture revolves around three core traits that define how raw packets flow through your custom implementation.

The Three Core Traits

Located in iroh/src/socket/transports/custom.rs, these traits form the contract between your code and Iroh's path selection machinery:

  • CustomTransport: The entry point trait. Its bind() method returns a Box<dyn CustomEndpoint> and is called once during endpoint construction.
  • CustomEndpoint: Represents a bound transport instance. It manages watch_local_addrs() for address advertisement, poll_recv() for inbound packet processing, and create_sender() for outbound transmission handles.
  • CustomSender: Handles actual packet transmission via poll_send() and validates destination addresses with is_valid_send_addr().

Lifecycle and Integration

When you call Builder::add_custom_transport, the builder stores an Arc<dyn CustomTransport> inside its internal transports vector. During Builder::bind(), the runtime invokes CustomTransport::bind() to create the endpoint. The runtime then polls CustomEndpoint::poll_recv to inject incoming packets into the path-selection logic via RecvInfo structures. For outbound traffic, create_sender provides an Arc<dyn CustomSender> that the runtime invokes when the path selector chooses your transport over IP or relay paths.

Implementing a Minimal Custom Transport

The following example demonstrates a basic echo transport that implements all required traits. This pattern references the trait definitions in iroh/src/socket/transports/custom.rs:

use std::io;
use std::sync::Arc;
use std::task::{Context, Poll};
use iroh_base::CustomAddr;
use iroh::endpoint::transports::custom::{CustomTransport, CustomEndpoint, CustomSender};

struct EchoTransport;

impl CustomTransport for EchoTransport {
    fn bind(&self) -> io::Result<Box<dyn CustomEndpoint>> {
        Ok(Box::new(EchoEndpoint))
    }
}

struct EchoEndpoint;

impl CustomEndpoint for EchoEndpoint {
    fn watch_local_addrs(&self) -> n0_watcher::Direct<Vec<CustomAddr>> {
        // Advertise a single dummy address for discovery
        n0_watcher::Direct::new(vec![CustomAddr::new(0, vec![0])])
    }

    fn create_sender(&self) -> Arc<dyn CustomSender> {
        Arc::new(EchoSender)
    }

    fn poll_recv(
        &mut self,
        _cx: &mut Context,
        _bufs: &mut [io::IoSliceMut<'_>],
        _metas: &mut [noq_udp::RecvMeta],
        _infos: &mut [iroh::socket::transports::RecvInfo],
    ) -> Poll<io::Result<usize>> {
        // Return 0 packets in this toy example
        Poll::Ready(Ok(0))
    }
}

struct EchoSender;

impl CustomSender for EchoSender {
    fn is_valid_send_addr(&self, _addr: &CustomAddr) -> bool {
        true
    }

    fn poll_send(
        &self,
        _cx: &mut Context,
        _dst: &CustomAddr,
        _src: Option<&CustomAddr>,
        _transmit: &iroh::socket::transports::Transmit<'_>,
    ) -> Poll<io::Result<()>> {
        // Drop packets in this example implementation
        Poll::Ready(Ok(()))
    }
}

Registering Your Transport with the Endpoint

To activate your implementation, wrap it in an Arc and register it during endpoint construction:

let my_transport = Arc::new(EchoTransport);
let endpoint = Endpoint::builder(presets::N0)
    .add_custom_transport(my_transport)
    .bind()
    .await?;

As implemented in iroh/src/endpoint.rs, add_custom_transport stores your transport configuration. When the endpoint binds, it automatically invokes your bind() method and integrates the resulting endpoint into the runtime's packet polling loop.

Path Selection and Production Patterns

By default, Iroh uses an internal path selector that prioritizes low-latency IP or relay paths. To prefer your custom transport, implement a PathSelector and attach it via Builder::path_selector. The repository's example in iroh/examples/custom-transport.rs demonstrates a PreferTestTransport selector that chooses custom addresses when available, falling back to standard paths otherwise.

For a complete reference implementation, examine the test transport in iroh/src/test_utils.rs. This implementation provides a full in-memory network simulation using TestTransport and TestNetwork, showing how to handle CustomAddr serialization, packet buffering, and async runtime integration.

Summary

  • Enable the unstable-custom-transports feature in your Cargo.toml to access the custom transport API.
  • Implement CustomTransport, CustomEndpoint, and CustomSender from iroh/src/socket/transports/custom.rs to define your protocol's behavior.
  • Register your transport via Builder::add_custom_transport before calling bind().
  • Use watch_local_addrs() to advertise custom addresses and poll_recv() to handle inbound packets from the network.
  • Optional: Provide a custom PathSelector to prioritize your transport over IP or relay paths when establishing connections.

Frequently Asked Questions

Is the unstable-custom-transports feature production-ready?

No. As indicated by the "unstable" prefix, this feature is not covered by semantic versioning guarantees. The API may change between minor releases without a major version bump, making it suitable for experimentation and controlled deployments but not for stable production systems requiring long-term API stability.

How does path selection work with custom transports?

The PathSelector implementation evaluates all available paths—including IP, relay, and custom addresses—for each remote peer. When your custom transport advertises addresses via watch_local_addrs, the selector can choose them based on your priority logic. If you do not provide a custom selector, Iroh defaults to standard latency-based selection, which may bypass your transport if IP paths offer lower round-trip times.

Can I use multiple custom transports simultaneously?

Yes. The Builder supports multiple transports via repeated calls to add_custom_transport. Each transport is stored in the internal transports vector and bound during Builder::bind(). The path selector then chooses between all available transport options based on your selection criteria and the remote peer's advertised addresses.

Where can I find a complete working example?

The iroh repository includes a working demonstration in iroh/examples/custom-transport.rs. This example wires a TestTransport from iroh/src/test_utils.rs, implements a custom path selector, and verifies that connections route through the custom transport layer. It serves as the definitive reference for implementing custom transports in real-world scenarios.

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 →