janus-whep-server 1.0.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/LICENSE +674 -0
- package/README.md +122 -0
- package/package.json +50 -0
- package/src/whep.js +680 -0
package/src/whep.js
ADDED
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* Simple WHEP server
|
|
5
|
+
*
|
|
6
|
+
* Author: Lorenzo Miniero <lorenzo@meetecho.com>
|
|
7
|
+
* License: GPLv3
|
|
8
|
+
*
|
|
9
|
+
* WHEP API and endpoint management
|
|
10
|
+
*
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// Dependencies
|
|
14
|
+
import express from 'express';
|
|
15
|
+
import cors from 'cors';
|
|
16
|
+
import fs from 'fs';
|
|
17
|
+
import http from 'http';
|
|
18
|
+
import https from 'https';
|
|
19
|
+
import Janode from 'janode';
|
|
20
|
+
import StreamingPlugin from 'janode/plugins/streaming';
|
|
21
|
+
import { EventEmitter } from 'events';
|
|
22
|
+
|
|
23
|
+
// WHEP server class
|
|
24
|
+
class JanusWhepServer extends EventEmitter {
|
|
25
|
+
|
|
26
|
+
// Constructor
|
|
27
|
+
constructor({ janus, rest, allowTrickle = true, strictETags = false, iceServers = [], debug }) {
|
|
28
|
+
super();
|
|
29
|
+
// Parse configuration
|
|
30
|
+
if(!janus || typeof janus !== 'object')
|
|
31
|
+
throw new Error('Invalid configuration, missing parameter "janus" or not an object');
|
|
32
|
+
if(!janus.address)
|
|
33
|
+
throw new Error('Invalid configuration, missing parameter "address" in "janus"');
|
|
34
|
+
if(!rest || typeof rest !== 'object')
|
|
35
|
+
throw new Error('Invalid configuration, missing parameter "rest" or not an object');
|
|
36
|
+
if(!rest.basePath)
|
|
37
|
+
throw new Error('Invalid configuration, missing parameter "basePath" in "rest"');
|
|
38
|
+
if(!rest.port && !rest.app)
|
|
39
|
+
throw new Error('Invalid configuration, at least one of "port" and "app" should be set in "rest"');
|
|
40
|
+
const debugLevels = [ 'err', 'warn', 'info', 'verb', 'debug' ];
|
|
41
|
+
if(debug && debugLevels.indexOf(debug) === -1)
|
|
42
|
+
throw new Error('Invalid configuration, unsupported "debug" level');
|
|
43
|
+
this.config = {
|
|
44
|
+
janus: {
|
|
45
|
+
address: janus.address
|
|
46
|
+
},
|
|
47
|
+
rest: {
|
|
48
|
+
port: rest.port,
|
|
49
|
+
basePath: rest.basePath,
|
|
50
|
+
app: rest.app
|
|
51
|
+
},
|
|
52
|
+
allowTrickle: (allowTrickle === true),
|
|
53
|
+
strictETags: (strictETags === true),
|
|
54
|
+
iceServers: Array.isArray(iceServers) ? iceServers : [iceServers]
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// Resources
|
|
58
|
+
this.janus = null;
|
|
59
|
+
this.endpoints = new Map();
|
|
60
|
+
this.subscribers = new Map();
|
|
61
|
+
this.logger = new JanusWhepLogger({ prefix: '[WHEP] ', level: debug ? debugLevels.indexOf(debug) : 2 });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async start() {
|
|
65
|
+
if(this.started)
|
|
66
|
+
throw new Error('WHEP server already started');
|
|
67
|
+
// Connect to Janus
|
|
68
|
+
await this._connectToJanus();
|
|
69
|
+
// WHEP REST API
|
|
70
|
+
if(!this.config.rest.app) {
|
|
71
|
+
// Spawn a new app and server
|
|
72
|
+
this.logger.verb('Spawning new Express app');
|
|
73
|
+
let app = express();
|
|
74
|
+
this._setupRest(app);
|
|
75
|
+
let options = null;
|
|
76
|
+
let useHttps = (this.config.rest.https && this.config.rest.https.cert && this.config.rest.https.key);
|
|
77
|
+
if(useHttps) {
|
|
78
|
+
options = {
|
|
79
|
+
cert: fs.readFileSync(this.config.rest.https.cert, 'utf8'),
|
|
80
|
+
key: fs.readFileSync(this.config.rest.https.key, 'utf8'),
|
|
81
|
+
passphrase: this.config.rest.https.passphrase
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
this.server = await (useHttps ? https : http).createServer(options, app);
|
|
85
|
+
await this.server.listen(this.config.rest.port);
|
|
86
|
+
} else {
|
|
87
|
+
// A server already exists, only add our endpoints to its router
|
|
88
|
+
this.logger.verb('Reusing existing Express app');
|
|
89
|
+
this._setupRest(this.config.rest.app);
|
|
90
|
+
}
|
|
91
|
+
// We're up and running
|
|
92
|
+
this.logger.info('WHEP server started');
|
|
93
|
+
this.started = true;
|
|
94
|
+
return this;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async destroy() {
|
|
98
|
+
if(!this.started)
|
|
99
|
+
throw new Error('WHEP server not started');
|
|
100
|
+
if(this.janus)
|
|
101
|
+
await this.janus.close();
|
|
102
|
+
if(this.server)
|
|
103
|
+
this.server.close();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
generateRandomString(len) {
|
|
107
|
+
const charSet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
|
108
|
+
let randomString = '';
|
|
109
|
+
for(let i=0; i<len; i++) {
|
|
110
|
+
let randomPoz = Math.floor(Math.random() * charSet.length);
|
|
111
|
+
randomString += charSet.substring(randomPoz,randomPoz+1);
|
|
112
|
+
}
|
|
113
|
+
return randomString;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
createEndpoint({ id, mountpoint, pin, label, token, iceServers }) {
|
|
117
|
+
if(!id || !mountpoint)
|
|
118
|
+
throw new Error('Invalid arguments');
|
|
119
|
+
if(this.endpoints.has(id))
|
|
120
|
+
throw new Error('Endpoint already exists');
|
|
121
|
+
let endpoint = new JanusWhepEndpoint({
|
|
122
|
+
id: id,
|
|
123
|
+
mountpoint: mountpoint,
|
|
124
|
+
pin: pin,
|
|
125
|
+
label: label,
|
|
126
|
+
token: token,
|
|
127
|
+
iceServers: iceServers
|
|
128
|
+
});
|
|
129
|
+
this.logger.info('[' + id + '] Created new WHEP endpoint');
|
|
130
|
+
this.endpoints.set(id, endpoint);
|
|
131
|
+
return endpoint;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
listEndpoints() {
|
|
135
|
+
let list = [];
|
|
136
|
+
this.endpoints.forEach(function(endpoint, id) {
|
|
137
|
+
list.push({ id: id, subscribers: endpoint.countSubscribers() });
|
|
138
|
+
});
|
|
139
|
+
return list;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
getEndpoint({ id }) {
|
|
143
|
+
return this.endpoints.get(id);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async destroyEndpoint({ id }) {
|
|
147
|
+
let endpoint = this.endpoints.get(id);
|
|
148
|
+
if(!id || !endpoint)
|
|
149
|
+
throw new Error('Invalid endpoint ID');
|
|
150
|
+
// Get rid of the Janus subscribers, if there's any
|
|
151
|
+
endpoint.subscribers.forEach(async function(_val, uuid) {
|
|
152
|
+
let subscriber = this.subscribers.get(uuid);
|
|
153
|
+
if(!subscriber)
|
|
154
|
+
return;
|
|
155
|
+
if(this.janus && subscriber.handle)
|
|
156
|
+
await subscriber.handle.detach().catch(_err => {});
|
|
157
|
+
this.subscribers.delete(uuid);
|
|
158
|
+
}, this);
|
|
159
|
+
this.endpoints.delete(id);
|
|
160
|
+
this.logger.info('[' + id + '] Destroyed WHEP endpoint');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
countSubscribers() {
|
|
164
|
+
console.log(this.subscribers);
|
|
165
|
+
return this.subscribers.size;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
listSubscribers() {
|
|
169
|
+
let list = [];
|
|
170
|
+
this.subscribers.forEach(function(subscriber, uuid) {
|
|
171
|
+
list.push({ endpoint: subscriber.whepId, uuid: uuid });
|
|
172
|
+
});
|
|
173
|
+
return list;
|
|
174
|
+
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Janus setup
|
|
178
|
+
async _connectToJanus() {
|
|
179
|
+
const connection = await Janode.connect({
|
|
180
|
+
is_admin: false,
|
|
181
|
+
address: {
|
|
182
|
+
url: this.config.janus.address,
|
|
183
|
+
},
|
|
184
|
+
retry_time_secs: 3,
|
|
185
|
+
max_retries: Number.MAX_VALUE
|
|
186
|
+
});
|
|
187
|
+
connection.once(Janode.EVENT.CONNECTION_ERROR, () => {
|
|
188
|
+
this.logger.warn('Lost connectivity to Janus, reset the manager and try reconnecting');
|
|
189
|
+
// Teardown existing endpoints
|
|
190
|
+
this.endpoints.forEach(function(endpoint, id) {
|
|
191
|
+
endpoint.subscribers.forEach(async function(_val, uuid) {
|
|
192
|
+
let subscriber = this.subscribers.get(uuid);
|
|
193
|
+
if(!subscriber)
|
|
194
|
+
return;
|
|
195
|
+
if(subscriber.handle) {
|
|
196
|
+
endpoint.emit('subscriber-gone');
|
|
197
|
+
this.emit('subscriber-gone', id);
|
|
198
|
+
}
|
|
199
|
+
}, this);
|
|
200
|
+
endpoint.subscribers.clear();
|
|
201
|
+
this.logger.info('[' + id + '] Terminating WHEP subscriber sessions');
|
|
202
|
+
endpoint.emit('janus-disconnected');
|
|
203
|
+
}, this);
|
|
204
|
+
this.subscribers.clear();
|
|
205
|
+
this.emit('janus-disconnected');
|
|
206
|
+
// Reconnect
|
|
207
|
+
this.janus = null;
|
|
208
|
+
setTimeout(this._connectToJanus.bind(this), 1);
|
|
209
|
+
});
|
|
210
|
+
this.janus = await connection.create();
|
|
211
|
+
this.logger.info('Connected to Janus:', this.config.janus.address);
|
|
212
|
+
if(this.started)
|
|
213
|
+
this.emit('janus-reconnected');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// REST server setup
|
|
217
|
+
_setupRest(app) {
|
|
218
|
+
const router = express.Router();
|
|
219
|
+
|
|
220
|
+
// Just a helper to make sure this API is up and running
|
|
221
|
+
router.get('/healthcheck', (_req, res) => {
|
|
222
|
+
this.logger.debug('/healthcheck');
|
|
223
|
+
res.sendStatus(200);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// Subscribe to a WHEP endpoint
|
|
227
|
+
router.post('/endpoint/:id', async (req, res) => {
|
|
228
|
+
let id = req.params.id;
|
|
229
|
+
let endpoint = this.endpoints.get(id);
|
|
230
|
+
if(!id || !endpoint) {
|
|
231
|
+
res.status(404);
|
|
232
|
+
res.send('Invalid endpoint ID');
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if(endpoint.enabled) {
|
|
236
|
+
res.status(403);
|
|
237
|
+
res.send('Endpoint ID already in use');
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
this.logger.verb('/endpoint/:', id);
|
|
241
|
+
// If we received a payload, make sure it's an SDP
|
|
242
|
+
this.logger.debug(req.body);
|
|
243
|
+
let offer = null;
|
|
244
|
+
if(req.headers['content-type']) {
|
|
245
|
+
if(req.headers['content-type'] !== 'application/sdp' || req.body.indexOf('v=0') < 0) {
|
|
246
|
+
res.status(406);
|
|
247
|
+
res.send('Unsupported content type');
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
offer = req.body;
|
|
251
|
+
}
|
|
252
|
+
// Check the Bearer token
|
|
253
|
+
let auth = req.headers['authorization'];
|
|
254
|
+
if(endpoint.token) {
|
|
255
|
+
if(!auth || auth.indexOf('Bearer ') < 0) {
|
|
256
|
+
res.status(403);
|
|
257
|
+
res.send('Unauthorized');
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
let authtoken = auth.split('Bearer ')[1];
|
|
261
|
+
if(typeof endpoint.token === 'function') {
|
|
262
|
+
if(!endpoint.token(authtoken)) {
|
|
263
|
+
res.status(403);
|
|
264
|
+
res.send('Unauthorized');
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
} else if(!authtoken || authtoken.length === 0 || authtoken !== endpoint.token) {
|
|
268
|
+
res.status(403);
|
|
269
|
+
res.send('Unauthorized');
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
// Make sure Janus is up and running
|
|
274
|
+
if(!this.janus) {
|
|
275
|
+
res.status(503);
|
|
276
|
+
res.send('Janus unavailable');
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
let uuid = this.generateRandomString(16);
|
|
280
|
+
let subscriber = {
|
|
281
|
+
uuid: uuid,
|
|
282
|
+
whepId: id
|
|
283
|
+
};
|
|
284
|
+
this.subscribers.set(uuid, subscriber);
|
|
285
|
+
// Create a new session
|
|
286
|
+
this.logger.info('[' + id + '] Subscribing to WHEP endpoint');
|
|
287
|
+
subscriber.enabling = true;
|
|
288
|
+
try {
|
|
289
|
+
// Connect to the Streaming plugin
|
|
290
|
+
subscriber.handle = await this.janus.attach(StreamingPlugin);
|
|
291
|
+
subscriber.handle.on(Janode.EVENT.HANDLE_DETACHED, () => {
|
|
292
|
+
// Janus notified us the session is gone, tear it down
|
|
293
|
+
let subscriber = this.subscribers.get(uuid);
|
|
294
|
+
if(subscriber) {
|
|
295
|
+
this.logger.info('[' + id + '][' + uuid + '] Handle detached');
|
|
296
|
+
let endpoint = this.endpoints.get(subscriber.whepId);
|
|
297
|
+
if(endpoint)
|
|
298
|
+
endpoint.subscribers.delete(uuid);
|
|
299
|
+
this.subscribers.delete(uuid);
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
subscriber.handle.on(Janode.EVENT.HANDLE_HANGUP, async () => {
|
|
303
|
+
// Janus notified us the session is gone, tear it down
|
|
304
|
+
let subscriber = this.subscribers.get(uuid);
|
|
305
|
+
if(subscriber) {
|
|
306
|
+
this.logger.info('[' + id + '][' + uuid + '] PeerConnection closed');
|
|
307
|
+
await subscriber.handle.detach().catch(_err => {});
|
|
308
|
+
if(endpoint) {
|
|
309
|
+
endpoint.subscribers.delete(uuid);
|
|
310
|
+
endpoint.emit('subscriber-gone');
|
|
311
|
+
}
|
|
312
|
+
this.emit('subscriber-gone', id);
|
|
313
|
+
this.subscribers.delete(uuid);
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
let details = {
|
|
317
|
+
id: endpoint.mountpoint,
|
|
318
|
+
pin: endpoint.pin
|
|
319
|
+
};
|
|
320
|
+
if(offer) {
|
|
321
|
+
// Client offer (we still support both modes)
|
|
322
|
+
details.jsep = {
|
|
323
|
+
type: 'offer',
|
|
324
|
+
sdp: offer
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
const result = await subscriber.handle.watch(details);
|
|
328
|
+
subscriber.enabling = false;
|
|
329
|
+
subscriber.enabled = true;
|
|
330
|
+
endpoint.subscribers.set(uuid, true);
|
|
331
|
+
subscriber.resource = this.config.rest.basePath + '/resource/' + uuid;
|
|
332
|
+
subscriber.latestEtag = this.generateRandomString(16);
|
|
333
|
+
if(offer) {
|
|
334
|
+
subscriber.sdpOffer = offer;
|
|
335
|
+
subscriber.ice = {
|
|
336
|
+
ufrag: subscriber.sdpOffer.match(/a=ice-ufrag:(.*)\r\n/)[1],
|
|
337
|
+
pwd: subscriber.sdpOffer.match(/a=ice-pwd:(.*)\r\n/)[1]
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
// Done
|
|
341
|
+
res.setHeader('Access-Control-Expose-Headers', 'Location, Link');
|
|
342
|
+
res.setHeader('Accept-Patch', 'application/trickle-ice-sdpfrag');
|
|
343
|
+
res.setHeader('Location', subscriber.resource);
|
|
344
|
+
res.set('ETag', '"' + subscriber.latestEtag + '"');
|
|
345
|
+
let iceServers = endpoint.iceServers ? endpoint.iceServers : this.config.iceServers;
|
|
346
|
+
if(iceServers && iceServers.length > 0) {
|
|
347
|
+
// Add a Link header for each static ICE server
|
|
348
|
+
let links = [];
|
|
349
|
+
for(let server of iceServers) {
|
|
350
|
+
if(!server.uri || (server.uri.indexOf('stun:') !== 0 &&
|
|
351
|
+
server.uri.indexOf('turn:') !== 0 &&
|
|
352
|
+
server.uri.indexOf('turns:') !== 0))
|
|
353
|
+
continue;
|
|
354
|
+
let link = '<' + server.uri + '>; rel="ice-server"';
|
|
355
|
+
if(server.username && server.credential) {
|
|
356
|
+
link += ';';
|
|
357
|
+
link += ' username="' + server.username + '";' +
|
|
358
|
+
' credential="' + server.credential + '";' +
|
|
359
|
+
' credential-type="password"';
|
|
360
|
+
}
|
|
361
|
+
links.push(link);
|
|
362
|
+
}
|
|
363
|
+
res.setHeader('Link', links);
|
|
364
|
+
}
|
|
365
|
+
res.writeHeader(201, { 'Content-Type': 'application/sdp' });
|
|
366
|
+
res.write(result.jsep.sdp);
|
|
367
|
+
res.end();
|
|
368
|
+
endpoint.emit('new-subscriber');
|
|
369
|
+
this.emit('new-subscriber', id);
|
|
370
|
+
} catch(err) {
|
|
371
|
+
this.logger.err('Error subscribing:', err);
|
|
372
|
+
this.subscribers.delete(uuid);
|
|
373
|
+
res.status(500);
|
|
374
|
+
res.send(err.error);
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// GET, HEAD and PUT on the endpoint must return a 405
|
|
379
|
+
router.get('/endpoint/:id', (_req, res) => {
|
|
380
|
+
res.sendStatus(405);
|
|
381
|
+
});
|
|
382
|
+
router.head('/endpoint/:id', (_req, res) => {
|
|
383
|
+
res.sendStatus(405);
|
|
384
|
+
});
|
|
385
|
+
router.put('/endpoint/:id', (_req, res) => {
|
|
386
|
+
res.sendStatus(405);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// Patch can be used both for the SDP answer and to trickle a WHEP resource
|
|
390
|
+
router.patch('/resource/:uuid', async (req, res) => {
|
|
391
|
+
let uuid = req.params.uuid;
|
|
392
|
+
let subscriber = this.subscribers.get(uuid);
|
|
393
|
+
if(subscriber && subscriber.latestEtag)
|
|
394
|
+
res.set('ETag', '"' + subscriber.latestEtag + '"');
|
|
395
|
+
if(!uuid || !subscriber || !subscriber.handle) {
|
|
396
|
+
res.status(404);
|
|
397
|
+
res.send('Invalid resource ID');
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
let endpoint = this.endpoints.get(subscriber.whepId);
|
|
401
|
+
if(!endpoint) {
|
|
402
|
+
res.status(404);
|
|
403
|
+
res.send('Invalid endpoint ID');
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if(!this.janus) {
|
|
407
|
+
res.status(503);
|
|
408
|
+
res.send('Janus unavailable');
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
if(req.headers['content-type'] === 'application/sdp') {
|
|
412
|
+
// We received an SDP answer from the client
|
|
413
|
+
this.logger.verb('/resource[answer]/:', uuid);
|
|
414
|
+
this.logger.debug(req.body);
|
|
415
|
+
// Prepare the JSEP object
|
|
416
|
+
await subscriber.handle.start({
|
|
417
|
+
jsep: {
|
|
418
|
+
type: 'answer',
|
|
419
|
+
sdp: req.body
|
|
420
|
+
}
|
|
421
|
+
}).catch(err => {
|
|
422
|
+
this.logger.err('Error finalizing subscription:', err);
|
|
423
|
+
let endpoint = this.endpoints.get(subscriber.whepId);
|
|
424
|
+
if(endpoint)
|
|
425
|
+
endpoint.subscribers.delete(uuid);
|
|
426
|
+
this.subscribers.delete(uuid);
|
|
427
|
+
res.status(500);
|
|
428
|
+
res.send(err.error);
|
|
429
|
+
});
|
|
430
|
+
this.logger.info('[' + uuid + '] Completed WHEP negotiation');
|
|
431
|
+
res.sendStatus(204);
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
// If we got here, we're handling a trickle candidate
|
|
435
|
+
this.logger.verb('/resource[trickle]/:', uuid);
|
|
436
|
+
this.logger.debug(req.body);
|
|
437
|
+
// Check the Bearer token
|
|
438
|
+
let auth = req.headers['authorization'];
|
|
439
|
+
if(endpoint.token) {
|
|
440
|
+
if(!auth || auth.indexOf('Bearer ') < 0) {
|
|
441
|
+
res.status(403);
|
|
442
|
+
res.send('Unauthorized');
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
let authtoken = auth.split('Bearer ')[1];
|
|
446
|
+
if(typeof endpoint.token === 'function') {
|
|
447
|
+
if(!endpoint.token(authtoken)) {
|
|
448
|
+
res.status(403);
|
|
449
|
+
res.send('Unauthorized');
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
} else if(!authtoken || authtoken.length === 0 || authtoken !== endpoint.token) {
|
|
453
|
+
res.status(403);
|
|
454
|
+
res.send('Unauthorized');
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
// Check the latest ETag
|
|
459
|
+
if(req.headers['if-match'] !== '"*"' && req.headers['if-match'] !== ('"' + endpoint.latestEtag + '"')) {
|
|
460
|
+
if(this.config.strictETags) {
|
|
461
|
+
// Only return a failure if we're configured with strict ETag checking, ignore it otherwise
|
|
462
|
+
res.status(412);
|
|
463
|
+
res.send('Precondition Failed');
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
// Make sure we received a trickle candidate
|
|
468
|
+
if(req.headers['content-type'] !== 'application/trickle-ice-sdpfrag') {
|
|
469
|
+
res.status(406);
|
|
470
|
+
res.send('Unsupported content type');
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
// Parse the RFC 8840 payload
|
|
474
|
+
let fragment = req.body;
|
|
475
|
+
let lines = fragment.split(/\r?\n/);
|
|
476
|
+
let iceUfrag = null, icePwd = null, restart = false;
|
|
477
|
+
let candidates = [];
|
|
478
|
+
for(let line of lines) {
|
|
479
|
+
if(line.indexOf('a=ice-ufrag:') === 0) {
|
|
480
|
+
iceUfrag = line.split('a=ice-ufrag:')[1];
|
|
481
|
+
} else if(line.indexOf('a=ice-pwd:') === 0) {
|
|
482
|
+
icePwd = line.split('a=ice-pwd:')[1];
|
|
483
|
+
} else if(line.indexOf('a=candidate:') === 0) {
|
|
484
|
+
let candidate = {
|
|
485
|
+
sdpMLineIndex: 0,
|
|
486
|
+
candidate: line.split('a=')[1]
|
|
487
|
+
};
|
|
488
|
+
candidates.push(candidate);
|
|
489
|
+
} else if(line.indexOf('a=end-of-candidates') === 0) {
|
|
490
|
+
// Signal there won't be any more candidates
|
|
491
|
+
candidates.push({ completed: true });
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
// Check if there's a restart involved
|
|
495
|
+
if(iceUfrag && icePwd && subscriber.ice && (iceUfrag !== subscriber.ice.ufrag || icePwd !== subscriber.ice.pwd)) {
|
|
496
|
+
// We need to restart
|
|
497
|
+
restart = true;
|
|
498
|
+
}
|
|
499
|
+
// Do one more ETag check (make sure restarts have '*' as ETag, and only them)
|
|
500
|
+
if(req.headers['if-match'] === '*' && this.config.strictETags) {
|
|
501
|
+
// Only return a failure if we're configured with strict ETag checking, ignore it otherwise
|
|
502
|
+
res.status(412);
|
|
503
|
+
res.send('Precondition Failed');
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
try {
|
|
507
|
+
if(!restart) {
|
|
508
|
+
// Trickle the candidate(s)
|
|
509
|
+
if(candidates.length > 0)
|
|
510
|
+
await subscriber.handle.trickle(candidates);
|
|
511
|
+
// We're done
|
|
512
|
+
res.sendStatus(204);
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
// TODO Restarts not supported yet, throw an error
|
|
516
|
+
throw new Error('Restarts not supported yet');
|
|
517
|
+
} catch(err) {
|
|
518
|
+
this.logger.err('Error patching:', err);
|
|
519
|
+
res.status(500);
|
|
520
|
+
res.send(err.error);
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
// Stop subscribing to a WHEP endpoint
|
|
525
|
+
router.delete('/resource/:uuid', async (req, res) => {
|
|
526
|
+
let uuid = req.params.uuid;
|
|
527
|
+
let subscriber = this.subscribers.get(uuid);
|
|
528
|
+
if(subscriber && subscriber.latestEtag)
|
|
529
|
+
res.set('ETag', '"' + subscriber.latestEtag + '"');
|
|
530
|
+
if(!uuid || !subscriber) {
|
|
531
|
+
res.status(404);
|
|
532
|
+
res.send('Invalid resource ID');
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
let endpoint = this.endpoints.get(subscriber.whepId);
|
|
536
|
+
if(!endpoint) {
|
|
537
|
+
res.status(404);
|
|
538
|
+
res.send('Invalid endpoint ID');
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
// Check the Bearer token
|
|
542
|
+
let auth = req.headers['authorization'];
|
|
543
|
+
if(endpoint.token) {
|
|
544
|
+
if(!auth || auth.indexOf('Bearer ') < 0) {
|
|
545
|
+
res.status(403);
|
|
546
|
+
res.send('Unauthorized');
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
let authtoken = auth.split('Bearer ')[1];
|
|
550
|
+
if(typeof endpoint.token === 'function') {
|
|
551
|
+
if(!endpoint.token(authtoken)) {
|
|
552
|
+
res.status(403);
|
|
553
|
+
res.send('Unauthorized');
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
} else if(!authtoken || authtoken.length === 0 || authtoken !== endpoint.token) {
|
|
557
|
+
res.status(403);
|
|
558
|
+
res.send('Unauthorized');
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
this.logger.verb('/resource[delete]/:', uuid);
|
|
563
|
+
// Get rid of the Janus subscriber
|
|
564
|
+
if(this.janus && subscriber.handle)
|
|
565
|
+
await subscriber.handle.detach().catch(_err => {});
|
|
566
|
+
endpoint.subscribers.delete(uuid);
|
|
567
|
+
this.subscribers.delete(uuid);
|
|
568
|
+
this.logger.info('[' + uuid + '] Terminating WHEP session');
|
|
569
|
+
endpoint.emit('subscriber-gone');
|
|
570
|
+
this.emit('subscriber-gone', endpoint.id);
|
|
571
|
+
// Done
|
|
572
|
+
res.sendStatus(200);
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
// GET, HEAD, POST and PUT on the resource must return a 405
|
|
576
|
+
router.get('/resource/:id', (_req, res) => {
|
|
577
|
+
res.sendStatus(405);
|
|
578
|
+
});
|
|
579
|
+
router.head('/resource/:id', (_req, res) => {
|
|
580
|
+
res.sendStatus(405);
|
|
581
|
+
});
|
|
582
|
+
router.post('/resource/:id', (_req, res) => {
|
|
583
|
+
res.sendStatus(405);
|
|
584
|
+
});
|
|
585
|
+
router.put('/resource/:id', (_req, res) => {
|
|
586
|
+
res.sendStatus(405);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
// Setup CORS
|
|
590
|
+
app.use(cors({ preflightContinue: true }));
|
|
591
|
+
|
|
592
|
+
// Initialize the REST API
|
|
593
|
+
app.use(express.json());
|
|
594
|
+
app.use(express.text({ type: 'application/sdp' }));
|
|
595
|
+
app.use(express.text({ type: 'application/trickle-ice-sdpfrag' }));
|
|
596
|
+
app.use(this.config.rest.basePath, router);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// WHEP endpoint class
|
|
601
|
+
class JanusWhepEndpoint extends EventEmitter {
|
|
602
|
+
constructor({ id, mountpoint, pin, label, token, iceServers }) {
|
|
603
|
+
super();
|
|
604
|
+
this.id = id;
|
|
605
|
+
this.mountpoint = mountpoint;
|
|
606
|
+
this.pin = pin;
|
|
607
|
+
this.label = label;
|
|
608
|
+
this.token = token;
|
|
609
|
+
this.iceServers = iceServers;
|
|
610
|
+
// Resources
|
|
611
|
+
this.subscribers = new Map();
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
countSubscribers() {
|
|
615
|
+
return this.subscribers.size;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
listSubscribers() {
|
|
619
|
+
let list = [];
|
|
620
|
+
this.subscribers.forEach(function(_val, uuid) {
|
|
621
|
+
list.push({ uuid: uuid });
|
|
622
|
+
});
|
|
623
|
+
return list;
|
|
624
|
+
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Logger class
|
|
629
|
+
class JanusWhepLogger {
|
|
630
|
+
constructor({ prefix, level }) {
|
|
631
|
+
this.prefix = prefix;
|
|
632
|
+
this.debugLevel = level;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
err() {
|
|
636
|
+
if(this.debugLevel < 0)
|
|
637
|
+
return;
|
|
638
|
+
let args = Array.prototype.slice.call(arguments);
|
|
639
|
+
args.unshift(this.prefix + '[err]');
|
|
640
|
+
console.log.apply(console, args);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
warn() {
|
|
644
|
+
if(this.debugLevel < 1)
|
|
645
|
+
return;
|
|
646
|
+
let args = Array.prototype.slice.call(arguments);
|
|
647
|
+
args.unshift(this.prefix + '[warn]');
|
|
648
|
+
console.log.apply(console, args);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
info() {
|
|
652
|
+
if(this.debugLevel < 2)
|
|
653
|
+
return;
|
|
654
|
+
let args = Array.prototype.slice.call(arguments);
|
|
655
|
+
args.unshift(this.prefix + '[info]');
|
|
656
|
+
console.log.apply(console, args);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
verb() {
|
|
660
|
+
if(this.debugLevel < 3)
|
|
661
|
+
return;
|
|
662
|
+
let args = Array.prototype.slice.call(arguments);
|
|
663
|
+
args.unshift(this.prefix + '[verb]');
|
|
664
|
+
console.log.apply(console, args);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
debug() {
|
|
668
|
+
if(this.debugLevel < 4)
|
|
669
|
+
return;
|
|
670
|
+
let args = Array.prototype.slice.call(arguments);
|
|
671
|
+
args.unshift(this.prefix + '[debug]');
|
|
672
|
+
console.log.apply(console, args);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// Exports
|
|
677
|
+
export {
|
|
678
|
+
JanusWhepServer,
|
|
679
|
+
JanusWhepEndpoint
|
|
680
|
+
};
|