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 N0 preset configuration. Publishes signed PKARR packets to a relay (iroh-dns crate) and resolves via DNS TXT records.
  • MemoryLookup: An in-process hash map useful for unit tests and local development.
  • Custom transports: Any type implementing AddressLookup can be registered via builder.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:

  1. Endpoint sends QUIC probe to relay's QAD server
  2. Relay records the observed source address
  3. Relay writes ObservedAddressFrame back to the client
  4. 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:

  1. Publish: Endpoint A publishes its address set (relay URL + any direct IPs) via AddressLookupServices::publish (lines 516-527 in address_lookup.rs).
  2. Resolve: When Endpoint B calls connect(endpoint_a_id), it queries AddressLookupServices::resolve, merging results from all configured providers (lines 540-566).
  3. Relay Fallback: If Endpoint A's DNS record contains only a relay URL, Endpoint B connects via the relay.
  4. QAD Trigger: Endpoint A's net_report::Client sends QUIC probes to its home relay's QAD endpoint (iroh-relay/src/quic.rs).
  5. Address Learning: The relay returns Endpoint A's observed public IP in a QUIC frame.
  6. Update: Endpoint A publishes the newly discovered direct IP via its address lookup service.
  7. 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 remote EndpointIds via the AddressLookup trait, with the N0 preset 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.rs ensures 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:

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 →