room

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 28, 2026 License: MIT Imports: 23 Imported by: 0

README

go-room

Pure-Go rooms: copies of an app find each other with no account and no server of their own, then talk directly over WebRTC data channels.

import room "github.com/StephenSHorton/go-room" // package room
  • Signaling goes over free public MQTT brokers (EMQX and HiveMQ by default; TLS first, WebSockets-over-TLS second). Every envelope is sealed with a key derived from a secret the members share, published through every broker and de-duplicated on arrival, so one broker being down changes nothing.
  • Peers are pion PeerConnections, one per pair of members, with STUN for hole punching and an optional TURN relay. Each connection is bound to both sides' ed25519 keys by a signed-fingerprint handshake. The app gets one reliable, ordered message channel per peer and as many byte streams as it opens.
  • No cgo, no helper process. It builds for Windows, macOS and Linux.

It was extracted from Toru's rooms (internal/stream/room.go and ice.go). The code alphabet, scrypt parameters, sealing and envelope JSON are Toru's, so Toru can move onto it without a wire change. Irori's device mesh is the second user.

Pieces

NewCode, NormalizeCode Room codes like ABCD-EFGH-JKMN-PQRS: 16 symbols from a 31-letter alphabet with no 0/O or 1/I/L, about 79 bits. Typed in any case, with or without dashes.
SecretFromCode(app, code) scrypt(code, salt = app, N=2^15, r=8, p=1) gives a 32-byte sealing key and a topic app/<hex>. Use it for anything a person types.
SecretFromKey(app, ikm, info) HKDF-SHA256 version for high-entropy keys, for example a per-person topic derived from a secret every device holds, plus an epoch. Never feed it a typed code.
OpenSignal(secret, self, opts) The live room over the public brokers. It returns at once and connects and reconnects in the background. Send(to, type, body), with to set to "" for everyone. OnMsg gets every envelope that isn't one of Mesh's own rtc.* types. OnConnect fires on each broker (re)connect, so you can announce yourself.
NewMemHub().Open(...) The same Signaler over an in-process fake broker, for tests. It uses the same sealing, filtering, de-duplication and age-check code.
NewRelay(cfg, peer, onChange) The ICE list: DefaultSTUN, plus TURN while short-lived credentials from CredentialURL (the TURN REST scheme) are good. It refreshes at 80% of the ttl and keeps working credentials through fetch failures until they expire. Status() is none, pending, ok or error.
NewMesh(sig, cfg) Connections to other members. Connect(peer) offers if this side is the offerer (default: the lower id), and otherwise sends rtc.want so the other side offers. It is idempotent, with a backoff from 1 s up to 60 s after failed attempts. OnPeer fires once a peer is authenticated and accepted on both sides, OnPeerDown when one goes away. After a drop neither side asked for (the connection failed), Mesh reconnects by itself, about six tries over a minute; after a Close, a bye or a refusal it doesn't. Call Connect again whenever you see the peer in the room. When a newer connection to a member replaces an up one, OnPeerDown gets ErrReplaced for the old *Peer just before OnPeer for the new one, so key per-peer state on the *Peer. Errors to tell apart: ErrClosed, ErrTooBig, ErrRefused, ErrReplaced, *ByeError, *LocalCloseError.
Peer Messages() is a *Channel with Send and Recv of whole messages up to MaxMessage (default 4 MiB). OpenStream(ctx, label) and OnStream give byte streams, each io.ReadWriteCloser on its own data channel. A stream is raw bytes with no message boundaries, so frame any request you send on it yourself. OpenStream waits while MaxStreams (default 4) of your streams are open; the receiving side takes up to twice that, because it frees a slot only when its handler closes the stream. Close unblocks a Write stuck on a reader that stopped reading, and WriteContext takes a context. Pub(), Fingerprints(), Path() (direct or relay), RTT(), Close(reason).
LoopbackOnly() A Config.Tune that keeps pion on 127.0.0.1, for tests. Pair it with ICE: func() []ICEServer { return nil }.

Messages and stream writes go out in SCTP messages of at most 16 KiB, because pion's default cap is 64 KiB. Writers block while more than 1 MiB is queued on a channel and resume once it drains to 256 KiB. SCTP keeps channels independent, so a big stream never holds up the message channel. Data channels are detached (SettingEngine.DetachDataChannels), and mDNS is off.

Security model

What the brokers see. They see a topic, which is a hash-derived name that identifies nobody, and sealed bytes (NaCl secretbox, XSalsa20-Poly1305, with a fresh 24-byte nonce per envelope). They also see your IP address, the times you publish and the sizes of your messages. MQTT client ids are a prefix plus random bytes, never the member id. Nobody without the room secret can read, forge or alter an envelope. A broker can drop, delay or replay envelopes: duplicates are dropped by id, and envelopes more than MaxAge (default 2 minutes) old or ahead are dropped. That also means a machine whose clock is more than 2 minutes off can't signal at all.

Codes. A room code is the secret. Anyone who has it can join that room, read its signaling and send envelopes into it. Treat it like a one-use password. About 79 bits, behind scrypt, makes guessing one from a captured topic or message impractical. It does not stop someone who saw the code.

What go-room authenticates.

  • Envelopes: membership of the room, meaning the sender holds the room key. Envelope.From is whatever a member claims. It is not proof of who sent it.
  • Peers: every connection starts with an auth message in each direction on the message channel, before anything else:
    auth { v:1, deviceId, devicePub, sig }
    sig = ed25519(identity key, "go-room/auth/1" || 0x00 || role ('O' offerer | 'A' answerer)
                  || sha256(offerer's DTLS cert) || sha256(answerer's DTLS cert)
                  || sha256(topic) || len(deviceId) || deviceId)
    
    The fingerprints are read from the connected DTLS transport, not from the SDP that crossed the broker. Each side checks the other's signature against the devicePub it sent, and checks that deviceId is the id signaling used. Then it asks your Accept(peer, pub). The two sides exchange the verdicts ({v:1, ok}), and the peer is up only when both accepted, so the app's first message on Messages() is the third on the channel. Accept runs inside the 10 s handshake and gets no fingerprints: a pairing SAS runs after OnPeer, over Messages(), and until it matches the app should treat the peer as unconfirmed. The auth message also carries a random per-connection token (see below). A relayed or replayed signature is useless: it names this connection's DTLS certificates, whose private keys are fresh for every connection and never leave the two machines.

What a room-key holder can still do. Anyone with the room secret (a code holder during a pairing, or a device that was revoked but still has an old key while that topic stays open) can read every member's SDP, and so their candidate addresses, and see who is present. They can spoil attempts that haven't finished the handshake: a forged rtc.answer that wins the race, or a forged rtc.bye with the attempt's session id, costs that attempt and a backoff. They can't take down a connection that is up: a newer rtc.offer is answered beside it and replaces it only after the new connection passes the handshake, so it needs that member's device key and your Accept; an offer not newer on the offerer's clock than the live one's is dropped (that also catches a broker replaying an old offer); rtc.want closes an up connection only when ICE has stopped hearing the peer; and rtc.bye closes it only when it carries the token from that side's auth message, which travelled inside DTLS.

What it doesn't authenticate.

  • Whether a key belongs to who you think. go-room proves "this connection is held by whoever holds the private key for pub". Accept is where you decide whether that pub may be peer: check it against a key you pinned or certified earlier.
  • A first contact. When two sides have no keys for each other yet, as in a pairing, the code holder in the middle problem is yours to close. Someone who knows the code can sit between the two sides and run two DTLS sessions. Compare a short authentication string over both keys and Peer.Fingerprints() on both screens. They differ under a man in the middle.
  • Signaling metadata, such as who is present. Presence is just envelopes, so any room member can claim to be anyone there. Only a peer that passes the handshake is authenticated.
  • Traffic analysis by brokers, STUN servers or a TURN relay. A TURN relay sees encrypted DTLS packets and both ends' addresses.

Example

secret, _ := room.SecretFromCode("myapp-room-v1", code)
sig, _ := room.OpenSignal(secret, self, room.SignalOptions{
    ClientPrefix: "myapp-",
    OnConnect:    func() { announce() },                          // sig.Send("", "hello", nil)
    OnMsg:        func(e room.Envelope) { mesh.Connect(e.From) }, // on "hello"
})
relay := room.NewRelay(room.RelayConfig{CredentialURL: credURL}, self, nil)
relay.Start()
mesh, _ := room.NewMesh(sig, room.Config{
    Self:     self,
    Identity: room.KeyIdentity(privateKey),
    Accept:   func(peer string, pub ed25519.PublicKey) error { return checkKnown(peer, pub) },
    ICE:      relay.Servers,
})
mesh.OnPeer(func(p *room.Peer) {
    go func() {
        for {
            msg, err := p.Messages().Recv(ctx)
            if err != nil {
                return // p went down; OnPeerDown says why
            }
            handle(p.ID(), msg)
        }
    }()
    p.OnStream(func(label string, s *room.Stream) { serve(label, s) })
})

See example_test.go for a version that compiles, and mesh_test.go for two peers in one process over MemHub and loopback.

Notes

  • One Mesh per Signaler. To listen on two topics at once, for example while a key rotates, open two signalers and two meshes. Whether two members that both reached the new topic also stay connected on the old one is the app's choice (Allow).
  • Secret.Topic is app + "/" + hex. An app with an existing wire format can rewrite the prefix and keep the suffix. Toru's is toru/v1/.
  • Config.ICE nil means DefaultSTUN; a func that returns nil means no servers.
  • pion never reuses a data channel id within one connection, so a connection can open about 32,000 streams in its lifetime.

Checks (run locally - no CI)

gofmt -l .                              # prints nothing
go vet ./...
go test ./... -count=1 -timeout 300s
GOOS=darwin GOARCH=arm64 go build ./...
GOOS=linux go build ./...

Tests run on 127.0.0.1 only: MemHub signaling, LoopbackOnly peers, and the credential endpoint on httptest. They never contact the public brokers or STUN servers, and they never bind a LAN address, so a fresh test binary doesn't raise a Windows Firewall prompt.

Licence

MIT. See LICENSE.

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 {}
}

Index

Examples

Constants

View Source
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.

View Source
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.

View Source
const (
	DefaultMaxMessage     = 4 << 20
	DefaultMaxStreams     = 4
	DefaultAuthTimeout    = 10 * time.Second
	DefaultConnectTimeout = 30 * time.Second
)

Defaults for Config's zero values.

View Source
const CodeLen = 16

CodeLen is the number of symbols in a room code: 16 of 31 is about 79 bits.

View Source
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.

View Source
const RTCPrefix = "rtc."

RTCPrefix starts the envelope types Mesh owns ("rtc.want", "rtc.offer", "rtc.answer", "rtc.bye"). They never reach SignalOptions.OnMsg.

View Source
const SyncLabel = "sync"

SyncLabel is the label of every peer's message channel (pre-negotiated, stream id 0). OpenStream refuses it.

Variables

View Source
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.

View Source
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.

View Source
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.

View Source
var ErrClosed = errors.New("room: closed")

ErrClosed is returned by anything used after Close.

View Source
var ErrNotConnected = errors.New("not connected to the room yet")

ErrNotConnected is Send's error while no broker is connected.

View Source
var ErrRefused = errors.New("room: refused by peer")

ErrRefused means the other side's Accept turned this side down.

View Source
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.

View Source
var ErrTooBig = errors.New("room: message too big")

ErrTooBig is returned for a message over the channel's cap.

Functions

func CleanICEURLs

func CleanICEURLs(in []string) []string

CleanICEURLs keeps the well-formed stun:, turn: and turns: entries.

func Decode

func Decode[T any](env Envelope) (T, error)

Decode unmarshals an envelope's body.

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

func NormalizeCode(in string) (string, bool)

NormalizeCode accepts a code typed or pasted any way (lower case, spaces, missing dashes) and returns its canonical form.

func WebRTCServers

func WebRTCServers(list []ICEServer) []webrtc.ICEServer

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.

func (*ByeError) Error

func (e *ByeError) Error() string

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.

func (*Channel) Recv

func (c *Channel) Recv(ctx context.Context) ([]byte, error)

Recv returns the next whole message.

func (*Channel) Send

func (c *Channel) Send(ctx context.Context, msg []byte) error

Send sends one whole message. It blocks while the channel is backed up. If ctx ends part way through a message the channel can't be resynced, so the peer is closed.

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

type Identity struct {
	Pub  ed25519.PublicKey
	Sign func(msg []byte) []byte
}

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.

func NewMemHub

func NewMemHub() *MemHub

NewMemHub returns an empty hub.

func (*MemHub) Open

func (h *MemHub) Open(s Secret, self string, o SignalOptions) Signaler

Open joins the room for s as member self. OnConnect fires once, at once.

func (*MemHub) Publish

func (h *MemHub) Publish(topic string, payload []byte)

Publish delivers a raw payload on topic as a broker would, for tests that replay or forge traffic.

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 NewMesh

func NewMesh(sig Signaler, cfg Config) (*Mesh, error)

NewMesh starts handling rtc.* envelopes on sig.

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

func (m *Mesh) Connect(peer string)

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

func (m *Mesh) OnPeer(fn func(*Peer))

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

func (m *Mesh) OnPeerDown(fn func(peer string, err error))

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.

func (*Mesh) Peer

func (m *Mesh) Peer(id string) *Peer

Peer returns the connected peer with id, or nil.

func (*Mesh) Peers

func (m *Mesh) Peers() []*Peer

Peers returns every connected peer.

type Peer

type Peer struct {
	// contains filtered or unexported fields
}

Peer is one authenticated connection to another member.

func (*Peer) Close

func (p *Peer) Close(reason string)

Close closes the connection and every stream on it, and tells the peer why with an rtc.bye.

func (*Peer) Done

func (p *Peer) Done() <-chan struct{}

Done is closed once the peer is down.

func (*Peer) Err

func (p *Peer) Err() error

Err is why the peer went down (nil while up).

func (*Peer) Fingerprints

func (p *Peer) Fingerprints() (local, remote string)

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) ID

func (p *Peer) ID() string

ID is the peer's member id.

func (*Peer) Messages

func (p *Peer) Messages() *Channel

Messages is the peer's one reliable, ordered message channel.

func (*Peer) OnStream

func (p *Peer) OnStream(fn func(label string, s *Stream))

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

func (p *Peer) OpenStream(ctx context.Context, label string) (*Stream, error)

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

func (p *Peer) Path() string

Path is "relay" when the selected ICE pair goes through TURN, "direct" otherwise ("" before a pair is selected).

func (*Peer) Pub

func (p *Peer) Pub() ed25519.PublicKey

Pub is the ed25519 key the peer proved it holds.

func (*Peer) RTT

func (p *Peer) RTT() time.Duration

RTT is the latest ICE round-trip time on the nominated pair (0 if none measured yet).

func (*Peer) Session

func (p *Peer) Session() string

Session is this connection's id (fresh for every attempt).

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

func (r *Relay) Servers() []ICEServer

Servers is the ICE list right now: STUN, then TURN while the credentials haven't expired. Pass it as Config.ICE.

func (*Relay) Start

func (r *Relay) Start()

Start begins fetching and refreshing credentials; a no-op with no CredentialURL. It never blocks.

func (*Relay) Status

func (r *Relay) Status() RelayStatus

Status returns the relay's state.

func (*Relay) Stop

func (r *Relay) Stop()

Stop ends the refresh loop and waits for it.

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

type Secret struct {
	Key   [32]byte
	Topic string
}

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

func SecretFromCode(app, code string) (Secret, error)

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

func SecretFromKey(app string, ikm, info []byte) Secret

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

func (s *Stream) Close() error

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.

func (*Stream) Label

func (s *Stream) Label() string

Label is the label the opener gave the stream.

func (*Stream) Read

func (s *Stream) Read(p []byte) (int, error)

Read reads bytes the other side wrote; io.EOF once it closed.

func (*Stream) Write

func (s *Stream) Write(p []byte) (int, error)

Write writes all of p, blocking while the channel is backed up. Close (or the peer going down) ends a blocked Write with ErrClosed.

func (*Stream) WriteContext

func (s *Stream) WriteContext(ctx context.Context, p []byte) (int, error)

WriteContext is Write that also gives up when ctx ends. What was written before that stays written; the stream stays usable.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL