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 keyIt 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.
- How it works
- Install
- Step 1 — Run a discovery server
- Step 2 — Create a peer and point it at the server
- Step 3 — Connect two peers
- Full runnable example
- API reference
- Keys and identity
- Authorizing who may connect
- Production notes (TLS, NAT/TURN)
- How the pieces fit (sequence)
- Testing
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:
- 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.
- The token authenticates everything the peer does afterwards: opening its signaling stream and sending signaling messages.
- When
peer.WriteTo(remoteKey, ...)is called for the first time, the engine negotiates a WebRTC connection toremoteKeythrough the discovery server, caches the resulting data channel, and writes to it. Subsequent writes reuse the cached channel. - Incoming datagrams from every connected peer are funneled into one queue that
peer.ReadFromdrains. peer.Closetears down all connections and releases the token.
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/peerimport "ella.to/peer"Requires Go 1.25+.
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 :8080That'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 withhttp.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.
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),
)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
WriteToany 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 fromReadFrom(to reply).
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)
}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)
}WriteTosendsbtopublicKey, dialing on first contact. Writes to the same peer are serialized (one in-flight write per peer); writes to different peers run concurrently. Returnslen(b)on success. A datagram may be at most 1 MiB.ReadFromreturns the next datagram from any connected peer plus the sender's public key. Ifbis smaller than the datagram, the excess is discarded (net.PacketConnsemantics). Honorsctxcancellation.Closetears down every connection, releases the discovery token, and unblocks any in-flightReadFromwithErrClosed. Idempotent — a second call returnsErrClosed.Keysreturns the peer's own public and private keys.
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 |
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
)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).
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
VerifyPublicKeyproves the signer controls that key.
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.
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}),
)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).
WithRecvBufferraises the inbound queue so bursts aren't dropped whileReadFromis busy. Always drainReadFromin a tight loop — if no one reads, datagrams are dropped afterWithDeliverTimeoutso the network layer never stalls.- Each datagram is capped at 1 MiB; chunk larger payloads yourself.
WithIdleTimeoutreclaims connections that go quiet. A live data channel keeps a WebRTCPeerConnection(and its ICE/DTLS state) resident, so idle sessions are closed after the timeout and transparently re-dialed on the nextWriteTo. 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.
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).
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