ep_webrtc 2.2.0 → 2.3.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/README.md CHANGED
@@ -185,6 +185,31 @@ Example:
185
185
  },
186
186
  ```
187
187
 
188
+ #### Horizontally scaled TURN servers
189
+
190
+ To spread load across multiple TURN services, you can enable sharding:
191
+
192
+ ```json
193
+ "ep_webrtc": {
194
+ "iceServers": [
195
+ {"urls": ["stun:shard0.example.com", "turn:shard0.example.com"]},
196
+ {"urls": ["stun:shard1.example.com", "turn:shard1.example.com"]},
197
+ {"urls": ["stun:shard2.example.com", "turn:shard2.example.com"]},
198
+ {"urls": ["stun:shard3.example.com", "turn:shard3.example.com"]},
199
+ ],
200
+ "shardIceServers": true
201
+ },
202
+ ```
203
+
204
+ When `shardIceServers` is `false` (the default), all clients receive all
205
+ RTCIceServer objects in the `iceServers` list and it's up to the browser to
206
+ figure out how to use them to connect with peers. When `true`, this plugin
207
+ assigns a single entry from `iceServers` to each pad and gives out only that
208
+ assigned entry to users that connect to the pad. The intention is to provide a
209
+ better guarantee of load distribution across a set of TURN servers, and to avoid
210
+ an unnecessary network hop when both peers are configured to force the use of
211
+ TURN.
212
+
188
213
  ### Microphone Settings
189
214
 
190
215
  The microphone can be configured by setting `audio.constraints` to any [audio
package/index.js CHANGED
@@ -49,8 +49,10 @@ const defaultSettings = {
49
49
  iceServers: [{urls: ['stun:stun.l.google.com:19302']}],
50
50
  listenClass: null,
51
51
  moreInfoUrl: {},
52
+ shardIceServers: false,
52
53
  };
53
54
  let settings = null;
55
+ let shardIceServersHmacSecret;
54
56
  let socketio;
55
57
 
56
58
  const addContextToError = (err, pfx) => {
@@ -139,54 +141,90 @@ const fetchJson = async (url, opts = {}) => {
139
141
  return await res.json();
140
142
  };
141
143
 
142
- exports.clientVars = async (hookName, {clientVars: {userId: authorId}}) => ({ep_webrtc: {
143
- ...settings,
144
- iceServers: await Promise.all(settings.iceServers.map(async (server) => {
145
- switch (server.credentialType) {
146
- case 'coturn ephemeral password': {
147
- const {lifetime = 60 * 60 * 12 /* seconds */} = server;
148
- const username = `${Math.floor(Date.now() / 1000) + lifetime}:${authorId}`;
149
- const hmac = crypto.createHmac('sha1', server.credential);
150
- hmac.update(username);
151
- const credential = hmac.digest('base64');
152
- return {urls: server.urls, username, credential};
153
- }
154
- case 'xirsys ephemeral credentials': {
155
- const {
156
- url,
157
- username,
158
- credential,
159
- lifetime: expire = 12 * 60 * 60, // seconds
160
- method = 'PUT',
161
- headers: h = {},
162
- jsonBody: b = {},
163
- } = server;
164
- // Can't set default values for the Content-Type and Authorization headers by using an
165
- // object literal with spread (e.g., `{'content-type': 'foo', ...h}`) because the Headers
166
- // constructor uses `.append()` internally instead of `.set()`. This matters if a header is
167
- // repeated multiple times by using different mixes of upper- and lower-case letters.
168
- const headers = new globalThis.Headers(h);
169
- if (!headers.has('content-type')) headers.set('content-type', 'application/json');
170
- if (username && !headers.has('authorization')) {
171
- headers.set('authorization',
172
- `Basic ${Buffer.from(`${username}:${credential}`).toString('base64')}`);
144
+ exports.clientVars = async (hookName, {clientVars: {userId: authorId}, pad: {id: padId}}) => {
145
+ let iceServers = settings.iceServers;
146
+ if (settings.shardIceServers && iceServers.length > 1) {
147
+ // We could simply hash the pad ID, but we include some randomness to make it slightly harder
148
+ // for a malicious user to overload a particular shard by picking pad IDs that all use the same
149
+ // shard. (The randomness forces malicious users to try multiple pad IDs and keep the ones that
150
+ // use the same shard.) The randomness also helps avoid chronic imbalance due to unlucky
151
+ // assignments; generating a new secret will reassign the shards.
152
+ //
153
+ // The secret is generated at startup, so all users visiting the same pad will get the same HMAC
154
+ // value (and thus the same shard) until Etherpad is restarted. Users that connect after
155
+ // Etherpad restarts might be assigned a different shard from the users on the pad that received
156
+ // their clientVars before Etherpad restarted. This doesn't affect protocol correctness, but it
157
+ // might result in three network hops instead of two: client A sends to TURN A which relays to
158
+ // TURN B which relays to client B, instead of client A sends to TURN AB which relays to
159
+ // localhost (TURN AB) which relays to client B. This should be rare because it will only happen
160
+ // if all of the following are true:
161
+ //
162
+ // * Both users have configured their browsers to force relay.
163
+ // * One user loaded the pad before Etherpad restarted and the other loaded after.
164
+ // * The new random value caused the pad to be assigned to a different shard.
165
+ //
166
+ // TODO: Convey ICE servers via a message that is sent every time a user connects. (CLIENT_VARS
167
+ // is only sent on initial connection, so if a client reconnects due to Etherpad restarting, a
168
+ // new CLIENT_VARS is not sent.) This will allow the server to select a different shard for a
169
+ // pad when it restarts, and all clients (old and new) will use the new shard for new sessions.
170
+ //
171
+ // TODO: Select the shard for the pad when the first user joins the pad and forget that
172
+ // selection once all users have left. This would enable alternative load balancing schemes such
173
+ // as true random or least loaded.
174
+ const hmac = crypto.createHmac('sha256', shardIceServersHmacSecret);
175
+ hmac.update(padId);
176
+ const i = Number(BigInt(`0x${hmac.digest('hex')}`) % BigInt(iceServers.length));
177
+ iceServers = iceServers.slice(i, i + 1);
178
+ }
179
+ return {ep_webrtc: {
180
+ ...settings,
181
+ iceServers: await Promise.all(iceServers.map(async (server) => {
182
+ switch (server.credentialType) {
183
+ case 'coturn ephemeral password': {
184
+ const {lifetime = 60 * 60 * 12 /* seconds */} = server;
185
+ const username = `${Math.floor(Date.now() / 1000) + lifetime}:${authorId}`;
186
+ const hmac = crypto.createHmac('sha1', server.credential);
187
+ hmac.update(username);
188
+ const credential = hmac.digest('base64');
189
+ return {urls: server.urls, username, credential};
173
190
  }
174
- const body =
175
- JSON.stringify(b && typeof b === 'object' ? {format: 'urls', expire, ...b} : b);
176
- try {
177
- const {v, s} = await fetchJson(url, {method, headers, body});
178
- if (s !== 'ok') throw new Error(`API error: ${v}`);
179
- return v.iceServers;
180
- } catch (err) {
181
- const newErr = addContextToError(err, 'failed to get TURN credentials: ');
182
- logger.error(newErr.stack || newErr.toString());
183
- throw newErr;
191
+ case 'xirsys ephemeral credentials': {
192
+ const {
193
+ url,
194
+ username,
195
+ credential,
196
+ lifetime: expire = 12 * 60 * 60, // seconds
197
+ method = 'PUT',
198
+ headers: h = {},
199
+ jsonBody: b = {},
200
+ } = server;
201
+ // Can't set default values for the Content-Type and Authorization headers by using an
202
+ // object literal with spread (e.g., `{'content-type': 'foo', ...h}`) because the Headers
203
+ // constructor uses `.append()` internally instead of `.set()`. This matters if a header
204
+ // is repeated multiple times by using different mixes of upper- and lower-case letters.
205
+ const headers = new globalThis.Headers(h);
206
+ if (!headers.has('content-type')) headers.set('content-type', 'application/json');
207
+ if (username && !headers.has('authorization')) {
208
+ headers.set('authorization',
209
+ `Basic ${Buffer.from(`${username}:${credential}`).toString('base64')}`);
210
+ }
211
+ const body =
212
+ JSON.stringify(b && typeof b === 'object' ? {format: 'urls', expire, ...b} : b);
213
+ try {
214
+ const {v, s} = await fetchJson(url, {method, headers, body});
215
+ if (s !== 'ok') throw new Error(`API error: ${v}`);
216
+ return v.iceServers;
217
+ } catch (err) {
218
+ const newErr = addContextToError(err, 'failed to get TURN credentials: ');
219
+ logger.error(newErr.stack || newErr.toString());
220
+ throw newErr;
221
+ }
184
222
  }
223
+ default: return server;
185
224
  }
186
- default: return server;
187
- }
188
- })),
189
- }});
225
+ })),
226
+ }};
227
+ };
190
228
 
191
229
  exports.handleMessage = async (hookName, {message, socket}) => {
192
230
  if (message.type === 'COLLABROOM' && message.data.type === 'RTC_MESSAGE') {
@@ -243,6 +281,9 @@ exports.loadSettings = async (hookName, {settings: {ep_webrtc: s = {}}}) => {
243
281
  }
244
282
  return false;
245
283
  })();
284
+ if (settings.shardIceServers && settings.iceServers.length > 1) {
285
+ shardIceServersHmacSecret = await util.promisify(crypto.randomBytes.bind(crypto))(16);
286
+ }
246
287
  logger.info('configured:', util.inspect({
247
288
  ...settings,
248
289
  iceServers: settings.iceServers.map((s) => s.credential ? {...s, credential: '*****'} : s),
package/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "url": "git@github.com:ether/ep_webrtc.git",
6
6
  "type": "git"
7
7
  },
8
- "version": "2.2.0",
8
+ "version": "2.3.0",
9
9
  "description": "WebRTC based audio/video chat to Etherpad",
10
10
  "author": "John McLear <john@mclear.co.uk>",
11
11
  "contributors": [],
@@ -22,13 +22,15 @@
22
22
  "url": "https://etherpad.org/"
23
23
  },
24
24
  "devDependencies": {
25
- "eslint": "^8.10.0",
26
- "eslint-config-etherpad": "^3.0.5",
25
+ "eslint": "^8.11.0",
26
+ "eslint-config-etherpad": "^3.0.9",
27
+ "mocha": "^9.2.2",
27
28
  "typescript": "^4.6.2"
28
29
  },
29
30
  "scripts": {
30
31
  "lint": "eslint .",
31
- "lint:fix": "eslint --fix ."
32
+ "lint:fix": "eslint --fix .",
33
+ "test": "mocha --recursive static/tests/backend/specs"
32
34
  },
33
35
  "peerDependencies": {
34
36
  "ep_etherpad-lite": ">=1.8.7"
@@ -0,0 +1,24 @@
1
+ 'use strict';
2
+
3
+ const common = require('ep_etherpad-lite/tests/backend/common');
4
+ const fsp = require('fs').promises;
5
+ const path = require('path');
6
+ const pluginDefs = require('ep_etherpad-lite/static/js/pluginfw/plugin_defs');
7
+ const plugins = require('ep_etherpad-lite/static/js/pluginfw/plugins');
8
+
9
+ module.exports = async () => {
10
+ const agent = await common.init();
11
+ if (pluginDefs.plugins.ep_webrtc == null) {
12
+ const packagePath = path.dirname(require.resolve('../../../package.json'));
13
+ plugins.getPackages = async () => ({
14
+ 'ep_etherpad-lite': pluginDefs.plugins['ep_etherpad-lite'].package,
15
+ 'ep_webrtc': {
16
+ ...require('../../../package.json'),
17
+ path: packagePath,
18
+ realPath: await fsp.realpath(packagePath),
19
+ },
20
+ });
21
+ await plugins.update();
22
+ }
23
+ return agent;
24
+ };
@@ -0,0 +1,130 @@
1
+ 'use strict';
2
+
3
+ const assert = require('assert').strict;
4
+ const common = require('ep_etherpad-lite/tests/backend/common');
5
+ const init = require('../init');
6
+ const plugin = require('../../../../index');
7
+ const settings = require('ep_etherpad-lite/node/utils/Settings');
8
+
9
+ describe(__filename, function () {
10
+ let agent;
11
+ const backup = {settings: {...settings}};
12
+ const iceServers = [...Array(1000).keys()].map((i) => ({urls: [`turn:turn${i}.example.com`]}));
13
+
14
+ const reload = async (settings = {}) => {
15
+ await plugin.loadSettings('loadSettings', {settings: {ep_webrtc: {iceServers, ...settings}}});
16
+ };
17
+
18
+ const getIceServers = async (padId = common.randomString()) => {
19
+ while (getIceServers._busy != null) {
20
+ await getIceServers._busy;
21
+ }
22
+ if (++getIceServers._active >= getIceServers._limit) {
23
+ getIceServers._busy = new Promise((resolve) => getIceServers._resolve = resolve);
24
+ }
25
+ try {
26
+ const res = await agent.get(`/p/${padId}`).expect(200);
27
+ const socket = await common.connect(res);
28
+ try {
29
+ const {type, data: clientVars} = await common.handshake(socket, padId);
30
+ assert.equal(type, 'CLIENT_VARS');
31
+ return clientVars.ep_webrtc.iceServers;
32
+ } finally {
33
+ socket.close();
34
+ }
35
+ } finally {
36
+ if (--getIceServers._active < getIceServers._limit && getIceServers._busy != null) {
37
+ getIceServers._resolve();
38
+ getIceServers._busy = null;
39
+ }
40
+ }
41
+ };
42
+ getIceServers._limit = 5; // Avoid timeouts caused by overload.
43
+ getIceServers._active = 0;
44
+ getIceServers._resolve = () => {};
45
+
46
+ before(async function () {
47
+ settings.requireAuthentication = false;
48
+ agent = await init();
49
+ });
50
+
51
+ after(async function () {
52
+ Object.assign(settings, backup.settings);
53
+ await plugin.loadSettings('loadSettings', {settings});
54
+ });
55
+
56
+ it('defaults to disabled', async function () {
57
+ await reload();
58
+ const got = await getIceServers();
59
+ assert.deepEqual(got, iceServers);
60
+ });
61
+
62
+ it('explicitly disabled', async function () {
63
+ await reload({shardIceServers: false});
64
+ const got = await getIceServers();
65
+ assert.deepEqual(got, iceServers);
66
+ });
67
+
68
+ it('enabled, zero entries', async function () {
69
+ await reload({iceServers: [], shardIceServers: true});
70
+ assert.deepEqual(await getIceServers(), []);
71
+ });
72
+
73
+ it('enabled, one entry', async function () {
74
+ const entries = [{urls: ['turn:turn.example.com']}];
75
+ await reload({iceServers: entries, shardIceServers: true});
76
+ assert.deepEqual(await getIceServers(), entries);
77
+ });
78
+
79
+ describe('enabled, multiple entries', function () {
80
+ beforeEach(async function () {
81
+ await reload({shardIceServers: true});
82
+ });
83
+
84
+ it('only gives one entry to each client', async function () {
85
+ const got = await getIceServers();
86
+ assert.equal(got.length, 1);
87
+ assert(iceServers.some((s) => {
88
+ try {
89
+ assert.deepEqual(got[0], s);
90
+ return true;
91
+ } catch (err) {
92
+ return false;
93
+ }
94
+ }));
95
+ });
96
+
97
+ it('same pad gets same entry', async function () {
98
+ this.timeout(60000);
99
+ const assignments = new Map(await Promise.all([...Array(10).keys()].map(async () => {
100
+ const padId = common.randomString();
101
+ return [padId, await getIceServers(padId)];
102
+ })));
103
+ await Promise.all([...assignments].map(async ([padId, want]) => {
104
+ const got = await getIceServers(padId);
105
+ assert.deepEqual(got, want);
106
+ }));
107
+ });
108
+
109
+ it('randomizes assignments on reload', async function () {
110
+ this.timeout(60000);
111
+ const oldAssignments = new Map(await Promise.all([...Array(10).keys()].map(async () => {
112
+ const padId = common.randomString();
113
+ return [padId, await getIceServers(padId)];
114
+ })));
115
+ await reload({shardIceServers: true});
116
+ const newAssignments = new Map(await Promise.all(
117
+ [...oldAssignments.keys()].map(async (padId) => [padId, await getIceServers(padId)])));
118
+ // With 10 pad IDs and 1000 ICE servers, the probability that every new assignment exactly
119
+ // matches the old assignment is effectively zero.
120
+ assert([...newAssignments].some(([padId, newAssignment]) => {
121
+ try {
122
+ assert.deepEqual(newAssignment, oldAssignments.get(padId));
123
+ return false;
124
+ } catch (err) {
125
+ return true;
126
+ }
127
+ }));
128
+ });
129
+ });
130
+ });