How to Configure Custom Transports in iroh for Specialized Network Paths

You can configure custom transports in iroh by implementing the CustomTransport, CustomEndpoint, and CustomSender traits, registering them via Endpoint::builder().add_custom_transport(), and optionally controlling path selection with a custom PathSelector to prioritize specialized links like Bluetooth, LoRa, or Unix domain sockets.

The iroh networking stack supports pluggable transport abstractions that allow you to route QUIC traffic over non-standard network paths. While the library provides built-in transports for IP/UDP and relay connections, you can configure custom transports in iroh to leverage specialized hardware or logical channels. This guide walks through the trait implementations and builder configuration required to integrate alternative transports, referencing the actual source code in n0-computer/iroh.

Enable the Unstable Feature Flag

Custom transport support is currently gated behind the unstable-custom-transports Cargo feature because the API surface is still evolving. Add the feature to your Cargo.toml before implementing any transport traits.

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

Implement the Core Transport Traits

The transport system is built around three core traits defined in iroh/src/socket/transports/custom.rs. You must provide concrete implementations for each layer to create a functional transport.

CustomTransport: The Factory

The CustomTransport trait acts as a factory that produces endpoint instances. Implement the bind() method to initialize your underlying hardware or OS resources (e.g., opening a Bluetooth socket or LoRa radio).

use std::io;
use std::sync::Arc;
use iroh_base::CustomAddr;
use iroh::socket::transports::{CustomTransport, CustomEndpoint};

#[derive(Debug)]
struct MyTransport;

impl CustomTransport for MyTransport {
    fn bind(&self) -> io::Result<Box<dyn CustomEndpoint>> {
        // Initialize hardware resources here
        Ok(Box::new(MyEndpoint::new()?))
    }
}

CustomEndpoint: The Runtime Interface

The CustomEndpoint trait defined in iroh/src/socket/transports/custom.rs handles address discovery and packet reception. You must implement four key methods:

  • watch_local_addrs(): Returns the set of local addresses this transport exposes to the iroh endpoint
  • poll_recv(): The async polling method that pulls packets from the underlying socket into iroh's buffers
  • create_sender(): Returns a CustomSender instance for transmitting packets
  • max_transmit_segments(): Indicates GSO (Generic Segmentation Offload) support level
use std::task::{Context, Poll};
use std::io::IoSliceMut;
use iroh::socket::transports::{CustomEndpoint, CustomSender, RecvInfo};

#[derive(Debug)]
struct MyEndpoint {
    // Internal state, e.g., socket handle
}

impl MyEndpoint {
    fn new() -> io::Result<Self> {
        Ok(Self { /* ... */ })
    }
}

impl CustomEndpoint for MyEndpoint {
    fn watch_local_addrs(&self) -> n0_watcher::Direct<Vec<CustomAddr>> {
        // Return local custom addresses available on this endpoint
        n0_watcher::Direct::new(vec![
            CustomAddr::new(0x42, vec![0x01, 0x02])
        ])
    }

    fn create_sender(&self) -> Arc<dyn CustomSender> {
        Arc::new(MySender { /* ... */ })
    }

    fn poll_recv(
        &mut self,
        cx: &mut Context,
        bufs: &mut [IoSliceMut<'_>],
        metas: &mut [noq_udp::RecvMeta],
        recv_infos: &mut [RecvInfo],
    ) -> Poll<io::Result<usize>> {
        // Read from underlying socket and populate bufs
        // For each packet, write RecvInfo::new(custom_addr, local_addr_opt)
        unimplemented!()
    }

    fn max_transmit_segments(&self) -> std::num::NonZeroUsize {
        // Return 1 for no GSO support, or higher for batching
        std::num::NonZeroUsize::new(4).unwrap()
    }
}

CustomSender: Packet Transmission

The CustomSender trait handles the egress path. Implement is_valid_send_addr() to filter which remote addresses your transport can handle, and poll_send() to encode and transmit packets to the underlying medium.

use iroh::socket::transports::{CustomSender, Transmit};

#[derive(Debug)]
struct MySender { /* ... */ }

impl CustomSender for MySender {
    fn is_valid_send_addr(&self, addr: &CustomAddr) -> bool {
        // Only accept addresses belonging to this transport type
        addr.id() == 0x42
    }

    fn poll_send(
        &self,
        cx: &mut Context,
        dst: &CustomAddr,
        src: Option<&CustomAddr>,
        transmit: &Transmit<'_>,
    ) -> Poll<io::Result<()>> {
        // Encode packet and hand to OS/hardware socket
        unimplemented!()
    }
}

Register the Transport with the Endpoint Builder

Once your traits are implemented, register the transport using the builder API in iroh/src/endpoint.rs. The add_custom_transport() method accepts an Arc<dyn CustomTransport> and stores it in the builder's internal transports vector.

You can optionally disable built-in transports to create an endpoint that operates solely over your custom link.

use iroh::endpoint::{Builder, presets};
use std::sync::Arc;

let builder = Builder::new(presets::N0)
    .add_custom_transport(Arc::new(MyTransport))
    // Optional: remove default transports
    .clear_ip_transports()
    .clear_relay_transports();

When bind() is called, the builder iterates over all registered transports and invokes CustomTransport::bind() to instantiate the endpoints.

Implement Path Selection Strategy

By default, iroh uses an internal path selection algorithm that prefers IPv6, then IPv4, then relay connections. To prioritize your custom transport, implement the PathSelector trait and register it via path_selector() on the builder.

The select() method in iroh/src/endpoint.rs receives a PathSelectionContext containing all candidate paths and returns the chosen path.

use iroh::endpoint::transports::{Addr, PathSelection, PathSelectionContext, PathSelector};

struct PreferMyTransport;

impl PathSelector for PreferMyTransport {
    fn select(&self, ctx: &PathSelectionContext<'_>) -> PathSelection {
        // Prioritize custom transport paths with id 0x42
        if let Some(p) = ctx.paths().find(|p| {
            matches!(p.network_path().remote(), Addr::Custom(c) if c.id() == 0x42)
        }) {
            let mut sel = PathSelection::none();
            sel.set(&p);
            return sel;
        }
        
        // Fallback to lowest RTT path
        ctx.paths()
            .filter_map(|p| p.stats().map(|s| (p, s.rtt)))
            .min_by_key(|(_, rtt)| *rtt)
            .map_or_else(PathSelection::none, |(p, _)| {
                let mut sel = PathSelection::none();
                sel.set(p);
                sel
            })
    }
}

// Apply the selector
let builder = builder.path_selector(Arc::new(PreferMyTransport));

Complete Working Example

Below is a minimal, self-contained example that demonstrates binding a custom transport and configuring the endpoint. This mirrors the official example in iroh/examples/custom-transport.rs but focuses on the essential configuration points.

use std::{sync::Arc, time::Duration};
use iroh::{
    Endpoint, SecretKey, TransportAddr,
    endpoint::{
        Builder, presets,
        transports::{Addr, PathSelection, PathSelectionContext, PathSelector},
    },
};
use iroh_base::CustomAddr;

// Transport implementation
#[derive(Debug)]
struct MyTransport;
impl iroh::socket::transports::CustomTransport for MyTransport {
    fn bind(&self) -> std::io::Result<Box<dyn iroh::socket::transports::CustomEndpoint>> {
        Ok(Box::new(MyEndpoint {}))
    }
}

#[derive(Debug)]
struct MyEndpoint;
impl iroh::socket::transports::CustomEndpoint for MyEndpoint {
    fn watch_local_addrs(&self) -> n0_watcher::Direct<Vec<CustomAddr>> {
        n0_watcher::Direct::new(vec![CustomAddr::new(0x99, vec![0x10, 0x20])])
    }
    
    fn create_sender(&self) -> Arc<dyn iroh::socket::transports::CustomSender> {
        Arc::new(MySender {})
    }
    
    fn poll_recv(
        &mut self,
        _cx: &mut std::task::Context,
        _bufs: &mut [std::io::IoSliceMut<'_>],
        _metas: &mut [noq_udp::RecvMeta],
        _recv_infos: &mut [iroh::socket::RecvInfo],
    ) -> std::task::Poll<std::io::Result<usize>> {
        std::task::Poll::Pending
    }
    
    fn max_transmit_segments(&self) -> std::num::NonZeroUsize {
        std::num::NonZeroUsize::new(4).unwrap()
    }
}

#[derive(Debug)]
struct MySender;
impl iroh::socket::transports::CustomSender for MySender {
    fn is_valid_send_addr(&self, addr: &CustomAddr) -> bool {
        addr.id() == 0x99
    }
    
    fn poll_send(
        &self,
        _cx: &mut std::task::Context,
        _dst: &CustomAddr,
        _src: Option<&CustomAddr>,
        _tx: &iroh::socket::transports::Transmit<'_>,
    ) -> std::task::Poll<std::io::Result<()>> {
        std::task::Poll::Ready(Ok(()))
    }
}

// Path selector
#[derive(Debug)]
struct PreferMyTransport;
impl PathSelector for PreferMyTransport {
    fn select(&self, ctx: &PathSelectionContext<'_>) -> PathSelection {
        if let Some(p) = ctx.paths().find(|p| {
            matches!(p.network_path().remote(), Addr::Custom(c) if c.id() == 0x99)
        }) {
            let mut sel = PathSelection::none();
            sel.set(&p);
            return sel;
        }
        ctx.paths()
            .filter_map(|p| p.stats().map(|s| (p, s.rtt)))
            .min_by_key(|(_, rtt)| *rtt)
            .map_or_else(PathSelection::none, |(p, _)| {
                let mut sel = PathSelection::none();
                sel.set(p);
                sel
            })
    }
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let secret = SecretKey::from([0u8; 32]);
    
    let builder = Endpoint::builder(presets::N0)
        .secret_key(secret)
        .add_custom_transport(Arc::new(MyTransport))
        .clear_ip_transports()
        .clear_relay_transports()
        .path_selector(Arc::new(PreferMyTransport));
    
    let endpoint = builder.bind().await?;
    println!("Local addresses: {:?}", endpoint.addr());
    
    Ok(())
}

Summary

  • Enable the feature: Add unstable-custom-transports to your Cargo.toml to access the trait definitions in iroh/src/socket/transports/custom.rs.
  • Implement three traits: Provide CustomTransport as a factory, CustomEndpoint for receive logic and address discovery, and CustomSender for transmit operations.
  • Register with the builder: Use add_custom_transport() on the Endpoint::builder() to register your implementation, optionally clearing default IP or relay transports.
  • Control path selection: Implement PathSelector to prioritize custom paths over standard network routes, using path_selector() on the builder.
  • Reference the example: Study iroh/examples/custom-transport.rs for a complete end-to-end implementation including test network simulation.

Frequently Asked Questions

What Cargo feature is required to configure custom transports in iroh?

You must enable the unstable-custom-transports feature in your Cargo.toml because the API is not yet stabilized. This feature exposes the CustomTransport, CustomEndpoint, and CustomSender traits in iroh/src/socket/transports/custom.rs.

How does iroh decide which transport to use for a connection?

Iroh evaluates all available paths (IP, relay, and custom) through the PathSelector trait. The default selector prefers IPv6, then IPv4, then relay. By providing a custom PathSelector implementation and registering it via path_selector(), you can prioritize custom transports based on address type, latency, or other application-specific criteria.

Can custom transports run alongside standard IP networking?

Yes. You can register custom transports alongside built-in transports by using add_custom_transport() without calling clear_ip_transports() or clear_relay_transports(). The endpoint will advertise all available addresses, and the path selector will choose the appropriate transport for each connection attempt.

What performance considerations apply to custom transport implementations?

Your CustomEndpoint should implement max_transmit_segments() to indicate GSO support if your underlying hardware supports batching. The poll_recv() and poll_send() methods integrate with iroh's async runtime, so they should be non-blocking and return Poll::Pending when waiting for I/O to avoid blocking the connection driver.

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 →