Iroh — Architecture and How It Works
Project: n0-computer/iroh Rust workspace: iroh, iroh-relay, iroh-base, iroh-dns-server
What is Iroh?
Iroh is a peer-to-peer (P2P) networking library written in Rust that lets two devices connect directly without knowing each other’s IP address. Endpoints are identified by a public key (EndpointId), and iroh handles the rest: NAT traversal, relay fallback, encryption, multiplex streams. The tagline: “less net work for networks”.
It allows you to dial by public key (EndpointId instead of IP:port), automatically selects the best path (direct P2P, hole-punched, or relay fallback), uses QUIC for mandatory encryption and multiplexing with mutual authentication between both sides via TLS certificates derived from public keys, and has no central server dependency since relay servers only forward encrypted traffic.
The core problem. On today’s Internet, P2P connections face a few barriers. NAT keeps devices on private networks from receiving inbound connections. Firewalls block ports and unfamiliar protocols. Dynamic IPs change frequently and can’t be hardcoded. And personal devices don’t have a domain name to point to. Iroh solves all of this with a single abstraction: EndpointId (public key) as the unique identity, and the system automatically finds the actual path.
Foundational concepts
Each endpoint (device / process) has an Ed25519 key pair: a SecretKey (private key, never shared) and a PublicKey (public key, used as EndpointId at 32 bytes). 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.
RelayUrl is the URL of a relay server, e.g. https://use1-relay.iroh.link. Each endpoint has a home relay. The composite address structure EndpointAddr contains the EndpointId (public key) and available paths — Relay(RelayUrl) for relay server, Ip(SocketAddr) for direct IPv4/IPv6, and Custom(CustomAddr) for custom transports like Tor or Bluetooth.
Overall architecture
The workspace is organized into several crates. iroh is the core containing Endpoint, Router, magicsock, and AddressLookup. iroh-base provides shared types like PublicKey, EndpointId, RelayUrl, and EndpointAddr. iroh-relay is the relay client and server that powers the production relays. iroh-dns-server is the DNS server for Pkarr lookup at dns.iroh.link.
At the top level, the Application layer uses ProtocolHandlers like echo, blobs, gossip, and docs. Below that sits the Router, which manages these ProtocolHandlers by ALPN. The Endpoint layer handles connect, accept, and close operations. At the bottom is Magicsock, a virtual socket that multiplexes across QUIC (via noq), relay transport, direct UDP transport, and custom transports like Tor or BLE. The Address Lookup system (DNS/Pkarr/Memory) and Relay Servers sit alongside Magicsock as supporting infrastructure.
Routing without IP — how?
This is the most important question. Iroh solves it in 3 layers.
Layer 1: Relay server — a known rendezvous point. On startup, each endpoint runs latency tests against available relay servers, picks the closest relay as its home relay, and maintains a long-lived WebSocket/HTTP connection to that relay. Relay servers have real IPs and run 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 endpoint B’s relay URL, A uses the Address Lookup service. The default mechanism is DNS. A queries a TXT record at _iroh..dns.iroh.link, and the response returns relay=https://eu1-relay.iroh.link. This is real DNS using the global DNS system. Endpoint B publishes its relay info here via Pkarr. The “find the relay” problem becomes a DNS query.
Layer 3: Magicsock — virtualizing addresses. The QUIC library (noq) only understands SocketAddr (IP:port). Iroh can’t say “send to EndpointId abc123”, it has to produce an IP address. Iroh uses IPv6 Unique Local Address (ULA) — RFC 4193, the fd15:070a:0510:0b00::/56 range. The fd15:70a:510b::/64 maps EndpointId to a fake address that magicsock routes internally, fd15:70a:510b:1::/64 is for relay path, and fd15:70a:510b:3::/64 is for custom transport like Tor or BLE.
For example, endpoint A has EndpointId pubkey_A, magicsock assigns it an IPv6 ULA like fd15:70a:510b::1. Noq (QUIC) sees address fd15:70a:510b::1:12345 and sends packets there. Magicsock intercepts everything and does an internal lookup: fd15:70a:510b::1:12345 maps to pubkey_A with paths Relay(use1-relay) and Ip(1.2.3.4:5678), while fd15:70a:510b::2:12345 maps to pubkey_B with only Relay(eu1-relay). If direct UDP is available, magicsock sends to the real IP, otherwise it sends via relay. The “QUIC needs an IP” problem is solved by fake addresses plus 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. If not, 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 running on the Internet. Each endpoint connects to its relay via WebSocket and keeps the connection alive. For instance, endpoint A connects to use1-relay.iroh.link and endpoint B connects to eu1-relay.iroh.link. When A wants to send a packet to B via relay, it sends the encrypted packet to eu1-relay.iroh.link, which forwards it to B.
The relay only forwards packets based on EndpointId — it has no access to the content. n0 currently runs 3 relay servers. The closest one is chosen based on latency. You can run your own relay server with the iroh-relay crate:
1cargo run --release --bin iroh-relay -- --config relay.toml
Access control supports several modes: allow everyone (open), allowlist or denylist by EndpointId, a shared_token as a Bearer token, and http callout for external HTTP authentication.
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 — earlier, B had published its relay URL to Pkarr. Production origin domain is dns.iroh.link, staging is staging.dns.iroh.link. The z32-endpoint-id is the EndpointId encoded in z-base-32. Default query stagger is 200ms, 300ms, 600ms, 1s, 2s, 3s, failing fast on timeout.
Pkarr stands for Public-key Addressable Resource Records. It stores DNS records signed by the endpoint’s SecretKey, preventing anyone from forging the information. Two mechanisms are available: Pkarr Relay (HTTP) lets you PUT/GET records on an HTTP relay with no DHT client needed, while Mainline DHT stores records on the BitTorrent DHT for full decentralization.
When using presets::N0:
1let ep = Endpoint::bind(presets::N0).await?;
It automatically creates a PkarrPublisher to publish its own relay URL to Pkarr relay and DNS, and a DnsAddressLookup to query DNS when connecting to other endpoints.
You can also manually enter EndpointAddr into memory for LAN, testing, 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 that gets published alongside relay info and automatically synced via Pkarr/DNS. The AddrFilter controls which addresses get published to the Address Lookup service:
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 the heart of iroh — an AsyncUdpSocket (implementing noq/QUIC’s trait) that 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 goes through three steps: first, it looks up the RemoteMap to find the endpoint; second, the PathSelector picks the best path; third, it sends via the chosen path through one of the transports — Relay (HTTP/WS to relay server), UDP (raw socket to Internet), or Custom (Tor, BLE, etc.).
Magicsock maintains a RemoteMap tracking 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 initial connection when no path has been proven yet, it sends packets on all available paths (mixed path mode). If one path dies, it automatically switches to another — QUIC never notices because the fake destination address stays the same, only the underlying transport changes.
Magicsock uses virtual IPv6 ULA addresses because QUIC/noq requires a SocketAddr (IP:port), iroh has multiple transports (relay, custom) that aren’t IP-based, and a shared address space is needed so QUIC doesn’t know which transport it’s sending over. Each EndpointId gets a random IPv6 ULA assigned, like fd15:70a:510b::.
NAT Hole-punching
Both sides are behind NAT and connected to the relay server. When A sends a packet for B to the relay, the relay forwards it to B with A’s public IP included. When B responds, the relay forwards back with B’s public IP. At this point both sides know each other’s public IP. They then send UDP STUN-style packets to each other’s public IP directly. If packets get through NAT, a direct UDP path opens and magicsock switches traffic from relay to UDP direct.
The technique uses QUIC NAT traversal: both sides send packets through the relay, the relay forwards so each knows the other’s public IP, both send UDP STUN-style packets directly, and if those get through, a direct path opens and magicsock makes the switch.
If hole-punching fails (e.g., both sides are behind symmetric NAT), traffic continues through the relay. Direct is faster than relay, but both work.
End-to-end connection flow
Suppose endpoint A just started with home relay use1-relay.iroh.link, and endpoint B has been running with home relay eu1-relay.iroh.link. A wants to connect to B and only knows B’s EndpointId.
Step 1: A looks up B’s address. A queries TXT _iroh.z32-endpointId-B.dns.iroh.link. DNS responds with relay=https://eu1-relay.iroh.link and optionally addr=203.0.113.5:12345 if B has a public IP.
Step 2: A opens a QUIC connection to B:
1let conn = endpoint_A.connect(addr_B, ALPN).await?;
Magicsock creates a fake address for B (fd15:70a:510b::), noq starts a QUIC handshake through this fake address, and magicsock sends packets out through both paths — to relay eu1-relay.iroh.link forwarded to B, and if a direct IP is available, UDP straight to 203.0.113.5:12345.
Step 3: B receives the packet. The relay receives A’s packet, finds B by EndpointId, and sends it to B. B’s magicsock sees a packet from A, knows A is trying to connect, and sends packets back to A via relay or direct.
Step 4: Hole-punching. Both sides learn each other’s public IP from the QUIC transport parameters in the packets, send UDP packets to that public IP, and if the packets arrive, a direct path opens.
Step 5: Path selection. If hole-punch succeeds, switch entirely to UDP direct. If not, continue through relay. The switch is seamless — QUIC doesn’t 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?;
No need to know IPs, no need to worry about routing. Iroh handles it all.
Relay is not a permanent intermediary
Iroh is not client-server over relay. The relay is only a rendezvous point for two endpoints to find each other, a hole-punch assistant that helps exchange NAT information to establish a direct UDP path, and a fallback when hole-punching fails. Once a direct UDP path (IPv4 or IPv6) is established, the relay is no longer used — all packets go direct. In the path selector (BiasedRttPathSelector), the Primary tier handles IPv4/IPv6 direct UDP as soon as it’s available, while the Backup tier uses relay only when no primary path exists.
The switching rule is simple: “Always switch across tiers” — as soon as a primary (direct) path appears, immediately move away from backup (relay). No waiting, no hesitation.
When magicsock needs to send a packet, it checks if a primary path (IPv4/IPv6 direct) exists. If yes, it sends UDP directly P2P. If no primary path exists, it sends via the relay server while hole-punching is in progress. If hole-punching succeeds, the connection switches to direct UDP P2P. If it fails, traffic continues through the relay as fallback only.
References & Further Reading
- Iroh Official Documentation — the primary source for this article
- n0-computer/iroh — Iroh source code on GitHub
- Pkarr — Public-key Addressable Resource Records
- RFC 4193 — Unique Local IPv6 Unicast Addresses
- RFC 9000 — QUIC: A UDP-Based Multiplexed and Secure Transport