How to Implement Custom Protocol Handlers with ALPN in iroh

Implement the ProtocolHandler trait and register it with RouterBuilder::accept(alpn, handler) to route incoming QUIC connections based on the negotiated Application-Layer Protocol Negotiation (ALPN) identifier.

iroh uses QUIC/TLS Application-Layer Protocol Negotiation (ALPN) to enable secure peer-to-peer protocol multiplexing without requiring manual TLS configuration. By implementing custom protocol handlers with ALPN in iroh, you define application-level behaviors that activate automatically when remote peers negotiate specific protocol identifiers, with the routing logic handled entirely within the library's accept loop.

Understanding ALPN Routing Architecture

iroh routes incoming connections through a three-layer architecture that separates transport configuration from application logic.

The endpoint (iroh/src/endpoint.rs) manages the underlying QUIC/TLS transport and advertises supported protocols via Builder::alpns. When you call Builder::bind, the code executes static_config.create_server_config(self.alpn_protocols) to configure the server TLS certificate with your ALPN identifiers.

The router (iroh/src/protocol.rs) maintains a ProtocolMap that maps raw ALPN byte strings to boxed trait objects. The RouterBuilder::accept method populates this map, and RouterBuilder::spawn initiates the accept loop.

The accept loop (iroh/src/socket.rs) runs in handle_connection, extracting the negotiated ALPN via incoming.alpn().await and dispatching to the matching handler.

Implementing the ProtocolHandler Trait

Create a struct that implements ProtocolHandler, defining the accept method to receive a Connection and manage bi-directional streams.

In iroh/src/protocol.rs, the trait requires:

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

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

impl ProtocolHandler for Echo {
    async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
        // Split the QUIC connection into send and receive sides.
        let (mut send, mut recv) = connection.accept_bi().await?;
        
        // Echo everything received back to the peer.
        tokio::io::copy(&mut recv, &mut send).await?;
        
        // Gracefully finish the send side and wait for the peer to close.
        send.finish()?;
        connection.closed().await;
        Ok(())
    }
}

The accept method runs on a dedicated Tokio task, giving you full ownership of the connection lifecycle. Open streams with accept_bi() (incoming) or open_bi() (outgoing), then close gracefully with send.finish() or abruptly by dropping the connection.

Registering Handlers with the Router

Use Router::builder to construct a protocol router, registering handlers before spawning the accept loop.

use iroh::{Endpoint, protocol::Router, endpoint::presets};

#[tokio::main]
async fn main() -> n0_error::Result<()> {
    // Build an endpoint with a preset (e.g., N0) and bind it.
    let endpoint = Endpoint::builder(presets::N0).bind().await?;

    // Define a unique ALPN identifier for your protocol.
    const ECHO_ALPN: &[u8] = b"/iroh/echo/1";

    // Build the router, register the handler, and spawn the accept loop.
    let router = Router::builder(endpoint)
        .accept(ECHO_ALPN, Echo)
        .spawn();

    // The router now accepts connections negotiating the echo ALPN.
    // It runs until process exit or `router.shutdown().await`.
    Ok(())
}

When you call spawn(), the router automatically invokes endpoint.set_alpns(alpns) to update the endpoint's advertised protocols. The ProtocolMap in iroh/src/protocol.rs stores your handler as a DynProtocolHandler, enabling type-erased dispatch for multiple protocols on a single endpoint.

Configuring Endpoint ALPN Advertisements

The endpoint builder in iroh/src/endpoint.rs exposes Builder::alpns to statically configure supported protocols, though this is typically handled automatically when using the router pattern.

If building custom infrastructure without the router, you must manually set the ALPN list:

let endpoint = Endpoint::builder(presets::N0)
    .alpns(vec![b"/custom/1".to_vec()])
    .bind()
    .await?;

The bind method constructs the TLS server configuration via static_config.create_server_config(self.alpn_protocols), ensuring your ALPN identifiers appear in the TLS handshake.

Connecting from the Client Side

Clients connect by specifying the target ALPN in the connect method. The client endpoint does not need to advertise ALPNs for outgoing connections.

use iroh::{Endpoint, endpoint::presets};

#[tokio::main]
async fn main() -> n0_error::Result<()> {
    // Build a client endpoint (no ALPN list required for outgoing).
    let client = Endpoint::builder(presets::Minimal).bind().await?;

    // Obtain the server address from its `router.endpoint().addr()`.
    let server_addr = /* EndpointAddr obtained elsewhere */;

    // Connect using the same ALPN identifier.
    let conn = client.connect(server_addr, b"/iroh/echo/1").await?;
    
    // Open a bidirectional stream, send data, and read the echo response.
    let (mut send, mut recv) = conn.open_bi().await?;
    // ... write to send, read from recv ...
    
    Ok(())
}

The connect method ensures the TLS handshake only succeeds if the server supports the specified ALPN, preventing protocol mismatch errors before any application data flows.

Complete Example Reference

The repository provides a working implementation in iroh/examples/echo.rs, demonstrating both server and client implementations in a single file. This example illustrates proper error handling, connection cleanup, and the complete registration flow from endpoint construction to router shutdown.

Key implementation files to review:

  • iroh/src/protocol.rs: Defines ProtocolHandler, DynProtocolHandler, ProtocolMap, and the RouterBuilder API.
  • iroh/src/endpoint.rs: Contains Builder::bind and the TLS configuration logic via create_server_config.
  • iroh/src/socket.rs: Houses handle_connection, which extracts incoming.alpn() and dispatches via the ProtocolMap.
  • iroh/tests/patchbay/util.rs: Contains test constants like TEST_ALPN showing idiomatic ALPN string usage.

Summary

  • Implement ProtocolHandler in iroh/src/protocol.rs to define custom protocol logic using Connection and stream operations.
  • Register with RouterBuilder::accept(alpn, handler) to map ALPN byte strings to your implementations.
  • Spawn the router to automatically configure endpoint ALPN advertisements and start the accept loop in iroh/src/socket.rs.
  • Connect clients using Endpoint::connect(addr, alpn) with matching protocol identifiers.
  • Use unique ALPN strings (e.g., b"/myapp/1") to avoid collisions with other protocols on the same endpoint.

Frequently Asked Questions

What ALPN string format should I use for custom protocols?

Use byte string literals like b"/myapp/protocol/1" or b"\x03my\x05proto". The alpn parameter accepts any &[u8], but conventional ALPN identifiers follow the length-prefixed format or use clear text paths with version numbers. Avoid using standard TLS ALPN values like h2 or http/1.1 unless implementing those specific protocols.

Can I register multiple protocol handlers on a single iroh endpoint?

Yes. The ProtocolMap in iroh/src/protocol.rs supports multiple entries. Chain .accept(alpn1, handler1).accept(alpn2, handler2) when building the router. The endpoint will advertise all registered ALPNs in the TLS handshake, and incoming connections route to the specific handler matching the negotiated protocol.

How does iroh handle connections when the ALPN doesn't match any registered handler?

TLS negotiation fails at the transport level before application code runs. If a client attempts to connect with an ALPN not present in the endpoint's advertised list, the TLS handshake completes without agreement (or fails immediately, depending on client behavior), and the connection never reaches the handle_connection dispatch logic in iroh/src/socket.rs.

What is the difference between the Endpoint and the Router in iroh?

The Endpoint (iroh/src/endpoint.rs) manages the low-level QUIC/TLS transport, connection establishment, and network addressing. The Router (iroh/src/protocol.rs) sits above the endpoint and implements application-level protocol dispatch. While the endpoint handles wire encryption and flow control, the router manages the ProtocolMap and spawns tasks to run your ProtocolHandler::accept implementations.

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 →