How to Handle Connection Migration from Relay to Direct Peer-to-Peer in iroh
Enable QUIC handshake migration in your transport configuration, start the endpoint with a relay map for initial connectivity, and iroh automatically upgrades the connection to a direct peer-to-peer path once addresses become reachable.
The iroh networking library uses QUIC (via the noq crate) to seamlessly migrate connections from relayed pathways to direct peer-to-peer links. When endpoints start behind restrictive NATs or firewalls, they require a relay server to establish initial contact. This guide explains how to configure connection migration in the n0-computer/iroh repository so your application automatically upgrades to direct paths when network conditions allow.
How Connection Migration Works in iroh
iroh leverages QUIC's built-in path migration feature to switch from relayed connections to direct paths without dropping the connection. When peers discover valid direct addresses—typically after successful hole-punching or when NAT mappings stabilize—the QUIC stack performs a PATH_CHALLENGE and PATH_RESPONSE exchange. According to the implementation in iroh/src/socket.rs, this process happens automatically once you enable handshake migration in the transport configuration.
Configuring the Transport for Migration
To allow connections to migrate from relay to direct paths, you must explicitly enable server-side handshake migration in your transport configuration.
use std::time::Duration;
use noq::TransportConfig;
let mut transport_cfg = TransportConfig::default();
transport_cfg.server_handshake_migration(true);
transport_cfg.keep_alive_interval(Some(Duration::from_secs(1)));
The server_handshake_migration(true) flag is essential for both client and server endpoints. As implemented in iroh/src/socket.rs, the socket_ep function applies this configuration to enable the QUIC stack to accept path migration attempts. Additionally, setting the keep_alive_interval ensures NAT mappings remain valid during the discovery phase.
Building the Endpoint with Relay Support
Since peers may not be directly reachable initially, configure the endpoint to use a relay map for the initial handshake. This ensures connectivity even when direct paths are blocked by NAT.
use iroh::endpoint::{Builder, RelayMode};
use iroh::relay::RelayMap;
use std::sync::Arc;
let relay_map = RelayMap::default();
let mut builder = Builder::new()
.relay_mode(RelayMode::Custom(relay_map));
// Attach the transport config that enables migration
builder = builder.transport_config(Arc::new(transport_cfg));
let endpoint = builder.build().await?;
As demonstrated in iroh/tests/patchbay/util.rs, the endpoint_builder helper uses RelayMode::Custom to establish relay-only connectivity first. This pattern allows the connection to initially route through the relay while the library probes for direct paths in the background.
Monitoring the Migration Status
Checking if a Connection is Relayed
After establishing a connection, determine whether traffic is still flowing through the relay or has migrated to a direct path by checking the connection state.
if connection.is_relayed() {
println!("Currently relayed – waiting for migration");
} else {
println!("Direct peer-to-peer connection active");
}
The is_relayed method, available on the Connection object and demonstrated in iroh/tests/util.rs, queries the underlying QUIC path to determine if the relay is still the active path.
Reacting to Path Changes
For applications that need to trigger logic when migration completes, monitor the path change events provided by the noq crate.
// Listen for path migration events
while let Ok(event) = connection.on_path_changed().await {
match event {
PathEvent::Migrated => {
if !connection.is_relayed() {
println!("Migrated to direct connection");
}
}
_ => {}
}
}
The on_path_changed stream emits PathEvent::Migrated when QUIC successfully confirms a new direct path through the PATH_CHALLENGE and PATH_RESPONSE validation.
Complete Implementation Example
The following example demonstrates the full workflow: configuring migration, connecting via relay, and waiting for the direct path upgrade.
use std::sync::Arc;
use std::time::Duration;
use iroh::{
endpoint::{Builder, RelayMode},
transport::noq::TransportConfig,
key::SecretKey,
};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// 1. Prepare secret key
let secret = SecretKey::generate();
// 2. Configure transport to allow migration
let mut transport_cfg = TransportConfig::default();
transport_cfg.server_handshake_migration(true);
transport_cfg.keep_alive_interval(Some(Duration::from_secs(1)));
// 3. Build endpoint with relay support
let relay_map = iroh::relay::RelayMap::default();
let endpoint = Builder::new()
.secret_key(secret)
.relay_mode(RelayMode::Custom(relay_map))
.transport_config(Arc::new(transport_cfg))
.build()
.await?;
// 4. Connect to remote peer (initially via relay)
let conn = endpoint.connect("https://relay.example/node-id".parse()?).await?;
// 5. Wait for migration to direct path
println!("Connected – relayed? {}", conn.is_relayed());
while conn.is_relayed() {
conn.wait_path_changed().await?;
println!("Path changed – checking if relayed: {}", conn.is_relayed());
}
println!("Direct peer-to-peer connection established");
Ok(())
}
Summary
- Enable handshake migration by setting
transport_cfg.server_handshake_migration(true)in yourTransportConfig, as required by the QUIC implementation iniroh/src/socket.rs. - Start with relay connectivity using
RelayMode::Customto ensure initial reachability behind NATs and firewalls. - Let iroh handle the upgrade automatically when direct paths become available via QUIC path validation and socket rebinding.
- Monitor migration status using
is_relayed()or theon_path_changedevent stream to trigger application-level logic when the connection upgrades.
Frequently Asked Questions
How do I know if connection migration is enabled correctly?
Verify that transport_cfg.server_handshake_migration(true) is called before passing the configuration to the endpoint builder. If enabled, iroh will automatically attempt migration once valid direct addresses are discovered, which you can confirm by polling connection.is_relayed() until it returns false.
Can a connection migrate back to the relay if the direct path fails?
Yes, QUIC supports reverting to previous working paths. If the direct path becomes unreachable after migration, the connection can fall back to the relay path provided the relay connection remains valid and the transport configuration supports path migration. The keep_alive_interval setting helps maintain the relay path during transitions.
How long does migration from relay to direct typically take?
Migration latency depends on the time required to establish direct connectivity, typically ranging from a few hundred milliseconds for successful hole-punching to several seconds if STUN probing is required. The library monitors path quality continuously and migrates immediately upon validating a direct path through the PATH_CHALLENGE exchange.
Is there a way to prevent migration and keep traffic on the relay?
While you cannot easily disable migration on an existing connection after enabling it in the transport config, you can control initial connectivity by omitting the server_handshake_migration flag. However, disabling migration is not recommended for production use as it prevents optimization to direct paths and increases latency and server load.
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 →