Table of Contents
How go-iroh works
The layers between iroh.Bind and a packet on the wire — transports, paths, the TLS and QUIC forks, and what wire compatibility costs.
This page explains the runtime shape of go-iroh. It is background reading, not a set of steps; the how-to pages link back here when a decision depends on it.
The layer stack
your protocol handler (ALPN-routed, iroh.Router)
│
iroh.Conn / iroh.Stream package iroh
│
magic socket + path selection
│
internal/qng quic-go fork: multipath, QAD, QNT
│
internal/itls/tls crypto/tls fork: RFC 7250 raw public keys
│
UDP socket │ relay client (WebSocket) │ custom transport
Only the top two boxes are public API. Everything from the magic socket down
lives under internal/, which is deliberate: those are wire-compatibility
implementation details that upstream iroh may change.
Endpoints own a socket, not a connection
iroh.Bind produces an Endpoint: a secret key, a UDP socket, and the QUIC
transport used to dial and accept. One endpoint multiplexes every peer you talk
to across that single socket, which is why NAT bindings and hole-punching
results are reusable across connections.
Because the endpoint is the unit of identity, it is also the unit of
addressability: Endpoint.Addr() returns the current netaddr.EndpointAddr,
and Endpoint.WatchAddr() returns an observer that fires as relay assignment
and address discovery change what that address contains.
Transports and paths
An endpoint can carry traffic over three kinds of transport:
- Direct IP. UDP to an address the peer advertised or that hole punching discovered.
- Relay. A WebSocket connection to a relay server, which forwards to the peer’s relay connection. Available whenever the relay is reachable; higher latency than a direct path.
- Custom. Anything you implement, via the
iroh.CustomTransportinterface andnetaddr.CustomAddraddresses. This is how go-iroh runs over transports that are not UDP at all.
Either built-in family can be switched off at bind time with
iroh.WithoutIPTransports() or iroh.WithoutRelayTransports() — these are
runtime options, not build tags; the browser build passes the former.
At runtime a connection may hold several paths at once. Conn.Paths()
returns PathInfo values carrying the QUIC multipath path ID, whether the path
is validated, its address, and its smoothed RTT when observed. The usual
lifecycle is: connect over the relay immediately, discover direct candidates,
validate one, migrate application data to it, keep the relay path as a
fallback. Conn.WatchPaths lets you observe that transition instead of
guessing at it.
Path preference is pluggable with iroh.WithPathSelector.
Learning your own address
An endpoint cannot know its public address by looking at its socket. go-iroh
uses QAD (QUIC address discovery) round trips against relays to learn observed
addresses, summarized in Endpoint.NetReport(): whether IPv4 and IPv6 round
trips completed, whether the observed address varies by destination (the
classic symmetric-NAT signal), and which relay currently has the best latency.
Enable it with iroh.WithNetReport(); iroh.WithNATPMP adds port mapping.
Addresses learned this way are what get published to discovery services and what end up inside a ticket.
The two forks, and why they exist
Wire compatibility with Rust iroh is not reachable from the Go standard
library, so the repository carries two vendored forks under internal/:
internal/itls/tls, a crypto/tls fork supporting RFC 7250 raw public keys,
and internal/qng, a quic-go v0.59.1 fork that drives it and adds the
iroh/noq transport extensions.
Connections that are not peer-to-peer — relay, pkarr, DoH, DoT — use ordinary WebPKI TLS and the standard library.
The forks are a maintenance liability the project tracks explicitly, with a
“When to break this fork” section in each fork’s own README
(internal/itls/README.md, internal/qng/README.md). For why the standard
library cannot serve here and what would retire each fork, see
Standard library mapping.
Where discovery fits
Discovery is not part of the connection path; it is the step before it. An
address-lookup service turns an endpoint ID into an EndpointAddr (resolver)
and publishes your own address so others can do the same (publisher). It is
configured on the endpoint with iroh.WithAddressLookup and is entirely
optional — an address you already have, from a ticket or a config file, works
without any lookup service at all.
See Relays and discovery.
Browser builds
GOOS=js GOARCH=wasm compiles. The platform, not the port, limits what runs:
the relay WebSocket client has a js-specific dial path, but direct UDP QUIC,
direct paths, and NAT traversal are not available in browser WebAssembly. A
browser endpoint is therefore relay-only. The iroh/mdns package is excluded
from js builds entirely.
The wasmrelay experiment module carries browser demos, and
cmd/wasmrelaytest is the in-repo smoke test.
Reading upstream alongside this
The protocol these layers implement is designed and documented upstream. When you need to know why the wire looks the way it does — the relay protocol, the hole-punching sequence, ALPN conventions — read docs.iroh.computer and the Rust source at n0-computer/iroh. This site documents the Go realization of it.
Next steps
- Endpoints and connections — the API this describes.
- Relays and discovery — configuring the pieces above.
- Testing and interop — how the wire claims are checked.