@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/turn/codec
|
|
3
|
+
* @description Zero-dependency TURN (RFC 5766 / 8656) message + attribute
|
|
4
|
+
* codec used by the embedded {@link TurnServer}. Re-uses the
|
|
5
|
+
* core STUN framing helpers in `lib/webrtc/stun.js`.
|
|
6
|
+
*
|
|
7
|
+
* Implements the subset of attributes the server actually exchanges:
|
|
8
|
+
* USERNAME, REALM, NONCE, MESSAGE-INTEGRITY, ERROR-CODE,
|
|
9
|
+
* XOR-MAPPED-ADDRESS, XOR-PEER-ADDRESS, XOR-RELAYED-ADDRESS,
|
|
10
|
+
* LIFETIME, REQUESTED-TRANSPORT, DATA, CHANNEL-NUMBER, plus
|
|
11
|
+
* ChannelData framing (RFC 5766 §11).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const crypto = require('node:crypto');
|
|
17
|
+
const net = require('node:net');
|
|
18
|
+
|
|
19
|
+
const { TurnError } = require('../../errors');
|
|
20
|
+
const {
|
|
21
|
+
STUN_MAGIC_COOKIE, STUN_CLASS,
|
|
22
|
+
encodeXorMappedAddress, decodeXorMappedAddress,
|
|
23
|
+
decodeMessage,
|
|
24
|
+
} = require('../stun');
|
|
25
|
+
|
|
26
|
+
// -- Constants --------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
const HEADER_LEN = 20;
|
|
29
|
+
const TXID_LEN = 12;
|
|
30
|
+
|
|
31
|
+
/** TURN method codes (RFC 5766 §13). */
|
|
32
|
+
const TURN_METHOD = Object.freeze({
|
|
33
|
+
ALLOCATE: 0x003,
|
|
34
|
+
REFRESH: 0x004,
|
|
35
|
+
SEND: 0x006,
|
|
36
|
+
DATA: 0x007,
|
|
37
|
+
CREATE_PERMISSION: 0x008,
|
|
38
|
+
CHANNEL_BIND: 0x009,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/** STUN + TURN attribute type codes. */
|
|
42
|
+
const ATTR = Object.freeze({
|
|
43
|
+
MAPPED_ADDRESS: 0x0001,
|
|
44
|
+
USERNAME: 0x0006,
|
|
45
|
+
MESSAGE_INTEGRITY: 0x0008,
|
|
46
|
+
ERROR_CODE: 0x0009,
|
|
47
|
+
REALM: 0x0014,
|
|
48
|
+
NONCE: 0x0015,
|
|
49
|
+
XOR_MAPPED_ADDRESS: 0x0020,
|
|
50
|
+
CHANNEL_NUMBER: 0x000C,
|
|
51
|
+
LIFETIME: 0x000D,
|
|
52
|
+
XOR_PEER_ADDRESS: 0x0012,
|
|
53
|
+
DATA: 0x0013,
|
|
54
|
+
XOR_RELAYED_ADDRESS: 0x0016,
|
|
55
|
+
REQUESTED_TRANSPORT: 0x0019,
|
|
56
|
+
SOFTWARE: 0x8022,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/** RFC 5766 §15: protocol IDs for REQUESTED-TRANSPORT. */
|
|
60
|
+
const PROTO_UDP = 17;
|
|
61
|
+
|
|
62
|
+
/** Channel numbers occupy 0x4000-0x4FFF per RFC 5766 §11. */
|
|
63
|
+
const CHANNEL_MIN = 0x4000;
|
|
64
|
+
const CHANNEL_MAX = 0x7FFE;
|
|
65
|
+
|
|
66
|
+
// -- Type bits --------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
/** @private */
|
|
69
|
+
function makeType(method, cls)
|
|
70
|
+
{
|
|
71
|
+
const m = method & 0xfff;
|
|
72
|
+
const c = cls & 0x3;
|
|
73
|
+
return ((m & 0xf80) << 2)
|
|
74
|
+
| ((c & 0x2) << 7)
|
|
75
|
+
| ((m & 0x70) << 1)
|
|
76
|
+
| ((c & 0x1) << 4)
|
|
77
|
+
| (m & 0xf);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// -- Padding ---------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
/** @private */
|
|
83
|
+
function pad4(n) { return (4 - (n % 4)) % 4; }
|
|
84
|
+
|
|
85
|
+
// -- Attribute serialization ----------------------------------------------
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Serialize a list of attributes (without MESSAGE-INTEGRITY).
|
|
89
|
+
*
|
|
90
|
+
* @param {Array<{type:number, value:Buffer}>} attrs
|
|
91
|
+
* @returns {Buffer}
|
|
92
|
+
*/
|
|
93
|
+
function serializeAttributes(attrs)
|
|
94
|
+
{
|
|
95
|
+
const parts = [];
|
|
96
|
+
let total = 0;
|
|
97
|
+
for (const a of attrs)
|
|
98
|
+
{
|
|
99
|
+
const v = a.value || Buffer.alloc(0);
|
|
100
|
+
const head = Buffer.alloc(4);
|
|
101
|
+
head.writeUInt16BE(a.type, 0);
|
|
102
|
+
head.writeUInt16BE(v.length, 2);
|
|
103
|
+
parts.push(head, v);
|
|
104
|
+
const p = pad4(v.length);
|
|
105
|
+
if (p > 0) parts.push(Buffer.alloc(p));
|
|
106
|
+
total += 4 + v.length + p;
|
|
107
|
+
}
|
|
108
|
+
const out = Buffer.concat(parts, total);
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Encode a full TURN message, optionally signed with MESSAGE-INTEGRITY.
|
|
114
|
+
*
|
|
115
|
+
* @param {number} method
|
|
116
|
+
* @param {number} cls STUN_CLASS.*
|
|
117
|
+
* @param {Buffer} txid 12-byte transaction id
|
|
118
|
+
* @param {Array<{type:number,value:Buffer}>} attrs
|
|
119
|
+
* @param {Buffer} [integrityKey] If present, appends MESSAGE-INTEGRITY using
|
|
120
|
+
* HMAC-SHA1 over the message with the length
|
|
121
|
+
* field set to include the integrity attr.
|
|
122
|
+
* @returns {Buffer}
|
|
123
|
+
*/
|
|
124
|
+
function encodeMessage(method, cls, txid, attrs, integrityKey)
|
|
125
|
+
{
|
|
126
|
+
if (!Buffer.isBuffer(txid) || txid.length !== TXID_LEN)
|
|
127
|
+
throw new TurnError('encodeMessage: txid must be 12 bytes');
|
|
128
|
+
|
|
129
|
+
const body = serializeAttributes(attrs);
|
|
130
|
+
const includesMI = !!integrityKey;
|
|
131
|
+
const miLen = includesMI ? 24 : 0; // 4 header + 20 SHA1
|
|
132
|
+
|
|
133
|
+
const header = Buffer.alloc(HEADER_LEN);
|
|
134
|
+
header.writeUInt16BE(makeType(method, cls), 0);
|
|
135
|
+
header.writeUInt16BE(body.length + miLen, 2);
|
|
136
|
+
header.writeUInt32BE(STUN_MAGIC_COOKIE, 4);
|
|
137
|
+
txid.copy(header, 8);
|
|
138
|
+
|
|
139
|
+
if (!includesMI)
|
|
140
|
+
return Buffer.concat([header, body]);
|
|
141
|
+
|
|
142
|
+
const preMI = Buffer.concat([header, body]);
|
|
143
|
+
const mac = crypto.createHmac('sha1', integrityKey).update(preMI).digest();
|
|
144
|
+
const miAttr = Buffer.alloc(4 + 20);
|
|
145
|
+
miAttr.writeUInt16BE(ATTR.MESSAGE_INTEGRITY, 0);
|
|
146
|
+
miAttr.writeUInt16BE(20, 2);
|
|
147
|
+
mac.copy(miAttr, 4);
|
|
148
|
+
return Buffer.concat([preMI, miAttr]);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// -- Attribute helpers (extract from a decoded message) -------------------
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* @param {{attributes:Array<{type:number,value:Buffer}>}} msg
|
|
155
|
+
* @param {number} type
|
|
156
|
+
* @returns {Buffer|null}
|
|
157
|
+
*/
|
|
158
|
+
function getAttr(msg, type)
|
|
159
|
+
{
|
|
160
|
+
for (const a of msg.attributes) if (a.type === type) return a.value;
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* @param {{attributes:Array<{type:number,value:Buffer}>}} msg
|
|
166
|
+
* @param {number} type
|
|
167
|
+
* @returns {Buffer[]}
|
|
168
|
+
*/
|
|
169
|
+
function getAttrs(msg, type)
|
|
170
|
+
{
|
|
171
|
+
const out = [];
|
|
172
|
+
for (const a of msg.attributes) if (a.type === type) out.push(a.value);
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// -- Integrity validation -------------------------------------------------
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Compute the long-term-credential key per RFC 5766 §15.4:
|
|
180
|
+
* `MD5(username ":" realm ":" password)`.
|
|
181
|
+
*/
|
|
182
|
+
function longTermKey(username, realm, password)
|
|
183
|
+
{
|
|
184
|
+
return crypto.createHash('md5')
|
|
185
|
+
.update(`${username}:${realm}:${password}`)
|
|
186
|
+
.digest();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Validate MESSAGE-INTEGRITY on a freshly received buffer. Recomputes the
|
|
191
|
+
* HMAC over the prefix up to (but not including) the integrity attribute
|
|
192
|
+
* header, with the STUN length field rewritten to terminate after the
|
|
193
|
+
* integrity attribute.
|
|
194
|
+
*
|
|
195
|
+
* @param {Buffer} raw - The full datagram as received.
|
|
196
|
+
* @param {Buffer} key - Long-term key.
|
|
197
|
+
* @param {Buffer} integrityVal - The 20-byte MAC from the message.
|
|
198
|
+
* @param {number} integrityAttrStart - Offset where the MI TLV begins.
|
|
199
|
+
* @returns {boolean}
|
|
200
|
+
*/
|
|
201
|
+
function verifyIntegrity(raw, key, integrityVal, integrityAttrStart)
|
|
202
|
+
{
|
|
203
|
+
if (!Buffer.isBuffer(integrityVal) || integrityVal.length !== 20) return false;
|
|
204
|
+
// Build the buffer the sender HMAC'd: header (with length set to MI end)
|
|
205
|
+
// + body up to MI attribute header.
|
|
206
|
+
const lenField = (integrityAttrStart - HEADER_LEN) + 24;
|
|
207
|
+
const tmp = Buffer.from(raw.subarray(0, integrityAttrStart));
|
|
208
|
+
tmp.writeUInt16BE(lenField, 2);
|
|
209
|
+
const mac = crypto.createHmac('sha1', key).update(tmp).digest();
|
|
210
|
+
return crypto.timingSafeEqual(mac, integrityVal);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Locate the MESSAGE-INTEGRITY attribute inside a raw STUN message and
|
|
215
|
+
* return `{ value, offset }` where `offset` is the start of the TLV.
|
|
216
|
+
*
|
|
217
|
+
* @param {Buffer} raw
|
|
218
|
+
* @returns {{value:Buffer, offset:number}|null}
|
|
219
|
+
*/
|
|
220
|
+
function findIntegrity(raw)
|
|
221
|
+
{
|
|
222
|
+
if (raw.length < HEADER_LEN) return null;
|
|
223
|
+
const declaredLen = raw.readUInt16BE(2);
|
|
224
|
+
if (HEADER_LEN + declaredLen > raw.length) return null;
|
|
225
|
+
let off = HEADER_LEN;
|
|
226
|
+
const end = HEADER_LEN + declaredLen;
|
|
227
|
+
while (off + 4 <= end)
|
|
228
|
+
{
|
|
229
|
+
const t = raw.readUInt16BE(off);
|
|
230
|
+
const l = raw.readUInt16BE(off + 2);
|
|
231
|
+
const vEnd = off + 4 + l;
|
|
232
|
+
if (vEnd > end) return null;
|
|
233
|
+
if (t === ATTR.MESSAGE_INTEGRITY)
|
|
234
|
+
return { value: Buffer.from(raw.subarray(off + 4, vEnd)), offset: off };
|
|
235
|
+
off = vEnd + pad4(l);
|
|
236
|
+
}
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// -- Specific attribute encoders ------------------------------------------
|
|
241
|
+
|
|
242
|
+
/** Encode ERROR-CODE attribute body (RFC 8489 §14.8). */
|
|
243
|
+
function encodeErrorCode(code, reason)
|
|
244
|
+
{
|
|
245
|
+
const reasonBuf = Buffer.from(String(reason || ''), 'utf8');
|
|
246
|
+
const out = Buffer.alloc(4 + reasonBuf.length);
|
|
247
|
+
out.writeUInt8(0, 0);
|
|
248
|
+
out.writeUInt8(0, 1);
|
|
249
|
+
out.writeUInt8(Math.floor(code / 100) & 0x7, 2);
|
|
250
|
+
out.writeUInt8(code % 100, 3);
|
|
251
|
+
reasonBuf.copy(out, 4);
|
|
252
|
+
return out;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Decode ERROR-CODE attribute body. */
|
|
256
|
+
function decodeErrorCode(buf)
|
|
257
|
+
{
|
|
258
|
+
if (!Buffer.isBuffer(buf) || buf.length < 4)
|
|
259
|
+
throw new TurnError('decodeErrorCode: too short');
|
|
260
|
+
const cls = buf.readUInt8(2) & 0x7;
|
|
261
|
+
const num = buf.readUInt8(3);
|
|
262
|
+
const reason = buf.slice(4).toString('utf8');
|
|
263
|
+
return { code: cls * 100 + num, reason };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Encode 4-byte unsigned integer attribute (LIFETIME / CHANNEL-NUMBER). */
|
|
267
|
+
function encodeUInt32(n)
|
|
268
|
+
{
|
|
269
|
+
const b = Buffer.alloc(4);
|
|
270
|
+
b.writeUInt32BE(n >>> 0, 0);
|
|
271
|
+
return b;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function decodeUInt32(buf)
|
|
275
|
+
{
|
|
276
|
+
if (!Buffer.isBuffer(buf) || buf.length < 4)
|
|
277
|
+
throw new TurnError('decodeUInt32: too short');
|
|
278
|
+
return buf.readUInt32BE(0);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Encode REQUESTED-TRANSPORT (1 byte proto + 3 bytes RFFU). */
|
|
282
|
+
function encodeRequestedTransport(proto)
|
|
283
|
+
{
|
|
284
|
+
const b = Buffer.alloc(4);
|
|
285
|
+
b.writeUInt8(proto & 0xff, 0);
|
|
286
|
+
return b;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Encode CHANNEL-NUMBER (2 bytes + 2 RFFU). */
|
|
290
|
+
function encodeChannelNumber(num)
|
|
291
|
+
{
|
|
292
|
+
const b = Buffer.alloc(4);
|
|
293
|
+
b.writeUInt16BE(num & 0xffff, 0);
|
|
294
|
+
return b;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function decodeChannelNumber(buf)
|
|
298
|
+
{
|
|
299
|
+
if (!Buffer.isBuffer(buf) || buf.length < 2)
|
|
300
|
+
throw new TurnError('decodeChannelNumber: too short');
|
|
301
|
+
return buf.readUInt16BE(0);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// -- ChannelData framing --------------------------------------------------
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Wrap a payload in a ChannelData frame (RFC 5766 §11.4).
|
|
308
|
+
* Layout: `[ channel:2 ][ length:2 ][ payload ][ pad to 4 ]`.
|
|
309
|
+
*/
|
|
310
|
+
function encodeChannelData(channel, payload)
|
|
311
|
+
{
|
|
312
|
+
const len = payload.length;
|
|
313
|
+
const padLen = pad4(len);
|
|
314
|
+
const buf = Buffer.alloc(4 + len + padLen);
|
|
315
|
+
buf.writeUInt16BE(channel & 0xffff, 0);
|
|
316
|
+
buf.writeUInt16BE(len, 2);
|
|
317
|
+
payload.copy(buf, 4);
|
|
318
|
+
return buf;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Decode a ChannelData frame. Returns null when the buffer does not look
|
|
323
|
+
* like ChannelData (high two bits non-zero, or too short).
|
|
324
|
+
*/
|
|
325
|
+
function decodeChannelData(buf)
|
|
326
|
+
{
|
|
327
|
+
if (!Buffer.isBuffer(buf) || buf.length < 4) return null;
|
|
328
|
+
const first = buf.readUInt16BE(0);
|
|
329
|
+
if (first < CHANNEL_MIN || first > CHANNEL_MAX) return null;
|
|
330
|
+
const len = buf.readUInt16BE(2);
|
|
331
|
+
if (4 + len > buf.length) return null;
|
|
332
|
+
return { channel: first, payload: Buffer.from(buf.subarray(4, 4 + len)) };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Detect message kind from the first 2 bytes. STUN messages begin with
|
|
337
|
+
* `00`, ChannelData messages with `01`.
|
|
338
|
+
*/
|
|
339
|
+
function looksLikeChannelData(buf)
|
|
340
|
+
{
|
|
341
|
+
if (!Buffer.isBuffer(buf) || buf.length < 1) return false;
|
|
342
|
+
return (buf[0] & 0xc0) !== 0;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// -- XOR address helpers (re-exported from stun.js for convenience) ------
|
|
346
|
+
|
|
347
|
+
function encodeXorAddress(address, port, txid) { return encodeXorMappedAddress(address, port, txid); }
|
|
348
|
+
function decodeXorAddress(buf, txid) { return decodeXorMappedAddress(buf, txid); }
|
|
349
|
+
|
|
350
|
+
/** Quick textual key for an `{address,port}` pair. */
|
|
351
|
+
function endpointKey(address, port) { return `${address}:${port}`; }
|
|
352
|
+
|
|
353
|
+
/** True iff `s` looks like an IPv4 or IPv6 literal. */
|
|
354
|
+
function isIp(s) { return net.isIP(s) > 0; }
|
|
355
|
+
|
|
356
|
+
module.exports = {
|
|
357
|
+
HEADER_LEN, TXID_LEN, STUN_CLASS,
|
|
358
|
+
TURN_METHOD, ATTR, PROTO_UDP,
|
|
359
|
+
CHANNEL_MIN, CHANNEL_MAX,
|
|
360
|
+
serializeAttributes, encodeMessage, decodeMessage,
|
|
361
|
+
getAttr, getAttrs,
|
|
362
|
+
longTermKey, verifyIntegrity, findIntegrity,
|
|
363
|
+
encodeErrorCode, decodeErrorCode,
|
|
364
|
+
encodeUInt32, decodeUInt32,
|
|
365
|
+
encodeRequestedTransport,
|
|
366
|
+
encodeChannelNumber, decodeChannelNumber,
|
|
367
|
+
encodeChannelData, decodeChannelData, looksLikeChannelData,
|
|
368
|
+
encodeXorAddress, decodeXorAddress,
|
|
369
|
+
endpointKey, isIp,
|
|
370
|
+
};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file lib/webrtc/turn/credentials.js
|
|
3
|
+
* @module @zero-server/webrtc/turn/credentials
|
|
4
|
+
* @description RFC 7635 ephemeral TURN credentials.
|
|
5
|
+
*
|
|
6
|
+
* Generates time-limited username / credential pairs that any
|
|
7
|
+
* RFC 7635-compatible TURN server (notably `coturn` with
|
|
8
|
+
* `use-auth-secret` + `static-auth-secret=<S>`) will accept.
|
|
9
|
+
*
|
|
10
|
+
* Wire format (RFC 7635 §6.2):
|
|
11
|
+
* username = "<unix-expiry>:<userId>"
|
|
12
|
+
* credential = base64( HMAC-SHA1( <secret>, username ) )
|
|
13
|
+
*
|
|
14
|
+
* The returned object is shaped like an `RTCIceServer` entry so it can
|
|
15
|
+
* be embedded straight into the ICE-server list a signaling endpoint
|
|
16
|
+
* serves to browsers.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
const crypto = require('node:crypto');
|
|
22
|
+
const { TurnError } = require('../../errors');
|
|
23
|
+
|
|
24
|
+
// --- Constants ---
|
|
25
|
+
|
|
26
|
+
const DEFAULT_TTL_SECONDS = 86400; // 24h
|
|
27
|
+
|
|
28
|
+
/** Schemes the W3C `RTCIceServer.urls` field is allowed to contain. */
|
|
29
|
+
const VALID_SCHEMES = ['turn:', 'turns:', 'stun:', 'stuns:'];
|
|
30
|
+
|
|
31
|
+
// --- Public API ---
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @typedef {object} IssueTurnCredentialsOptions
|
|
35
|
+
* @property {string} secret - Shared secret matching the TURN server's `static-auth-secret`.
|
|
36
|
+
* @property {string|number} userId - Identifier embedded in the username (audited by coturn).
|
|
37
|
+
* @property {number|string} [ttl] - Lifetime: seconds (number) or duration string ("30s", "20m", "2h", "1d"). Default 24h.
|
|
38
|
+
* @property {string|string[]} servers - TURN/STUN URL(s) to embed in the returned `urls` field.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @typedef {object} TurnCredentials
|
|
43
|
+
* @property {string[]} urls - The TURN/STUN URLs the browser should try.
|
|
44
|
+
* @property {string} username - `<expiryUnix>:<userId>`.
|
|
45
|
+
* @property {string} credential - base64(HMAC-SHA1(secret, username)).
|
|
46
|
+
* @property {number} ttl - Lifetime in seconds (mirrors `ttl` input, for caching hints).
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Mint an ephemeral TURN credential per RFC 7635.
|
|
51
|
+
*
|
|
52
|
+
* @param {IssueTurnCredentialsOptions} opts
|
|
53
|
+
* @returns {TurnCredentials}
|
|
54
|
+
* @throws {TurnError} On missing / invalid input.
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* const creds = issueTurnCredentials({
|
|
58
|
+
* secret: process.env.TURN_SHARED_SECRET,
|
|
59
|
+
* userId: req.user.id,
|
|
60
|
+
* ttl: '20m',
|
|
61
|
+
* servers: ['turn:turn.example.com:3478?transport=udp'],
|
|
62
|
+
* });
|
|
63
|
+
* res.json(creds);
|
|
64
|
+
*
|
|
65
|
+
* @section ICE & TURN
|
|
66
|
+
*/
|
|
67
|
+
function issueTurnCredentials(opts)
|
|
68
|
+
{
|
|
69
|
+
const o = opts || {};
|
|
70
|
+
|
|
71
|
+
if (typeof o.secret !== 'string' || o.secret.length === 0)
|
|
72
|
+
throw new TurnError('issueTurnCredentials: opts.secret is required');
|
|
73
|
+
if (o.userId === undefined || o.userId === null || o.userId === '')
|
|
74
|
+
throw new TurnError('issueTurnCredentials: opts.userId is required');
|
|
75
|
+
|
|
76
|
+
const servers = _normalizeServers(o.servers);
|
|
77
|
+
const ttl = _normalizeTtl(o.ttl);
|
|
78
|
+
const expiry = Math.floor(Date.now() / 1000) + ttl;
|
|
79
|
+
|
|
80
|
+
const username = `${expiry}:${String(o.userId)}`;
|
|
81
|
+
const credential = crypto.createHmac('sha1', o.secret).update(username).digest('base64');
|
|
82
|
+
|
|
83
|
+
return { urls: servers, username, credential, ttl };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// --- Helpers ---
|
|
87
|
+
|
|
88
|
+
/** @private */
|
|
89
|
+
function _normalizeServers(servers)
|
|
90
|
+
{
|
|
91
|
+
if (servers === undefined || servers === null)
|
|
92
|
+
throw new TurnError('issueTurnCredentials: opts.servers is required');
|
|
93
|
+
|
|
94
|
+
const list = Array.isArray(servers) ? servers : [servers];
|
|
95
|
+
if (list.length === 0)
|
|
96
|
+
throw new TurnError('issueTurnCredentials: opts.servers must not be empty');
|
|
97
|
+
|
|
98
|
+
for (const url of list)
|
|
99
|
+
{
|
|
100
|
+
if (typeof url !== 'string' || url.length === 0)
|
|
101
|
+
throw new TurnError('issueTurnCredentials: server URL must be a non-empty string');
|
|
102
|
+
if (!VALID_SCHEMES.some(s => url.toLowerCase().startsWith(s)))
|
|
103
|
+
{
|
|
104
|
+
throw new TurnError(
|
|
105
|
+
`issueTurnCredentials: server URL must use turn:/turns:/stun:/stuns: scheme (got "${url}")`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return list.slice();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** @private */
|
|
113
|
+
function _normalizeTtl(ttl)
|
|
114
|
+
{
|
|
115
|
+
if (ttl === undefined || ttl === null) return DEFAULT_TTL_SECONDS;
|
|
116
|
+
|
|
117
|
+
if (typeof ttl === 'number')
|
|
118
|
+
{
|
|
119
|
+
if (!Number.isFinite(ttl) || ttl <= 0)
|
|
120
|
+
throw new TurnError(`issueTurnCredentials: ttl must be > 0 (got ${ttl})`);
|
|
121
|
+
return Math.floor(ttl);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (typeof ttl === 'string')
|
|
125
|
+
{
|
|
126
|
+
const m = /^(\d+)\s*(s|m|h|d)?$/i.exec(ttl.trim());
|
|
127
|
+
if (!m) throw new TurnError(`issueTurnCredentials: invalid ttl string "${ttl}"`);
|
|
128
|
+
const n = Number(m[1]);
|
|
129
|
+
const unit = (m[2] || 's').toLowerCase();
|
|
130
|
+
const mult = unit === 's' ? 1 : unit === 'm' ? 60 : unit === 'h' ? 3600 : 86400;
|
|
131
|
+
const secs = n * mult;
|
|
132
|
+
if (secs <= 0) throw new TurnError(`issueTurnCredentials: ttl must be > 0 (got "${ttl}")`);
|
|
133
|
+
return secs;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
throw new TurnError(`issueTurnCredentials: ttl must be a number or duration string (got ${typeof ttl})`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
module.exports = {
|
|
140
|
+
issueTurnCredentials,
|
|
141
|
+
};
|