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 +25 -0
- package/index.js +86 -45
- package/package.json +6 -4
- package/static/tests/backend/init.js +24 -0
- package/static/tests/backend/specs/sharding.js +130 -0
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}}) =>
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
26
|
-
"eslint-config-etherpad": "^3.0.
|
|
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
|
+
});
|