Implementing Protocol Routing with Router and ProtocolHandler in iroh
The iroh router multiplexes incoming QUIC connections to user-defined protocol handlers using Application-Layer Protocol Negotiation (ALPN) identifiers, enabling extensible peer-to-peer networking without modifying core networking code.
The iroh crate provides a flexible, asynchronous P2P networking stack built around a central routing mechanism. By implementing the ProtocolHandler trait and registering handlers with the Router, developers can define custom protocols that automatically dispatch based on ALPN identifiers. This architecture separates transport concerns from application logic, allowing you to compose complex networking behaviors through modular components.
Core Components of Protocol Routing
The protocol routing system in iroh consists of several tightly integrated components that work together to dispatch connections efficiently.
Endpoint
The Endpoint (located in iroh/src/endpoint.rs) serves as the foundation of the networking stack. It holds the QUIC listener, maintains knowledge of its own node ID, and advertises the ALPNs it supports. The Endpoint acts as the entry point for both inbound and outbound connections, handling the low-level QUIC protocol details.
Router and RouterBuilder
The Router (defined at line 97 in iroh/src/protocol.rs) owns an Endpoint and runs the main accept loop that dispatches incoming connections. It uses a ProtocolMap to route traffic to the appropriate handler. The RouterBuilder (line 104 in the same file) provides a fluent API for configuring the router before it starts. Through the builder, you register protocol handlers, set optional incoming filters, and finally spawn the accept loop with spawn().
ProtocolHandler Trait
The ProtocolHandler trait (line 27 in iroh/src/protocol.rs) defines the interface that all protocol implementations must satisfy. It requires an accept method that receives a Connection and returns a Result<(), AcceptError>. Handlers can also implement shutdown to define custom cleanup logic when the router terminates.
Type Erasure and ProtocolMap
To store heterogeneous handler types in a single collection, iroh uses DynProtocolHandler (line 31) as a type-erased wrapper. The router stores these in a ProtocolMap (line 75), which is a BTreeMap<Vec<u8>, Box<dyn DynProtocolHandler>> mapping ALPN byte strings to their corresponding handlers. This design allows the router to maintain a homogeneous collection while preserving each handler's concrete async semantics.
How Connection Routing Works
The routing process follows a strict lifecycle that ensures type-safe dispatch and graceful error handling.
1. Builder Configuration
You begin by creating a RouterBuilder via Router::builder(endpoint). For each protocol you wish to serve, call accept(alpn, handler), passing the ALPN identifier as a byte string and a type implementing ProtocolHandler. Internally, the builder inserts this pair into the ProtocolMap (see line 89 in iroh/src/protocol.rs). You may also optionally add an incoming_filter to reject or retry connections before the TLS handshake occurs.
2. Spawning the Accept Loop
Calling spawn() consumes the builder and performs several initialization steps: it registers the supported ALPNs on the endpoint via endpoint.set_alpns, creates a JoinSet for tracking connection tasks, and initializes a CancellationToken. The method then starts an async loop (run_loop_fut) that continuously:
- Waits for
endpoint.accept()to yield a newIncomingconnection - Applies the optional filter, potentially calling
retry(),refuse(), orignore() - Looks up the handler using the connection's ALPN via
protocols.get(&alpn) - Calls
handler.on_acceptingto obtain aConnection, then executeshandler.accept(connection)in its own task
3. Connection Handling
The internal handle_connection function (near the end of iroh/src/protocol.rs) coordinates the handoff. It extracts the ALPN from the incoming connection, retrieves the corresponding handler from the map, and invokes the acceptance sequence. Errors during acceptance are logged but do not crash the router, ensuring that malformed connections cannot destabilize the entire node.
4. Graceful Shutdown
When Router::shutdown is called, the system triggers the cancellation token to stop the main accept loop. The router then awaits the abort-on-drop task, calls protocols.shutdown() to let every handler run its own shutdown future, cancels any remaining accept futures, and finally closes the endpoint. This ensures no resources leak and active connections receive proper cleanup.
Implementing Custom Protocol Handlers
The following examples demonstrate how to implement ProtocolHandler for common use cases.
Minimal Echo Protocol
This example implements a simple bidirectional echo server that copies data from the receive stream back to the send stream:
use iroh::{
endpoint::{presets, Endpoint},
protocol::{AcceptError, ProtocolHandler, Router},
Connection,
};
#[derive(Debug, Clone)]
struct Echo;
#[iroh::async_trait]
impl ProtocolHandler for Echo {
async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
let (mut send, mut recv) = connection.accept_bi().await?;
tokio::io::copy(&mut recv, &mut send).await?;
send.finish()?;
connection.closed().await;
Ok(())
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Bind a local endpoint with the default preset.
let endpoint = Endpoint::bind(presets::N0).await?;
// Build a router that serves the echo protocol on `/iroh/echo/1`.
let router = Router::builder(endpoint)
.accept(b"/iroh/echo/1", Echo)
.spawn();
// Keep the process alive until Ctrl-C, then shut down cleanly.
tokio::signal::ctrl_c().await?;
router.shutdown().await?;
Ok(())
}
The key steps are: Router::builder creates the builder, accept registers the protocol, spawn starts the accept loop, and shutdown performs graceful termination.
Filtering Incoming Connections
You can reject connections before the expensive TLS handshake by installing an incoming filter:
use std::sync::Arc;
use iroh::{
protocol::{IncomingFilterOutcome, Router},
endpoint::{presets, Endpoint},
};
fn ipv4_filter(incoming: &iroh::endpoint::Incoming) -> IncomingFilterOutcome {
match incoming.remote_addr() {
iroh::endpoint::IncomingAddr::Ip(addr) if addr.is_ipv4() => IncomingFilterOutcome::Accept,
_ => IncomingFilterOutcome::Reject,
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let endpoint = Endpoint::bind(presets::Minimal).await?;
let router = Router::builder(endpoint)
.incoming_filter(Arc::new(ipv4_filter))
.accept(b"/iroh/echo/1", Echo)
.spawn();
router.shutdown().await?;
Ok(())
}
This filter runs before the TLS handshake, saving CPU cycles when the client should be rejected outright based on network address.
Custom Shutdown Logic
Protocol handlers can implement custom cleanup by overriding the shutdown method:
use std::sync::{Arc, Mutex};
use iroh::{
protocol::{AcceptError, ProtocolHandler, Router},
Connection,
};
#[derive(Debug, Default)]
struct Graceful {
connections: Arc<Mutex<Vec<Connection>>>,
}
#[iroh::async_trait]
impl ProtocolHandler for Graceful {
async fn accept(&self, conn: Connection) -> Result<(), AcceptError> {
self.connections.lock().unwrap().push(conn);
Ok(())
}
async fn shutdown(&self) {
// Give in-flight connections a chance to finish.
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
for conn in self.connections.lock().unwrap().drain(..) {
conn.close(42u32.into(), b"shutdown");
}
}
}
Register this with Router::builder(...).accept(b"/my/proto/1", Graceful::default()). When router.shutdown() is called, the handler's shutdown runs first, allowing it to close its connections cleanly before the endpoint closes.
Key Implementation Files
The protocol routing implementation spans several files in the iroh repository:
iroh/src/protocol.rs- Contains the full implementation ofRouter,RouterBuilder, theProtocolHandlertrait, and the accept-loop logic.iroh/src/endpoint.rs- Provides the low-level QUIC endpoint abstraction, includingEndpoint,Incoming, andConnectiontypes.iroh/examples/echo.rs- Demonstrates a minimal router setup with the echo protocol.iroh/tests/patchbay/switch-uplink.rs- Integration test showing router usage with multiple presets and filters.
These files illustrate how the router abstracts protocol routing, how a user supplies handlers, and how the system maintains clean shutdown semantics.
Summary
- Router uses ALPN identifiers to dispatch incoming QUIC connections to registered ProtocolHandler implementations.
- The RouterBuilder allows configuration of protocols via
accept()and optional pre-handshake filtering viaincoming_filter(). - ProtocolHandler requires implementing
accept()for connection handling and optionallyshutdown()for custom cleanup. - The router stores handlers as
Box<dyn DynProtocolHandler>in aProtocolMap, enabling type-erased storage of heterogeneous protocol implementations. - Graceful shutdown ensures all handlers receive cleanup time before the endpoint closes, preventing resource leaks.
Frequently Asked Questions
What is the relationship between Router and Endpoint in iroh?
The Router owns an Endpoint and manages its lifecycle. While the Endpoint handles the low-level QUIC protocol details and connection acceptance, the Router adds the multiplexing layer that inspects ALPN identifiers and dispatches connections to the appropriate ProtocolHandler. According to the iroh source code in iroh/src/protocol.rs, the Router calls endpoint.accept() in its main loop and registers supported ALPNs via endpoint.set_alpns.
How does the Router determine which ProtocolHandler to use for an incoming connection?
The Router extracts the ALPN identifier from the incoming connection's TLS handshake and performs a lookup in its internal ProtocolMap (a BTreeMap<Vec<u8>, Box<dyn DynProtocolHandler>>). As implemented in iroh/src/protocol.rs, the router calls protocols.get(&alpn) to find the matching handler, then invokes handler.accept(connection) to process the connection.
Can I reject connections before the TLS handshake completes?
Yes. The RouterBuilder supports an incoming_filter callback that runs before the TLS handshake. This filter receives an Incoming reference and returns an IncomingFilterOutcome indicating whether to accept, reject, ignore, or retry the connection. This allows for cheap firewalling or rate limiting without wasting CPU cycles on cryptographic operations.
What happens to active connections when I call Router::shutdown?
When shutdown() is called, the Router first cancels the main accept loop to stop accepting new connections. It then calls shutdown() on each registered ProtocolHandler, giving them time to clean up active connections. Finally, it closes the Endpoint and aborts any remaining tasks. This ensures that existing connections can finish gracefully rather than being forcefully terminated.
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 →