How to Build a Basic Echo Server and Client with Iroh
You can build an echo server and client with Iroh by creating an Endpoint using the builder pattern, implementing the ProtocolHandler trait to handle bidirectional streams, and spawning a Router that manages QUIC connections while automatically traversing NAT devices and falling back to relays.
The iroh crate from the n0-computer/iroh repository provides a high-level networking abstraction that eliminates the complexity of manual QUIC handshakes and hole-punching. By following the patterns established in iroh/examples/echo.rs, you can create a production-ready echo service that functions across heterogeneous networks without dedicated infrastructure or port forwarding rules.
Understanding the Iroh Networking Stack
Iroh simplifies peer-to-peer communication through three core abstractions defined in the source tree.
Endpoint – The central networking node defined in iroh/src/endpoint.rs (lines 47-49 for the builder) that owns UDP sockets, TLS credentials, and relay connections. An endpoint can both initiate outbound connections via Endpoint::connect and accept inbound connections.
ProtocolHandler – A user-defined trait implementation that determines how to process incoming connections. The handler's accept method receives a raw QUIC Connection object and manages the stream lifecycle.
Router – A convenience wrapper that binds protocol handlers to specific ALPN identifiers and runs the acceptance loop on a dedicated Tokio task, implemented in the protocol module.
Step 1: Bind the Server Endpoint
Start by creating an endpoint using the preset configuration that enables automatic relay fallback. In iroh/src/endpoint.rs, the Endpoint::bind method initializes the networking stack:
use iroh::{Endpoint, presets};
use n0_error::Result;
const ALPN: &[u8] = b"iroh-example/echo/0";
#[tokio::main]
async fn main() -> Result<()> {
// Create the endpoint with default relay configuration
let endpoint = Endpoint::bind(presets::N0).await?;
println!("Server node ID: {:?}", endpoint.node_id());
Ok(())
}
The presets::N0 constant configures the endpoint to use the default relay servers hosted by n0, ensuring connectivity even when both peers reside behind NAT devices without manual configuration.
Step 2: Implement the ProtocolHandler Trait
Create a struct that implements ProtocolHandler to handle incoming echo connections. According to the implementation in iroh/examples/echo.rs (lines 80-102), the handler must provide an accept method that processes bidirectional streams using tokio::io::copy:
use iroh::protocol::{ProtocolHandler, AcceptError};
use iroh::endpoint::Connection;
use n0_error::Result;
use tokio::io;
#[derive(Debug, Clone)]
struct Echo;
impl ProtocolHandler for Echo {
async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
// Accept a bidirectional stream from the client
let (mut send, mut recv) = connection.accept_bi().await?;
// Copy all received data back to the sender
io::copy(&mut recv, &mut send).await?;
// Signal EOF and wait for remote close
send.finish()?;
connection.closed().await;
Ok(())
}
}
The accept_bi method returns a (SendStream, RecvStream) tuple. The tokio::io::copy utility efficiently pipes data from the receive stream back to the send stream without intermediate buffering, while send.finish() signals the end of the stream to the client.
Step 3: Spawn the Router
The Router manages the relationship between ALPN identifiers and protocol handlers. As shown in iroh/examples/echo.rs (lines 71-77), chain the builder methods to register your handler and spawn the accept loop:
use iroh::protocol::Router;
let router = Router::builder(endpoint)
.accept(ALPN, Echo)
.spawn();
// Wait until the endpoint is reachable via relay
router.endpoint().online().await;
println!("Server online at: {:?}", router.endpoint().addr());
The router runs continuously in the background, routing incoming connections matching the ALPN byte string to your Echo handler. The online().await call blocks until the endpoint has established contact with at least one relay server.
Step 4: Build the Client Connection
The client follows a similar endpoint creation pattern but uses Endpoint::connect (defined in iroh/src/endpoint.rs, lines 48-67) to establish the outbound connection:
async fn run_client(server_addr: iroh::EndpointAddr) -> Result<()> {
let client = Endpoint::bind(presets::N0).await?;
// Connect to server using the same ALPN
let conn = client.connect(server_addr, ALPN).await?;
// Open a bidirectional stream
let (mut send, mut recv) = conn.open_bi().await?;
// Send payload and signal EOF
send.write_all(b"Hello, Iroh!").await?;
send.finish()?;
// Read echoed response (limit 1KB)
let response = recv.read_to_end(1000).await?;
assert_eq!(&response, b"Hello, Iroh!");
// Graceful shutdown
conn.close(0u32.into(), b"bye!");
client.close().await;
Ok(())
}
The client must use the identical ALPN identifier (b"iroh-example/echo/0") to negotiate the protocol with the server during the TLS handshake. The open_bi method creates a fresh bidirectional stream, distinct from the streams accepted by the server side.
Complete Working Example
Combine these components into a single executable that demonstrates both server and client functionality:
use iroh::{
Endpoint, EndpointAddr,
endpoint::{Connection, presets},
protocol::{AcceptError, ProtocolHandler, Router},
};
use n0_error::{Result, StdResultExt};
use tokio::io;
const ALPN: &[u8] = b"iroh-example/echo/0";
#[derive(Debug, Clone)]
struct Echo;
impl ProtocolHandler for Echo {
async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
let (mut send, mut recv) = connection.accept_bi().await?;
io::copy(&mut recv, &mut send).await?;
send.finish()?;
connection.closed().await;
Ok(())
}
}
#[tokio::main]
async fn main() -> Result<()> {
// Initialize server
let server_endpoint = Endpoint::bind(presets::N0).await?;
let router = Router::builder(server_endpoint)
.accept(ALPN, Echo)
.spawn();
router.endpoint().online().await;
let server_addr = router.endpoint().addr();
println!("Server ready at: {:?}", server_addr);
// Run client
let client = Endpoint::bind(presets::N0).await?;
let conn = client.connect(server_addr, ALPN).await.anyerr()?;
let (mut send, mut recv) = conn.open_bi().await.anyerr()?;
send.write_all(b"Test message").await.anyerr()?;
send.finish().anyerr()?;
let echoed = recv.read_to_end(1024).await.anyerr()?;
println!("Received: {:?}", String::from_utf8_lossy(&echoed));
// Cleanup
conn.close(0u32.into(), b"done");
router.shutdown().await.anyerr()?;
client.close().await;
Ok(())
}
This example compiles against the standard Iroh crate and leverages the n0_error utility for ergonomic error handling, though external projects may substitute anyhow or std::io::Result.
Summary
- Endpoint creation – Use
Endpoint::bind(presets::N0)iniroh/src/endpoint.rsto create a node with automatic relay support and NAT traversal. - Protocol implementation – Implement
ProtocolHandlerfor your application logic, handlingConnectionobjects viaaccept_bito obtain bidirectional streams. - Router registration – Register handlers with
Router::builder(endpoint).accept(ALPN, handler).spawn()to start the accept loop. - Client connections – Clients use
endpoint.connect(addr, ALPN)fromiroh/src/endpoint.rs(lines 48-67) andopen_bi()to establish streams and exchange data. - Graceful shutdown – Close individual streams with
send.finish(), close connections withconn.close(), and terminate endpoints withendpoint.close().await.
Frequently Asked Questions
What role does the ALPN identifier play in Iroh connections?
The ALPN (Application-Layer Protocol Negotiation) identifier is a byte string that both the client and server exchange during the TLS handshake to agree on which protocol handler to use. In iroh/src/endpoint.rs, the server routes incoming connections to the appropriate ProtocolHandler based on this identifier. Mismatched ALPN values result in immediate connection termination during the handshake phase, as the router cannot dispatch to a handler.
How does Iroh handle NAT traversal without explicit configuration?
Iroh's Endpoint type automatically coordinates hole-punching and relay fallback through the internal noq transport layer defined in iroh/src/socket/quic.rs. When you use presets::N0, the endpoint attempts direct UDP connectivity first, then utilizes STUN/ICE protocols for NAT traversal, and finally falls back to publicly hosted relay servers if direct paths fail. This behavior is encapsulated in the Endpoint::bind implementation and requires no additional code from the application developer.
Can the echo server handle multiple concurrent connections?
Yes. The Router spawns the ProtocolHandler::accept method in a new Tokio task for every incoming connection. As implemented in iroh/examples/echo.rs, each connection gets its own bidirectional stream pair, allowing the server to process multiple clients simultaneously without blocking the accept loop. The handler simply processes the connection to completion before returning, at which point the task cleans up the resources.
Where is the ProtocolHandler trait defined in the source code?
The ProtocolHandler trait is defined in the protocol module, accessible through iroh/src/protocol/mod.rs. The trait requires a single asynchronous method accept(&self, connection: Connection) that returns Result<(), AcceptError>. This design allows you to implement stateless handlers like the echo example, or stateful handlers that use struct fields to track connection metrics or session data across multiple streams within the same connection.
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 →