Skip to content

Repository files navigation

peer

ella.to/peer is a public-key addressed peer-to-peer overlay. You give it the public key of whoever you want to talk to, and it gives you a direct, encrypted WebRTC data channel to that peer — establishing it on demand through a small discovery (signaling) server the first time, and reusing it afterwards.

// Send bytes to a peer, addressed only by its public key.
n, err := p.WriteTo(ctx, remotePublicKey, []byte("hello"))

// Receive bytes from any connected peer.
from, n, err := p.ReadFrom(ctx, buf) // from == sender's public key

It is built on top of ella.to/pipe (the WebRTC Conn and the signal/sse signaling transport) and ella.to/crypto (NaCl keys). The data path is peer-to-peer; the discovery server only brokers the initial ICE handshake.


Table of contents

  1. How it works
  2. Install
  3. Step 1 — Run a discovery server
  4. Step 2 — Create a peer and point it at the server
  5. Step 3 — Connect two peers
  6. Full runnable example
  7. API reference
  8. Keys and identity
  9. Authorizing who may connect
  10. Production notes (TLS, NAT/TURN)
  11. How the pieces fit (sequence)
  12. Testing

How it works

There are two roles:

  • Discovery server — a single, public HTTP endpoint that every peer can reach. It authenticates peers by public key, hands out a session token, and relays the small ICE/SDP signaling messages peers use to find each other. It does not see your actual data.

  • Peer — your application. It is identified by a public key. To reach another peer you only need that peer's public key (how you obtain it — QR code, directory, config, etc. — is out of scope).

The lifecycle:

  1. A peer registers with the discovery server: it signs its own public key with its private key and POSTs both. The server verifies the signature and returns a crypto-random token.
  2. The token authenticates everything the peer does afterwards: opening its signaling stream and sending signaling messages.
  3. When peer.WriteTo(remoteKey, ...) is called for the first time, the engine negotiates a WebRTC connection to remoteKey through the discovery server, caches the resulting data channel, and writes to it. Subsequent writes reuse the cached channel.
  4. Incoming datagrams from every connected peer are funneled into one queue that peer.ReadFrom drains.
  5. peer.Close tears down all connections and releases the token.

Install

The module lives in the ella.to workspace. Within this monorepo it is wired via go.work, so no extra steps are needed. As a standalone dependency:

go get ella.to/peer
import "ella.to/peer"

Requires Go 1.25+.


Step 1 — Run a discovery server

The discovery server is a plain http.Handler. Mount it and serve it anywhere your peers can reach.

cmd/discovery/main.go:

package main

import (
	"log"
	"net/http"

	"ella.to/peer"
)

func main() {
	d, err := peer.NewDiscoveryServer()
	if err != nil {
		log.Fatal(err)
	}
	defer d.Close() // stops the background token sweeper

	addr := ":8080"
	log.Printf("discovery server listening on %s", addr)
	log.Fatal(http.ListenAndServe(addr, d))
}

Run it:

go run ./cmd/discovery
# discovery server listening on :8080

That's the whole server. It exposes two routes under the host you serve it on:

Method & path Purpose
POST /register Register a public key, receive a session token
DELETE /register Release the token (called by Close)
GET /signal Open the peer's signaling (SSE) stream
POST /signal Send a signaling message to another peer's inbox

Peers only ever need the base URL (http://your-host:8080); the client appends the paths for you.

Want it behind your own router/middleware? Because it's an http.Handler, you can mount it on a sub-path with http.StripPrefix, wrap it with logging/CORS, or put it behind a reverse proxy. Just make sure the base URL you give to peers resolves to it.


Step 2 — Create a peer and point it at the server

The only required option is the discovery server's base URL.

package main

import (
	"context"
	"fmt"
	"log"

	"ella.to/peer"
)

func main() {
	p, err := peer.NewPeer(
		peer.WithDiscoveryURL("http://localhost:8080"),
	)
	if err != nil {
		log.Fatal(err)
	}
	defer p.Close(context.Background())

	pub, _ := p.Keys()
	fmt.Println("my public key:", pub)
	// Share this public key with whoever wants to reach you.

	select {} // keep running
}

NewPeer performs the bootstrap (sign → register → obtain token → open the signaling stream) before it returns, so a successful NewPeer means the peer is online and addressable.

Reusing a key pair. By default a fresh key pair is generated each run. To keep a stable identity across restarts, generate a pair once, persist it, and pass it back in:

pub, priv, _ := peer.GenerateKeys() // store these somewhere safe

p, err := peer.NewPeer(
	peer.WithDiscoveryURL("http://localhost:8080"),
	peer.WithKeys(pub, priv),
)

Step 3 — Connect two peers

Connecting is implicit: just WriteTo the other peer's public key. The first write blocks while the connection is negotiated; later writes reuse it.

Receiver (cmd/receiver/main.go) — prints its key, then echoes whatever it receives back to the sender:

package main

import (
	"context"
	"fmt"
	"log"

	"ella.to/peer"
)

func main() {
	p, err := peer.NewPeer(peer.WithDiscoveryURL("http://localhost:8080"))
	if err != nil {
		log.Fatal(err)
	}
	defer p.Close(context.Background())

	pub, _ := p.Keys()
	fmt.Println("receiver public key:", pub) // copy this for the sender

	ctx := context.Background()
	buf := make([]byte, 64*1024)
	for {
		from, n, err := p.ReadFrom(ctx, buf)
		if err != nil {
			log.Fatal(err)
		}
		fmt.Printf("got %q from %s\n", buf[:n], from)

		// Echo it back — we learned the sender's key from ReadFrom.
		if _, err := p.WriteTo(ctx, from, buf[:n]); err != nil {
			log.Printf("echo failed: %v", err)
		}
	}
}

Sender (cmd/sender/main.go) — takes the receiver's key as an argument, sends a message, and reads the echo:

package main

import (
	"context"
	"fmt"
	"log"
	"os"
	"time"

	"ella.to/peer"
)

func main() {
	if len(os.Args) < 2 {
		log.Fatal("usage: sender <receiver-public-key>")
	}
	receiverKey := os.Args[1]

	p, err := peer.NewPeer(peer.WithDiscoveryURL("http://localhost:8080"))
	if err != nil {
		log.Fatal(err)
	}
	defer p.Close(context.Background())

	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	// First WriteTo to a new peer blocks while the WebRTC channel is negotiated.
	if _, err := p.WriteTo(ctx, receiverKey, []byte("hello from sender")); err != nil {
		log.Fatal(err)
	}

	buf := make([]byte, 64*1024)
	from, n, err := p.ReadFrom(ctx, buf)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("echo %q from %s\n", buf[:n], from)
}

Run all three in separate terminals:

# terminal 1
go run ./cmd/discovery

# terminal 2
go run ./cmd/receiver
# receiver public key: 9f2c...   <-- copy this

# terminal 3
go run ./cmd/sender 9f2c...
# echo "hello from sender" from 9f2c...

That's a full round trip: the sender reached the receiver knowing only its public key, the data flowed peer-to-peer over WebRTC, and the receiver learned the sender's key from ReadFrom.

Addressing is symmetric. Any peer can WriteTo any other peer it knows the key of; there is no fixed client/server. A peer learns a remote's key either out of band (to initiate) or from ReadFrom (to reply).


Full runnable example

A single-process demo that wires a discovery server and two peers together — handy for a smoke test:

package main

import (
	"context"
	"fmt"
	"log"
	"net/http/httptest"
	"time"

	"ella.to/peer"
)

func main() {
	// 1. Discovery server.
	d, err := peer.NewDiscoveryServer()
	if err != nil {
		log.Fatal(err)
	}
	defer d.Close()
	ts := httptest.NewServer(d)
	defer ts.Close()

	// 2. Two peers pointed at it.
	alice, err := peer.NewPeer(peer.WithDiscoveryURL(ts.URL))
	if err != nil {
		log.Fatal(err)
	}
	defer alice.Close(context.Background())

	bob, err := peer.NewPeer(peer.WithDiscoveryURL(ts.URL))
	if err != nil {
		log.Fatal(err)
	}
	defer bob.Close(context.Background())

	alicePub, _ := alice.Keys()
	bobPub, _ := bob.Keys()

	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	// 3. Alice -> Bob.
	if _, err := alice.WriteTo(ctx, bobPub, []byte("ping")); err != nil {
		log.Fatal(err)
	}
	buf := make([]byte, 1024)
	from, n, _ := bob.ReadFrom(ctx, buf)
	fmt.Printf("bob received %q from %s\n", buf[:n], from)

	// 4. Bob -> Alice.
	if _, err := bob.WriteTo(ctx, alicePub, []byte("pong")); err != nil {
		log.Fatal(err)
	}
	from, n, _ = alice.ReadFrom(ctx, buf)
	fmt.Printf("alice received %q from %s\n", buf[:n], from)
}

API reference

The Peer interface

type Peer interface {
	WriteTo(ctx context.Context, publicKey string, b []byte) (n int, err error)
	ReadFrom(ctx context.Context, b []byte) (publicKey string, n int, err error)
	Close(ctx context.Context) error
	Keys() (publicKey, privateKey string)
}
  • WriteTo sends b to publicKey, dialing on first contact. Writes to the same peer are serialized (one in-flight write per peer); writes to different peers run concurrently. Returns len(b) on success. A datagram may be at most 1 MiB.
  • ReadFrom returns the next datagram from any connected peer plus the sender's public key. If b is smaller than the datagram, the excess is discarded (net.PacketConn semantics). Honors ctx cancellation.
  • Close tears down every connection, releases the discovery token, and unblocks any in-flight ReadFrom with ErrClosed. Idempotent — a second call returns ErrClosed.
  • Keys returns the peer's own public and private keys.

Constructor & options

func NewPeer(opts ...Option) (Peer, error)
Option Description Default
WithDiscoveryURL(url string) Required. Base URL of the discovery server.
WithKeys(pub, priv string) Use a specific key pair instead of generating one. random pair
WithValidationFn(fn func(publicKey string) error) Authorize inbound peers; non-nil error refuses the connection. accept all
WithHTTPClient(c *http.Client) Custom HTTP client to reach the server (TLS, timeouts). Its transport is wrapped to attach the token. default client
WithRecvBuffer(n int) Inbound datagram queue depth before backpressure/drops. 256
WithDeliverTimeout(d time.Duration) How long a reader waits to hand a datagram to ReadFrom before dropping it. 3s
WithDialTimeout(d time.Duration) Upper bound on an on-demand dial (and on registration). 30s
WithIdleTimeout(d time.Duration) Close a session after this long with no read or write; the next WriteTo re-dials. Non-positive keeps the default. 30s
WithWebRTCOptions(opts *pipe.WebRTCOptions) STUN/TURN/ICE configuration (see Production notes). Google STUN

Errors

var (
	ErrEmptyPublicKey   = errors.New("peer: public key cannot be empty")
	ErrSelfPeer         = errors.New("peer: cannot send to self")
	ErrDatagramTooLarge = errors.New("peer: datagram exceeds maximum size")
	ErrClosed           = io.ErrClosedPipe // after Close, and from a second Close
)

Discovery server

func NewDiscoveryServer(opts ...DiscoveryOption) (*DiscoveryServer, error)
func (*DiscoveryServer) ServeHTTP(w http.ResponseWriter, r *http.Request) // http.Handler
func (*DiscoveryServer) Close() error // stops the token sweeper
Option Description Default
WithServerValidation(fn func(publicKey string) error) Extra check after the signature verifies; non-nil error rejects registration with 403 (e.g. an allowlist). none
WithTokenGrace(d time.Duration) How long a token with no active connection survives before being reclaimed (crash backstop). 30s

RegistrationError{StatusCode int; Body string} is returned by the client when the server rejects registration, so you can distinguish 401 (bad signature) from 403 (rejected by validation).


Keys and identity

A peer's identity is a NaCl key pair, hex-encoded as strings.

// Generate a fresh pair.
pub, priv, err := peer.GenerateKeys()

// Prove ownership of pub by signing it with priv (what registration does).
sig, err := peer.SignPublicKey(pub, priv)

// Verify a (publicKey, signature) pair. Returns nil if valid.
err = peer.VerifyPublicKey(pub, sig)
  • The public key is your network address — share it freely.
  • The private key must stay secret; it proves ownership during registration.
  • Producing any signature that verifies against a public key requires the matching private key, so a successful VerifyPublicKey proves the signer controls that key.

Authorizing who may connect

Two independent hooks, at two layers:

At the server — who may register at all (e.g. an allowlist of known keys):

allowed := map[string]bool{ /* ...public keys... */ }

d, _ := peer.NewDiscoveryServer(
	peer.WithServerValidation(func(pub string) error {
		if !allowed[pub] {
			return fmt.Errorf("public key not allowed")
		}
		return nil
	}),
)

At a peer — who may connect to me (checked during an inbound connection's handshake):

p, _ := peer.NewPeer(
	peer.WithDiscoveryURL(url),
	peer.WithValidationFn(func(remotePub string) error {
		if !isFriend(remotePub) {
			return fmt.Errorf("rejecting %s", remotePub)
		}
		return nil
	}),
)

The claimed key is then verified with a challenge–response: the listener sends a random nonce over the fresh data channel and the dialer must return a signature over it (bound to both endpoints' keys) made with the private key matching its claimed public key. A peer therefore cannot impersonate another peer's identity, and the publicKey reported by ReadFrom is authenticated. WithValidationFn itself runs on the claim (before the proof) so allowlist rejections stay cheap; a connection is only registered once the proof checks out.


Production notes

Serve discovery over TLS

Registration proves key ownership by signing the (public) public key, which is replayable on the wire. Run the discovery server behind HTTPS so tokens and signatures aren't exposed:

log.Fatal(http.ListenAndServeTLS(":8443", "cert.pem", "key.pem", d))

Then point peers at https://your-host:8443. If you use a custom CA or client config, pass it with WithHTTPClient:

p, _ := peer.NewPeer(
	peer.WithDiscoveryURL("https://your-host:8443"),
	peer.WithHTTPClient(&http.Client{Transport: myTLSTransport}),
)

NAT traversal — STUN and TURN

By default the engine uses Google's public STUN servers, which is enough for many networks (and for LAN/loopback, host candidates connect with no STUN at all). For peers behind strict/symmetric NATs you need a TURN relay:

import "ella.to/pipe"

p, _ := peer.NewPeer(
	peer.WithDiscoveryURL("https://your-host:8443"),
	peer.WithWebRTCOptions(&pipe.WebRTCOptions{
		TURNServers: []pipe.TURNServer{
			pipe.NewTURNServer(
				"turn:turn.example.com:3478?transport=udp",
				"username", "credential",
			),
		},
		// ForceRelay: true, // to verify TURN works end-to-end
	}),
)

WithWebRTCOptions accepts the full ella.to/pipe WebRTC configuration (ICEServers, TURNServers, ForceRelay, ICETransportPolicy).

Tuning throughput vs. memory

  • WithRecvBuffer raises the inbound queue so bursts aren't dropped while ReadFrom is busy. Always drain ReadFrom in a tight loop — if no one reads, datagrams are dropped after WithDeliverTimeout so the network layer never stalls.
  • Each datagram is capped at 1 MiB; chunk larger payloads yourself.
  • WithIdleTimeout reclaims connections that go quiet. A live data channel keeps a WebRTC PeerConnection (and its ICE/DTLS state) resident, so idle sessions are closed after the timeout and transparently re-dialed on the next WriteTo. Lower it to shed idle connections faster; raise it if peers exchange data in bursts spaced further apart than the default 30s and you want to avoid the re-dial latency between bursts.

How the pieces fit (sequence)

   Peer A (dialer)            Discovery server            Peer B (listener)
        │                            │                            │
        │  POST /register (sign pk)  │                            │
        ├───────────────────────────►│  verify sig → mint token   │
        │◄───────────── token ───────┤                            │
        │  GET /signal (own inbox)   │   ◄── GET /signal (B) ──────┤  (B already
        │═══════════ SSE ════════════│════════════ SSE ═══════════│   registered)
        │                            │                            │
   WriteTo(B, data) ─ first contact ─┤                            │
        │  POST /signal → B.inbox    │                            │
        │   (offer / ICE candidates) ├──────────► relayed ───────►│
        │◄─────────── answer / ICE ──┤◄────────── B replies ──────┤
        │                            │                            │
        │   ░░░ WebRTC data channel established (peer-to-peer) ░░░ │
        │════════════════════ data (not via server) ═════════════►│
        │                            │                       ReadFrom → (A, data)

After the data channel is up, application bytes flow directly between peers; the discovery server is only used for the initial handshake (and for negotiating new connections to other peers).


Testing

The package ships with unit and end-to-end tests (the E2E tests perform real WebRTC negotiation over loopback):

go test ./...            # full suite
go test -race ./...      # with the race detector
go test -run TestPeer    # just the peer end-to-end tests

About

Golang simple peer-to-peer with WebRTC

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages