Documentation
¶
Overview ¶
Package room lets two or more copies of an app find each other with no account and no server of their own, and then talk directly over WebRTC data channels.
Signaling (presence, offers and answers) goes over free public MQTT brokers, sealed with a key derived from a shared secret, so the brokers see only random bytes on a random-looking topic. Once two members have found each other, Mesh opens a pion PeerConnection between them, binds it to each side's ed25519 identity with a signed-fingerprint handshake, and hands the app one reliable ordered message channel plus on-demand byte streams.
It was extracted from Toru's rooms (internal/stream/room.go and ice.go).
Example ¶
Two copies of an app meet in a room named by a code one of them showed, connect directly, and swap a message. (Compiled, not run: it uses the public brokers.)
package main
import (
"context"
"crypto/ed25519"
"crypto/rand"
"fmt"
"log"
room "github.com/StephenSHorton/go-room"
)
func main() {
code := room.NewCode() // show this on one machine, type it on the other
secret, err := room.SecretFromCode("myapp-room-v1", code)
if err != nil {
log.Fatal(err)
}
_, key, _ := ed25519.GenerateKey(rand.Reader)
self := "device-1"
// Announce on every broker (re)connect, and connect to whoever is
// announced. (A real app also announces every 30 s or so, and guards
// these with a mutex or channel.)
var sig room.Signaler
var mesh *room.Mesh
sig, err = room.OpenSignal(secret, self, room.SignalOptions{
ClientPrefix: "myapp-",
OnConnect: func() {
if sig != nil {
_ = sig.Send("", "hello", nil)
}
},
OnMsg: func(env room.Envelope) {
if env.Type == "hello" && mesh != nil {
mesh.Connect(env.From)
}
},
})
if err != nil {
log.Fatal(err)
}
defer sig.Close()
relay := room.NewRelay(room.RelayConfig{}, self, nil) // STUN only: "Direct only"
relay.Start()
defer relay.Stop()
mesh, err = room.NewMesh(sig, room.Config{
Self: self,
Identity: room.KeyIdentity(key),
// Check the key against what you know about peer; a pairing flow
// records it instead and confirms with a SAS over Fingerprints.
Accept: func(peer string, pub ed25519.PublicKey) error { return nil },
ICE: relay.Servers,
})
if err != nil {
log.Fatal(err)
}
defer mesh.Close()
mesh.OnPeer(func(p *room.Peer) {
ctx := context.Background()
_ = p.Messages().Send(ctx, []byte("hi from "+self))
msg, err := p.Messages().Recv(ctx)
fmt.Println(string(msg), err)
})
select {}
}
Output:
Index ¶
- Constants
- Variables
- func CleanICEURLs(in []string) []string
- func Decode[T any](env Envelope) (T, error)
- func LoopbackOnly() func(*webrtc.SettingEngine)
- func NewCode() string
- func NormalizeCode(in string) (string, bool)
- func WebRTCServers(list []ICEServer) []webrtc.ICEServer
- type ByeError
- type Channel
- type Config
- type Envelope
- type ICEServer
- type Identity
- type LocalCloseError
- type MemHub
- type Mesh
- type Peer
- func (p *Peer) Close(reason string)
- func (p *Peer) Done() <-chan struct{}
- func (p *Peer) Err() error
- func (p *Peer) Fingerprints() (local, remote string)
- func (p *Peer) ID() string
- func (p *Peer) Messages() *Channel
- func (p *Peer) OnStream(fn func(label string, s *Stream))
- func (p *Peer) OpenStream(ctx context.Context, label string) (*Stream, error)
- func (p *Peer) Path() string
- func (p *Peer) Pub() ed25519.PublicKey
- func (p *Peer) RTT() time.Duration
- func (p *Peer) Session() string
- type Relay
- type RelayConfig
- type RelayStatus
- type Secret
- type SignalOptions
- type Signaler
- type Stream
Examples ¶
Constants ¶
const ( RelayNone = "none" // no relay set up: STUN only RelayPending = "pending" // fetching the first credentials RelayOK = "ok" // TURN is in the list RelayError = "error" // credentials couldn't be fetched (Detail says why) )
Relay states, for a "Direct only" note and diagnostics.
const ( MsgWant = "rtc.want" // to the offerer: please offer to me MsgOffer = "rtc.offer" // offerer → answerer: session, sdp MsgAnswer = "rtc.answer" // answerer → offerer: session, sdp MsgBye = "rtc.bye" // either way: session, reason )
Envelope types Mesh sends and owns.
const ( DefaultMaxMessage = 4 << 20 DefaultMaxStreams = 4 DefaultAuthTimeout = 10 * time.Second DefaultConnectTimeout = 30 * time.Second )
Defaults for Config's zero values.
const CodeLen = 16
CodeLen is the number of symbols in a room code: 16 of 31 is about 79 bits.
const DefaultMaxAge = 2 * time.Minute
DefaultMaxAge drops anything older (or further ahead) than this: a broker replaying a backlog after a reconnect, or a clock far off.
const RTCPrefix = "rtc."
RTCPrefix starts the envelope types Mesh owns ("rtc.want", "rtc.offer", "rtc.answer", "rtc.bye"). They never reach SignalOptions.OnMsg.
const SyncLabel = "sync"
SyncLabel is the label of every peer's message channel (pre-negotiated, stream id 0). OpenStream refuses it.
Variables ¶
var DefaultBrokers = [][]string{
{"tls://broker.emqx.io:8883", "wss://broker.emqx.io:8084/mqtt"},
{"tls://broker.hivemq.com:8883", "wss://broker.hivemq.com:8884/mqtt"},
}
DefaultBrokers are independent public MQTT brokers; each entry is one client tried over raw TLS first and WebSockets-over-TLS second (some networks block 8883). Messages go out through all of them and are de-duplicated on arrival, so one broker being down or slow changes nothing.
var DefaultSTUN = []ICEServer{ {URLs: []string{"stun:stun.l.google.com:19302", "stun:stun1.l.google.com:19302"}}, {URLs: []string{"stun:stun.cloudflare.com:3478"}}, }
DefaultSTUN are public STUN servers (no account) for finding each machine's public address. Data never passes through them.
var ErrBadCode = errors.New("that isn't a room code (16 letters and numbers, like ABCD-EFGH-JKMN-PQRS)")
ErrBadCode is returned for a string that isn't a room code.
var ErrClosed = errors.New("room: closed")
ErrClosed is returned by anything used after Close.
var ErrNotConnected = errors.New("not connected to the room yet")
ErrNotConnected is Send's error while no broker is connected.
var ErrRefused = errors.New("room: refused by peer")
ErrRefused means the other side's Accept turned this side down.
var ErrReplaced = errors.New("room: replaced by a new connection")
ErrReplaced is why an up peer went down (OnPeerDown) when a newer connection to the same member came up in its place: OnPeer for the new one follows. Mesh doesn't reconnect after it; there is nothing to fix.
var ErrTooBig = errors.New("room: message too big")
ErrTooBig is returned for a message over the channel's cap.
Functions ¶
func CleanICEURLs ¶
CleanICEURLs keeps the well-formed stun:, turn: and turns: entries.
func LoopbackOnly ¶
func LoopbackOnly() func(*webrtc.SettingEngine)
LoopbackOnly keeps pion on 127.0.0.1: UDP4 only, loopback candidates included, every other address filtered out (no LAN host candidates, so a fresh test binary raises no Windows Firewall prompt). Pair it with an ICE list that returns nil, so no STUN gathering socket is opened.
func NewCode ¶
func NewCode() string
NewCode returns a fresh random room code, formatted XXXX-XXXX-XXXX-XXXX.
func NormalizeCode ¶
NormalizeCode accepts a code typed or pasted any way (lower case, spaces, missing dashes) and returns its canonical form.
func WebRTCServers ¶
WebRTCServers converts a list to the shape pion takes.
Types ¶
type ByeError ¶
type ByeError struct{ Reason string }
ByeError is why a peer went down when the other side closed it.
type Channel ¶
type Channel struct {
// contains filtered or unexported fields
}
Channel is a peer's one reliable, ordered message channel ("sync"). Each message is sent as a 4-byte big-endian length, then the bytes, cut into SCTP messages of at most 16 KiB and joined again on arrival.
type Config ¶
type Config struct {
// Self is this member's id; it must match the Signaler's.
Self string
// Identity signs this side of each connection's auth handshake.
Identity Identity
// Accept is the app's decision on a peer, called once both auth
// signatures verified: pub is the key the peer proved it holds. A nil
// return lets the peer up. Required: during a pairing it may accept any
// key (and record it); afterwards it should check pub against what it
// knows about peer.
Accept func(peer string, pub ed25519.PublicKey) error
// Allow is asked before any PeerConnection is made for peer, for a local
// Connect, an incoming offer or an incoming want. nil allows everyone in
// the room.
Allow func(peer string) bool
// Offerer reports whether this side makes the offer to peer. Both sides
// must agree. nil: the lower id offers.
Offerer func(peer string) bool
// ICE is read for each new connection (Relay.Servers). nil means
// DefaultSTUN; a func returning nil means no servers (loopback tests).
ICE func() []ICEServer
// Tune adjusts the pion SettingEngine after go-room's own settings
// (tests: LoopbackOnly()).
Tune func(*webrtc.SettingEngine)
// MaxMessage caps one Channel message, both ways (default 4 MiB).
MaxMessage int
// MaxStreams caps the streams this side opens to one peer at a time
// (default 4): OpenStream waits for a free slot. Incoming streams may run
// to twice this, because the opener frees a slot on its own Close while
// this side frees it only when its handler closes the stream; past that,
// an incoming stream is closed at once (the opener reads io.EOF). Both
// sides should use the same value.
MaxStreams int
// AuthTimeout bounds the handshake once the data channel is open
// (default 10 s); ConnectTimeout bounds a whole attempt (default 30 s).
AuthTimeout time.Duration
ConnectTimeout time.Duration
// Logf, when set, gets diagnostics (never secrets).
Logf func(format string, args ...any)
}
Config sets up a Mesh.
type Envelope ¶
type Envelope struct {
V int `json:"v"`
ID string `json:"id"`
From string `json:"from"`
To string `json:"to,omitempty"`
Type string `json:"type"`
At int64 `json:"at"` // unix ms
Body json.RawMessage `json:"body,omitempty"`
}
Envelope is one signaling message. To == "" is for everyone. The JSON shape is Toru's, so Toru can move onto go-room without a wire change.
type ICEServer ¶
type ICEServer struct {
URLs []string `json:"urls"`
Username string `json:"username,omitempty"`
Credential string `json:"credential,omitempty"`
}
ICEServer is one STUN or TURN server, in the shape RTCPeerConnection takes.
type Identity ¶
Identity is this member's long-term ed25519 key, as a public key and a signing function, so the private key can stay wherever the app keeps it.
func KeyIdentity ¶
func KeyIdentity(priv ed25519.PrivateKey) Identity
KeyIdentity is an Identity for a private key held in memory.
type LocalCloseError ¶
type LocalCloseError struct{ Reason string }
LocalCloseError is why a peer went down when this side closed it.
func (*LocalCloseError) Error ¶
func (e *LocalCloseError) Error() string
type MemHub ¶
type MemHub struct {
// Tap, when set, sees every payload published (topic, sealed bytes):
// tests use it to check what a broker would see.
Tap func(topic string, payload []byte)
// contains filtered or unexported fields
}
MemHub is an in-process stand-in for the public brokers, for tests: every Signaler opened on it publishes sealed bytes to the hub, and the hub hands them to every Signaler subscribed to the same topic, each in order on its own goroutine. Sealing, filtering, de-duplication and the age check are the same code OpenSignal uses; only the transport differs.
type Mesh ¶
type Mesh struct {
// contains filtered or unexported fields
}
Mesh makes and keeps authenticated connections to other members of one room. It owns the rtc.* envelopes on its Signaler; use one Mesh per Signaler. Closing the Mesh doesn't close the Signaler.
func (*Mesh) Close ¶
func (m *Mesh) Close()
Close closes every connection (telling each peer) and stops handling the signaler's rtc.* envelopes. It waits for pion to finish closing, and for queued OnPeer/OnPeerDown callbacks to run; don't call it from one.
func (*Mesh) Connect ¶
Connect makes sure there is (or soon will be) a connection to peer: it offers if this side is the offerer, and asks peer to offer (rtc.want) otherwise. It is idempotent while an attempt is running or the peer is up, and after a failed attempt it waits out a backoff (1 s doubling to 60 s) before offering again. Call it whenever peer is seen in the room.
func (*Mesh) OnPeer ¶
OnPeer sets the handler for a peer coming up: both auth signatures verified and Accept passed on both sides. Set it before Connect.
func (*Mesh) OnPeerDown ¶
OnPeerDown sets the handler for an up peer going away, with why. After a drop that neither side asked for (the connection failed), Mesh tries to reconnect on its own for about a minute; after a Close, a bye or a refusal it doesn't. Apps still call Connect when they see the peer again. When a newer connection replaces an up one, OnPeerDown for the old *Peer (ErrReplaced) comes just before OnPeer for the new one.
type Peer ¶
type Peer struct {
// contains filtered or unexported fields
}
Peer is one authenticated connection to another member.
func (*Peer) Close ¶
Close closes the connection and every stream on it, and tells the peer why with an rtc.bye.
func (*Peer) Fingerprints ¶
Fingerprints are the SHA-256 fingerprints of this side's and the peer's DTLS certificates, as the connected transport saw them (not the SDP), in lower-case colon hex. Both sides see the same pair, swapped; a pairing SAS over both catches a man in the middle.
func (*Peer) OnStream ¶
OnStream sets the handler for streams the peer opens. Streams that arrived before it was set are handed to it at once. Without a handler, streams wait (up to MaxStreams) until one is set.
func (*Peer) OpenStream ¶
OpenStream opens a new byte stream to the peer. It waits for one of the MaxStreams slots, then for the channel to open.
func (*Peer) Path ¶
Path is "relay" when the selected ICE pair goes through TURN, "direct" otherwise ("" before a pair is selected).
type Relay ¶
type Relay struct {
// contains filtered or unexported fields
}
Relay keeps the ICE list: STUN always, TURN while credentials are good.
func NewRelay ¶
func NewRelay(cfg RelayConfig, peer string, onChange func()) *Relay
NewRelay reads the relay setup; peer is sent to the credential endpoint (the TURN REST username is usually "expiry:peer"). onChange, if set, runs after every fetch.
func (*Relay) Servers ¶
Servers is the ICE list right now: STUN, then TURN while the credentials haven't expired. Pass it as Config.ICE.
type RelayConfig ¶
type RelayConfig struct {
// CredentialURL answers GET ?peer=<peer> with {username, credential (or
// password), ttl, uris}. It must be https, except on loopback.
CredentialURL string
// TurnURLs, when set, replace the uris the credential endpoint returns.
TurnURLs []string
// Logf, when set, gets credential failures.
Logf func(format string, args ...any)
}
RelayConfig sets up an optional TURN relay with short-lived credentials (the TURN REST scheme).
type RelayStatus ¶
type RelayStatus struct {
State string `json:"state"`
Detail string `json:"detail,omitempty"`
Expires int64 `json:"expires,omitempty"` // unix ms the current credentials stop working
}
RelayStatus is how the TURN relay is doing.
type Secret ¶
Secret is what members of one room share: the key every envelope is sealed with, and the broker topic they meet on. Topic is not secret on its own (brokers see it), but it is derived from the same secret, so it names nobody. Callers may rewrite Topic's prefix (keeping the derived suffix) to stay on an existing wire format; Toru's is "toru/v1/".
func SecretFromCode ¶
SecretFromCode derives a room's secret from a room code (any spelling NormalizeCode accepts): scrypt(code, salt = app), as Toru does, so guessing codes from a captured topic or message is expensive on top of the ~79-bit code space. The first 32 bytes are the sealing key; the topic is app + "/" + hex(the next 16).
func SecretFromKey ¶
SecretFromKey derives a room's secret from a high-entropy key (32 random bytes or more), such as a secret every device of one person holds: HKDF-SHA256(ikm, salt = app, info), 48 bytes, split as in SecretFromCode. It is fast, so never feed it something a person typed: use SecretFromCode.
type SignalOptions ¶
type SignalOptions struct {
// Brokers are the broker sets (default DefaultBrokers). Each inner list
// is one client's broker URLs, tried in order.
Brokers [][]string
// ClientPrefix starts each MQTT client id (default "go-room-"). The rest
// is random, so the member id never reaches the broker.
ClientPrefix string
// MaxAge drops envelopes older or further ahead than this (default
// DefaultMaxAge).
MaxAge time.Duration
// OnMsg gets every envelope from another member, addressed to everyone
// or to self, that isn't one of Mesh's own rtc.* types. It runs on the
// signaler's receive goroutine; don't block it for long.
OnMsg func(Envelope)
// OnConnect fires whenever a broker (re)connects, so callers can announce
// themselves.
OnConnect func()
}
SignalOptions tune a Signaler. The zero value is Toru's behaviour.
type Signaler ¶
type Signaler interface {
// Send seals and publishes one envelope to member to ("" = everyone).
Send(to, typ string, body any) error
// Connected reports how many brokers are connected right now.
Connected() int
// Self is this member's id.
Self() string
// Topic is the room's topic (from its Secret).
Topic() string
// Close leaves the room.
Close()
// contains filtered or unexported methods
}
Signaler is a live connection to one room: OpenSignal (public brokers) or MemHub.Open (tests). The unexported method keeps implementations inside go-room, because Mesh relies on how they seal, filter and route.
func OpenSignal ¶
func OpenSignal(s Secret, self string, o SignalOptions) (Signaler, error)
OpenSignal joins the room for s as member self over the public brokers. It returns at once; brokers connect (and reconnect) in the background.
type Stream ¶
type Stream struct {
// contains filtered or unexported fields
}
Stream is a byte stream over its own data channel, opened with Peer.OpenStream or received through Peer.OnStream. Writes go out in 16 KiB pieces under the same flow control as Channel. Close flushes what was written, then closes the channel; the other side's Read returns io.EOF. Close also unblocks a Write stuck on a reader that stopped reading (that Write returns ErrClosed, and Close then skips the flush). When the peer goes down, Read usually returns the peer's error instead, but a connection that dies at the wrong moment can still look like io.EOF: check lengths or hashes before trusting a stream as complete.
func (*Stream) Close ¶
Close flushes what was written (up to 30 s) and closes the stream. If it had to stop a blocked Write, it doesn't flush: the data is cut anyway.