freehop 0.1.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +204 -0
- package/LICENSE +202 -0
- package/LICENSE-docs +396 -0
- package/NOTICE +34 -0
- package/PROTOCOL.md +310 -0
- package/README.md +221 -0
- package/SDK.md +113 -0
- package/bin/freehop-gate.mjs +51 -0
- package/deploy/Caddyfile.snippet +6 -0
- package/deploy/freehop-gate.service +46 -0
- package/package.json +79 -0
- package/src/client/crypto.mjs +49 -0
- package/src/client/gate-client.mjs +95 -0
- package/src/client/ice-urls.mjs +14 -0
- package/src/client/peer.mjs +272 -0
- package/src/client/peerlane.mjs +6 -0
- package/src/client/room.mjs +1188 -0
- package/src/client/tracker-client.mjs +123 -0
- package/src/electron/main.mjs +70 -0
- package/src/electron/preload.cjs +17 -0
- package/src/gate/gate.mjs +357 -0
- package/src/gate/stun-responder.mjs +52 -0
- package/src/relay/agent.mjs +230 -0
- package/src/relay/member.mjs +169 -0
- package/src/relay/port-mapper.mjs +1058 -0
- package/src/relay/turn-server.mjs +790 -0
- package/src/sdk/authority.mjs +85 -0
- package/src/sdk/client.mjs +113 -0
- package/src/sdk/host.mjs +72 -0
- package/src/sdk/ticket.mjs +29 -0
- package/src/shared/stun.mjs +282 -0
- package/src/shared/tokens.mjs +35 -0
package/PROTOCOL.md
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
# Freehop protocol, version 1
|
|
2
|
+
|
|
3
|
+
Freehop connects small groups with audio, video and data. `maxPeers` defaults to eight other participants; larger supported sizes require benchmarks. Its operator invariant:
|
|
4
|
+
**no server that the application operator runs ever carries media.** Media goes peer-to-peer.
|
|
5
|
+
When no direct route exists, it goes through a machine that belongs to the same session —
|
|
6
|
+
a participant, a participant's own gateway or the session's host node. Volunteers may be
|
|
7
|
+
added by configuration. The operator's servers ("gates") only introduce peers.
|
|
8
|
+
|
|
9
|
+
This document is normative for the wire formats and behaviour. `README.md` covers usage and
|
|
10
|
+
measured results.
|
|
11
|
+
|
|
12
|
+
## 1. Roles
|
|
13
|
+
|
|
14
|
+
| Role | Runs on | Carries | Never carries |
|
|
15
|
+
|---|---|---|---|
|
|
16
|
+
| **Gate** | Any server (operator, community, anyone). Any number per room. | Sealed signalling envelopes (opaque bytes), STUN Binding answers | Media, plaintext SDP/candidates/credentials |
|
|
17
|
+
| **Peer** | Browser or desktop renderer (WebRTC) | Its own media, plus forwarded media when bridging (§9) | — |
|
|
18
|
+
| **Gateway** | A peer's own machine (desktop app main process) or the session host node; TURN server + router port mapping | Relayed packets of the session's peers, DTLS-SRTP encrypted end to end | Anything for peers outside its session (credentials are per room and per peer) |
|
|
19
|
+
| **Gateway member** | The session's host node (a participant's desktop app, or a community server hosting the session) | Joins the room through gates without media and offers its gateway | Media of its own |
|
|
20
|
+
|
|
21
|
+
Cost rule: a byte of media only crosses machines that belong to the call. These are the
|
|
22
|
+
two endpoints, another participant, or the session's own host. The operator's gate count
|
|
23
|
+
is bounded by construction (§4.5) and measured in every lab run.
|
|
24
|
+
|
|
25
|
+
## 2. Identifiers and keys
|
|
26
|
+
|
|
27
|
+
- **Room secret**: ≥128 bits, generated by the application's room service when a room is
|
|
28
|
+
created. It is handed to members together with the room information they already
|
|
29
|
+
receive. The app need not display it, but members can extract and share it. A share link is a bearer credential.
|
|
30
|
+
- `salt = "peerlane/v1/" + app`, where `app` namespaces applications.
|
|
31
|
+
- `roomTag = base64url(HKDF-SHA256(secret, salt, info="room-tag", 32 bytes))`. This is the
|
|
32
|
+
only room identifier a gate sees.
|
|
33
|
+
- `envelopeKey = HKDF-SHA256(secret, salt, info="envelope-key")` as an AES-256-GCM key.
|
|
34
|
+
- **Peer id**: 16 random bytes, base64url (22 characters), fresh for every join. Gateway
|
|
35
|
+
members use ids that start with `gw_` (22 characters in total). The prefix is a hint;
|
|
36
|
+
their role is only accepted from sealed caps.
|
|
37
|
+
|
|
38
|
+
Kicking a member means the application issues a new room secret to the remaining members.
|
|
39
|
+
Every remaining member closes old-key links and reconnects under the new secret; previous-key envelopes are rejected immediately. Host nodes and desktop gateways revoke the entire old room epoch, including aliases. Rotation briefly interrupts media. Update every remaining member and host; any machine still using the old secret can communicate with its holders.
|
|
40
|
+
|
|
41
|
+
## 3. Sealed envelopes
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
box = base64url( iv[12] || AES-256-GCM(envelopeKey, iv, plaintext, aad) )
|
|
45
|
+
aad = "peerlane/v1|" + roomTag + "|" + from + "|" + to
|
|
46
|
+
plaintext = JSON { kind, n, ...body }
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
- `to` is the recipient's peer id. The AAD binds room, sender and recipient. A gate that
|
|
50
|
+
re-labels or re-routes an envelope makes it fail authentication.
|
|
51
|
+
- `n` is a per-sender counter. Receivers keep a 1024-wide replay window per sender and drop
|
|
52
|
+
duplicates. The same envelope legitimately arrives via several gates and the mesh.
|
|
53
|
+
- Kinds: `caps`, `description`, `candidate`, `bye`, `restart-request`, `video-quality`, `bridge-request`,
|
|
54
|
+
`bridge-offer`, `bridge-accept`, `bridge-confirm`, `bridge-ready`, `bridge-active`, `bridge-release`, `bridge-fail`,
|
|
55
|
+
`forward-map`, `forward-unmap`, `app`. Unknown kinds are dropped. `app` carries application data
|
|
56
|
+
(`session.send`): at most 4096 characters of JSON, and each receiver accepts at most 20 per
|
|
57
|
+
second from one sender (burst 40). Tracker gates add a
|
|
58
|
+
room-broadcast `hello` (to `*`).
|
|
59
|
+
- **Adaptive video request.** A receiver that opted in sends `{kind:"video-quality", level:"normal"|"reduced"|"minimal"|"paused"}` to the peer sending video. Requests are sealed peer-to-peer envelopes, ordered by the envelope counter and rate-limited to one accepted change per second. `normal` releases the request; other levels cap or pause video on this link only. Audio is never changed. A peer that did not opt in ignores requests, and older implementations drop the unknown kind.
|
|
60
|
+
- **Admission.** Gate rosters and arrival events are unauthenticated hints. A hint only makes a
|
|
61
|
+
peer greet the hinted id with its sealed caps, at most once per 20 s and for at most 64
|
|
62
|
+
hints. A peer becomes a member, with `peer` events, a link and a slot, only after an
|
|
63
|
+
envelope from it authenticates. A gate cannot authenticate an invented member, but can still prevent discovery or disrupt recovery by dropping traffic. Roster presence alone does not prove membership.
|
|
64
|
+
|
|
65
|
+
## 4. Gate protocol (WebSocket, UTF-8 JSON text frames)
|
|
66
|
+
|
|
67
|
+
### 4.1 Messages
|
|
68
|
+
|
|
69
|
+
| Direction | Message | Notes |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| C→G | `{"t":"hello","v":1,"auth"?:string}` | Must be the first frame, within 5 s |
|
|
72
|
+
| G→C | `{"t":"welcome","v":1,"stun":[url],"limits":{...}}` | Informational only; clients use application/ticket STUN configuration |
|
|
73
|
+
| C→G | `{"t":"join","room":tag,"peer":id}` | At most 4 rooms per socket |
|
|
74
|
+
| G→C | `{"t":"peers","room":tag,"peers":[id]}` | Current members |
|
|
75
|
+
| G→C | `{"t":"peer","room":tag,"peer":id,"on":bool}` | Arrivals and departures |
|
|
76
|
+
| C→G | `{"t":"send","room":tag,"to":id or "*","box":string}` | The box is opaque base64url |
|
|
77
|
+
| G→C | `{"t":"recv","room":tag,"from":id,"box":string}` | `from` is the socket's own peer id |
|
|
78
|
+
| C→G | `{"t":"leave","room":tag}` / `{"t":"ping"}` | |
|
|
79
|
+
| G→C | `{"t":"error","code":string}` | `schema`, `rate`, `auth`, `auth-expired`, `busy`, `room-full`, `room-not-allowed`, `no-such-peer`, `not-joined`, … |
|
|
80
|
+
|
|
81
|
+
### 4.2 Admission
|
|
82
|
+
A gate is either open or token-gated. A token is
|
|
83
|
+
`base64url(JSON claims) "." base64url(HMAC-SHA256(gateSecret, body))` with claims
|
|
84
|
+
`{exp, room?, aud}`. A token with `room` may only join that room tag. An application mints room-bound tokens for its admitted members. Tokens require a future safe-integer `exp`, and both parts must be canonical unpadded base64url. An expired socket is closed at its next request, heartbeat or delivery, whichever comes first. A client renews its token for the same epoch with `session.refresh(ticket)`; the new token is used at the next (re)connect.
|
|
85
|
+
|
|
86
|
+
### 4.3 Multiple gates and tracker gates
|
|
87
|
+
Clients connect to every gate in their list and announce on each. Peers that share no gate
|
|
88
|
+
still meet through the mesh: §6 introduction. Established calls do not depend on any
|
|
89
|
+
gate. Lab evidence shows all gates shut down while media continues.
|
|
90
|
+
|
|
91
|
+
A gate URL `bt+wss://…` names a public WebTorrent tracker used as a gate.
|
|
92
|
+
- `info_hash` is the first 20 ASCII characters of the hex-encoded, decoded room tag (80 bits of tag material), and `peer_id` is a random
|
|
93
|
+
20-character ASCII string.
|
|
94
|
+
- Hellos travel as announced offers, `"pl1:<peer id>:<sealed hello>"`. Envelopes travel as
|
|
95
|
+
answers addressed by `to_peer_id`.
|
|
96
|
+
- A peer's tracker address is bound only after its envelope authenticates. Hellos carry the
|
|
97
|
+
sender's counter and fall under the replay window.
|
|
98
|
+
|
|
99
|
+
### 4.4 Liveness and proxies
|
|
100
|
+
- Clients send `{"t":"ping"}` every 30 s, and the gate also pings at the WebSocket level.
|
|
101
|
+
Each gate ping carries a fresh random nonce; only a pong that echoes it counts as activity.
|
|
102
|
+
- Output the gate has handed to a socket but not yet flushed is bounded: 1 MiB per socket
|
|
103
|
+
and 32 MiB for the whole gate. Over the gate-wide budget, the receiver that has been
|
|
104
|
+
behind the longest is closed first.
|
|
105
|
+
- Behind a local reverse proxy (`trustProxy`), per-address limits use the last
|
|
106
|
+
`X-Forwarded-For` hop.
|
|
107
|
+
- After a network change a join can briefly meet `peer-taken`; clients retry with backoff.
|
|
108
|
+
|
|
109
|
+
### 4.5 STUN
|
|
110
|
+
A gate may answer RFC 8489 Binding requests on UDP, IPv4 and IPv6. It answers Binding
|
|
111
|
+
only, rate-limits per source address and globally, and never relays.
|
|
112
|
+
|
|
113
|
+
### 4.6 Bounds
|
|
114
|
+
A gate frame is at most 64 KiB and a box at most 48 KiB. Each socket gets a token bucket
|
|
115
|
+
over the bytes it makes the gate forward, fan-out included: 512 KiB burst, 2 KiB/s
|
|
116
|
+
refill. Each socket is also limited to 40 messages/s (burst 400). Further limits: 16 peers
|
|
117
|
+
per room, 32 sockets per source address (an IPv6 /64 counts as one address), 1024
|
|
118
|
+
admitted sockets, and a 90 s idle timeout. Sockets that have not been admitted yet have
|
|
119
|
+
their own budget (256 in total, 8 per address, 64 frames or 64 KiB queued while admission
|
|
120
|
+
is pending), so silent sockets cannot lock admitted clients out; refused and timed-out
|
|
121
|
+
sockets are torn down within 1 s. These limits make media tunnelling through a gate
|
|
122
|
+
impractical, while one five-peer join needs about 100 KB.
|
|
123
|
+
|
|
124
|
+
## 5. Capabilities (`caps` envelope)
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
{ v:1, role?:"gateway", forward:bool, peers:[id], gateway: null |
|
|
128
|
+
{ urls:["turn:host:port?transport=udp", "turn:host:port?transport=tcp"],
|
|
129
|
+
username, credential, external:[ip], internal: ip|null } }
|
|
130
|
+
```
|
|
131
|
+
- `peers` lists the sender's connected links. It is used for mesh routing and introductions.
|
|
132
|
+
- `forward` means the sender may bridge other pairs (§9).
|
|
133
|
+
- `gateway` holds credentials minted **for the recipient** (§8.2). A gateway member sets
|
|
134
|
+
`role:"gateway"`.
|
|
135
|
+
|
|
136
|
+
A peer sends caps to every peer it discovers (through gates), on every new data channel,
|
|
137
|
+
and when its link set changes. It also embeds caps in every `description` envelope.
|
|
138
|
+
|
|
139
|
+
Gate admission tokens include `aud`, the exact gate URL, and `room`. Tickets carry an `auth` map keyed by URL. Each gate rejects another audience even when signing keys are shared. Behind a reverse proxy configure the public `tokenAudience`. Clients never apply `welcome.stun`; the application or ticket pins its own STUN list.
|
|
140
|
+
|
|
141
|
+
## 6. Signalling transport
|
|
142
|
+
|
|
143
|
+
1. Every link carries a pre-negotiated data channel (`negotiated:true, id:0`, label
|
|
144
|
+
`peerlane`). Once it is open, all envelopes for that peer go over it as
|
|
145
|
+
`{"t":"env","from","to","box"}`.
|
|
146
|
+
2. Otherwise the envelope is sent through every gate on which the recipient is present.
|
|
147
|
+
3. Otherwise it goes through a connected neighbour whose caps list the recipient:
|
|
148
|
+
`{"t":"env",...,"hop":1}`. The neighbour forwards it once as `hop:2`, limited to 600
|
|
149
|
+
per minute per link. This is how peers introduce each other without a common gate.
|
|
150
|
+
4. Otherwise it waits in an outbox: 32 envelopes per peer, for 20 s. The outbox is flushed
|
|
151
|
+
when the peer appears on a gate or its link opens.
|
|
152
|
+
|
|
153
|
+
A data channel is trusted alone only while its link is connected. A dead connection's
|
|
154
|
+
channel can read `open` for tens of seconds, so gates carry the envelope as well.
|
|
155
|
+
|
|
156
|
+
## 7. Path ladder (per pair)
|
|
157
|
+
|
|
158
|
+
Negotiation follows W3C *perfect negotiation*. The peer with the lexicographically greater
|
|
159
|
+
id is *polite*. A link created to answer an incoming offer suppresses its own first offer.
|
|
160
|
+
A suppressed `negotiationneeded` is re-checked once the state is stable again.
|
|
161
|
+
|
|
162
|
+
| Phase | ICE servers | Expected route | Leaves the phase when |
|
|
163
|
+
|---|---|---|---|
|
|
164
|
+
| 0 ENDPOINT | Application-approved STUN + own gateway (internal URL) + remote peer's gateway | host/LAN, IPv6, srflx/prflx, or one endpoint's own gateway | not connected 5 s after the description, with one grace period if checks receive answers |
|
|
165
|
+
| 1 SESSION | + gateways of connected participants and of gateway members (max 2 added) | `relay` through a session gateway | 7 s without connection, with one extra 7 s grace period if checks receive answers |
|
|
166
|
+
| 2 BRIDGED | unchanged | media forwarded by a connected participant (§9) | the direct or session route later succeeds |
|
|
167
|
+
| unreachable | unchanged | none (no bridge candidate) | ICE restart retries with exponential backoff (30 s … 300 s) |
|
|
168
|
+
|
|
169
|
+
- Escalation adds gateways with `setConfiguration`. Only the impolite side restarts ICE.
|
|
170
|
+
The polite side applies the same servers and takes over after `restartFallbackMs`
|
|
171
|
+
(2.5 s) if no restart offer arrived. Simultaneous restarts collide, and a rolled-back
|
|
172
|
+
restart offer was observed to leave Chromium senders silent (lab finding, §12).
|
|
173
|
+
- Descriptions carry `phase` and `gateways` (the ids of extra gateways in use). The receiver
|
|
174
|
+
adds those servers **before** answering, so both sides gather against the same relays.
|
|
175
|
+
- Candidates for an unknown ICE generation are buffered until the matching description
|
|
176
|
+
arrives: at most 128, oldest evicted first.
|
|
177
|
+
- Descriptions apply in envelope-counter order. An older one never overtakes a newer one,
|
|
178
|
+
whatever route each took.
|
|
179
|
+
- An offer that receives no answer within 8 s is rolled back and sent again, and an ICE
|
|
180
|
+
restart is re-requested, so a lost envelope cannot deadlock the pair.
|
|
181
|
+
- Media watchdog: if a connected link's live track sends no packets for two 5 s checks, it is
|
|
182
|
+
restarted. The polite side sends `restart-request` instead of restarting itself.
|
|
183
|
+
- Departures: a member that has been absent from every gate for 8 s, and whose link is not
|
|
184
|
+
connected, is dropped as `gone`. Its replay window is kept, and it may return by
|
|
185
|
+
authenticating again. `bye` and kicks are final.
|
|
186
|
+
- On `disconnected` the link waits 4 s, then restarts. On `failed` it restarts immediately.
|
|
187
|
+
Both follow the single-initiator rule.
|
|
188
|
+
|
|
189
|
+
Path classification uses the selected candidate pair: `direct` (no relay on either side),
|
|
190
|
+
`gateway` (relay owned by one endpoint), `relay` (relay owned by another session member),
|
|
191
|
+
`bridged` (§9) or `unreachable`. A local peer-reflexive candidate whose `relayProtocol` is
|
|
192
|
+
set still rides a relay.
|
|
193
|
+
|
|
194
|
+
## 8. Gateways
|
|
195
|
+
|
|
196
|
+
### 8.1 Reachability
|
|
197
|
+
The gateway runs a TURN server (RFC 8656 subset: UDP relay allocations over UDP and TCP
|
|
198
|
+
client transports) on the peer's LAN address. It asks the router for mappings: PCP (RFC 6887),
|
|
199
|
+
then NAT-PMP (RFC 6886), then UPnP IGD v1/v2. It requests the same external port as the internal
|
|
200
|
+
one, for the listener (UDP and TCP) and for each relayed port on demand. A host with a public
|
|
201
|
+
address skips mapping. If the router reports a private or CGNAT external address, the gateway
|
|
202
|
+
is not offered.
|
|
203
|
+
|
|
204
|
+
### 8.2 Credentials (TURN REST convention, room-scoped)
|
|
205
|
+
`username = "<unix expiry>:<room tag[0..8]>:<label>"`,
|
|
206
|
+
`credential = base64(HMAC-SHA1(secret, username))`. `label` is the recipient's peer id, or
|
|
207
|
+
`self` for the owner's own allocations via the internal URL.
|
|
208
|
+
- The signing key stays in the privileged gateway process. `info()` exposes no key; an origin- and room-checked broker returns bounded credentials to the renderer.
|
|
209
|
+
- The gateway accepts only rooms it was told to serve (`allowRoom`). Individual peers can be
|
|
210
|
+
revoked on kick or leave (`revokePeer`), which tears down their allocations. A whole room
|
|
211
|
+
is revoked on key rotation or when the session ends, tearing down every old allocation.
|
|
212
|
+
- Credentials are issued only to authenticated members, never in response to a hint.
|
|
213
|
+
- Default TTL is 2 h, renewed at half-life; configurable from 1 second to 24 hours. The server rejects even correctly signed expiry values beyond that configured horizon.
|
|
214
|
+
- Peer revocations are kept per room (up to 256) until previously minted credentials expire. A room that exceeds its bound is revoked as a whole; other rooms are never affected. A host member only revokes peers it issued credentials to.
|
|
215
|
+
- Revoking a room records a floor: credentials minted before it are refused even if the same room is allowed again later.
|
|
216
|
+
|
|
217
|
+
### 8.3 Policy and hairpinning
|
|
218
|
+
- **A gateway relays only inside the session.** With its default `relayScope: 'internal'`,
|
|
219
|
+
a gateway relays only between allocations on itself and refuses every other destination
|
|
220
|
+
(403). Every path in §7 meets on the same gateway, including the owner's own `self`
|
|
221
|
+
allocation, so credentials cannot be used to send traffic to other internet hosts.
|
|
222
|
+
`relayScope: 'public'` restores general-purpose TURN behaviour; use it only when every
|
|
223
|
+
member is trusted.
|
|
224
|
+
- The TURN server's general peer policy (`peerScope: 'public'`) refuses loopback,
|
|
225
|
+
link-local, multicast and private peers, plus 6to4, Teredo, local-use NAT64, 192.0.0.0/24,
|
|
226
|
+
198.18.0.0/15 and site-local prefixes, and unwraps IPv4-mapped, IPv4-translated and NAT64
|
|
227
|
+
forms before deciding.
|
|
228
|
+
- The gateway machine's own addresses are reachable only relay↔relay. Services on the host
|
|
229
|
+
stay unreachable.
|
|
230
|
+
- Traffic between two allocations on the same gateway is delivered internally. Many home
|
|
231
|
+
routers lack NAT hairpinning, and the owner's own `self` allocation therefore reaches
|
|
232
|
+
remote allocations without it.
|
|
233
|
+
- The TURN server answers unauthenticated UDP at a bounded rate per source and globally, to
|
|
234
|
+
bound reflection. Binding answers and challenge or error answers have separate global
|
|
235
|
+
budgets, and addresses that authenticated in the last 10 minutes are exempt from the global
|
|
236
|
+
budgets (not from their per-source limit), so a flood cannot starve renewals.
|
|
237
|
+
- It bounds TCP connections per source address (16), with a 10 s absolute pre-authentication deadline, unaffected by incoming bytes. A TCP connection closes when its allocation ends.
|
|
238
|
+
- Media stays DTLS-SRTP encrypted end to end. The gateway relays ciphertext.
|
|
239
|
+
|
|
240
|
+
## 9. Participant bridging
|
|
241
|
+
|
|
242
|
+
When a pair (A,B) has no route, and no untried session gateway, the lower id asks
|
|
243
|
+
connected participants that report a link to the other peer (`bridge-request`). The first
|
|
244
|
+
`bridge-offer` wins (`bridge-accept`). Forwarder C then:
|
|
245
|
+
|
|
246
|
+
1. asks B with `bridge-confirm` and waits for its `bridge-ready`; B validates that C is eligible and its A link is still unreachable;
|
|
247
|
+
2. sends `bridge-active` to both endpoints to establish the forwarding relationship;
|
|
248
|
+
3. sends `forward-map {origin, stream}` to each side before renegotiating;
|
|
249
|
+
4. adds the origin's received tracks to its link with the other side, which re-encodes them.
|
|
250
|
+
|
|
251
|
+
Receivers attribute tracks by stream id. When the direct route later connects, an endpoint sends
|
|
252
|
+
`bridge-release`. C removes the forwarded tracks, stopping their transceivers so SDP does not
|
|
253
|
+
grow, and sends `forward-unmap`. If C leaves or its link drops, the endpoints re-escalate.
|
|
254
|
+
|
|
255
|
+
Hardening:
|
|
256
|
+
- A forwarder accepts `bridge-accept` only for an offer it made to that endpoint, within
|
|
257
|
+
30 s, while forwarding is enabled and under its bridge limit.
|
|
258
|
+
- An endpoint accepts `bridge-active` only from the eligible forwarder it already consented to. Unsolicited and conflicting activations are released. Pending second-endpoint consent expires after 30 seconds. All three participants must run this consent handshake; upgrade clients together.
|
|
259
|
+
- `forward-map` is accepted only for an origin the receiver cannot reach itself, and only
|
|
260
|
+
from that pair's forwarder. A forwarder is a participant of the same call, so it
|
|
261
|
+
already receives both media streams. Bridging exposes nothing new to it. Forwarded video is
|
|
262
|
+
capped at 200 kbit/s.
|
|
263
|
+
|
|
264
|
+
## 9a. SDK roles
|
|
265
|
+
`SDK.md` describes how applications consume the protocol:
|
|
266
|
+
- an **authority** (application backend) issues per-member **tickets**
|
|
267
|
+
`{v, app, roomId, epoch, gates, secret, auth?, stun?, expires}` and rotates the room on kick;
|
|
268
|
+
- clients `connect(ticket)` and `update(newTicket, {dropped})`;
|
|
269
|
+
- hosts run `hostSession(ticket)` (gateway member);
|
|
270
|
+
- desktop apps expose their gateway through the Electron preload (`window.freehopGateway`,
|
|
271
|
+
restricted by exact HTTPS origins (HTTP only on loopback) in both the preload and main-process IPC handlers; subframes are rejected).
|
|
272
|
+
|
|
273
|
+
## 10. Media profile (defaults)
|
|
274
|
+
- Audio: Opus, echo cancellation, noise suppression and AGC, max 32 kbit/s.
|
|
275
|
+
- Video: 640×360 at up to 24 fps, max 300 kbit/s per link.
|
|
276
|
+
- Muting the microphone disables the track (no renegotiation). Turning the camera off
|
|
277
|
+
stops the track and replaces it with `null`.
|
|
278
|
+
- Mesh upstream at 5 participants with video ≈ 4 × 332 kbit/s before overhead.
|
|
279
|
+
|
|
280
|
+
## 11. Security considerations
|
|
281
|
+
- **Gates** learn: socket IP addresses, opaque room tags and peer ids, envelope sizes and
|
|
282
|
+
timing. They cannot read SDP, ICE candidates, caps or credentials. They cannot forge or
|
|
283
|
+
re-route envelopes without detection, and cannot inject peers into a room. A hostile gate
|
|
284
|
+
can drop or delay traffic; using several gates mitigates that.
|
|
285
|
+
- **Room members** are mutually trusted for signalling: any member can forge envelopes in
|
|
286
|
+
the room's name. Per-peer signatures are a possible v2 addition. Removal requires a secret
|
|
287
|
+
rotation (§2). SDK ticket expiry does not erase copies of the shared secret or end established media; it is not a replacement for rotation.
|
|
288
|
+
- **Gateways**: credentials are per peer and short-lived. The peer policy prevents use as a
|
|
289
|
+
proxy into private networks. Quotas: 6 allocations per username, 4 Mbit/s per
|
|
290
|
+
allocation, 32 allocations and 20 Mbit/s per room, 40 Mbit/s in total, separately per direction; at most 64 allocations overall. Room quotas aggregate every alias in that room; global limits still apply. By default a gateway relays only between allocations on itself, so a member cannot use its credentials to reach other internet hosts (§8.3). Expiry blocks further authenticated requests; existing allocations may continue until their granted lifetime ends. Revocation ends them immediately, and a participant's desktop gateway releases a room when the participant leaves.
|
|
291
|
+
- **IP privacy**: direct paths expose network addresses to other participants. Gate and tracker operators also see connecting IP addresses. The SDK does not provide an IP-anonymity mode.
|
|
292
|
+
|
|
293
|
+
## 12. Known limits
|
|
294
|
+
- A network that permits traffic only to the gate host cannot carry media without the gate
|
|
295
|
+
operator carrying it. Freehop reports `unreachable`. A gate operator who *chooses* to also
|
|
296
|
+
run a gateway (community gates) can serve such users; the application operator's own gates
|
|
297
|
+
do not.
|
|
298
|
+
- Two browser-only participants that are both behind hard NATs or UDP-blocking networks, with
|
|
299
|
+
no IPv6, no gateway in the session and no third participant, cannot connect. A session host node reachable by both endpoints can provide a TURN relay path.
|
|
300
|
+
- Chromium, lab finding: simultaneous ICE restarts (glare) intermittently left the polite
|
|
301
|
+
side's RTP senders silent after its restart offer was rolled back. Freehop avoids
|
|
302
|
+
simultaneous restarts (§7).
|
|
303
|
+
- Playwright 1.62's WebKit build rejects `?transport=` in TURN URLs (WebKit bug 320931). The
|
|
304
|
+
client detects this and degrades to UDP-only TURN URLs for that engine.
|
|
305
|
+
- Desktop apps can alternatively pin WebRTC's UDP port range
|
|
306
|
+
(`webContents.setWebRTCUDPPortRange`, Electron ≥ 28) and map it directly. Freehop's
|
|
307
|
+
gateway approach needs no Chromium cooperation.
|
|
308
|
+
|
|
309
|
+
---
|
|
310
|
+
Documentation licensed under CC BY 4.0. Copyright 2026 Jolyn Studios. Provided as is, without warranty of any kind.
|
package/README.md
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# Freehop
|
|
4
|
+
|
|
5
|
+
### Voice and video between your users, without your servers carrying the call.
|
|
6
|
+
|
|
7
|
+
**[Live demo](https://jolynstudios.github.io/freehop/demo)** ·
|
|
8
|
+
**[Documentation](https://jolynstudios.github.io/freehop/)** ·
|
|
9
|
+
**[Quickstart](#quickstart)** ·
|
|
10
|
+
**[Architecture](ARCHITECTURE.md)** ·
|
|
11
|
+
**[Protocol](PROTOCOL.md)**
|
|
12
|
+
|
|
13
|
+

|
|
14
|
+

|
|
15
|
+

|
|
16
|
+

|
|
17
|
+

|
|
18
|
+
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
Adding voice or video to a game or app comes with a hidden bill. WebRTC connects people
|
|
24
|
+
directly when it can. When it can't, because of strict NATs or firewalls that block UDP, the
|
|
25
|
+
standard answer is a **TURN relay that you run and pay for**. Every byte of those calls flows
|
|
26
|
+
through your servers. Historical measurements from 2015–2017 found relay use in roughly one fifth of calls or conferences on those services; they are not a current prediction
|
|
27
|
+
([callstats.io: ~22%](https://webrtchacks.com/usage-stats/),
|
|
28
|
+
[appear.in: ~17.7%](https://medium.com/@fippo/what-kind-of-turn-server-is-being-used-d67dbfc2ff5d)),
|
|
29
|
+
and managed TURN is billed per gigabyte (e.g.
|
|
30
|
+
[$0.05/GB](https://developers.cloudflare.com/realtime/turn/)).
|
|
31
|
+
|
|
32
|
+
**Freehop removes your servers from the media path entirely.** Your infrastructure only runs
|
|
33
|
+
small *gates* that introduce people to each other: tens of kilobytes of sealed signalling per
|
|
34
|
+
call, plus presence announcements and connection recovery. When a direct connection is impossible, the call hops through machines that
|
|
35
|
+
**already belong to the session**: a desktop participant's own router-mapped gateway, the machine
|
|
36
|
+
hosting the session, or another participant. The cost of a call stays with the people on it.
|
|
37
|
+
|
|
38
|
+
## Why Freehop
|
|
39
|
+
|
|
40
|
+
| | |
|
|
41
|
+
|---|---|
|
|
42
|
+
| 💸 **Zero media on your servers** | Gates carry sealed signalling only. In every lab run, gate traffic for an entire scenario stayed between 30 and 210 KB, with no media at all. |
|
|
43
|
+
| 🧱 **Connects the "impossible" pairs** | Two strict (symmetric) NATs, or a network that blocks UDP, can't connect directly. Freehop routes them through a session member's gateway or the session host, still with no operator relay. |
|
|
44
|
+
| 🛰️ **No single point of failure** | Run one gate or many, operated by you, your community, or public WebTorrent trackers. Peers on different gates still find each other. **Calls keep running when every gate is down.** |
|
|
45
|
+
| 🔐 **Private by construction** | Signalling is sealed (HKDF + AES-256-GCM), so gates can't read or forge envelopes. Direct and gateway paths preserve end-to-end DTLS-SRTP; a forwarding participant decodes and re-encodes media. |
|
|
46
|
+
| ⚙️ **Automatic** | No manual room link is required in a ticket-based integration. Your backend issues a ticket, and the SDK takes the cheapest path that works: direct, then gateway, then relay, then bridge. |
|
|
47
|
+
| 📦 **Small and open** | Zero-dependency browser client; a gate with one dependency (`ws`); TURN gateway and PCP / NAT-PMP / UPnP port mapping in plain Node. Apache-2.0. |
|
|
48
|
+
|
|
49
|
+
## How it works
|
|
50
|
+
|
|
51
|
+
```mermaid
|
|
52
|
+
flowchart LR
|
|
53
|
+
subgraph session["One session (a call)"]
|
|
54
|
+
A["🏠 Participant A<br/>(browser)"]
|
|
55
|
+
B["🏠 Participant B<br/>(phone, strict NAT)"]
|
|
56
|
+
H["🗼 Host node or<br/>desktop gateway"]
|
|
57
|
+
end
|
|
58
|
+
G["📮 Gate<br/>(your server)"]
|
|
59
|
+
A -. "sealed envelopes (KB)" .-> G
|
|
60
|
+
B -. "sealed envelopes (KB)" .-> G
|
|
61
|
+
A == "media: direct when possible" ==> B
|
|
62
|
+
A == "or via a session gateway" ==> H
|
|
63
|
+
H ==> B
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
1. **Your backend issues tickets.** `createAuthority()` owns each room's secret. A member gets a ticket over your own authenticated channel.
|
|
67
|
+
2. **Gates introduce.** Clients announce on every gate in the ticket and exchange sealed envelopes. Once linked, signalling moves onto the peers' own data channels. A gate can be your own small server or a public WebTorrent tracker: the envelopes ride in the offer and answer messages that trackers already pass between browsers ([how](https://jolynstudios.github.io/freehop/docs/concepts/trackers)).
|
|
68
|
+
3. **The path ladder finds a route.** Freehop tries direct first (LAN, IPv6, STUN). If that fails it uses an endpoint's own gateway, then a gateway of another session member, then forwarding through a participant. A failure is reported honestly as `unreachable`.
|
|
69
|
+
4. **Kicks rotate keys.** `authority.kick()` issues a new room secret; remaining members `update()` and drop the kicked peer.
|
|
70
|
+
|
|
71
|
+
## Proof, not promises
|
|
72
|
+
|
|
73
|
+
The original qualification ran in an isolated Linux lab with real **Chromium 151, Firefox 153 and WebKit 26.5**.
|
|
74
|
+
Each browser sits behind its own kernel NAT router profile, with fake camera and microphone.
|
|
75
|
+
Every run asserts the path taken and that audio *and* video actually arrive.
|
|
76
|
+
|
|
77
|
+
| Network situation | Result | Route Freehop chose |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| Two home routers | ✅ 3/3 | direct |
|
|
80
|
+
| IPv6 available, IPv4 UDP blocked | ✅ 3/3 (+ Firefox/WebKit) | direct over IPv6 |
|
|
81
|
+
| Two strict/symmetric NATs + a third participant | ✅ 3/3 (+ cross-browser) | forwarded by the participant |
|
|
82
|
+
| Strict NAT ↔ desktop participant behind a UPnP router | ✅ 3/3 (+ cross-browser) | the desktop's own gateway |
|
|
83
|
+
| Two strict NATs + a desktop participant | ✅ 3/3 (+ cross-browser) | relay through that participant's gateway |
|
|
84
|
+
| UDP-blocking firewall ↔ desktop participant | ✅ 3/3 (+ cross-browser) | gateway over TCP |
|
|
85
|
+
| Two UDP-blocked peers + a desktop participant | ✅ 3/3 | relay over TCP |
|
|
86
|
+
| Two strict NATs, session hosted on a server node | ✅ 3/3 (+ cross-browser) | the host node's gateway |
|
|
87
|
+
| Two UDP-blocked peers, server-hosted session | ✅ 3/3 | the host node's gateway |
|
|
88
|
+
| Two strict NATs and nobody else | ✅ 3/3 | `unreachable` (correctly reported) |
|
|
89
|
+
| A network that can only reach the gate | ✅ 3/3 | `unreachable` (no route exists without your server) |
|
|
90
|
+
|
|
91
|
+
**40/40 lab runs passed** on the current revision, re-run on 2 October 2026 after security hardening, including gateways that relay only inside their session. Other checks:
|
|
92
|
+
- **151/151 unit tests**, including the RFC 5769 STUN vectors and the security regressions.
|
|
93
|
+
- **coturn's own test client** against Freehop's TURN server: 800/800 messages over UDP and 800/800 over TCP, 0 lost.
|
|
94
|
+
- **Browser suites:** multi-gate with every gate shut down mid-call, kick/rekey, a public WebTorrent tracker as the only gate, and the SDK example app.
|
|
95
|
+
|
|
96
|
+
Full details are in [RESULTS.md](RESULTS.md).
|
|
97
|
+
|
|
98
|
+
## Example consumer: Redline Wars
|
|
99
|
+
|
|
100
|
+
[**Redline Wars**](https://redlinewars.online) is a real-time strategy game that runs in the
|
|
101
|
+
browser and as a desktop app ([source](https://github.com/jolynstudios/redlinewars)). It is
|
|
102
|
+
one planned consumer of Freehop. The game is integrating the independent SDK
|
|
103
|
+
**through the public SDK only**, exactly like any other app would:
|
|
104
|
+
|
|
105
|
+
- the match host acts as the session's gateway;
|
|
106
|
+
- desktop participants open their own front door through their router;
|
|
107
|
+
- browser participants simply join.
|
|
108
|
+
|
|
109
|
+
Freehop is an independent project; any app can use the same public SDK.
|
|
110
|
+
|
|
111
|
+
## Quickstart
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
npm install github:jolynstudios/freehop # npm registry release follows with 1.0
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
**Backend:** decide who is in a room.
|
|
118
|
+
```js
|
|
119
|
+
import { createAuthority } from 'freehop/authority';
|
|
120
|
+
const authority = createAuthority({
|
|
121
|
+
app: 'my-app',
|
|
122
|
+
gates: ['wss://example.com/freehop'],
|
|
123
|
+
gateTokenSecrets: { 'wss://example.com/freehop': process.env.FREEHOP_GATE_TOKEN_SECRET },
|
|
124
|
+
stun: ['stun:example.com:3478']
|
|
125
|
+
});
|
|
126
|
+
await authority.openRoom('room-42');
|
|
127
|
+
const ticket = await authority.ticket('room-42', userId); // send over your own channel
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
**Client:** join the call.
|
|
131
|
+
```js
|
|
132
|
+
import { connect } from 'freehop';
|
|
133
|
+
const session = await connect(ticket, { media: { audio: true } });
|
|
134
|
+
session.on('track', ({ peer, track }) => session.attach(track, audioElementFor(peer)));
|
|
135
|
+
session.on('path', ({ peer, kind }) => console.log(peer, 'is', kind)); // direct | gateway | relay | bridged | unreachable
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
**Gate:** the signalling service. Set `FREEHOP_GATE_TOKEN_SECRET` to the same private signing key (at least 32 characters) in the backend and gate environments, and put the gate behind your TLS proxy.
|
|
139
|
+
```bash
|
|
140
|
+
FREEHOP_GATE_PORT=8787 FREEHOP_GATE_PUBLIC_HOST=example.com \
|
|
141
|
+
FREEHOP_GATE_STUN='0.0.0.0:3478,[::]:3478' \
|
|
142
|
+
FREEHOP_GATE_TOKEN_SECRET="$FREEHOP_GATE_TOKEN_SECRET" \
|
|
143
|
+
FREEHOP_GATE_TOKEN_AUDIENCE=wss://example.com/freehop \
|
|
144
|
+
FREEHOP_GATE_TRUST_PROXY=1 ./node_modules/.bin/freehop-gate
|
|
145
|
+
```
|
|
146
|
+
Run the installed copy, not `npx freehop-gate`: Freehop is not on npm, so npx could fetch a stranger's package and hand it your token secret.
|
|
147
|
+
|
|
148
|
+
Desktop apps (Electron) and session hosts get one call each. See [SDK.md](SDK.md). For a
|
|
149
|
+
complete runnable consumer, run `npm run example` and open it in two windows. Existing integrations should follow the [security model](PROTOCOL.md): gateway credentials now come from a privileged broker, tokens name their gate, and STUN servers come from application configuration. Use `authority.kick()` for membership revocation; `disconnectPeer()` is local removal only.
|
|
150
|
+
|
|
151
|
+
## How Freehop compares
|
|
152
|
+
|
|
153
|
+
Every option below can put people in a call. What differs is where the audio and video go when
|
|
154
|
+
two people cannot connect directly, and who pays for that traffic.
|
|
155
|
+
|
|
156
|
+
| Option | What it is | When a direct route fails, media goes through | Your media bill | Built for | License |
|
|
157
|
+
|---|---|---|---|---|---|
|
|
158
|
+
| **Freehop** | Peer-to-peer SDK plus small signalling gates | Machines in the session: a participant's desktop gateway, the host node, or a forwarding participant | **No operator media bill; session machines carry the traffic** | 2 to 8 people (mesh) | Apache-2.0 |
|
|
159
|
+
| WebRTC + your own TURN | The browser API, plus the servers you build (e.g. coturn) | Your TURN server | Every relayed byte | Small groups (mesh), more with an SFU you add | coturn: BSD-3-Clause |
|
|
160
|
+
| [PeerJS](https://peerjs.com) | Library for one-to-one connections by peer id, with PeerServer signalling | A TURN server you supply; its free TURN service closed in December 2023 | Yours, once you add TURN | One-to-one; groups are a mesh you build | MIT |
|
|
161
|
+
| [Trystero](https://github.com/dmotz/trystero) | Serverless peer-to-peer matchmaking library | A TURN server you add; without one, hard-NAT pairs fail | Yours, once you add TURN | Small groups (mesh) | MIT |
|
|
162
|
+
| [LiveKit](https://livekit.io) | Open-source SFU server and SDKs, or LiveKit Cloud | Every stream goes through the SFU, with built-in TURN | Your servers' bandwidth, or Cloud pricing per minute and GB | Large rooms, livestreams, AI agents | Apache-2.0 |
|
|
163
|
+
| [Jitsi Meet](https://jitsi.org) | Complete meeting app: Videobridge SFU with XMPP signalling | With 2 people it tries a direct link; with 3 or more, every stream goes through the Videobridge | Your servers' bandwidth, or 8x8's hosted JaaS | Meetings with dozens of people | Apache-2.0 |
|
|
164
|
+
| [mediasoup](https://mediasoup.org) | SFU library for Node.js or Rust, with a C++ media worker | Always your SFU server | Your servers' bandwidth | Large rooms you build yourself | ISC |
|
|
165
|
+
| Hosted video APIs | Daily, Agora, Twilio Video, Cloudflare Realtime and others | The provider's servers: most send every stream through them | Per participant-minute, or per GB (Cloudflare) | Large rooms, nothing to run | Proprietary |
|
|
166
|
+
|
|
167
|
+
Freehop is built for products where **people in the session can help carry their own call**:
|
|
168
|
+
games, small-group voice and video, communities. If you need 50-person rooms, recording, or a
|
|
169
|
+
guarantee on networks that block everything except your server, an SFU or a paid relay is the
|
|
170
|
+
right tool. The [full comparison](https://jolynstudios.github.io/freehop/docs/comparison) adds
|
|
171
|
+
signalling and encryption, with sources.
|
|
172
|
+
|
|
173
|
+
## Honest limits
|
|
174
|
+
|
|
175
|
+
- **Room size needs admission control.** The initial target is rooms of 2 to 8 people; eight was chosen for that target, not established by a capacity benchmark. The SDK's
|
|
176
|
+
`limits.maxPeers` defaults to 8 remote peers per client; it is not a room-size cap.
|
|
177
|
+
Your backend must limit membership before issuing tickets. Excess peers are silently
|
|
178
|
+
ignored, which can leave a larger room only partly connected.
|
|
179
|
+
- **Unrouteable networks.** A network that lets a user reach *only* your gate has no route
|
|
180
|
+
for media without your server carrying it. Freehop reports `unreachable` instead of
|
|
181
|
+
silently paying for a relay.
|
|
182
|
+
- **Hard NATs need someone with a reachable route.** Two browser-only users, both behind
|
|
183
|
+
strict NATs or UDP-blocking networks, with no IPv6 and nobody else in the session, cannot
|
|
184
|
+
connect.
|
|
185
|
+
- **Lab-qualified, not field-proven yet.** Real ISPs, 4G/5G carrier NAT, corporate networks
|
|
186
|
+
and mobile browsers are being tested next. Status: **alpha**.
|
|
187
|
+
- **Forwarded media is re-encoded.** When a participant forwards the call, it re-encodes the
|
|
188
|
+
media. That participant is in the call anyway.
|
|
189
|
+
|
|
190
|
+
## Repository map
|
|
191
|
+
|
|
192
|
+
| Path | What |
|
|
193
|
+
|---|---|
|
|
194
|
+
| `src/client/` | Browser client: rooms, sealed signalling, path ladder, bridging, media |
|
|
195
|
+
| `src/sdk/` | SDK: `authority` (backend), `connect` (client), `host`, tickets |
|
|
196
|
+
| `src/gate/` + `bin/freehop-gate.mjs` | Gate service with optional STUN |
|
|
197
|
+
| `src/relay/` | TURN gateway, PCP / NAT-PMP / UPnP port mapper, host-node member |
|
|
198
|
+
| `src/electron/` | Desktop helper (main + preload) |
|
|
199
|
+
| `examples/minimal/` | A complete reference app |
|
|
200
|
+
| `lab/` | Disposable Linux NAT lab that reproduces every result |
|
|
201
|
+
| `website/` | The documentation site (Docusaurus) |
|
|
202
|
+
|
|
203
|
+
## Get involved
|
|
204
|
+
|
|
205
|
+
- ⭐ **Star the repo** to follow progress toward the field-tested 1.0.
|
|
206
|
+
- 🎮 **Try the [live demo](https://jolynstudios.github.io/freehop/demo)**: a real call between two
|
|
207
|
+
browser tabs, introduced through public trackers with no server of ours.
|
|
208
|
+
- 🛠️ **Building a game or app on Freehop?** [Open an issue](https://github.com/jolynstudios/freehop/issues)
|
|
209
|
+
and tell us about your use case. Integration reports directly shape the SDK.
|
|
210
|
+
|
|
211
|
+
## License and warranty
|
|
212
|
+
|
|
213
|
+
- Code: **Apache License 2.0** ([LICENSE](LICENSE)).
|
|
214
|
+
- Documentation and protocol specification: **CC BY 4.0** ([LICENSE-docs](LICENSE-docs)).
|
|
215
|
+
- Attributions: [NOTICE](NOTICE).
|
|
216
|
+
|
|
217
|
+
> **No warranty.** Freehop is provided "as is", without warranties or conditions of any kind.
|
|
218
|
+
> You use it entirely at your own risk.
|
|
219
|
+
|
|
220
|
+
Copyright 2026 Jolyn Studios. Freehop was developed under the codename *Peerlane*; some protocol
|
|
221
|
+
identifiers keep that name.
|