How to Implement a Custom Protocol Handler Using iroh's Router

To implement a custom protocol handler in iroh, create a type that implements the ProtocolHandler trait, register it with a RouterBuilder using the accept() method with a unique ALPN identifier, and spawn the router to start accepting connections.

The iroh library provides a high-performance QUIC-based networking stack for peer-to-peer applications. To add custom application logic, you implement a custom protocol handler using the Router API, which dispatches incoming connections based on Application-Layer Protocol Negotiation (ALPN) identifiers as defined in iroh/src/protocol.rs.

Understanding the ProtocolHandler Trait

The foundation of any custom protocol is the ProtocolHandler trait defined in iroh/src/protocol.rs (lines 27-48). This trait defines how your application handles incoming QUIC connections.

The trait requires three primary methods:

  • accept – The core method that processes established connections. It receives a Connection object (from iroh/src/endpoint.rs) and runs your protocol logic.
  • on_accepting – An optional hook called before the TLS handshake completes. Use this to implement 0-RTT (zero round-trip time) acceptance or early validation.
  • shutdown – An optional hook for resource cleanup when the router terminates.

When a connection arrives, the router's internal accept loop (run_loop_fut) looks up the ALPN in the ProtocolMap and calls these methods in sequence.

Registering Your Handler with the Router

To wire your handler into the networking stack, use the Router builder pattern. The Router::builder function creates a RouterBuilder that collects protocol registrations before spawning the accept loop.

In iroh/src/protocol.rs (lines 85-90), the builder stores your handler in a ProtocolMap:

let router = iroh::protocol::Router::builder(endpoint)
    .accept(MY_ALPN, MyHandler)
    .spawn();

Key implementation details from the source code:

  • RouterBuilder::spawn() (lines 99-122) creates the background accept loop and returns a Router handle.
  • The router automatically adds your ALPN to the endpoint's advertised protocols.
  • Router::shutdown gracefully terminates all protocol handlers and closes connections.

Minimal Implementation: Echo Protocol

Here is a complete, minimal example implementing an echo protocol. This handler accepts bidirectional streams and echoes data back to the client.

use iroh::{
    protocol::{ProtocolHandler, AcceptError},
    endpoint::Connection,
};

const MY_ECHO_ALPN: &[u8] = b"/myapp/echo/1";

#[derive(Debug, Clone)]
struct MyEcho;

impl ProtocolHandler for MyEcho {
    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() -> anyhow::Result<()> {
    let endpoint = iroh::endpoint::presets::N0.bind().await?;
    let router = iroh::protocol::Router::builder(endpoint)
        .accept(MY_ECHO_ALPN, MyEcho)
        .spawn();
    
    println!("Router listening – press Ctrl-C to stop");
    tokio::signal::ctrl_c().await?;
    router.shutdown().await?;
    Ok(())
}

This example implements only the required accept method. The default on_accepting implementation awaits the standard handshake, and the router handles all connection dispatching automatically.

Advanced Hooks: 0-RTT and Graceful Shutdown

For protocols requiring early handshake intervention or resource management, implement the optional trait hooks.

Implementing 0-RTT Support

Use on_accepting to accept connections before the full TLS handshake completes. This is critical for latency-sensitive protocols.

use iroh::{
    protocol::{ProtocolHandler, AcceptError},
    endpoint::{Connection, Accepting},
};

const MY_0RTT_ALPN: &[u8] = b"/myapp/0rtt/1";

#[derive(Debug, Clone)]
struct MyZeroRtt;

impl ProtocolHandler for MyZeroRtt {
    async fn on_accepting(&self, accepting: Accepting) -> Result<Connection, AcceptError> {
        let conn = accepting.into_0rtt().await?;
        println!("0-RTT connection established");
        Ok(conn)
    }

    async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
        let (mut send, mut recv) = connection.accept_bi().await?;
        send.write_all(b"hello from 0-RTT").await?;
        send.finish()?;
        connection.closed().await;
        Ok(())
    }

    async fn shutdown(&self) {
        println!("MyZeroRtt cleaning up resources");
    }
}

The on_accepting hook receives an Accepting object and can convert it to a Connection immediately using into_0rtt(), bypassing standard handshake latency.

Resource Cleanup

Implement shutdown to close pending connections, flush state, or signal background tasks when the router terminates via router.shutdown().await.

Connection Filtering

Optionally, provide an incoming_filter to the RouterBuilder to reject, retry, or ignore connections based on the client's address before the ALPN is read.

use iroh::protocol::{IncomingFilterOutcome, Router};
use std::sync::Arc;

let router = Router::builder(endpoint)
    .incoming_filter(Arc::new(|incoming| {
        if incoming.remote_addr_validated() {
            IncomingFilterOutcome::Accept
        } else {
            IncomingFilterOutcome::Retry
        }
    }))
    .accept(MY_ECHO_ALPN, MyEcho)
    .spawn();

The filter can return Accept, Reject, Retry, or Ignore to control connection flow. See the test suite in iroh/src/protocol.rs (lines 720-800) for examples of each outcome.

Summary

  • Implement ProtocolHandler in iroh/src/protocol.rs to define your protocol logic.
  • Register with RouterBuilder using accept(alpn, handler) to map ALPN identifiers to your implementation.
  • Spawn the router to start the accept loop that dispatches connections based on the ALPN.
  • Use on_accepting for 0-RTT or early handshake logic, and shutdown for graceful cleanup.
  • Reference key files: iroh/src/protocol.rs for the router and trait definitions, and iroh/src/endpoint.rs for the Connection API.

Frequently Asked Questions

What is the ALPN identifier and why does it matter?

The ALPN (Application-Layer Protocol Negotiation) identifier is a byte string that uniquely identifies your protocol, such as b"/myapp/echo/1". When a client connects, iroh uses this identifier to dispatch the connection to the correct handler registered in the ProtocolMap. Without a unique ALPN, the router cannot distinguish between different protocol handlers.

How do I gracefully shut down a custom protocol handler?

Implement the shutdown method on your ProtocolHandler trait. When you call router.shutdown().await, the router invokes this method on all registered handlers, allowing you to close streams, flush buffers, or terminate background tasks before the QUIC connections are closed.

Can I accept connections before the TLS handshake completes?

Yes. Implement the on_accepting method to intercept the connection during the handshake phase. Use accepting.into_0rtt().await to convert the Accepting state into a Connection immediately, enabling 0-RTT data transmission. This is implemented in iroh/src/protocol.rs as part of the ProtocolHandler trait hooks.

How do I reject suspicious incoming connections?

Provide an incoming_filter closure to the RouterBuilder. This filter receives incoming connection metadata and returns an IncomingFilterOutcome—Accept, Reject, Retry, or Ignore—allowing you to block addresses or require QUIC retry tokens before the protocol handler is invoked.

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 →