@zero-server/webrtc 0.9.7
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/LICENSE +21 -0
- package/README.md +37 -0
- package/index.d.ts +2 -0
- package/index.js +53 -0
- package/lib/auth/index.js +1 -0
- package/lib/debug.js +372 -0
- package/lib/errors.js +1 -0
- package/lib/middleware/index.js +1 -0
- package/lib/observe/index.js +1 -0
- package/lib/webrtc/bot.js +361 -0
- package/lib/webrtc/cli.js +182 -0
- package/lib/webrtc/cluster.js +350 -0
- package/lib/webrtc/e2ee.js +282 -0
- package/lib/webrtc/ice.js +370 -0
- package/lib/webrtc/index.js +132 -0
- package/lib/webrtc/joinToken.js +116 -0
- package/lib/webrtc/observe.js +229 -0
- package/lib/webrtc/peer.js +116 -0
- package/lib/webrtc/room.js +171 -0
- package/lib/webrtc/sdp.js +508 -0
- package/lib/webrtc/sfu/index.js +201 -0
- package/lib/webrtc/sfu/livekit.js +301 -0
- package/lib/webrtc/sfu/mediasoup.js +317 -0
- package/lib/webrtc/sfu/memory.js +204 -0
- package/lib/webrtc/signaling.js +546 -0
- package/lib/webrtc/stun.js +492 -0
- package/lib/webrtc/turn/codec.js +370 -0
- package/lib/webrtc/turn/credentials.js +141 -0
- package/lib/webrtc/turn/server.js +633 -0
- package/lib/ws/index.js +1 -0
- package/package.json +62 -0
- package/types/app.d.ts +223 -0
- package/types/auth.d.ts +520 -0
- package/types/body.d.ts +14 -0
- package/types/cli.d.ts +2 -0
- package/types/cluster.d.ts +75 -0
- package/types/env.d.ts +80 -0
- package/types/errors.d.ts +316 -0
- package/types/fetch.d.ts +43 -0
- package/types/grpc.d.ts +432 -0
- package/types/index.d.ts +396 -0
- package/types/lifecycle.d.ts +60 -0
- package/types/middleware.d.ts +320 -0
- package/types/observe.d.ts +304 -0
- package/types/orm.d.ts +1887 -0
- package/types/request.d.ts +109 -0
- package/types/response.d.ts +157 -0
- package/types/router.d.ts +78 -0
- package/types/sse.d.ts +78 -0
- package/types/webrtc.d.ts +501 -0
- package/types/websocket.d.ts +126 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module webrtc/ice
|
|
3
|
+
* @description Zero-dependency ICE candidate parser, serializer, and address
|
|
4
|
+
* classifiers (private / loopback / link-local / mDNS), plus a
|
|
5
|
+
* `filterCandidates` helper used by `SignalingHub` to enforce
|
|
6
|
+
* privacy-preserving policies on relayed offers/answers.
|
|
7
|
+
*
|
|
8
|
+
* Candidate grammar follows RFC 8839 §5.1 / RFC 5245 §15.1:
|
|
9
|
+
*
|
|
10
|
+
* candidate-attribute = "candidate" ":" foundation SP component-id
|
|
11
|
+
* SP transport SP priority SP connection-address
|
|
12
|
+
* SP port SP cand-type [SP rel-addr] [SP rel-port]
|
|
13
|
+
* *(SP extension-att-name SP extension-att-value)
|
|
14
|
+
*
|
|
15
|
+
* @see https://datatracker.ietf.org/doc/html/rfc8839
|
|
16
|
+
* @see https://datatracker.ietf.org/doc/html/rfc5245
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
const { IceError } = require('../errors');
|
|
22
|
+
|
|
23
|
+
// -- Constants -----------------------------------------------------
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Recognised ICE candidate types (RFC 5245).
|
|
27
|
+
* @type {ReadonlyArray<string>}
|
|
28
|
+
*/
|
|
29
|
+
const CANDIDATE_TYPES = Object.freeze(['host', 'srflx', 'prflx', 'relay']);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Recognised TCP candidate types (RFC 6544 §4.5).
|
|
33
|
+
* @type {ReadonlyArray<string>}
|
|
34
|
+
*/
|
|
35
|
+
const TCP_TYPES = Object.freeze(['active', 'passive', 'so']);
|
|
36
|
+
|
|
37
|
+
// -- Public types --------------------------------------------------
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @typedef {object} IceCandidate
|
|
41
|
+
* @property {string} foundation
|
|
42
|
+
* @property {number} component
|
|
43
|
+
* @property {string} transport - 'udp' or 'tcp' (lowercased).
|
|
44
|
+
* @property {number} priority
|
|
45
|
+
* @property {string} address - IPv4, IPv6, or mDNS hostname.
|
|
46
|
+
* @property {number} port
|
|
47
|
+
* @property {string} type - One of CANDIDATE_TYPES.
|
|
48
|
+
* @property {string} [relatedAddress] - From `raddr`.
|
|
49
|
+
* @property {number} [relatedPort] - From `rport`.
|
|
50
|
+
* @property {string} [tcpType] - From `tcptype` (active/passive/so).
|
|
51
|
+
* @property {Object<string,string>} extensions - All other key/value pairs, insertion-ordered.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
// =================================================================
|
|
55
|
+
// Parser
|
|
56
|
+
// =================================================================
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Parse a single ICE candidate line.
|
|
60
|
+
*
|
|
61
|
+
* Accepts inputs with or without the `a=` SDP-attribute prefix. Returns
|
|
62
|
+
* a plain object; throws `IceError` on any structural problem.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} line - Candidate line, e.g.
|
|
65
|
+
* `candidate:842163049 1 udp 1677729535 192.168.1.5 50000 typ host`.
|
|
66
|
+
* @returns {IceCandidate} Parsed candidate.
|
|
67
|
+
* @throws {IceError} On malformed input.
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* const c = parseCandidate('candidate:1 1 udp 2122194687 1.2.3.4 50001 typ srflx raddr 192.168.1.5 rport 50000');
|
|
71
|
+
* if (c.type === 'relay') { console.log('relay candidate'); }
|
|
72
|
+
*
|
|
73
|
+
* @section ICE & TURN
|
|
74
|
+
*/
|
|
75
|
+
function parseCandidate(line)
|
|
76
|
+
{
|
|
77
|
+
if (typeof line !== 'string')
|
|
78
|
+
throw new IceError('parseCandidate: input must be a string');
|
|
79
|
+
|
|
80
|
+
let s = line.trim();
|
|
81
|
+
if (s.startsWith('a=')) s = s.slice(2);
|
|
82
|
+
if (!s.startsWith('candidate:'))
|
|
83
|
+
throw new IceError('parseCandidate: missing "candidate:" prefix', { candidate: line });
|
|
84
|
+
s = s.slice('candidate:'.length);
|
|
85
|
+
|
|
86
|
+
const tok = s.split(/\s+/);
|
|
87
|
+
if (tok.length < 8)
|
|
88
|
+
throw new IceError('parseCandidate: too few tokens', { candidate: line });
|
|
89
|
+
|
|
90
|
+
const [foundation, componentStr, transportRaw, priorityStr,
|
|
91
|
+
address, portStr, typKw, type, ...rest] = tok;
|
|
92
|
+
|
|
93
|
+
if (typKw !== 'typ')
|
|
94
|
+
throw new IceError('parseCandidate: expected "typ" keyword', { candidate: line });
|
|
95
|
+
if (!CANDIDATE_TYPES.includes(type))
|
|
96
|
+
throw new IceError(`parseCandidate: unknown type "${type}"`, { candidate: line });
|
|
97
|
+
|
|
98
|
+
const component = Number(componentStr);
|
|
99
|
+
const priority = Number(priorityStr);
|
|
100
|
+
const port = Number(portStr);
|
|
101
|
+
if (!Number.isInteger(component) || component < 0)
|
|
102
|
+
throw new IceError('parseCandidate: invalid component', { candidate: line });
|
|
103
|
+
if (!Number.isFinite(priority))
|
|
104
|
+
throw new IceError('parseCandidate: invalid priority', { candidate: line });
|
|
105
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535)
|
|
106
|
+
throw new IceError('parseCandidate: invalid port', { candidate: line });
|
|
107
|
+
|
|
108
|
+
/** @type {IceCandidate} */
|
|
109
|
+
const out = {
|
|
110
|
+
foundation,
|
|
111
|
+
component,
|
|
112
|
+
transport: transportRaw.toLowerCase(),
|
|
113
|
+
priority,
|
|
114
|
+
address,
|
|
115
|
+
port,
|
|
116
|
+
type,
|
|
117
|
+
extensions: {},
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// Walk remaining key/value pairs. raddr / rport / tcptype are lifted
|
|
121
|
+
// to named fields; everything else lands in `extensions` in input order.
|
|
122
|
+
for (let i = 0; i < rest.length - 1; i += 2)
|
|
123
|
+
{
|
|
124
|
+
const k = rest[i];
|
|
125
|
+
const v = rest[i + 1];
|
|
126
|
+
if (k === 'raddr') out.relatedAddress = v;
|
|
127
|
+
else if (k === 'rport') out.relatedPort = Number(v);
|
|
128
|
+
else if (k === 'tcptype') out.tcpType = v;
|
|
129
|
+
else out.extensions[k] = v;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// =================================================================
|
|
136
|
+
// Serializer
|
|
137
|
+
// =================================================================
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Serialize a parsed candidate back to its canonical line format.
|
|
141
|
+
* Round-trips outputs of `parseCandidate` exactly, including the
|
|
142
|
+
* insertion order of `extensions`.
|
|
143
|
+
*
|
|
144
|
+
* @param {IceCandidate} c - Parsed candidate object.
|
|
145
|
+
* @returns {string} `candidate:...` line (no `a=` prefix).
|
|
146
|
+
* @throws {IceError} If required fields are missing.
|
|
147
|
+
*
|
|
148
|
+
* @example
|
|
149
|
+
* const out = stringifyCandidate(parseCandidate(line));
|
|
150
|
+
*
|
|
151
|
+
* @section ICE & TURN
|
|
152
|
+
*/
|
|
153
|
+
function stringifyCandidate(c)
|
|
154
|
+
{
|
|
155
|
+
if (!c || typeof c !== 'object')
|
|
156
|
+
throw new IceError('stringifyCandidate: input must be an object');
|
|
157
|
+
const required = ['foundation', 'component', 'transport', 'priority', 'address', 'port', 'type'];
|
|
158
|
+
for (const k of required)
|
|
159
|
+
{
|
|
160
|
+
if (c[k] === undefined || c[k] === null)
|
|
161
|
+
throw new IceError(`stringifyCandidate: missing "${k}"`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let s = `candidate:${c.foundation} ${c.component} ${c.transport} ${c.priority} ${c.address} ${c.port} typ ${c.type}`;
|
|
165
|
+
if (c.relatedAddress !== undefined) s += ` raddr ${c.relatedAddress}`;
|
|
166
|
+
if (c.relatedPort !== undefined) s += ` rport ${c.relatedPort}`;
|
|
167
|
+
if (c.tcpType !== undefined) s += ` tcptype ${c.tcpType}`;
|
|
168
|
+
if (c.extensions)
|
|
169
|
+
{
|
|
170
|
+
for (const [k, v] of Object.entries(c.extensions))
|
|
171
|
+
s += ` ${k} ${v}`;
|
|
172
|
+
}
|
|
173
|
+
return s;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// =================================================================
|
|
177
|
+
// Address classifiers
|
|
178
|
+
// =================================================================
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Test whether the address looks like an IPv4 string.
|
|
182
|
+
* @private
|
|
183
|
+
*/
|
|
184
|
+
function _isIPv4(addr)
|
|
185
|
+
{
|
|
186
|
+
if (typeof addr !== 'string') return false;
|
|
187
|
+
const m = addr.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
188
|
+
if (!m) return false;
|
|
189
|
+
for (let i = 1; i <= 4; i++) if (Number(m[i]) > 255) return false;
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Test whether the address looks like an IPv6 string (very permissive).
|
|
195
|
+
* @private
|
|
196
|
+
*/
|
|
197
|
+
function _isIPv6(addr)
|
|
198
|
+
{
|
|
199
|
+
if (typeof addr !== 'string') return false;
|
|
200
|
+
return addr.includes(':') && /^[0-9a-fA-F:]+$/.test(addr);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* True for RFC 1918, RFC 6598 (CGNAT), and IPv6 ULA (RFC 4193) addresses.
|
|
205
|
+
*
|
|
206
|
+
* @param {string} addr - Address to classify.
|
|
207
|
+
* @returns {boolean}
|
|
208
|
+
*
|
|
209
|
+
* @section ICE & TURN
|
|
210
|
+
*/
|
|
211
|
+
function isPrivateIp(addr)
|
|
212
|
+
{
|
|
213
|
+
if (_isIPv4(addr))
|
|
214
|
+
{
|
|
215
|
+
const [a, b] = addr.split('.').map(Number);
|
|
216
|
+
if (a === 10) return true;
|
|
217
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
218
|
+
if (a === 192 && b === 168) return true;
|
|
219
|
+
if (a === 100 && b >= 64 && b <= 127) return true; // RFC 6598 CGNAT
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
if (_isIPv6(addr))
|
|
223
|
+
{
|
|
224
|
+
// fc00::/7 ULA
|
|
225
|
+
const head = addr.toLowerCase().split(':')[0];
|
|
226
|
+
if (head.length === 0) return false;
|
|
227
|
+
const n = parseInt(head, 16);
|
|
228
|
+
return (n & 0xfe00) === 0xfc00;
|
|
229
|
+
}
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* True for IPv4 127.0.0.0/8 and IPv6 ::1.
|
|
235
|
+
*
|
|
236
|
+
* @param {string} addr - Address to classify.
|
|
237
|
+
* @returns {boolean}
|
|
238
|
+
*
|
|
239
|
+
* @section ICE & TURN
|
|
240
|
+
*/
|
|
241
|
+
function isLoopbackIp(addr)
|
|
242
|
+
{
|
|
243
|
+
if (_isIPv4(addr)) return addr.startsWith('127.');
|
|
244
|
+
if (_isIPv6(addr)) return addr === '::1' || /^0*:0*:0*:0*:0*:0*:0*:0*1$/.test(addr);
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* True for IPv4 169.254/16 and IPv6 fe80::/10.
|
|
250
|
+
*
|
|
251
|
+
* @param {string} addr - Address to classify.
|
|
252
|
+
* @returns {boolean}
|
|
253
|
+
*
|
|
254
|
+
* @section ICE & TURN
|
|
255
|
+
*/
|
|
256
|
+
function isLinkLocalIp(addr)
|
|
257
|
+
{
|
|
258
|
+
if (_isIPv4(addr)) return addr.startsWith('169.254.');
|
|
259
|
+
if (_isIPv6(addr))
|
|
260
|
+
{
|
|
261
|
+
const head = addr.toLowerCase().split(':')[0];
|
|
262
|
+
if (head.length === 0) return false;
|
|
263
|
+
const n = parseInt(head, 16);
|
|
264
|
+
return (n & 0xffc0) === 0xfe80;
|
|
265
|
+
}
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* True for mDNS `.local` hostnames used by browsers to avoid leaking
|
|
271
|
+
* local IPs (Chrome's mDNS ICE candidates - RFC 8624 / draft-ietf-mmusic-mdns-ice-candidates).
|
|
272
|
+
*
|
|
273
|
+
* @param {string} host - Hostname to test.
|
|
274
|
+
* @returns {boolean}
|
|
275
|
+
*
|
|
276
|
+
* @section ICE & TURN
|
|
277
|
+
*/
|
|
278
|
+
function isMdnsHostname(host)
|
|
279
|
+
{
|
|
280
|
+
if (typeof host !== 'string') return false;
|
|
281
|
+
if (_isIPv4(host) || _isIPv6(host)) return false;
|
|
282
|
+
return host.toLowerCase().endsWith('.local');
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// =================================================================
|
|
286
|
+
// Policy filter
|
|
287
|
+
// =================================================================
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* @typedef {object} CandidateFilterPolicy
|
|
291
|
+
* @property {boolean} [blockPrivate=false] - Drop private / loopback / link-local addresses.
|
|
292
|
+
* @property {boolean} [blockMdns=false] - Drop `.local` (mDNS) hostnames.
|
|
293
|
+
* @property {boolean} [blockTcp=false] - Drop TCP-transport candidates.
|
|
294
|
+
* @property {ReadonlyArray<string>} [allowedTypes] - Whitelist of `type` values (host/srflx/prflx/relay).
|
|
295
|
+
* @property {number} [maxCandidates] - Cap the number of returned candidates.
|
|
296
|
+
* @property {(c:IceCandidate)=>boolean} [predicate] - Custom drop function (return false to drop).
|
|
297
|
+
*/
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Filter an array of candidates (lines or parsed objects) against a policy.
|
|
301
|
+
*
|
|
302
|
+
* Returns the same shape it was given: if you pass strings you get strings
|
|
303
|
+
* back; if you pass parsed objects you get parsed objects back. Unparseable
|
|
304
|
+
* string lines are silently skipped so a single bad candidate never poisons
|
|
305
|
+
* the whole offer.
|
|
306
|
+
*
|
|
307
|
+
* @param {Array<string|IceCandidate>} candidates - Input list.
|
|
308
|
+
* @param {CandidateFilterPolicy} [policy={}] - Policy (all defaults are permissive).
|
|
309
|
+
* @returns {Array<string|IceCandidate>} Surviving candidates, same element shape as input.
|
|
310
|
+
*
|
|
311
|
+
* @example
|
|
312
|
+
* const safe = filterCandidates(offer.candidates, {
|
|
313
|
+
* blockPrivate: true,
|
|
314
|
+
* blockMdns: true,
|
|
315
|
+
* allowedTypes: ['srflx', 'relay'],
|
|
316
|
+
* });
|
|
317
|
+
*
|
|
318
|
+
* @section ICE & TURN
|
|
319
|
+
*/
|
|
320
|
+
function filterCandidates(candidates, policy = {})
|
|
321
|
+
{
|
|
322
|
+
if (!Array.isArray(candidates)) return [];
|
|
323
|
+
const {
|
|
324
|
+
blockPrivate = false,
|
|
325
|
+
blockMdns = false,
|
|
326
|
+
blockTcp = false,
|
|
327
|
+
allowedTypes,
|
|
328
|
+
maxCandidates,
|
|
329
|
+
predicate,
|
|
330
|
+
} = policy;
|
|
331
|
+
|
|
332
|
+
const out = [];
|
|
333
|
+
for (const item of candidates)
|
|
334
|
+
{
|
|
335
|
+
const isString = typeof item === 'string';
|
|
336
|
+
let parsed;
|
|
337
|
+
try { parsed = isString ? parseCandidate(item) : item; }
|
|
338
|
+
catch { continue; }
|
|
339
|
+
if (!parsed) continue;
|
|
340
|
+
|
|
341
|
+
if (allowedTypes && !allowedTypes.includes(parsed.type)) continue;
|
|
342
|
+
if (blockTcp && parsed.transport === 'tcp') continue;
|
|
343
|
+
if (blockMdns && isMdnsHostname(parsed.address)) continue;
|
|
344
|
+
if (blockPrivate)
|
|
345
|
+
{
|
|
346
|
+
const a = parsed.address;
|
|
347
|
+
const r = parsed.relatedAddress;
|
|
348
|
+
const isLocal = (x) => x && (isPrivateIp(x) || isLoopbackIp(x) || isLinkLocalIp(x));
|
|
349
|
+
if (isLocal(a) || isLocal(r)) continue;
|
|
350
|
+
}
|
|
351
|
+
if (predicate && !predicate(parsed)) continue;
|
|
352
|
+
|
|
353
|
+
out.push(isString ? item : parsed);
|
|
354
|
+
if (maxCandidates && out.length >= maxCandidates) break;
|
|
355
|
+
}
|
|
356
|
+
return out;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
module.exports = {
|
|
360
|
+
parseCandidate,
|
|
361
|
+
stringifyCandidate,
|
|
362
|
+
isPrivateIp,
|
|
363
|
+
isLoopbackIp,
|
|
364
|
+
isLinkLocalIp,
|
|
365
|
+
isMdnsHostname,
|
|
366
|
+
filterCandidates,
|
|
367
|
+
CANDIDATE_TYPES,
|
|
368
|
+
TCP_TYPES,
|
|
369
|
+
IceError,
|
|
370
|
+
};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module @zero-server/webrtc
|
|
3
|
+
* @description First-class WebRTC support for Zero Server.
|
|
4
|
+
*
|
|
5
|
+
* Signaling hub, room / peer orchestration, RFC 8489 STUN client,
|
|
6
|
+
* RFC 7635 TURN credential issuance, optional embedded TURN server,
|
|
7
|
+
* SFrame E2EE key relay, and a pluggable SFU adapter interface.
|
|
8
|
+
*
|
|
9
|
+
* Implementation is landing PR-by-PR per `.myshit/WEBRTC-ROADMAP.md`.
|
|
10
|
+
* Real exports already live in this barrel; the rest throw
|
|
11
|
+
* `WEBRTC_NOT_IMPLEMENTED` so accidental production use fails loud.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const {
|
|
17
|
+
WebRTCError, SignalingError, IceError, TurnError, SdpError,
|
|
18
|
+
} = require('../errors');
|
|
19
|
+
|
|
20
|
+
const { parseSdp, stringifySdp } = require('./sdp');
|
|
21
|
+
const {
|
|
22
|
+
parseCandidate, stringifyCandidate, filterCandidates,
|
|
23
|
+
isPrivateIp, isLoopbackIp, isLinkLocalIp, isMdnsHostname,
|
|
24
|
+
CANDIDATE_TYPES, TCP_TYPES,
|
|
25
|
+
} = require('./ice');
|
|
26
|
+
const {
|
|
27
|
+
stunBinding, encodeBindingRequest, decodeMessage,
|
|
28
|
+
encodeXorMappedAddress, decodeXorMappedAddress,
|
|
29
|
+
STUN_MAGIC_COOKIE, STUN_METHOD, STUN_CLASS, STUN_ATTR,
|
|
30
|
+
} = require('./stun');
|
|
31
|
+
const { issueTurnCredentials } = require('./turn/credentials');
|
|
32
|
+
const { TurnServer } = require('./turn/server');
|
|
33
|
+
const { SignalingHub, Room, Peer, PEER_STATE } = require('./signaling');
|
|
34
|
+
const { signJoinToken, verifyJoinToken } = require('./joinToken');
|
|
35
|
+
const { bindObservability } = require('./observe');
|
|
36
|
+
const {
|
|
37
|
+
E2eeChannel, attachE2ee,
|
|
38
|
+
generateE2eeKeyPair, sealKey, openSealedKey,
|
|
39
|
+
} = require('./e2ee');
|
|
40
|
+
const {
|
|
41
|
+
useCluster, ClusterCoordinator, MemoryClusterAdapter,
|
|
42
|
+
} = require('./cluster');
|
|
43
|
+
const { runWebRTCCommand } = require('./cli');
|
|
44
|
+
const { SfuAdapter, loadSfuAdapter } = require('./sfu');
|
|
45
|
+
const { MemorySfuAdapter } = require('./sfu/memory');
|
|
46
|
+
const { MediasoupSfuAdapter } = require('./sfu/mediasoup');
|
|
47
|
+
const { LiveKitSfuAdapter } = require('./sfu/livekit');
|
|
48
|
+
const { spawnBotPeer } = require('./bot');
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @private
|
|
52
|
+
* Sentinel for surfaces that have not yet been implemented. Each PR in the
|
|
53
|
+
* roadmap replaces one of these with a real function/class export.
|
|
54
|
+
*/
|
|
55
|
+
const notImplemented = (name) =>
|
|
56
|
+
{
|
|
57
|
+
throw new WebRTCError(
|
|
58
|
+
`${name} is not implemented yet - see .myshit/WEBRTC-ROADMAP.md for the implementation plan.`,
|
|
59
|
+
{ code: 'WEBRTC_NOT_IMPLEMENTED' },
|
|
60
|
+
);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
module.exports = {
|
|
64
|
+
// Signaling - landing in a later PR
|
|
65
|
+
createWebRTC: () => notImplemented('createWebRTC'),
|
|
66
|
+
SignalingHub,
|
|
67
|
+
Room,
|
|
68
|
+
Peer,
|
|
69
|
+
PEER_STATE,
|
|
70
|
+
|
|
71
|
+
// SDP - PR 1
|
|
72
|
+
parseSdp,
|
|
73
|
+
stringifySdp,
|
|
74
|
+
|
|
75
|
+
// ICE - PR 1
|
|
76
|
+
parseCandidate,
|
|
77
|
+
stringifyCandidate,
|
|
78
|
+
filterCandidates,
|
|
79
|
+
isPrivateIp,
|
|
80
|
+
isLoopbackIp,
|
|
81
|
+
isLinkLocalIp,
|
|
82
|
+
isMdnsHostname,
|
|
83
|
+
CANDIDATE_TYPES,
|
|
84
|
+
TCP_TYPES,
|
|
85
|
+
|
|
86
|
+
// NAT traversal - later PRs
|
|
87
|
+
stunBinding,
|
|
88
|
+
encodeBindingRequest,
|
|
89
|
+
decodeMessage,
|
|
90
|
+
encodeXorMappedAddress,
|
|
91
|
+
decodeXorMappedAddress,
|
|
92
|
+
STUN_MAGIC_COOKIE,
|
|
93
|
+
STUN_METHOD,
|
|
94
|
+
STUN_CLASS,
|
|
95
|
+
STUN_ATTR,
|
|
96
|
+
issueTurnCredentials,
|
|
97
|
+
TurnServer,
|
|
98
|
+
|
|
99
|
+
// SFU + tokens - later PRs
|
|
100
|
+
SfuAdapter,
|
|
101
|
+
MemorySfuAdapter,
|
|
102
|
+
MediasoupSfuAdapter,
|
|
103
|
+
LiveKitSfuAdapter,
|
|
104
|
+
loadSfuAdapter,
|
|
105
|
+
signJoinToken,
|
|
106
|
+
verifyJoinToken,
|
|
107
|
+
|
|
108
|
+
// Server-side WebRTC peer (wrtc bot)
|
|
109
|
+
spawnBotPeer,
|
|
110
|
+
|
|
111
|
+
// Observability - PR 6
|
|
112
|
+
bindObservability,
|
|
113
|
+
|
|
114
|
+
// E2EE key relay - PR 7
|
|
115
|
+
E2eeChannel,
|
|
116
|
+
attachE2ee,
|
|
117
|
+
generateE2eeKeyPair,
|
|
118
|
+
sealKey,
|
|
119
|
+
openSealedKey,
|
|
120
|
+
|
|
121
|
+
// Cluster - PR 8
|
|
122
|
+
useCluster,
|
|
123
|
+
ClusterCoordinator,
|
|
124
|
+
MemoryClusterAdapter,
|
|
125
|
+
|
|
126
|
+
// CLI
|
|
127
|
+
runWebRTCCommand,
|
|
128
|
+
|
|
129
|
+
// Errors (re-exported from lib/errors.js so consumers can `instanceof` them
|
|
130
|
+
// through @zero-server/webrtc without also requiring @zero-server/errors).
|
|
131
|
+
WebRTCError, SignalingError, IceError, TurnError, SdpError,
|
|
132
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module webrtc/joinToken
|
|
3
|
+
* @description Signed, short-TTL join tokens that authenticate a peer's
|
|
4
|
+
* right to join a specific room. JWT-shaped (HS256 by default)
|
|
5
|
+
* and audience-scoped to `room:<name>` so a token leaked from
|
|
6
|
+
* one channel cannot be replayed against another.
|
|
7
|
+
*
|
|
8
|
+
* Reuses the canonical sign/verify primitives from `lib/auth/jwt.js`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const { sign, verify } = require('../auth');
|
|
14
|
+
const { SignalingError, WebRTCError } = require('../errors');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Issue a join token for `user` to enter `room`.
|
|
18
|
+
*
|
|
19
|
+
* @param {object} opts
|
|
20
|
+
* @param {string|Buffer} opts.secret - HMAC secret (HS256) or PEM key (RS256).
|
|
21
|
+
* @param {string|object} opts.user - User identifier (string) or object containing `id`.
|
|
22
|
+
* @param {string} opts.room - Target room name.
|
|
23
|
+
* @param {number} [opts.ttl=300] - Seconds until expiry. Negative values are accepted
|
|
24
|
+
* (used by tests to mint already-expired tokens).
|
|
25
|
+
* @param {string} [opts.algorithm='HS256']
|
|
26
|
+
* @param {string|string[]} [opts.audience] - Override the default `room:<name>` audience.
|
|
27
|
+
* @param {object} [opts.claims] - Additional claims merged into the payload.
|
|
28
|
+
* @returns {string} Compact JWT.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* const token = signJoinToken({
|
|
32
|
+
* secret: process.env.JOIN_SECRET,
|
|
33
|
+
* user: req.user,
|
|
34
|
+
* room: 'boardroom',
|
|
35
|
+
* ttl: 300,
|
|
36
|
+
* });
|
|
37
|
+
* res.json({ wsUrl: '/rtc', token });
|
|
38
|
+
*
|
|
39
|
+
* @section Signaling
|
|
40
|
+
*/
|
|
41
|
+
function signJoinToken(opts = {})
|
|
42
|
+
{
|
|
43
|
+
if (!opts || typeof opts !== 'object')
|
|
44
|
+
throw new SignalingError('signJoinToken: opts must be an object');
|
|
45
|
+
if (!opts.secret) throw new SignalingError('signJoinToken: secret is required');
|
|
46
|
+
if (opts.user === undefined || opts.user === null)
|
|
47
|
+
throw new SignalingError('signJoinToken: user is required');
|
|
48
|
+
if (typeof opts.room !== 'string' || opts.room.length === 0)
|
|
49
|
+
throw new SignalingError('signJoinToken: room is required');
|
|
50
|
+
|
|
51
|
+
const ttl = Number.isFinite(opts.ttl) ? opts.ttl : 300;
|
|
52
|
+
const sub = typeof opts.user === 'string' ? opts.user
|
|
53
|
+
: (opts.user && (opts.user.id || opts.user.userId || opts.user.sub));
|
|
54
|
+
if (!sub) throw new SignalingError('signJoinToken: user.id is required');
|
|
55
|
+
|
|
56
|
+
const payload = Object.assign({}, opts.claims || {}, {
|
|
57
|
+
room: opts.room,
|
|
58
|
+
user: typeof opts.user === 'object' ? opts.user : { id: sub },
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return sign(payload, opts.secret, {
|
|
62
|
+
algorithm: opts.algorithm || 'HS256',
|
|
63
|
+
expiresIn: ttl,
|
|
64
|
+
subject: String(sub),
|
|
65
|
+
audience: opts.audience || ('room:' + opts.room),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Verify a join token and return its payload. Throws a `WebRTCError` with
|
|
71
|
+
* `code: 'INVALID_TOKEN'` on any failure - bad signature, expired, audience
|
|
72
|
+
* mismatch, malformed, etc.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} token
|
|
75
|
+
* @param {object} opts
|
|
76
|
+
* @param {string|Buffer} opts.secret
|
|
77
|
+
* @param {string} [opts.room] - If supplied, audience must be `room:<room>`.
|
|
78
|
+
* @param {string|string[]} [opts.audience] - Explicit audience override.
|
|
79
|
+
* @param {string|string[]} [opts.algorithms=['HS256']]
|
|
80
|
+
* @param {number} [opts.clockTolerance=0]
|
|
81
|
+
* @returns {object} Verified payload.
|
|
82
|
+
*
|
|
83
|
+
* @section Signaling
|
|
84
|
+
*/
|
|
85
|
+
function verifyJoinToken(token, opts = {})
|
|
86
|
+
{
|
|
87
|
+
if (!opts || typeof opts !== 'object')
|
|
88
|
+
throw new WebRTCError('verifyJoinToken: opts must be an object', { code: 'INVALID_TOKEN' });
|
|
89
|
+
if (!opts.secret)
|
|
90
|
+
throw new WebRTCError('verifyJoinToken: secret is required', { code: 'INVALID_TOKEN' });
|
|
91
|
+
if (typeof token !== 'string' || token.length === 0)
|
|
92
|
+
throw new WebRTCError('verifyJoinToken: token must be a non-empty string', { code: 'INVALID_TOKEN' });
|
|
93
|
+
|
|
94
|
+
const audience = opts.audience || (opts.room ? 'room:' + opts.room : undefined);
|
|
95
|
+
try
|
|
96
|
+
{
|
|
97
|
+
const { payload } = verify(token, opts.secret, {
|
|
98
|
+
algorithms: opts.algorithms || ['HS256'],
|
|
99
|
+
audience,
|
|
100
|
+
clockTolerance: opts.clockTolerance || 0,
|
|
101
|
+
});
|
|
102
|
+
if (opts.room && payload.room && payload.room !== opts.room)
|
|
103
|
+
throw new WebRTCError('verifyJoinToken: room claim mismatch', { code: 'INVALID_TOKEN' });
|
|
104
|
+
return payload;
|
|
105
|
+
}
|
|
106
|
+
catch (err)
|
|
107
|
+
{
|
|
108
|
+
if (err instanceof WebRTCError) throw err;
|
|
109
|
+
throw new WebRTCError(
|
|
110
|
+
'verifyJoinToken: ' + (err && err.message ? err.message : 'invalid token'),
|
|
111
|
+
{ code: 'INVALID_TOKEN', cause: err && err.code ? err.code : undefined },
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = { signJoinToken, verifyJoinToken };
|