Table of Contents

Endpoints and connections

Bind an endpoint, route by ALPN, open streams and datagrams, and observe paths.

This page is the working guide to the iroh package: creating endpoints, accepting and dialing connections, and moving bytes.

Bind an endpoint

iroh.Bind(ctx, opts...) is the only constructor. Every knob is a functional option:

ep, err := iroh.Bind(ctx,
	iroh.WithSecretKey(sk),               // stable identity across restarts
	iroh.WithALPNs("example/echo/1"),     // protocols this endpoint accepts
	iroh.WithRelayMode(relay.ModeDefault()),
)
if err != nil {
	return err
}
defer ep.Shutdown(ctx)

Options you are most likely to need:

Option Effect
WithSecretKey(sk) use a fixed identity instead of a fresh random key
WithALPNs(alpns...) the protocols this endpoint will accept
WithBindAddr(ap) bind a specific address/port instead of the default
WithRelayMode(m) which relays to use (relay.ModeDefault/Staging/Disabled/Custom)
WithAddressLookup(s) discovery publishers and resolvers
WithNetReport() run QAD probes and populate Endpoint.NetReport()
WithPathSelector(sel) override path preference
WithCustomTransport(t) carry traffic over a non-UDP transport
WithoutIPTransports() / WithoutRelayTransports() compile a family out
WithRelayFirstDial() dial the relay path first, then upgrade

There is no Close; Endpoint.Shutdown(ctx) is the shutdown call, and Endpoint.Closed() returns a channel that closes when it completes.

Endpoint.SetALPNs changes the accepted set after binding.

Identity and address

id := ep.ID()            // key.EndpointID — the endpoint's public name
addr := ep.Addr()        // netaddr.EndpointAddr — id plus current paths
local := ep.LocalAddr()  // netip.AddrPort — the bound socket

ep.Addr() is a snapshot: relay assignment and discovered addresses change it over time. Use ep.WatchAddr() when you need to re-publish or re-share the address as it changes, and ep.Online(ctx) to block until a home relay connection exists before handing your address to anyone.

You can add addresses the endpoint cannot discover for itself (a port-forwarded public address, say) with ep.AddExternalAddr / ep.RemoveExternalAddr.

Serve protocols with a Router

For anything beyond a single connection, register handlers instead of writing an accept loop:

router, err := iroh.NewRouter(ep, map[string]iroh.ProtocolHandler{
	"example/echo/1": iroh.ProtocolHandlerFunc(handleEcho),
	"example/files/1": filesHandler{},
}, nil)
if err != nil {
	return err
}
defer router.Shutdown(ctx)

A ProtocolHandler receives an accepted *iroh.Conn and owns it until it returns. iroh.ProtocolHandlerFunc adapts a plain function. The Router is the Go analog of Rust iroh’s Router, and it registers its ALPNs on the endpoint for you.

For a single hand-rolled loop, ep.Accept(ctx) returns the next *Conn, and ep.AcceptIncoming(ctx) returns an *Incoming you can inspect or reject before completing the handshake. Only one accept loop may run at a time — starting a second returns iroh.ErrEndpointAcceptLoopInUse.

Dial

conn, err := ep.Connect(ctx, addr, "example/echo/1")

addr is a netaddr.EndpointAddr. It must either carry at least one path or be resolvable by a configured address-lookup service; a bare ID with neither returns iroh.ErrNoAddress.

Variants:

Errors worth handling by name: ErrNoAddress, ErrNoRelay, ErrSelfConnect, ErrConnectRejected, ErrHandshakeRejected, ErrConnClosedDuringHandshake, ErrEndpointClosed.

Streams and datagrams

A *iroh.Conn multiplexes QUIC streams:

s, err := conn.OpenStreamSync(ctx)   // bidirectional
u, err := conn.OpenUniStreamSync(ctx) // send-only
s, err := conn.AcceptStream(ctx)      // the peer's bidirectional stream
r, err := conn.AcceptUniStream(ctx)   // the peer's send-only stream

Streams implement the usual io interfaces. Closing a bidirectional stream closes your send side and signals EOF to the peer while you keep reading — that half-close is what makes request/response protocols terminate.

OpenStreamConn and AcceptStreamConn return net.Conn wrappers when you want to hand a stream to code that expects one. ep.ListenStreams() returns a *StreamListener, i.e. a net.Listener-shaped view of incoming streams.

Unreliable datagrams are available with conn.SendDatagram(p) and conn.ReadDatagram(ctx).

Close with conn.CloseWithError(code, reason) so the peer learns why.

Observe the connection

for _, p := range conn.Paths() {
	fmt.Println(p.ID, p.Validated, p.Addr, p.RTT)
}

Choose among paths

When several paths to a peer are usable, a PathSelector decides which one carries traffic:

type PathSelector interface {
	Select(current netaddr.TransportAddr, candidates []PathCandidate) (netaddr.TransportAddr, bool)
}

Returning ok = false keeps the current selection. Implementations must not block — they run on the path-management path.

The default is iroh.BiasedRttPathSelector, and its bias is worth knowing because it explains behavior you will otherwise find surprising: it sorts by (tier, biased RTT), so direct IP and custom paths always beat relay paths regardless of RTT; within a tier the lowest biased RTT wins; IPv6 gets a 3ms advantage; and switching within a tier requires the candidate to be at least 5ms better than the current path. That last rule is hysteresis — it is why a path that looks marginally faster in your metrics does not get selected.

Override with iroh.WithPathSelector when you need a different policy, for example pinning to a relay for privacy or preferring a metered-link-avoiding route.

Next steps

Last updated: 2026-08-10