How to Integrate iroh with Existing QUIC Implementations and Custom Socket Factories
Enable the unstable-custom-transports feature and implement the CustomTransport, CustomEndpoint, and CustomSender traits to plug any QUIC stack or socket factory into iroh's networking layer.
The iroh networking stack builds its QUIC layer on top of the noq library, but exposes an extension point for swapping the underlying transport. When you need to integrate iroh with existing QUIC implementations—such as a fork of Quinn with custom TLS settings—or use a custom socket factory for constrained environments like WebAssembly or kernel-bypass networking, you can leverage the unstable custom transport API to retain iroh's protocol features while controlling the transport layer.
Enabling the Custom Transport Feature
Access to the transport layer is gated behind the unstable-custom-transports Cargo feature. Add the following to your Cargo.toml:
[dependencies]
iroh = { version = "0.8", features = ["unstable-custom-transports"] }
This unlocks the custom_transport method on the endpoint builder and the trait definitions required to bridge your own networking code.
Implementing the Three Core Traits
To integrate iroh with existing QUIC implementations, you must implement three traits defined in iroh/src/socket/transports/custom.rs. These traits abstract the transport lifecycle, packet reception, and transmission.
CustomTransport
The CustomTransport trait acts as a factory that produces CustomEndpoint instances. It defines a single method:
bind(&self) -> io::Result<Box<dyn CustomEndpoint>>: Creates and returns the endpoint instance that iroh will drive.
CustomEndpoint
The CustomEndpoint trait represents the local socket or QUIC endpoint. It must implement:
watch_local_addrs(&self) -> n0_watcher::Direct<Vec<CustomAddr>>: Returns the local addresses the endpoint is bound to.create_sender(&self) -> Arc<dyn CustomSender>: Produces a sender handle for outgoing packets.poll_recv(...) -> Poll<io::Result<usize>>: Yields received datagrams to thenoqstate machine. This method drives all inbound traffic.max_transmit_segments(&self) -> NonZeroUsize: Indicates how many packets the transport can batch per send operation.
CustomSender
The CustomSender trait handles outbound transmission:
is_valid_send_addr(&self, addr: &CustomAddr) -> bool: Filters whether a destination address is supported by this transport.poll_send(...) -> Poll<io::Result<()>>: Transmits a single datagram.noqcalls this repeatedly during its send loop.
Registering Your Transport with the Endpoint Builder
Once your traits are implemented, register the transport using the Builder in iroh/src/endpoint.rs. The custom_transport method accepts any Box<dyn CustomTransport>:
use iroh::{endpoint::Builder, socket::transports::custom::CustomTransport};
struct MyQuicTransport;
impl CustomTransport for MyQuicTransport { /* ... */ }
let endpoint = Builder::default()
.custom_transport(Box::new(MyQuicTransport))
.bind()
.await?;
After calling bind(), iroh treats your implementation as the exclusive transport, disabling the default UDP socket creation.
How the Integration Works Under the Hood
When you call Builder::custom_transport, the endpoint builder stores your implementation in the Transport enum defined in iroh/src/socket/transports.rs. During the bind sequence:
- The builder invokes
CustomTransport::bindto create yourCustomEndpoint. - The endpoint is wrapped in the
Transport::Customvariant and held by the iroh endpoint. - Internally, iroh drives your transport through the
noq::AsyncUdpSockettrait, callingpoll_recvto ingest packets andpoll_sendvia theCustomSenderto egress them. - All QUIC-level operations—connection establishment, stream multiplexing, and datagram support—continue to be handled by
noq, ensuring that features like hole-punching, relay fallback, and path selection remain functional regardless of the underlying socket type.
This architecture means you can swap the transport layer without altering any application-level code that uses iroh for blob transfer, gossip, or document synchronization.
Use Cases for Custom Transports
Implementing a custom transport is useful in several scenarios where the default UDP socket is insufficient:
- Alternative QUIC implementations: Bridge iroh to a fork of Quinn that includes specialized TLS configuration or congestion control algorithms, while retaining iroh's protocol stack.
- Sandboxed or restricted environments: Use kernel-bypass sockets (DPDK, netmap) or WebAssembly networking APIs that do not expose standard UDP sockets.
- Testing and simulation: Inject mock transports that record traffic, simulate packet loss, or run entirely in-process for deterministic integration tests.
Complete Implementation Example
The following example demonstrates wrapping an existing Quinn endpoint to use as a custom transport. It shows the required trait implementations and wiring:
use std::{io, net::SocketAddr, sync::Arc, task::{Context, Poll}};
use iroh::{
endpoint::{Builder, Endpoint},
socket::{
transports::custom::{CustomTransport, CustomEndpoint, CustomSender},
RecvInfo, Transmit,
},
base::CustomAddr,
};
/// Wraps an existing quinn::Endpoint as an iroh transport.
struct QuinnTransport(quinn::Endpoint);
impl CustomTransport for QuinnTransport {
fn bind(&self) -> io::Result<Box<dyn CustomEndpoint>> {
Ok(Box::new(QuinnEndpoint {
inner: self.0.clone(),
}))
}
}
struct QuinnEndpoint {
inner: quinn::Endpoint,
}
impl CustomEndpoint for QuinnEndpoint {
fn watch_local_addrs(&self) -> n0_watcher::Direct<Vec<CustomAddr>> {
let addr = self.inner.local_addr().unwrap();
n0_watcher::Direct::new(vec![CustomAddr::Udp(addr)])
}
fn create_sender(&self) -> Arc<dyn CustomSender> {
Arc::new(QuinnSender {
inner: self.inner.clone(),
})
}
fn poll_recv(
&mut self,
cx: &mut Context<'_>,
bufs: &mut [io::IoSliceMut<'_>],
metas: &mut [noq_udp::RecvMeta],
recv_infos: &mut [RecvInfo],
) -> Poll<io::Result<usize>> {
// Forward received datagrams from the QUIC endpoint to noq.
// Implementation depends on how you expose raw UDP from quinn.
Poll::Pending
}
fn max_transmit_segments(&self) -> std::num::NonZeroUsize {
std::num::NonZeroUsize::new(16).unwrap()
}
}
struct QuinnSender {
inner: quinn::Endpoint,
}
impl CustomSender for QuinnSender {
fn is_valid_send_addr(&self, addr: &CustomAddr) -> bool {
matches!(addr, CustomAddr::Udp(_))
}
fn poll_send(
&self,
_cx: &mut Context<'_>,
dst: &CustomAddr,
_src: Option<&CustomAddr>,
transmit: &Transmit<'_>,
) -> Poll<io::Result<()>> {
match dst {
CustomAddr::Udp(sockaddr) => {
// Convert transmit to raw UDP send via your QUIC stack
// Example: self.inner.send_datagram(*sockaddr, transmit.contents);
Poll::Ready(Ok(()))
}
_ => Poll::Ready(Err(io::Error::new(
io::ErrorKind::InvalidInput,
"unsupported address",
))),
}
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Initialize your existing QUIC endpoint
let quinn_endpoint = quinn::Endpoint::client("[::]:0".parse::<SocketAddr>()?)?;
// Wire it into iroh
let iroh_endpoint: Endpoint = Builder::default()
.custom_transport(Box::new(QuinnTransport(quinn_endpoint)))
.bind()
.await?;
// Use iroh_endpoint normally for dialing, accepting, and streaming
Ok(())
}
Replace the stubbed poll_recv and poll_send bodies with actual calls into your QUIC library or raw socket to complete the integration.
Summary
- Enable the
unstable-custom-transportsfeature in yourCargo.tomlto access the custom transport API. - Implement the
CustomTransport,CustomEndpoint, andCustomSendertraits defined iniroh/src/socket/transports/custom.rsto bridge your socket factory. - Register your implementation with
Builder::custom_transportbefore callingbind()iniroh/src/endpoint.rs. - Retain all iroh features—hole-punching, relay fallback, and protocol handlers—because the QUIC state machine remains in
noqwhile only the packet I/O is delegated to your code. - Reference the built-in transports in
iroh/src/socket/transports/ip.rsandrelay.rsas templates for your implementation.
Frequently Asked Questions
What is the performance impact of using a custom transport?
The custom transport abstraction adds one layer of trait object dispatch per packet, but the overhead is minimal compared to the system calls for network I/O. The max_transmit_segments method allows you to batch sends (typically up to 16 or 64 datagrams) to amortize dispatch costs, matching the performance characteristics of the built-in UDP transport.
Can I use multiple transports simultaneously with iroh?
Currently, the Builder::custom_transport method accepts a single transport implementation that replaces the default UDP socket entirely. To use multiple transports (for example, a custom socket for specific interfaces and standard UDP for others), you would need to implement a composite CustomTransport that internally multiplexes between your desired backends.
Which iroh features continue to work when using a custom transport?
All high-level features—including NAT hole-punching, relay fallback, path selection, and protocol handlers (blobs, gossip, docs)—continue to function unchanged. These features operate above the QUIC layer managed by noq, which still performs congestion control, encryption, and stream multiplexing. Your custom transport only replaces the raw packet ingress and egress.
Where can I find reference implementations to use as templates?
The iroh source code includes two built-in transport implementations that serve as canonical examples: iroh/src/socket/transports/ip.rs implements standard UDP socket handling, and iroh/src/socket/transports/relay.rs implements relay-over-HTTP fallback. Both demonstrate proper handling of poll_recv, poll_send, and address watching in production code.
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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →