Building Multi-Protocol Servers with Router in iroh

The iroh Router allows a single QUIC Endpoint to accept and route connections for multiple protocols simultaneously using ALPN identifiers, with support for early connection filtering and graceful shutdown.

The n0-computer/iroh repository provides a Rust networking stack designed for peer-to-peer applications. Building multi-protocol servers with Router in iroh enables developers to multiplex different application protocols over a single QUIC endpoint, reducing operational complexity while maintaining clean separation of concerns between protocol implementations.

Router Architecture and Core Components

The router implementation resides primarily in iroh/src/protocol.rs, where the core types coordinate to manage connection lifecycle and protocol dispatch.

RouterBuilder and Protocol Registration

The RouterBuilder serves as the configuration stage before the server begins accepting connections. According to the source code in iroh/src/protocol.rs, calling Router::builder(endpoint) returns a builder instance (lines 405–407) that accumulates protocol handlers and optional configuration.

Key methods on RouterBuilder include:

  • accept(alpn, handler) – Registers a ProtocolHandler for a specific ALPN byte string, storing the mapping in an internal ProtocolMap (lines 85–92).
  • incoming_filter(filter) – Sets an IncomingFilter function to evaluate connections before the TLS handshake completes (lines 72–74).
  • spawn() – Finalizes the builder, registers the complete ALPN list on the endpoint, and launches the background accept loop (lines 99–122).

ProtocolHandler Trait

Each protocol implements the ProtocolHandler trait, which defines how the router handles incoming connections for a specific ALPN. The trait requires an accept method and optionally supports on_accepting:

#[async_trait::async_trait]
impl ProtocolHandler for MyHandler {
    async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
        // Protocol-specific logic here
        Ok(())
    }
}

When a client connects, the router extracts the ALPN from the TLS handshake (lines 33–40) and looks up the corresponding handler in the ProtocolMap (lines 42–45). If no handler matches, the connection is silently ignored (line 44).

IncomingFilter for Early Gating

The IncomingFilter trait enables security and resource management decisions before committing to a full TLS handshake. The filter receives an Incoming connection reference and returns one of four IncomingFilterOutcome variants:

  • Accept – Proceed with normal handshake and protocol selection.
  • Retry – Send a QUIC retry token to validate the client's address (incoming.retry()), then skip further processing.
  • Reject – Actively refuse the connection with a CONNECTION_REFUSED error.
  • Ignore – Drop the connection silently, causing the client to time out.

The router evaluates the filter at lines 62–85 of iroh/src/protocol.rs, allowing you to reject unvalidated addresses or implement rate limiting before cryptographic operations occur.

Creating a Multi-Protocol Server

To instantiate a router, bind an Endpoint and chain the builder methods to register your handlers:

use iroh::{
    Endpoint,
    endpoint::presets,
    protocol::{ProtocolHandler, Router, AcceptError},
};
use n0_error::Result;

#[derive(Clone)]
struct Echo;
#[async_trait::async_trait]
impl ProtocolHandler for Echo {
    async fn accept(&self, conn: iroh::endpoint::Connection) -> Result<(), AcceptError> {
        let (mut send, mut recv) = conn.accept_bi().await?;
        tokio::io::copy(&mut recv, &mut send).await?;
        send.finish()?;
        Ok(())
    }
}

#[tokio::main]
async fn main() -> Result<()> {
    let endpoint = Endpoint::bind(presets::N0).await?;
    
    let router = Router::builder(endpoint)
        .accept(b"/echo/1", Echo)
        .accept(b"/chat/1", ChatHandler)  // Additional protocol
        .spawn();
    
    // Server runs until explicit shutdown
    tokio::signal::ctrl_c().await?;
    router.shutdown().await?;
    Ok(())
}

This pattern, demonstrated in iroh/examples/echo.rs, binds a single endpoint that simultaneously serves the /echo/1 and /chat/1 protocols, routing each connection to its respective handler based on the ALPN negotiated during the QUIC handshake.

Implementing Protocol Handlers

Protocol handlers operate on Connection objects provided by the router. The handler fully owns the connection once the router delegates it, responsible for stream management and clean termination.

The echo server example in iroh/examples/echo.rs (lines 84–100) illustrates a complete implementation:

#[async_trait::async_trait]
impl ProtocolHandler for Echo {
    async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
        // Accept a bidirectional stream
        let (mut send, mut recv) = connection.accept_bi().await?;
        
        // Copy received data back to sender
        tokio::io::copy(&mut recv, &mut send).await?;
        send.finish()?;
        
        // Wait for connection close to ensure clean shutdown
        connection.closed().await;
        Ok(())
    }
}

Handlers should await connection.closed() or explicit stream closure to prevent resource leaks, as the router tracks active connections during shutdown.

Filtering Connections Before TLS

Implement IncomingFilter to reject unwanted connections before cryptographic handshaking consumes resources. This is particularly useful for mitigating denial-of-service attacks or enforcing address validation:

use iroh::protocol::{IncomingFilterOutcome, IncomingFilter};

fn validation_filter(incoming: &iroh::endpoint::Incoming) -> IncomingFilterOutcome {
    if incoming.remote_addr_validated() {
        IncomingFilterOutcome::Accept
    } else {
        IncomingFilterOutcome::Retry  // Force address validation
    }
}

// Applied during router construction
let router = Router::builder(endpoint)
    .accept(b"/secure/1", SecureHandler)
    .incoming_filter(validation_filter)
    .spawn();

The filter executes synchronously on the accept loop thread, so avoid blocking operations. For complex logic, return Accept and perform authentication inside the async ProtocolHandler::accept method instead.

Graceful Shutdown

The Router::shutdown method ensures all protocol handlers terminate cleanly without dropping active connections mid-stream. According to the implementation in iroh/src/protocol.rs:

  1. Cancels the accept loop via cancel_token.cancel() (line 34).
  2. Awaits background task completion for the main accept loop (lines 40–44).
  3. Signals all handlers by calling ProtocolMap::shutdown concurrently (lines 95–101).
  4. Cancels pending accepts and closes the endpoint (lines 97–106).

Invoke shutdown explicitly to prevent connection errors:

router.shutdown().await?;

If the router is dropped without calling shutdown, the background task aborts immediately, potentially interrupting active transfers.

Complete Example: Logger and Ping Server

The following example demonstrates serving two distinct protocols—one that logs connections and another that implements a request-response ping protocol—under a single endpoint with address validation filtering:

use iroh::{
    Endpoint,
    endpoint::presets,
    protocol::{AcceptError, IncomingFilterOutcome, ProtocolHandler, Router},
};
use n0_error::Result;

#[derive(Clone, Debug)]
struct Logger;
#[async_trait::async_trait]
impl ProtocolHandler for Logger {
    async fn accept(&self, conn: iroh::endpoint::Connection) -> Result<(), AcceptError> {
        println!("Connection from {}", conn.remote_id());
        Ok(())
    }
}

#[derive(Clone, Debug)]
struct Ping;
#[async_trait::async_trait]
impl ProtocolHandler for Ping {
    async fn accept(&self, conn: iroh::endpoint::Connection) -> Result<(), AcceptError> {
        let (mut send, mut recv) = conn.accept_bi().await?;
        let mut buf = Vec::new();
        recv.read_to_end(1024).await?.into_bytes(&mut buf);
        send.write_all(b"pong").await?;
        send.finish()?;
        Ok(())
    }
}

fn addr_filter(incoming: &iroh::endpoint::Incoming) -> IncomingFilterOutcome {
    if incoming.remote_addr_validated() {
        IncomingFilterOutcome::Accept
    } else {
        IncomingFilterOutcome::Reject
    }
}

#[tokio::main]
async fn main() -> Result<()> {
    let ep = Endpoint::bind(presets::Minimal).await?;
    
    let router = Router::builder(ep)
        .accept(b"/example/logger/1", Logger)
        .accept(b"/example/ping/1", Ping)
        .incoming_filter(addr_filter)
        .spawn();
    
    tokio::signal::ctrl_c().await?;
    router.shutdown().await?;
    Ok(())
}

This configuration registers both ALPN strings on the underlying QUIC endpoint, allowing clients to select their desired protocol via the ALPN extension during connection establishment.

Summary

  • The Router in iroh/src/protocol.rs multiplexes multiple protocols over a single QUIC endpoint using ALPN routing.
  • RouterBuilder configures handlers via accept() and optional incoming_filter() before spawn() starts the accept loop.
  • ProtocolHandler implementations receive full ownership of Connection objects and must handle stream management and graceful closure.
  • IncomingFilter executes before the TLS handshake, supporting Accept, Retry, Reject, or Ignore outcomes for early connection gating.
  • Graceful shutdown via router.shutdown().await signals all handlers, cancels pending accepts, and closes the endpoint cleanly.

Frequently Asked Questions

What is the ALPN string used for in iroh Router?

The ALPN (Application-Layer Protocol Negotiation) string identifies which protocol handler should process an incoming connection. When a client connects, it proposes an ALPN during the QUIC handshake; the router matches this against registered handlers in the ProtocolMap (lines 42–45 of iroh/src/protocol.rs) and dispatches the connection accordingly.

How does the Router handle unknown protocols?

If the client proposes an ALPN for which no handler is registered, the router silently ignores the connection (line 44 of iroh/src/protocol.rs). This causes the client to time out or receive a protocol error, depending on the QUIC implementation, preventing resource exhaustion from unhandled protocols.

Can I filter connections before the TLS handshake completes?

Yes. Implement the IncomingFilter trait and register it via Router::builder().incoming_filter(). This filter runs synchronously before cryptographic negotiation (lines 62–85 of iroh/src/protocol.rs), allowing you to reject connections based on source address validation, rate limits, or other criteria without performing expensive TLS operations.

How do I gracefully shut down a multi-protocol server in iroh?

Call router.shutdown().await to initiate a graceful shutdown sequence. This cancels the accept loop, waits for the background task to finish, invokes shutdown on each registered ProtocolHandler concurrently, and finally closes the endpoint. Dropping the router without calling shutdown aborts connections immediately.

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 →