Table of Contents
Build an echo protocol
Write a protocol handler, serve it with a Router, and connect two processes with an endpoint ticket.
This tutorial builds a small but complete iroh application: an echo protocol
served through a Router, first in one process, then split into a server and a
client that find each other with an endpoint ticket. Every program on this page
was compiled and run against github.com/tmc/go-iroh before publication.
By the end you will have used the four things every iroh application needs: an endpoint, an ALPN, a protocol handler, and an address.
Set up
mkdir irohecho && cd irohecho
go mod init example.com/irohecho
go get github.com/tmc/go-iroh
Part 1 — one process, two endpoints
A protocol handler is a function that owns an accepted connection. It receives
a *iroh.Conn and returns when it is done with it:
// handleEcho copies one stream back to its sender.
func handleEcho(ctx context.Context, conn *iroh.Conn) error {
s, err := conn.AcceptStream(ctx)
if err != nil {
return err
}
if _, err := io.Copy(s, s); err != nil {
return err
}
return s.Close()
}
A Router maps ALPN strings to handlers and runs the accept loop for you.
Create echo/main.go:
// Command echo runs an iroh echo protocol between two endpoints in one process.
package main
import (
"context"
"fmt"
"io"
"log"
"net/netip"
"github.com/tmc/go-iroh/iroh"
"github.com/tmc/go-iroh/netaddr"
)
const alpn = "example/echo/1"
// handleEcho copies one stream back to its sender.
func handleEcho(ctx context.Context, conn *iroh.Conn) error {
s, err := conn.AcceptStream(ctx)
if err != nil {
return err
}
if _, err := io.Copy(s, s); err != nil {
return err
}
return s.Close()
}
func main() {
ctx := context.Background()
loopback := netip.AddrPortFrom(netip.IPv6Loopback(), 0)
server, err := iroh.Bind(ctx, iroh.WithBindAddr(loopback))
if err != nil {
log.Fatal(err)
}
router, err := iroh.NewRouter(server, map[string]iroh.ProtocolHandler{
alpn: iroh.ProtocolHandlerFunc(handleEcho),
}, nil)
if err != nil {
log.Fatal(err)
}
defer router.Shutdown(ctx)
client, err := iroh.Bind(ctx, iroh.WithBindAddr(loopback))
if err != nil {
log.Fatal(err)
}
defer client.Shutdown(ctx)
addr := netaddr.NewEndpointAddr(server.ID()).WithIP(server.LocalAddr())
conn, err := client.Connect(ctx, addr, alpn)
if err != nil {
log.Fatal(err)
}
defer conn.Close()
s, err := conn.OpenStreamSync(ctx)
if err != nil {
log.Fatal(err)
}
if _, err := s.Write([]byte("hello from go-iroh")); err != nil {
log.Fatal(err)
}
s.Close()
got, err := io.ReadAll(s)
if err != nil {
log.Fatal(err)
}
fmt.Printf("server %s echoed: %s\n", server.ID().Z32()[:8], got)
}
go run ./echo
server 37w49eed echoed: hello from go-iroh
The endpoint ID prefix changes on every run because each Bind without
iroh.WithSecretKey generates a new key.
Three details worth noticing:
iroh.NewRoutertakes the endpoint, an ALPN-to-handler map, and a config (nilaccepts the defaults). It registers the ALPNs on the endpoint, so you do not also passiroh.WithALPNs.s.Close()closes the send side of the bidirectional stream. That is what letsio.Copyin the handler return, which is what letsio.ReadAllreturn on the client. Forgetting it is the most common way to hang an echo protocol.netaddr.NewEndpointAddr(id).WithIP(...)builds the address by hand because both endpoints are on this machine. In Part 2 the address travels as a ticket.
Part 2 — two processes, one ticket
An endpoint ticket is the compact string form of an
endpoint address, meant for out-of-band sharing: paste it into a terminal, a
chat message, or a QR code. endpointticket.Encode produces one and
endpointticket.Decode returns the netaddr.EndpointAddr back.
echo-server/main.go:
// Command echo-server serves the example echo protocol and prints its ticket.
package main
import (
"context"
"fmt"
"io"
"log"
"net/netip"
"github.com/tmc/go-iroh/endpointticket"
"github.com/tmc/go-iroh/iroh"
"github.com/tmc/go-iroh/netaddr"
)
const alpn = "example/echo/1"
func handleEcho(ctx context.Context, conn *iroh.Conn) error {
s, err := conn.AcceptStream(ctx)
if err != nil {
return err
}
if _, err := io.Copy(s, s); err != nil {
return err
}
return s.Close()
}
func main() {
ctx := context.Background()
ep, err := iroh.Bind(ctx, iroh.WithBindAddr(netip.AddrPortFrom(netip.IPv6Loopback(), 0)))
if err != nil {
log.Fatal(err)
}
router, err := iroh.NewRouter(ep, map[string]iroh.ProtocolHandler{
alpn: iroh.ProtocolHandlerFunc(handleEcho),
}, nil)
if err != nil {
log.Fatal(err)
}
defer router.Shutdown(ctx)
addr := netaddr.NewEndpointAddr(ep.ID()).WithIP(ep.LocalAddr())
fmt.Println(endpointticket.Encode(addr))
<-ctx.Done()
}
echo-client/main.go:
// Command echo-client dials an echo server named by an endpoint ticket.
package main
import (
"context"
"fmt"
"io"
"log"
"os"
"github.com/tmc/go-iroh/endpointticket"
"github.com/tmc/go-iroh/iroh"
)
const alpn = "example/echo/1"
func main() {
if len(os.Args) != 3 {
log.Fatal("usage: echo-client <ticket> <message>")
}
addr, err := endpointticket.Decode(os.Args[1])
if err != nil {
log.Fatal(err)
}
ctx := context.Background()
ep, err := iroh.Bind(ctx)
if err != nil {
log.Fatal(err)
}
defer ep.Shutdown(ctx)
conn, err := ep.Connect(ctx, addr, alpn)
if err != nil {
log.Fatal(err)
}
defer conn.Close()
s, err := conn.OpenStreamSync(ctx)
if err != nil {
log.Fatal(err)
}
if _, err := s.Write([]byte(os.Args[2])); err != nil {
log.Fatal(err)
}
s.Close()
got, err := io.ReadAll(s)
if err != nil {
log.Fatal(err)
}
fmt.Printf("echo: %s\n", got)
}
In one terminal:
go run ./echo-server
endpointade7dxku6e7gise7…
The ticket is a single line beginning with endpoint followed by lowercase
base32 without padding; a loopback ticket like this one is about 100
characters. Copy it, and in a second terminal:
go run ./echo-client 'endpointade7dxku6e7gise7…' 'hello over a ticket'
echo: hello over a ticket
Note that the client called iroh.Bind(ctx) with no options at all. A client
needs no ALPN registration and no fixed bind address — it dials.
Where to take it next
This program only works between two machines that can reach each other’s IP address, because the ticket only carries an IP path. To make it work across NATs, the server needs a relay URL, a discovery service, or both in its address:
- Relays and discovery — put a relay URL in the ticket, or publish the address so the client can look it up from the endpoint ID alone.
- Endpoints and connections — streams, datagrams, 0-RTT, path selection, and the rest of the connection API.
- Protocol packages — instead of inventing a protocol, run
blobs,gossip, ordocsbehind the sameRouter.