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/SDK.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# Freehop SDK: integrating it into an application
|
|
2
|
+
|
|
3
|
+
Freehop is built to be consumed. Any application uses the same five pieces. Redline Wars is
|
|
4
|
+
a planned consumer and test environment, using the same public API. Nothing below is
|
|
5
|
+
game-specific.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
your backend your gate host every client hosts (optional)
|
|
9
|
+
─────────── ───────────── ──────────── ────────────────
|
|
10
|
+
createAuthority() ───► freehop-gate (gate) ◄── connect(ticket) hostSession(ticket)
|
|
11
|
+
openRoom / ticket signalling only media P2P / via a desktop app or server
|
|
12
|
+
kick (rotate) never media session gateway that hosts the session
|
|
13
|
+
│ ▲
|
|
14
|
+
└──────── tickets over YOUR authenticated channel┘ desktop apps: installFreehopGateway()
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## 1. Backend: decide who is in a room (`freehop/authority`)
|
|
18
|
+
```js
|
|
19
|
+
import { createAuthority } from 'freehop/authority';
|
|
20
|
+
const authority = createAuthority({
|
|
21
|
+
app: 'my-app', // namespaces room keys
|
|
22
|
+
gates: ['wss://example.com/freehop'], // one or more gates (yours, community, bt+wss:// trackers)
|
|
23
|
+
gateTokenSecrets: { 'wss://example.com/freehop': process.env.FREEHOP_GATE_TOKEN_SECRET },
|
|
24
|
+
stun: ['stun:example.com:3478'] // application-approved discovery servers
|
|
25
|
+
});
|
|
26
|
+
await authority.openRoom(roomId); // creates the room secret (never leaves the backend except in tickets)
|
|
27
|
+
const ticket = await authority.ticket(roomId, memberId); // for members your app admitted
|
|
28
|
+
const { tickets } = await authority.kick(roomId, memberId); // rotates the secret; deliver new tickets to the rest
|
|
29
|
+
```
|
|
30
|
+
A ticket (`{ v, app, roomId, epoch, gates, secret, auth?, stun?, expires }`) is a bearer credential for the
|
|
31
|
+
room. Deliver it only to that member, over the application's own authenticated channel.
|
|
32
|
+
`auth` maps exact gate URLs to audience-bound tokens; community gates without a configured signing key receive no token. `stun` lists approved STUN URLs: clients ignore gate-supplied STUN hints.
|
|
33
|
+
`encodeTicket`/`decodeTicket` (`freehop/ticket`) turn it into a compact string.
|
|
34
|
+
|
|
35
|
+
## 2. Gate: run the signalling service (`bin/freehop-gate.mjs`)
|
|
36
|
+
```sh
|
|
37
|
+
FREEHOP_GATE_PORT=8787 FREEHOP_GATE_PUBLIC_HOST=example.com FREEHOP_GATE_STUN='0.0.0.0:3478,[::]:3478' \
|
|
38
|
+
FREEHOP_GATE_TOKEN_SECRET=… FREEHOP_GATE_TOKEN_AUDIENCE=wss://example.com/freehop FREEHOP_GATE_TRUST_PROXY=1 node bin/freehop-gate.mjs
|
|
39
|
+
```
|
|
40
|
+
Put it behind your TLS proxy (`deploy/Caddyfile.snippet`, `deploy/freehop-gate.service`). It
|
|
41
|
+
carries sealed signalling only: about 15–35 KB per peer pair at setup, roughly zero
|
|
42
|
+
afterwards. More gates mean more resilience. Clients use every gate in the ticket, and a call
|
|
43
|
+
survives all gates going down. Public trackers can replace your own gate; configure approved STUN separately. Keepalives, tracker discovery and recovery still contribute signalling traffic.
|
|
44
|
+
|
|
45
|
+
## 3. Client: join with the ticket (`freehop/sdk`)
|
|
46
|
+
|
|
47
|
+
This runs in the browser or Electron renderer. Bundle the package import with your frontend build tool, fetch the ticket from your authenticated backend, and join from a user control on an HTTPS page (localhost works for development). The callbacks below are UI placeholders; the media, update and leave calls illustrate separate controls, not a startup sequence. Node.js 22 or newer is needed for the backend/gate/host helpers, not for browser execution.
|
|
48
|
+
|
|
49
|
+
```js
|
|
50
|
+
import { connect } from 'freehop';
|
|
51
|
+
const session = await connect(ticket, { media: { audio: true, video: false }, adaptiveVideo: true });
|
|
52
|
+
session.on('peer', ({ id }) => …); // an authenticated member appeared
|
|
53
|
+
session.on('track', ({ peer, track }) => session.attach(track, elementFor(peer)));
|
|
54
|
+
session.on('path', ({ peer, kind, via }) => …); // direct | gateway | relay | bridged | unreachable
|
|
55
|
+
session.on('peer-left', ({ id, reason }) => …);
|
|
56
|
+
await session.setMicrophone(false); await session.setCamera(true);
|
|
57
|
+
const levels = await session.levels(); // speaking indicators
|
|
58
|
+
await session.update(newTicket, { dropped: [kickedPeerId] }); // after a kick
|
|
59
|
+
await session.switchDevice('audio', deviceId); // another microphone or camera, no renegotiation
|
|
60
|
+
await session.send({ type: 'chat', text: 'hi' }); // app data to everyone (or { to: peerId })
|
|
61
|
+
session.on('message', ({ from, data }) => …); // untrusted input: render as text
|
|
62
|
+
session.on('video-quality', ({ peer, direction, level, reason }) => …);
|
|
63
|
+
await session.setAdaptiveVideo(false); // disable at runtime
|
|
64
|
+
await session.refresh(reissuedTicket); // same epoch, fresh gate tokens for long calls
|
|
65
|
+
await session.leave(); // also releases the room on a desktop gateway
|
|
66
|
+
```
|
|
67
|
+
`session.disconnectPeer(peerId)` removes only a local connection. It does not revoke membership. The former `session.kick()` throws a migration error; use `authority.kick()` and distribute replacement tickets for removal.
|
|
68
|
+
|
|
69
|
+
`adaptiveVideo` is opt-in and defaults to `false`. When enabled, Freehop uses per-link WebRTC statistics to lower video after sustained packet loss, dropped frames, encoder CPU limitation or a low outgoing bitrate estimate. It never changes audio. It can ask the other endpoint on that link to lower video too; peers that did not opt in ignore the request. Recovery requires 25 seconds of healthy samples and moves one level at a time. Severe sustained pressure can pause video; a minimal-quality probe checks for recovery before restoring it. A manual `setCamera(false)` remains authoritative.
|
|
70
|
+
|
|
71
|
+
The `video-quality` event reports `{ peer, direction: 'send'|'receive', level: 'normal'|'reduced'|'minimal'|'paused', reason }`. `reason` is `monitoring`, `cpu`, `bandwidth`, `peer-request`, `recovery-probe`, `recovery` or `disabled`. Browser support and stats availability vary; missing measurements leave the current quality unchanged.
|
|
72
|
+
|
|
73
|
+
Map your member ids to Freehop peer ids (`session.id`) in your backend, so that a kick can
|
|
74
|
+
name the peer the remaining clients must drop.
|
|
75
|
+
|
|
76
|
+
Audio packets arriving does not guarantee sound playback. `attach()` attempts playback, but browsers may block it. Check `element.play()` and offer a button that retries it directly from a click; preserve an existing audio attachment when only video changes. See the [browser playback example](https://jolynstudios.github.io/freehop/docs/sdk/client#audio-playback-in-the-browser).
|
|
77
|
+
|
|
78
|
+
## 4. Desktop apps: become reachable (`freehop/electron`)
|
|
79
|
+
Main process:
|
|
80
|
+
```js
|
|
81
|
+
import { installFreehopGateway } from 'freehop/electron';
|
|
82
|
+
const freehop = installFreehopGateway({ ipcMain, allowedOrigins: ['https://play.example.com'] });
|
|
83
|
+
app.on('will-quit', () => freehop.close());
|
|
84
|
+
new BrowserWindow({ webPreferences: { preload: require.resolve('freehop/electron/preload'),
|
|
85
|
+
additionalArguments: ['--freehop-origins=https://play.example.com'], contextIsolation: true } });
|
|
86
|
+
```
|
|
87
|
+
`connect()` finds `window.freehopGateway` automatically. It starts the gateway (TURN plus a
|
|
88
|
+
PCP/NAT-PMP/UPnP router mapping) on first use, allows that room, and offers it to the room's
|
|
89
|
+
peers with per-peer credentials. Participants behind hard NATs or UDP-blocking networks can then
|
|
90
|
+
reach the desktop participant without any third party. The minting key stays in the main process; the renderer requests short-lived credentials through an origin-checked broker. HTTPS origins are required except for loopback development. A raw gateway integration supplies `credentialsFor(tag, peer)` alongside its public `info()` metadata, rather than sending the key to a page.
|
|
91
|
+
|
|
92
|
+
The gateway relays only between allocations on itself (`relayScope: 'internal'`, the default), so room members cannot use it to reach other internet hosts. A window's rooms are released when its page leaves, navigates away or closes.
|
|
93
|
+
|
|
94
|
+
## 5. Hosts: let the session's own host relay for it (`freehop/host`)
|
|
95
|
+
Whoever hosts a session can make its machine the session's gateway: an app server, a
|
|
96
|
+
community server, or a desktop app that hosts. It needs a public address or a router mapping.
|
|
97
|
+
```js
|
|
98
|
+
import { hostSession } from 'freehop/host';
|
|
99
|
+
const host = await hostSession(hostTicket); // host.available === false when unreachable
|
|
100
|
+
await host.update(nextTicket, { dropped: [peerId] });
|
|
101
|
+
await host.close();
|
|
102
|
+
```
|
|
103
|
+
Never call this on infrastructure whose bandwidth you do not want to spend. The operator's own
|
|
104
|
+
servers should not host sessions' media.
|
|
105
|
+
|
|
106
|
+
## Runnable reference
|
|
107
|
+
`examples/minimal/` is a complete consumer: backend, gate on the same origin, and a page with
|
|
108
|
+
join, mic/camera, per-peer path and level, and kick through the app's API. Run it with
|
|
109
|
+
`node examples/minimal/server.mjs` and open it in several windows. `test/browser/example-app.mjs`
|
|
110
|
+
drives it with three real browsers and asserts the mesh, the kick rotation and the removal.
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
Documentation licensed under CC BY 4.0. Copyright 2026 Jolyn Studios. Provided as is, without warranty of any kind.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
// Freehop gate service. Configuration by environment:
|
|
4
|
+
// FREEHOP_GATE_HOST=127.0.0.1 FREEHOP_GATE_PORT=8787 FREEHOP_GATE_PATH=/freehop
|
|
5
|
+
// FREEHOP_GATE_PUBLIC_HOST=gate.example.com (advertised in stun: URLs)
|
|
6
|
+
// FREEHOP_GATE_STUN=0.0.0.0:3478[,[::]:3478] (optional STUN Binding responders)
|
|
7
|
+
// FREEHOP_GATE_TOKEN_SECRET=... (32+ characters: only clients with valid tokens)
|
|
8
|
+
// FREEHOP_GATE_TOKEN_AUDIENCE=wss://gate.example.com/freehop (required with a token secret)
|
|
9
|
+
// FREEHOP_GATE_TRUST_PROXY=1 (behind a local reverse proxy: use X-Forwarded-For)
|
|
10
|
+
// FREEHOP_GATE_ALLOW_ANONYMOUS=1 (or FREEHOP_GATE_ANONYMOUS=1: open gate beyond loopback)
|
|
11
|
+
// TLS is terminated by the reverse proxy (Caddy) in front of it.
|
|
12
|
+
import { isIP } from 'node:net';
|
|
13
|
+
import { lookup } from 'node:dns/promises';
|
|
14
|
+
import { createGate } from '../src/gate/gate.mjs';
|
|
15
|
+
|
|
16
|
+
const env = process.env;
|
|
17
|
+
const log = (event, details) => console.error(JSON.stringify({ at: new Date().toISOString(), event, ...details }));
|
|
18
|
+
const fail = message => { console.error(`freehop-gate: ${message}`); process.exit(1); };
|
|
19
|
+
const flag = name => { const v = env[name] ?? ''; if (!['', '0', '1'].includes(v)) fail(`${name} must be 0 or 1`); return v === '1'; };
|
|
20
|
+
const portNumber = (value, name) => { const n = Number(value); if (!/^\d+$/.test(String(value)) || !Number.isInteger(n) || n < 1 || n > 65535) fail(`${name} must be a port from 1 to 65535`); return n; };
|
|
21
|
+
|
|
22
|
+
const host = env.FREEHOP_GATE_HOST ?? '127.0.0.1';
|
|
23
|
+
if (!host) fail('FREEHOP_GATE_HOST is empty; set 0.0.0.0 or :: to listen on every interface');
|
|
24
|
+
const port = portNumber(env.FREEHOP_GATE_PORT ?? 8787, 'FREEHOP_GATE_PORT');
|
|
25
|
+
const tokenSecret = env.FREEHOP_GATE_TOKEN_SECRET || undefined;
|
|
26
|
+
if (tokenSecret && tokenSecret.length < 32) fail('FREEHOP_GATE_TOKEN_SECRET must be at least 32 characters, for example from: openssl rand -base64 32');
|
|
27
|
+
if (tokenSecret && !/^wss?:\/\//.test(env.FREEHOP_GATE_TOKEN_AUDIENCE ?? '')) fail('Set FREEHOP_GATE_TOKEN_AUDIENCE to the exact public WebSocket gate URL');
|
|
28
|
+
const trustProxy = flag('FREEHOP_GATE_TRUST_PROXY');
|
|
29
|
+
const anonymous = [flag('FREEHOP_GATE_ALLOW_ANONYMOUS'), flag('FREEHOP_GATE_ANONYMOUS')].includes(true);
|
|
30
|
+
// Reachable beyond loopback: the listen address is (or resolves to) anything but loopback, or a
|
|
31
|
+
// proxy or public host name is configured. Such a gate needs tokens or an explicit anonymous mode.
|
|
32
|
+
const loopback = address => /^(127\.|::1$|::ffff:127\.)/i.test(address);
|
|
33
|
+
const addresses = isIP(host) ? [host] : await lookup(host, { all: true }).then(list => list.map(a => a.address), () => fail(`FREEHOP_GATE_HOST ${host} does not resolve`));
|
|
34
|
+
if ((trustProxy || env.FREEHOP_GATE_PUBLIC_HOST || !addresses.every(loopback)) && !tokenSecret && !anonymous)
|
|
35
|
+
fail('A gate reachable beyond loopback needs FREEHOP_GATE_TOKEN_SECRET, or FREEHOP_GATE_ALLOW_ANONYMOUS=1 for an open gate');
|
|
36
|
+
const stun = (env.FREEHOP_GATE_STUN ?? '').split(',').filter(Boolean).map(spec => {
|
|
37
|
+
const m = /^\[?([^\]]+?)\]?:(\d+)$/.exec(spec.trim());
|
|
38
|
+
if (!m) fail(`Bad FREEHOP_GATE_STUN entry: ${spec}`);
|
|
39
|
+
return { host: m[1], port: portNumber(m[2], 'FREEHOP_GATE_STUN') };
|
|
40
|
+
});
|
|
41
|
+
if (!tokenSecret) log('warning', { message: 'anonymous gate: no FREEHOP_GATE_TOKEN_SECRET, so any client can use it' });
|
|
42
|
+
const gate = await createGate({
|
|
43
|
+
host, port, path: env.FREEHOP_GATE_PATH ?? '/freehop',
|
|
44
|
+
publicHost: env.FREEHOP_GATE_PUBLIC_HOST, stun, tokenSecret,
|
|
45
|
+
tokenAudience: env.FREEHOP_GATE_TOKEN_AUDIENCE,
|
|
46
|
+
trustProxy,
|
|
47
|
+
log
|
|
48
|
+
});
|
|
49
|
+
console.log(JSON.stringify({ event: 'listening', url: gate.url(), stun: gate.stunUrls }));
|
|
50
|
+
const report = setInterval(() => console.log(JSON.stringify({ event: 'stats', ...gate.stats() })), 300000);
|
|
51
|
+
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, async () => { clearInterval(report); await gate.close(); process.exit(0); });
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Inside the existing site block for play.example.com: the gate shares the TLS origin.
|
|
2
|
+
# UDP 3478 (STUN) must be open in the host firewall; no TURN/relay ports are needed.
|
|
3
|
+
# Caddy replaces X-Forwarded-For with the client address, which FREEHOP_GATE_TRUST_PROXY=1 relies on.
|
|
4
|
+
handle /freehop {
|
|
5
|
+
reverse_proxy 127.0.0.1:8787
|
|
6
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Freehop gate: signalling-only rendezvous. It never carries media; its traffic is a few
|
|
3
|
+
# KB per call setup. Runs unprivileged, loopback-only behind Caddy, plus a public STUN port.
|
|
4
|
+
[Unit]
|
|
5
|
+
Description=Freehop gate (signalling only)
|
|
6
|
+
After=network-online.target
|
|
7
|
+
Wants=network-online.target
|
|
8
|
+
|
|
9
|
+
[Service]
|
|
10
|
+
User=freehop
|
|
11
|
+
Group=freehop
|
|
12
|
+
WorkingDirectory=/opt/freehop
|
|
13
|
+
Environment=FREEHOP_GATE_HOST=127.0.0.1
|
|
14
|
+
Environment=FREEHOP_GATE_PORT=8787
|
|
15
|
+
Environment=FREEHOP_GATE_PATH=/freehop
|
|
16
|
+
Environment=FREEHOP_GATE_PUBLIC_HOST=play.example.com
|
|
17
|
+
Environment=FREEHOP_GATE_STUN=0.0.0.0:3478,[::]:3478
|
|
18
|
+
Environment=FREEHOP_GATE_TRUST_PROXY=1
|
|
19
|
+
Environment=FREEHOP_GATE_TOKEN_AUDIENCE=wss://play.example.com/freehop
|
|
20
|
+
EnvironmentFile=-/etc/freehop/gate.env
|
|
21
|
+
ExecStart=/usr/bin/node /opt/freehop/bin/freehop-gate.mjs
|
|
22
|
+
Restart=on-failure
|
|
23
|
+
RestartSec=2
|
|
24
|
+
NoNewPrivileges=true
|
|
25
|
+
ProtectSystem=strict
|
|
26
|
+
ProtectHome=true
|
|
27
|
+
PrivateTmp=true
|
|
28
|
+
PrivateDevices=true
|
|
29
|
+
ProtectKernelTunables=true
|
|
30
|
+
ProtectKernelModules=true
|
|
31
|
+
ProtectKernelLogs=true
|
|
32
|
+
ProtectControlGroups=true
|
|
33
|
+
ProtectClock=true
|
|
34
|
+
RestrictNamespaces=true
|
|
35
|
+
RestrictRealtime=true
|
|
36
|
+
RestrictSUIDSGID=true
|
|
37
|
+
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
|
38
|
+
CapabilityBoundingSet=
|
|
39
|
+
UMask=0077
|
|
40
|
+
# V8 uses JIT executable memory; do not enable MemoryDenyWriteExecute here.
|
|
41
|
+
# The gate's default limits need about 225 MiB at worst, plus garbage from bursts (src/gate/gate.mjs).
|
|
42
|
+
MemoryMax=512M
|
|
43
|
+
LimitNOFILE=8192
|
|
44
|
+
|
|
45
|
+
[Install]
|
|
46
|
+
WantedBy=multi-user.target
|
package/package.json
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "freehop",
|
|
3
|
+
"version": "0.1.0-alpha.0",
|
|
4
|
+
"description": "Peer-to-peer voice, video and data where your servers never carry or pay for media: blind gates for signalling, session-owned gateways and forwarding instead of operator relays.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=22"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/sdk/client.mjs",
|
|
12
|
+
"./client": "./src/client/peerlane.mjs",
|
|
13
|
+
"./gate": "./src/gate/gate.mjs",
|
|
14
|
+
"./relay": "./src/relay/agent.mjs",
|
|
15
|
+
"./turn": "./src/relay/turn-server.mjs",
|
|
16
|
+
"./port-mapper": "./src/relay/port-mapper.mjs",
|
|
17
|
+
"./member": "./src/relay/member.mjs",
|
|
18
|
+
"./stun": "./src/shared/stun.mjs",
|
|
19
|
+
"./sdk": "./src/sdk/client.mjs",
|
|
20
|
+
"./authority": "./src/sdk/authority.mjs",
|
|
21
|
+
"./host": "./src/sdk/host.mjs",
|
|
22
|
+
"./ticket": "./src/sdk/ticket.mjs",
|
|
23
|
+
"./electron": "./src/electron/main.mjs",
|
|
24
|
+
"./electron/preload": "./src/electron/preload.cjs",
|
|
25
|
+
"./tokens": "./src/shared/tokens.mjs"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"test": "node --test test/*.test.mjs",
|
|
29
|
+
"test:browsers": "node test/browser/smoke.mjs chromium,firefox,webkit --tls && node test/browser/multigate.mjs && node test/browser/rekey.mjs && node test/browser/example-app.mjs",
|
|
30
|
+
"gate": "node bin/freehop-gate.mjs",
|
|
31
|
+
"example": "node examples/minimal/server.mjs"
|
|
32
|
+
},
|
|
33
|
+
"author": "Jolyn Studios",
|
|
34
|
+
"homepage": "https://jolynstudios.github.io/freehop/",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/jolynstudios/freehop.git"
|
|
38
|
+
},
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/jolynstudios/freehop/issues"
|
|
41
|
+
},
|
|
42
|
+
"keywords": [
|
|
43
|
+
"webrtc",
|
|
44
|
+
"p2p",
|
|
45
|
+
"peer-to-peer",
|
|
46
|
+
"voice-chat",
|
|
47
|
+
"video-chat",
|
|
48
|
+
"nat-traversal",
|
|
49
|
+
"turn",
|
|
50
|
+
"stun",
|
|
51
|
+
"upnp",
|
|
52
|
+
"pcp",
|
|
53
|
+
"nat-pmp",
|
|
54
|
+
"signalling",
|
|
55
|
+
"games",
|
|
56
|
+
"sdk"
|
|
57
|
+
],
|
|
58
|
+
"bin": {
|
|
59
|
+
"freehop-gate": "bin/freehop-gate.mjs"
|
|
60
|
+
},
|
|
61
|
+
"files": [
|
|
62
|
+
"src/",
|
|
63
|
+
"bin/",
|
|
64
|
+
"deploy/",
|
|
65
|
+
"README.md",
|
|
66
|
+
"ARCHITECTURE.md",
|
|
67
|
+
"PROTOCOL.md",
|
|
68
|
+
"SDK.md",
|
|
69
|
+
"LICENSE",
|
|
70
|
+
"LICENSE-docs",
|
|
71
|
+
"NOTICE"
|
|
72
|
+
],
|
|
73
|
+
"dependencies": {
|
|
74
|
+
"ws": "^8.21.3"
|
|
75
|
+
},
|
|
76
|
+
"devDependencies": {
|
|
77
|
+
"playwright": "1.62.0"
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The room secret never leaves the peers. HKDF derives an opaque room tag (the only room
|
|
3
|
+
// identifier a gate sees) and an AES-GCM key that seals every signalling envelope end to end.
|
|
4
|
+
const te = new TextEncoder(), td = new TextDecoder();
|
|
5
|
+
const subtle = globalThis.crypto.subtle;
|
|
6
|
+
|
|
7
|
+
export function toBase64Url(bytes) {
|
|
8
|
+
let s = ''; for (const b of bytes) s += String.fromCharCode(b);
|
|
9
|
+
return btoa(s).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
|
|
10
|
+
}
|
|
11
|
+
export function fromBase64Url(text) {
|
|
12
|
+
const s = atob(text.replaceAll('-', '+').replaceAll('_', '/') + '='.repeat((4 - text.length % 4) % 4));
|
|
13
|
+
const out = new Uint8Array(s.length); for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
|
|
14
|
+
return out;
|
|
15
|
+
}
|
|
16
|
+
export const randomId = (bytes = 16) => toBase64Url(globalThis.crypto.getRandomValues(new Uint8Array(bytes)));
|
|
17
|
+
|
|
18
|
+
/** deriveRoom(secret, app) -> { tag, key }. `secret` is a string or bytes with >=128 bits of entropy. */
|
|
19
|
+
export async function deriveRoom(secret, app = 'peerlane') {
|
|
20
|
+
const raw = typeof secret === 'string' ? te.encode(secret) : secret;
|
|
21
|
+
if (!(raw instanceof Uint8Array) || raw.length < 16) throw new TypeError('Room secret must carry at least 16 bytes.');
|
|
22
|
+
const base = await subtle.importKey('raw', raw, 'HKDF', false, ['deriveBits', 'deriveKey']);
|
|
23
|
+
const salt = te.encode('peerlane/v1/' + app);
|
|
24
|
+
const tag = toBase64Url(new Uint8Array(await subtle.deriveBits({ name: 'HKDF', hash: 'SHA-256', salt, info: te.encode('room-tag') }, base, 256)));
|
|
25
|
+
const key = await subtle.deriveKey({ name: 'HKDF', hash: 'SHA-256', salt, info: te.encode('envelope-key') }, base,
|
|
26
|
+
{ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
|
|
27
|
+
return { tag, key };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The additional data binds room, sender and recipient: a gate that re-routes or relabels an
|
|
31
|
+
// envelope makes it fail authentication instead of reaching the wrong peer.
|
|
32
|
+
const aad = (room, from, to) => te.encode(`peerlane/v1|${room.tag}|${from}|${to}`);
|
|
33
|
+
|
|
34
|
+
export async function seal(room, from, to, payload) {
|
|
35
|
+
const iv = globalThis.crypto.getRandomValues(new Uint8Array(12));
|
|
36
|
+
const ct = new Uint8Array(await subtle.encrypt({ name: 'AES-GCM', iv, additionalData: aad(room, from, to) }, room.key, te.encode(JSON.stringify(payload))));
|
|
37
|
+
const out = new Uint8Array(12 + ct.length); out.set(iv); out.set(ct, 12);
|
|
38
|
+
return toBase64Url(out);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function open(room, from, to, box) {
|
|
42
|
+
try {
|
|
43
|
+
const bytes = fromBase64Url(box);
|
|
44
|
+
if (bytes.length < 29) return null;
|
|
45
|
+
const plain = await subtle.decrypt({ name: 'AES-GCM', iv: bytes.subarray(0, 12), additionalData: aad(room, from, to) }, room.key, bytes.subarray(12));
|
|
46
|
+
const payload = JSON.parse(td.decode(plain));
|
|
47
|
+
return payload && typeof payload === 'object' && !Array.isArray(payload) ? payload : null;
|
|
48
|
+
} catch { return null; }
|
|
49
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// One connection to one gate. Gates are interchangeable mailboxes: a room uses several at
|
|
3
|
+
// once, reconnects each independently and never depends on any single one.
|
|
4
|
+
export class Emitter {
|
|
5
|
+
#handlers = new Map();
|
|
6
|
+
on(type, fn) { (this.#handlers.get(type) ?? this.#handlers.set(type, new Set()).get(type)).add(fn); return () => this.off(type, fn); }
|
|
7
|
+
off(type, fn) { this.#handlers.get(type)?.delete(fn); }
|
|
8
|
+
emit(type, detail) { for (const fn of [...(this.#handlers.get(type) ?? [])]) { try { fn(detail); } catch (error) { console.error(error); } } }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class GateClient extends Emitter {
|
|
12
|
+
constructor(url, { room, peer, auth, WebSocketImpl = globalThis.WebSocket, backoffMs = [500, 1000, 2000, 4000, 8000, 15000, 30000] } = {}) {
|
|
13
|
+
super();
|
|
14
|
+
Object.assign(this, { url, room, peer, auth, WebSocketImpl, backoffMs, pingMs: 30000 });
|
|
15
|
+
this.state = 'idle'; this.attempt = 0; this.ws = null; this.closed = false; this.stun = [];
|
|
16
|
+
this.counters = { sent: 0, received: 0, bytesOut: 0, bytesIn: 0, errors: 0, connects: 0 };
|
|
17
|
+
}
|
|
18
|
+
connect() {
|
|
19
|
+
if (this.closed || this.ws) return;
|
|
20
|
+
this.state = 'connecting';
|
|
21
|
+
let ws;
|
|
22
|
+
try { ws = new this.WebSocketImpl(this.url); } catch { this.#retry(); return; }
|
|
23
|
+
this.ws = ws;
|
|
24
|
+
ws.onopen = async () => {
|
|
25
|
+
let auth;
|
|
26
|
+
try { auth = typeof this.auth === 'function' ? await this.auth(this.url) : typeof this.auth === 'object' && this.auth ? this.auth[this.url] : this.auth; }
|
|
27
|
+
catch { if (ws === this.ws) { this.counters.errors++; ws.close(); } return; }
|
|
28
|
+
if (ws !== this.ws) return;
|
|
29
|
+
this.#raw({ t: 'hello', v: 1, ...(auth ? { auth } : {}) });
|
|
30
|
+
};
|
|
31
|
+
ws.onmessage = event => {
|
|
32
|
+
if (ws !== this.ws || typeof event.data !== 'string' || event.data.length > 65536) return;
|
|
33
|
+
this.counters.bytesIn += event.data.length;
|
|
34
|
+
let m; try { m = JSON.parse(event.data); } catch { return; }
|
|
35
|
+
switch (m?.t) {
|
|
36
|
+
case 'welcome':
|
|
37
|
+
// STUN belongs to application configuration, never to an untrusted mailbox.
|
|
38
|
+
this.stun = [];
|
|
39
|
+
this.#raw({ t: 'join', room: this.room, peer: this.peer });
|
|
40
|
+
break;
|
|
41
|
+
case 'peers':
|
|
42
|
+
if (m.room !== this.room || !Array.isArray(m.peers) || m.peers.length > 64) return;
|
|
43
|
+
this.state = 'joined'; this.attempt = 0; this.joinAttempts = 0; this.counters.connects++;
|
|
44
|
+
// Application-level keepalive: the gate drops sockets it has not heard from.
|
|
45
|
+
clearInterval(this.pinger);
|
|
46
|
+
this.pinger = setInterval(() => this.#raw({ t: 'ping' }), this.pingMs);
|
|
47
|
+
this.emit('joined', { gate: this, peers: m.peers.filter(p => typeof p === 'string'), stun: this.stun });
|
|
48
|
+
break;
|
|
49
|
+
case 'peer': if (m.room === this.room) this.emit('peer', { gate: this, peer: m.peer, on: !!m.on }); break;
|
|
50
|
+
case 'recv':
|
|
51
|
+
if (m.room === this.room && typeof m.from === 'string' && typeof m.box === 'string' && m.box.length <= 49152) {
|
|
52
|
+
this.counters.received++; this.emit('recv', { gate: this, from: m.from, box: m.box });
|
|
53
|
+
}
|
|
54
|
+
break;
|
|
55
|
+
case 'error':
|
|
56
|
+
this.counters.errors++; this.emit('gate-error', { gate: this, code: m.code });
|
|
57
|
+
// After a network change the gate may still hold our previous socket for a moment.
|
|
58
|
+
if (m.code === 'peer-taken' && this.state !== 'joined' && (this.joinAttempts = (this.joinAttempts ?? 0) + 1) <= 8) {
|
|
59
|
+
setTimeout(() => { if (ws === this.ws && this.state !== 'joined') this.#raw({ t: 'join', room: this.room, peer: this.peer }); }, 1000 * this.joinAttempts);
|
|
60
|
+
}
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
ws.onclose = () => {
|
|
65
|
+
if (ws !== this.ws) return;
|
|
66
|
+
clearInterval(this.pinger);
|
|
67
|
+
this.ws = null; const was = this.state; this.state = 'idle';
|
|
68
|
+
if (was === 'joined') this.emit('left', { gate: this });
|
|
69
|
+
this.#retry();
|
|
70
|
+
};
|
|
71
|
+
ws.onerror = () => { this.counters.errors++; };
|
|
72
|
+
}
|
|
73
|
+
#retry() {
|
|
74
|
+
if (this.closed) return;
|
|
75
|
+
const delay = this.backoffMs[Math.min(this.attempt++, this.backoffMs.length - 1)];
|
|
76
|
+
this.timer = setTimeout(() => { this.timer = null; this.connect(); }, delay * (0.75 + Math.random() / 2));
|
|
77
|
+
}
|
|
78
|
+
#raw(message) {
|
|
79
|
+
if (this.ws?.readyState !== 1) return false;
|
|
80
|
+
const text = JSON.stringify(message);
|
|
81
|
+
this.ws.send(text); this.counters.bytesOut += text.length;
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
send(to, box) {
|
|
85
|
+
if (this.state !== 'joined') return false;
|
|
86
|
+
const ok = this.#raw({ t: 'send', room: this.room, to, box });
|
|
87
|
+
if (ok) this.counters.sent++;
|
|
88
|
+
return ok;
|
|
89
|
+
}
|
|
90
|
+
close() {
|
|
91
|
+
this.closed = true; clearTimeout(this.timer); clearInterval(this.pinger);
|
|
92
|
+
const ws = this.ws; this.ws = null; this.state = 'closed';
|
|
93
|
+
if (ws) { try { if (ws.readyState === 1) ws.send(JSON.stringify({ t: 'leave', room: this.room })); ws.close(1000); } catch {} }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Validate one ICE server URI, including host/port syntax; never accept lists in one URI.
|
|
3
|
+
export function validIceUrl(value, kind) {
|
|
4
|
+
if (typeof value !== 'string' || value.length > 240) return false;
|
|
5
|
+
const m = /^(stun|stuns|turn|turns):(\[[0-9a-fA-F:.]+\]|[a-zA-Z0-9.-]+)(?::([0-9]{1,5}))?(?:\?transport=(udp|tcp))?$/.exec(value);
|
|
6
|
+
if (!m || !m[1].startsWith(kind) || kind === 'stun' && m[4] || m[3] && (Number(m[3]) < 1 || Number(m[3]) > 65535)) return false;
|
|
7
|
+
// Hostnames: dot-separated labels of 1-63 letters, digits or inner hyphens (no empty or edge-hyphen labels).
|
|
8
|
+
if (!m[2].startsWith('[') && !/^(?=.{1,253}$)([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\.?$/.test(m[2])) return false;
|
|
9
|
+
try {
|
|
10
|
+
const url = new URL(`http://${m[2]}:${m[3] ?? 3478}`);
|
|
11
|
+
return !!url.hostname && !url.username && !url.password;
|
|
12
|
+
} catch { return false; }
|
|
13
|
+
}
|
|
14
|
+
export const validStunUrls = urls => Array.isArray(urls) && urls.length <= 4 && urls.every(url => validIceUrl(url, 'stun'));
|