How to Integrate iroh with Custom Transport Layers (Unstable Feature)

Integrating iroh with custom transport layers requires implementing the CustomTransport and CustomEndpoint traits, registering the transport via add_custom_transport on the Endpoint builder, and configuring a PathSelector to prioritize your transport when available, all gated behind the unstable-custom-transports feature flag.

The iroh networking library provides a pluggable transport architecture that allows developers to integrate custom networking layers beyond standard UDP and relay connections. This capability enables Bluetooth, LoRa, in-memory testing channels, or proprietary protocols to plug into iroh's QUIC-based API. By implementing the core traits defined in iroh/src/endpoint/transports/mod.rs and registering your transport with the endpoint builder, you can extend iroh's path selection logic to include your custom networking medium.

Enable the Unstable Feature

Custom transports are deliberately gated behind the unstable-custom-transports feature flag to allow API iteration without breaking stable releases. To use this functionality, enable the feature in your Cargo.toml:

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

For development and testing, you can also run examples directly:

cargo run --example custom-transport --features unstable-custom-transports

Architecture Overview

The custom transport integration follows a four-stage architecture:

  1. Transport Registration – Add your transport to the Endpoint builder using add_custom_transport, which stores the transport in the builder's internal Preset configuration.
  2. Address Discovery – Custom transports expose CustomAddr values (TransportAddr::Custom) that are included in the peer's EndpointAddr during discovery.
  3. Path Selection – When establishing connections, iroh evaluates candidate paths via the PathSelector trait. You can implement a custom selector (like PreferTestTransport) to prioritize your transport over standard IP paths.
  4. Sending and Receiving – The transport implements CustomSender for packetization and transmission, while CustomEndpoint handles local address monitoring and packet reception via poll_recv().

Implementing the Transport Traits

A complete custom transport requires implementing three core traits defined in iroh/src/endpoint/transports/mod.rs.

CustomTransport and CustomEndpoint

Your transport struct must implement both CustomTransport (for binding) and CustomEndpoint (for runtime operations). The reference implementation in iroh/src/test_utils/test_transport.rs demonstrates the required methods:

pub const TEST_TRANSPORT_ID: u64 = 0x20;

pub struct TestTransport {
    id_watchable: n0_watcher::Watchable<Vec<CustomAddr>>,
    network: TestNetwork,
    id: EndpointId,
}

impl CustomTransport for TestTransport {
    fn bind(&self, _socket: Option<std::net::UdpSocket>) -> Result<Arc<dyn CustomEndpoint>> {
        // Return self as the endpoint
        Ok(Arc::new(self.clone()))
    }
}

impl CustomEndpoint for TestTransport {
    fn watch_local_addrs(&self) -> Watchable<Vec<CustomAddr>> {
        self.id_watchable.clone()
    }
    
    fn poll_recv(&self, cx: &mut Context) -> Poll<Result<RecvMeta>> {
        // Implement packet reception logic
        // ...
    }
}

CustomSender

The CustomSender trait handles packet transmission. According to the test transport implementation, you must implement poll_send() to handle actual packet dispatch:

impl CustomSender for TestSender {
    fn poll_send(&self, cx: &mut Context, transmits: &[Transmit]) -> Poll<Result<usize>> {
        // Packetize and send via your underlying channel
        // Return number of packets sent
        // ...
    }
}

Address Handling

Custom transports use CustomAddr::from_parts() to construct addresses with a unique transport ID and endpoint identifier:

fn to_custom_addr(&self) -> CustomAddr {
    CustomAddr::from_parts(TEST_TRANSPORT_ID, self.id.as_bytes())
}

This allows the transport to identify its own addresses during path selection.

Registering with the Endpoint Builder

Register your transport using the Endpoint builder API in iroh/src/endpoint/builder.rs. The example in iroh/examples/custom-transport.rs shows the complete registration pattern:

let network = TestNetwork::new();
let secret = SecretKey::generate();

let transport = network.create_transport(secret.public())?;

let builder = Endpoint::builder(presets::N0)
    .secret_key(secret)
    .add_custom_transport(transport.clone())
    .path_selector(Arc::new(PreferTestTransport));

The Preset implementation (visible in test_transport.rs) automatically forwards the transport to add_custom_transport() when using the .preset() method:

impl Preset for Arc<TestTransport> {
    fn apply(self, builder: Builder) -> Builder {
        builder
            .add_custom_transport(self.clone())
            .address_lookup(self.network.address_lookup())
    }
}

Path Selection Strategies

The PathSelector trait in iroh/src/endpoint/path_selector.rs determines which transport wins when multiple paths exist. The custom-transport.rs example provides a PreferTestTransport implementation that prioritizes custom transport addresses:

struct PreferTestTransport;

impl PathSelector for PreferTestTransport {
    fn select(&self, ctx: &PathSelectionContext<'_>) -> PathSelection {
        // First, try to find any candidate on our custom transport
        if let Some(p) = ctx.paths().find(|p| {
            matches!(p.network_path().remote(),
                     Addr::Custom(c) if c.id() == TEST_TRANSPORT_ID)
        }) {
            let mut sel = PathSelection::none();
            sel.set(&p);
            return sel;
        }

        // Fallback: pick the lowest-RTT path
        ctx.paths()
            .filter_map(|p| p.stats().map(|s| (p, s.rtt)))
            .min_by_key(|(_, rtt)| *rtt)
            .map(|(p, _)| {
                let mut sel = PathSelection::none();
                sel.set(p);
                sel
            })
            .unwrap_or_default()
    }
}

Alternatively, you can use the built-in BiasedRttPathSelector with a TransportBias configuration to prefer your custom transport's AddrKind without implementing a custom selector.

Complete Integration Example

The following end-to-end example from iroh/examples/custom-transport.rs demonstrates two endpoints communicating over an in-memory custom transport:

#[tokio::main]
async fn main() -> Result<()> {
    tracing_subscriber::fmt::init();

    // Build two endpoints that talk over the test transport
    let network = TestNetwork::new();
    let s1 = SecretKey::from([0u8; 32]);
    let s2 = SecretKey::from([1u8; 32]);

    let t1 = network.create_transport(s1.public())?;
    let t2 = network.create_transport(s2.public())?;

    let ep1 = Endpoint::builder(presets::N0)
        .secret_key(s1)
        .preset(t1)
        .path_selector(Arc::new(PreferTestTransport))
        .bind().await?;

    let ep2 = Endpoint::builder(presets::N0)
        .secret_key(s2)
        .preset(t2)
        .bind().await?;

    // Set up a simple echo protocol
    let server = Router::builder(ep2)
        .accept(ALPN, Echo)
        .spawn();

    // Connect using only the endpoint ID (discovery returns the custom address)
    let conn = ep1.connect(s2.public(), ALPN).await?;

    // Verify that the selected path is the custom transport
    assert!(conn.paths().iter().any(|p|
        p.is_selected() && matches!(p.remote_addr(),
        TransportAddr::Custom(a) if a.id() == TEST_TRANSPORT_ID)));

    // Exchange a message
    let (mut send, mut recv) = conn.open_bi().await?;
    send.write_all(b"hello").await?;
    send.finish()?;
    let reply = recv.read_to_end(100).await?;
    assert_eq!(reply, b"hello");

    conn.close(0u32.into(), b"bye");
    server.shutdown().await?;
    Ok(())
}

Summary

  • Enable the feature using unstable-custom-transports in your Cargo.toml or cargo commands.
  • Implement three traits: CustomTransport for binding, CustomEndpoint for address watching and reception, and CustomSender for transmission.
  • Register via the builder using add_custom_transport() or the Preset pattern demonstrated in iroh/src/test_utils/test_transport.rs.
  • Control path selection by implementing PathSelector or using BiasedRttPathSelector with transport bias to prioritize your custom transport.
  • Verify integration by checking that TransportAddr::Custom appears in the connection's selected paths.

Frequently Asked Questions

What is the stability status of custom transports in iroh?

Custom transports are marked as unstable and require the unstable-custom-transports feature flag. This allows the iroh team to iterate on the CustomTransport, CustomEndpoint, and CustomSender trait APIs without breaking semantic versioning guarantees for the stable crate surface.

How does path selection work with custom transports?

When a connection is established, iroh evaluates all available paths (IP, relay, and custom) through the PathSelector trait. The selector receives a PathSelectionContext containing all candidate paths and their statistics. You can implement a custom selector to prefer TransportAddr::Custom addresses, or use the built-in biased RTT selector with a TransportBias configuration.

Can I use custom transports alongside standard IP and relay transports?

Yes. The builder API supports hybrid configurations. You can call add_custom_transport() while retaining the default IP and relay transports from presets::N0, or explicitly clear them using clear_ip_transports() and clear_relay_transports() if you want an isolated custom transport network.

What methods must a custom transport implement?

According to iroh/src/endpoint/transports/mod.rs, you must implement bind() on CustomTransport, and watch_local_addrs(), create_sender(), and poll_recv() on CustomEndpoint. Additionally, the CustomSender returned by create_sender() must implement poll_send() to handle packet transmission.

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 →