Sang's Blog

Iroh - less net work for networks

Iroh is a peer-to-peer (P2P) networking library written in Rust which enables two devices to connect directly without knowing each other’s IP addresses. Endpoints are identified by a public key (EndpointId), and Iroh handles the rest: NAT traversal, relay fallback and encryption. The tagline: ’less net work for networks’. It enables dialling by public key (EndpointId instead of IP:port), automatically selects the optimal route (direct P2P, hole-punched or relay fallback), employs QUIC for mandatory encryption and multiplexing, and facilitates mutual authentication between both parties via TLS certificates derived from public keys. Furthermore, it has no dependency on a central server since relay servers merely forward encrypted traffic.

In today’s internet landscape, P2P connections encounter several barriers. NAT prevents devices on private networks from receiving inbound connections. Firewalls block ports and unfamiliar protocols. Dynamic IP addresses change frequently and cannot be hardcoded. Personal devices also don’t have a domain name to point to. Iroh solves all these issues with a single abstraction: Endpoint ID (public key) as the unique identifier, meaning the system can automatically find the actual path. Each endpoint (device/process) has an Ed25519 key pair: a SecretKey (private key, never shared) and a PublicKey (public key, used as the 32-byte EndpointId). The EndpointId is a unique identifier on the network. It cannot be spoofed because the TLS handshake requires signing with the SecretKey.

EndpointId = PublicKey(Ed25519) — 32 bytes, z-base-32 encoded.

The RelayUrl is the URL of a relay server (e.g. https://use1-relay.iroh.link). Each endpoint has a home relay. The EndpointAddr composite address structure contains the EndpointId (public key) and available paths: ‘Relay’ for a relay server, ‘Ip’ for a direct IPv4/IPv6 connection, and ‘Custom’ for custom transports such as Tor or Bluetooth.

The workspace is organised into several crates:

Routing without IP?

This is the most important question. Iroh solves it in three layers.

Layer 1: the relay server, which is a known rendezvous point. When started up, each endpoint runs latency tests against the available relay servers, selects the closest relay as its home relay and maintains a long-lived WebSocket/HTTP connection to that relay. Relay servers have real IP addresses and operate on the internet. Anyone can connect to a relay. The ‘don’t know the IP’ problem becomes ‘know the other side’s relay URL’.

Layer 2: Address Lookup — find an endpoint’s relay. To discover the relay URL of endpoint B, endpoint A uses the Address Lookup service. The default mechanism is DNS. A queries a TXT record at _iroh.z32-endpoint-id.dns.iroh.link and receives a response containing the value ‘relay=https://eu1-relay.iroh.link’. This is real DNS, using the global DNS system. Endpoint B publishes its relay information here via Pkarr. The ‘find the relay’ problem is solved by a DNS query.

Layer 3: Magicsock — virtualising addresses. The QUIC library (noq) only recognises SocketAddr (IP:port). Iroh cannot say ‘send to EndpointId abc123’; it has to produce an IP address. Iroh uses the IPv6 Unique Local Address (ULA) range defined in RFC 4193: fd15:070a:0510:0b00::/56. The fd15:70a:510b::/64 range maps EndpointId to a fake address that Magicsock routes internally. The fd15:70a:510b:1::/64 range is used for the relay path and the fd15:70a:510b:3::/64 range is used for custom transports such as Tor or BLE.

For example, Endpoint A has an Endpoint ID of pubkey_A and Magicsock assigns it an IPv6 ULA address such as fd15:70a:510b::1. QUIC then sees the address fd15:70a:510b::1:12345 and sends packets there. Magicsock then intercepts everything and performs an internal lookup. In this case, fd15:70a:510b::1:12345 maps to pubkey_A, with paths ‘Relay’ (use1-relay) and ‘Ip’ (1.2.3.4:5678). Meanwhile, fd15:70a:510b::2:12345 maps to pubkey_B, with only ‘Relay’ (eu1-relay). If direct UDP is available, Magicsock sends packets to the real IP; otherwise, it sends them via the relay. The ‘QUIC needs an IP’ problem is solved using fake addresses and internal routing.

In summary, when QUIC wants to send a packet to fd15:70a:510b::dead:beef:12345, Magicsock looks it up. If a direct IP exists for that endpoint, it sends UDP directly. Otherwise, it wraps the packet in HTTP/WebSocket and sends it to the relay server. The destination receives the packet either way.

Relay Server

A relay server is an HTTP (or HTTPS) server that runs on the internet. Each endpoint connects to its respective relay server via WebSocket and maintains the connection. For example, Endpoint A connects to use1-relay.iroh.link, while Endpoint B connects to eu1-relay.iroh.link. When endpoint A wants to send a packet to endpoint B via the relay, it sends the encrypted packet to eu1-relay.iroh.link, which then forwards it to endpoint B. The relay only forwards packets based on EndpointId and has no access to the content. n0 currently runs three relay servers. The closest one is chosen based on latency. You can run your own relay server using the iroh-relay crate.

1cargo run --release --bin iroh-relay -- --config relay.toml

Access control supports the following modes:

Address Lookup

Uses real DNS (UDP/TCP 53) to look up addresses. The flow goes like this: A wants to know B’s relay URL. A queries TXT _iroh..dns.iroh.link. The DNS system returns ‘relay=https://eu1-relay.iroh.link’. A now knows B’s relay URL — B had previously published this to Pkarr. The production origin domain is dns.iroh.link and the staging domain is staging.dns.iroh.link. The z32-endpoint-id is the EndpointId encoded in z-base-32. The default query stagger is 200 ms, 300 ms, 600 ms, 1 s, 2 s and 3 s, with timeout resulting in a failure.

Pkarr stands for ‘Public-key Addressable Resource Records’. It stores DNS records signed by the endpoint’s SecretKey, which prevents anyone from forging the information. Two mechanisms are available: Pkarr Relay (HTTP), which lets you PUT/GET records on an HTTP relay without the need for a DHT client, and Mainline DHT, which stores records on the BitTorrent DHT for full decentralisation.

When using presets::N0:

1let ep = Endpoint::bind(presets::N0).await?;

This automatically creates a PkarrPublisher to publish its own relay URL to the Pkarr relay and DNS, as well as a DnsAddressLookup to query the DNS when connecting to other endpoints. You can also manually enter an EndpointAddr into memory for a local area network (LAN), for testing purposes, or when addresses are known in advance.

1ep.add_address(EndpointAddr::new(pubkey_B)
2.with_ip_addr("10.0.0.5:1234".parse()?));

Applications can attach metadata (under 1000 bytes) to an endpoint’s address, which is published alongside relay info and is automatically synced via Pkarr/DNS. The AdrFilter controls which addresses are published to

1// Default: only publish relay, do not publish IP
2AddrFilter::relay_only()
3// Also publish IP if the endpoint has a public IP
4AddrFilter::unfiltered()

Magicsock — the virtual socket layer

Magicsock is at the heart of IROH, which is an AsyncUdpSocket that implements the noq/QUIC trait and can switch paths mid-flight without breaking the QUIC connection. When QUIC (via noq) calls send_to(fd15:70a:510b::1:12345, data), Magicsock intercepts and performs three actions: first, it looks up the RemoteMap to find the endpoint; second, the PathSelector selects the optimal route; and third, data is sent via the chosen route using one of the transports: Relay (HTTP/WS to relay server), UDP (raw socket to the internet) or Custom (Tor, BLE, etc.).

Magicsock maintains a RemoteMap that tracks paths to each endpoint. For example, pubkey_A might have an IP path (1.2.3.4:5678, alive, direct) and a Relay path (use1-relay, alive, fallback), while pubkey_B might only have a Relay path (eu1-relay, alive, only path). During the initial connection, when no path has yet been proven, packets are sent on all available paths (mixed path mode). If one path fails, the system automatically switches to another — QUIC never notices because the destination address remains the same; only the underlying transport changes.

Magicsock uses virtual IPv6 ULA addresses because QUIC/noq requires an SocketAddr (IP:port). Iroh has multiple transports (relay and custom) that aren’t IP-based and a shared address space is needed so that QUIC does not know which transport it is sending over. Each EndpointId is assigned a random IPv6 ULA address, such as fd15:70a:510b::counter.

NAT Hole-punching

This occurs when both sides are behind NAT and connected to the relay server. When A sends a packet to B via the relay, the relay forwards it to B with A’s public IP address included. When B responds, the relay forwards it back with B’s public IP address included. At this point, both parties know each other’s public IP address. They then send UDP STUN-style packets directly to each other’s public IP addresses. If the packets get through the NAT, a direct UDP path is established and Magicsock switches the traffic from the relay to direct UDP.

The technique uses QUIC NAT traversal, whereby both sides send packets through the relay. The relay then forwards them so that each side knows the other’s public IP address. Both sides then send UDP STUN-style packets directly. If these packets are successful, a direct path is established and Magicsock makes the switch. If hole-punching fails (e.g. if both sides are behind a symmetric NAT), traffic continues to be sent via the relay. A direct connection is faster than a relay connection, but both work.

End-to-end connection flow

Suppose endpoint A has just started using the home relay use1-relay.iroh.link and endpoint B has been using the home relay eu1-relay.iroh.link. A wants to connect to B but only knows B’s EndpointId.

Step 1: A looks up B’s address. It queries TXT _iroh.z32-endpointId-B.dns.iroh.link. The DNS response includes the value ‘relay=https://eu1-relay.iroh.link’ and, if B has a public IP, the value ‘addr=203.0.113.5:12345’.

Step 2: A opens a QUIC connection to B.

1let conn = endpoint_A.connect(addr_B, ALPN).await?;

Magicsock then creates a fake address for B (fd15:70a:510b::) and starts a QUIC handshake through this address. Magicsock then sends packets out through both paths: to the relay (eu1-relay.iroh.link) and, if a direct IP address is available, to 203.0.113.5:12345 via UDP.

Step 3: B receives the packet. The relay receives A’s packet, finds B by EndpointId and sends the packet to B. B’s Magicsock recognises a packet from A and knows that A is trying to connect. It then sends packets back to A via the relay or directly.

Step 4: Hole-punching. Both sides learn each other’s public IP address from the QUIC transport parameters in the packets. They then send UDP packets to that public IP address. If the packets arrive, a direct path opens.

Step 5: Path selection. If the hole-punching process succeeds, switch entirely to direct UDP. If not, continue through the relay. This switch is seamless — QUIC does not get reset.

Step 6: Communication.

1// A sends data
2send.write_all(b"Hello").await?;
3// B reads it
4let data = recv.read_to_end(1024).await?;

There is no need to know IP addresses or worry about routing. Iroh handles it all.

References & Further Reading