How iroh Peer Discovery Works: Architecture and Implementation
iroh discovers peers through a dual-layer architecture that combines pluggable address-lookup services for publishing and resolving endpoint addresses, and QUIC Address Discovery (QAD) for learning public IPs when only relay URLs are known.
iroh is a Rust-based peer-to-peer networking stack that uses public keys as stable endpoint identifiers. When an endpoint needs to establish a connection to a remote peer identified only by its EndpointId, the iroh peer discovery system resolves this identifier into usable network addresses (IP:port combinations or relay URLs). The implementation separates concerns between service-based address publication and protocol-level NAT traversal.
The Dual-Layer Discovery Architecture
iroh separates peer discovery into two complementary layers that work automatically when using the standard Endpoint builder.
| Layer | Function | Primary Source Files |
|---|---|---|
| Address-lookup services | Publish the endpoint's own addressing information and resolve remote EndpointIds to EndpointInfo on demand. The default N0 preset uses DNS/Pkarr, but the system supports custom implementations via the AddressLookup trait. |
iroh/src/address_lookup.rs |
| QUIC Address Discovery (QAD) | When direct IP addresses are unknown, send tiny QUIC probes to the home relay to learn the endpoint's public-facing IP and port. This works even behind restrictive NATs. | iroh/src/socket.rs (lines 1041-1055), iroh/src/net_report/ |
Both layers are wired together by the Endpoint::builder(), which accepts presets like presets::N0 (enables both layers) or presets::Minimal (relay-only).
Address Lookup Services
The address lookup layer provides the publish-subscribe mechanism that maps static EndpointIds (public keys) to dynamic network addresses.
Publishing Endpoint Addresses
Every iroh endpoint creates an EndpointData structure containing its EndpointId, a list of transport addresses (IP+port or relay URL), and optional user data. When the set of addresses changes—such as after a successful STUN probe or relay assignment—the endpoint calls address_lookup.publish(&data) to advertise the update.
In iroh/src/socket.rs (lines 2100-2105), the Socket actor invokes this publication whenever the endpoint's addressing state changes:
// Simplified from socket.rs
if let Some(ref lookup) = self.address_lookup {
lookup.publish(&endpoint_data)?;
}
Resolving Remote Addresses
When the application calls Endpoint::connect(remote_id, alpn), iroh queries the configured AddressLookupServices for the remote's current addresses. The system returns a merged stream (AddressLookupStream) that yields the first usable result while continuing to listen to slower services.
The resolution logic in iroh/src/address_lookup.rs (lines 540-570) implements a race between lookup providers:
// Conceptual flow from address_lookup.rs
self.sock.address_lookup().resolve(remote_id)
.into_stream() // AddressLookupStream
.next() // First usable EndpointInfo
.await
This ensures that if DNS resolution is slow but a local cache (or mDNS) responds quickly, the connection attempt proceeds immediately.
Service Implementations
The AddressLookup trait is implemented by several providers:
- PkarrPublisher / DnsAddressLookup: The default
N0preset configuration. Publishes signed PKARR packets to a relay (iroh-dnscrate) and resolves via DNS TXT records. - MemoryLookup: An in-process hash map useful for unit tests and local development.
- Custom transports: Any type implementing
AddressLookupcan be registered viabuilder.address_lookup(my_lookup).
QUIC Address Discovery (QAD)
When an endpoint advertises only a relay URL (common behind NAT), iroh uses QUIC Address Discovery to learn its public IP address dynamically.
Enabling QAD
QAD is configured in iroh/src/socket.rs (lines 1041-1055) when the endpoint has IP transports (UDP sockets). The builder creates a net_report::QuicConfig containing the endpoint handle, TLS client configuration, and IPv4/IPv6 probe flags:
// From socket.rs
let quic_config = has_ip_transports.then(|| net_report::QuicConfig {
ep: self.endpoint.clone(),
client_config: tls_config,
ipv4: true,
ipv6: self.enable_ipv6,
});
let net_opts = net_opts.quic_config(quic_config);
If the endpoint is relay-only (has_ip_transports is false), the code skips QAD to avoid unnecessary timeouts.
The Probe Flow
The net_report::Client spawns background probe tasks (iroh/src/net_report/probes.rs) that open short-lived QUIC connections to the relay's QAD endpoint (iroh-relay/src/quic.rs). The relay observes the source IP address of the incoming QUIC packet and returns it in an ObservedAddressFrame:
- Endpoint sends QUIC probe to relay's QAD server
- Relay records the observed source address
- Relay writes
ObservedAddressFrameback to the client - Client receives the frame and extracts the public IP:port
Applying Discovered Addresses
When a probe succeeds, the discovered address is stored in the endpoint's DirectAddrUpdateState (see socket.rs lines 1069-1080). This state then triggers a new publication via the address lookup services, making the direct IP immediately available for subsequent connections without needing a relay.
End-to-End Discovery Flow
The complete peer discovery process follows this sequence:
- Publish: Endpoint A publishes its address set (relay URL + any direct IPs) via
AddressLookupServices::publish(lines 516-527 inaddress_lookup.rs). - Resolve: When Endpoint B calls
connect(endpoint_a_id), it queriesAddressLookupServices::resolve, merging results from all configured providers (lines 540-566). - Relay Fallback: If Endpoint A's DNS record contains only a relay URL, Endpoint B connects via the relay.
- QAD Trigger: Endpoint A's
net_report::Clientsends QUIC probes to its home relay's QAD endpoint (iroh-relay/src/quic.rs). - Address Learning: The relay returns Endpoint A's observed public IP in a QUIC frame.
- Update: Endpoint A publishes the newly discovered direct IP via its address lookup service.
- Direct Connection: Endpoint B resolves the updated address set and establishes a direct QUIC connection, upgrading from the relay.
Implementation Examples
Basic Usage with the N0 Preset
The presets::N0 configuration enables both DNS/Pkarr lookup and QAD automatically:
use iroh::{Endpoint, endpoint::presets};
use iroh::protocol::{AcceptError, ProtocolHandler, Router};
const ALPN: &[u8] = b"my-app/1";
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// N0 preset includes PkarrPublisher and QAD configuration
let endpoint = Endpoint::bind(presets::N0).await?;
// Start accepting connections and publishing addresses
endpoint.online().await;
let addr = endpoint.addr();
println!("Node address: {}", addr);
// Connect from another endpoint
let other = Endpoint::bind(presets::N0).await?;
let conn = other.connect(addr, ALPN).await?;
Ok(())
}
Custom Memory Lookup for Testing
For testing environments, replace the DNS infrastructure with an in-memory lookup:
use iroh::{Endpoint, address_lookup::{MemoryLookup, AddressLookupBuilder}, endpoint::presets};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let mut builder = Endpoint::builder(presets::Minimal);
let mem_lookup = MemoryLookup::default();
builder.address_lookup(mem_lookup.clone());
let ep = builder.bind().await?;
// Manually inject a peer's address
let peer_id = /* some EndpointId */;
let peer_addr = iroh_base::EndpointAddr::from_parts(
peer_id,
[iroh_base::TransportAddr::Ip("203.0.113.42:4000".parse().unwrap())],
);
let data = iroh::address_lookup::EndpointData::from_iter([peer_addr.transport()]);
mem_lookup.publish(&data);
// Now connect using the injected address
let conn = ep.connect(peer_id, b"test/alpn").await?;
Ok(())
}
Manual QAD Configuration
To enable QUIC Address Discovery without the full N0 DNS infrastructure:
use iroh::{Endpoint, net_report::{self, Options}, endpoint::presets};
use iroh::tls::ClientConfig;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let tls_cfg = ClientConfig::default(); // Load proper certificates in production
let qad = net_report::QuicConfig {
ep: Endpoint::builder(presets::Minimal).bind().await?.inner,
client_config: tls_cfg,
ipv4: true,
ipv6: false,
};
let net_opts = Options::default()
.quic_config(Some(qad))
.net_report_config(net_report::Config::default());
let endpoint = Endpoint::builder(presets::Minimal)
.net_report(net_opts)
.bind()
.await?;
Ok(())
}
This corresponds to the internal configuration logic found in socket.rs (lines 1041-1055).
Key Source Files
| File | Purpose |
|---|---|
iroh/src/address_lookup.rs |
Defines AddressLookup trait, AddressLookupServices registry, and the merging AddressLookupStream. |
iroh/src/socket.rs |
Builds net_report::Client, conditionally creates QuicConfig for QAD (lines 1041-1055), and handles publishing at lines 2100-2105. |
iroh/src/net_report/ |
Implements background probe tasks and the client-side QAD logic. |
iroh/src/endpoint/quic.rs |
Contains QUIC transport flags like send_observed_address_reports (lines 453-470). |
iroh-relay/src/quic.rs |
Runs the server-side QAD endpoint that echoes observed addresses back to clients. |
iroh-dns/src/endpoint_info.rs |
Handles signed PKARR packets for the DNS-based discovery service. |
Summary
- iroh peer discovery operates through two layers: pluggable address lookup services and QUIC Address Discovery (QAD).
- Address lookup services publish
EndpointData(containing relay URLs and direct IPs) and resolve remoteEndpointIds via theAddressLookuptrait, with theN0preset providing DNS/Pkarr as the default. - QUIC Address Discovery enables NAT traversal by sending probes to the home relay's QAD endpoint (
iroh-relay/src/quic.rs), learning the public IP from the relay's observed address frame. - The merge stream architecture in
address_lookup.rsensures the fastest lookup provider wins, while slower services continue in the background. - QAD is conditional: it only activates when the endpoint has IP transports, avoiding timeouts in relay-only deployments.
Frequently Asked Questions
What is the difference between address lookup and QUIC address discovery in iroh?
Address lookup services resolve an EndpointId to network addresses (IP or relay URL) using external infrastructure like DNS or local memory maps. QUIC Address Discovery (QAD) is a protocol-level mechanism that learns the endpoint's own public IP address by probing the relay, enabling direct connections even when the endpoint only knows its private IP. Lookup services answer "where is the remote peer," while QAD answers "what is my public address."
How does iroh handle peer discovery when both endpoints are behind NAT?
When both endpoints are behind NAT, they initially publish only their relay URLs via the address lookup service. Upon connection, the endpoints use QUIC Address Discovery to learn their respective public IPs through probes to the relay. Once each endpoint discovers and publishes its public address, iroh attempts hole punching; if successful, the connection upgrades from relayed to direct. If hole punching fails, traffic continues through the relay.
Can I use iroh peer discovery without the N0 DNS infrastructure?
Yes. The AddressLookup trait is fully pluggable. You can implement custom discovery using mDNS for local networks, a private HTTP service, or the built-in MemoryLookup for testing. Configure custom lookup services via Endpoint::builder().address_lookup(my_service). However, without DNS/Pkarr, you must provide an alternative mechanism to distribute endpoint addresses out-of-band.
Where is the observed address reported back to the endpoint in the source code?
The observed address is reported in iroh-relay/src/quic.rs, where the relay server writes an ObservedAddressFrame to the QUIC stream. The client receives this frame in iroh/src/net_report/ (specifically within the probe handling logic), extracts the address, and forwards it to the DirectAddrUpdateState in iroh/src/socket.rs (lines 1069-1080), which then triggers a new publication via the configured address lookup services.
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 →