@soundworks/core 4.0.0-alpha.4 → 4.0.0-alpha.6

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.
@@ -5,10 +5,11 @@ import path from 'node:path';
5
5
  import os from 'node:os';
6
6
  import { X509Certificate, createPrivateKey } from 'node:crypto';
7
7
 
8
+ import { getTime } from '@ircam/sc-gettime';
9
+ import { isPlainObject, idGenerator } from '@ircam/sc-utils';
8
10
  import chalk from 'chalk';
9
11
  import compression from 'compression';
10
12
  import express from 'express';
11
- import isPlainObject from 'is-plain-obj';
12
13
  import Keyv from 'keyv';
13
14
  import { KeyvFile } from 'keyv-file';
14
15
  import merge from 'lodash.merge';
@@ -16,6 +17,7 @@ import pem from 'pem';
16
17
  import compile from 'template-literal';
17
18
 
18
19
  import auditSchema from './audit-schema.js';
20
+ import { encryptData, decryptData } from './crypto.js';
19
21
  import Client from './Client.js';
20
22
  import ContextManager from './ContextManager.js';
21
23
  import PluginManager from './PluginManager.js';
@@ -29,6 +31,7 @@ import {
29
31
  AUDIT_STATE_NAME,
30
32
  } from '../common/constants.js';
31
33
 
34
+
32
35
  let _dbNamespaces = new Set();
33
36
 
34
37
  /**
@@ -73,6 +76,8 @@ const DEFAULT_CONFIG = {
73
76
  },
74
77
  };
75
78
 
79
+ const TOKEN_VALID_DURATION = 20; // sec
80
+
76
81
  // set terminal title
77
82
  /** @private */
78
83
  function setTerminalTitle(server) {
@@ -156,8 +161,8 @@ class Server {
156
161
  throw new Error(`[soundworks:Server] Invalid argument for Server constructor, config should be an object`);
157
162
  }
158
163
  /**
159
- * Given config object merged with the following defaults:
160
- * ```
164
+ * @description Given config object merged with the following defaults:
165
+ * @example
161
166
  * {
162
167
  * env: {
163
168
  * type: 'development',
@@ -178,7 +183,6 @@ class Server {
178
183
  * clients: {},
179
184
  * }
180
185
  * }
181
- * ```
182
186
  */
183
187
  this.config = merge({}, DEFAULT_CONFIG, config);
184
188
 
@@ -236,8 +240,10 @@ class Server {
236
240
  * // expose assets located in the `soundfiles` directory on the network
237
241
  * server.router.use('/soundfiles', express.static('soundfiles')));
238
242
  */
243
+ // @note: we use express() instead of express.Router() because all 404 and
244
+ // error stuff is handled by default
239
245
  this.router = express();
240
- // compression (must be set before serve-static)
246
+ // compression (must be set before express.static())
241
247
  this.router.use(compression());
242
248
 
243
249
  /**
@@ -313,6 +319,10 @@ class Server {
313
319
  this._onStatusChangeCallbacks = new Set();
314
320
  /** @private */
315
321
  this._auditState = null;
322
+ /** @private */
323
+ this._pendingConnectionTokens = new Set();
324
+ /** @private */
325
+ this._trustedClients = new Set();
316
326
 
317
327
  // register audit state schema
318
328
  this.stateManager.registerSchema(AUDIT_STATE_NAME, auditSchema);
@@ -357,11 +367,24 @@ class Server {
357
367
 
358
368
  // basic http authentication
359
369
  if (this.config.env.auth) {
360
- this.router.use((req, res, next) => {
370
+ const ids = idGenerator();
361
371
 
362
- const isProtected = this.config.env.auth.clients
363
- .map(type => req.path.endsWith(`/${type}`))
364
- .reduce((acc, value) => acc || value, false);
372
+ const soundworksAuth = (req, res, next) => {
373
+ let role = null;
374
+
375
+ for (let [_role, config] of Object.entries(this.config.app.clients)) {
376
+ if (req.path === config.route) {
377
+ role = _role;
378
+ }
379
+ }
380
+
381
+ // route that are not client entry points just pass through the middleware
382
+ if (role === null) {
383
+ next();
384
+ return;
385
+ }
386
+
387
+ const isProtected = this.isProtected(role);
365
388
 
366
389
  if (isProtected) {
367
390
  // authentication middleware
@@ -373,20 +396,39 @@ class Server {
373
396
  // verify login and password are set and correct
374
397
  if (login && password && login === auth.login && password === auth.password) {
375
398
  // -> access granted...
399
+ // generate token for web socket to check connections
400
+ const id = ids.next().value;
401
+ const ip = req.ip;
402
+ const time = getTime();
403
+ const token = { id, ip, time };
404
+ const encryptedToken = encryptData(token);
405
+
406
+ this._pendingConnectionTokens.add(encryptedToken);
407
+
408
+ setTimeout(() => {
409
+ this._pendingConnectionTokens.delete(encryptedToken);
410
+ }, TOKEN_VALID_DURATION * 1000);
411
+
412
+ // pass to the response object to be send to the client
413
+ res.swToken = encryptedToken;
414
+
376
415
  return next();
377
416
  }
378
417
 
379
- // -> access denied...
418
+ // show login / password modal
380
419
  res.writeHead(401, {
381
420
  'WWW-Authenticate':'Basic',
382
421
  'Content-Type':'text/plain',
383
422
  });
423
+
384
424
  res.end('Authentication required.');
385
425
  } else {
386
- // route not protected
426
+ // route is not protected
387
427
  return next();
388
428
  }
389
- });
429
+ };
430
+
431
+ this.router.use(soundworksAuth);
390
432
  }
391
433
 
392
434
  // start http server
@@ -411,7 +453,7 @@ class Server {
411
453
  // this fails with self-signed certificates for whatever reason...
412
454
  try {
413
455
  x509 = new X509Certificate(cert);
414
- } catch(err) {
456
+ } catch (err) {
415
457
  this._dispatchStatus('errored');
416
458
  throw new Error(`[soundworks:Server] Invalid https cert file`);
417
459
  }
@@ -423,7 +465,7 @@ class Server {
423
465
  this._dispatchStatus('errored');
424
466
  throw new Error(`[soundworks:Server] Invalid https key file`);
425
467
  }
426
- } catch(err) {
468
+ } catch (err) {
427
469
  this._dispatchStatus('errored');
428
470
  throw new Error(`[soundworks:Server] Invalid https key file`);
429
471
  }
@@ -447,7 +489,7 @@ class Server {
447
489
  };
448
490
 
449
491
  this.httpServer = https.createServer({ key, cert }, this.router);
450
- } catch(err) {
492
+ } catch (err) {
451
493
  logger.error(`
452
494
  Invalid certificate files, please check your:
453
495
  - key file: ${httpsInfos.key}
@@ -591,7 +633,7 @@ Invalid certificate files, please check your:
591
633
  await this.sockets.start(
592
634
  this,
593
635
  this.config.env.websockets,
594
- (role, socket) => this._onSocketConnection(role, socket),
636
+ (...args) => this._onSocketConnection(...args),
595
637
  );
596
638
 
597
639
  // ------------------------------------------------------------
@@ -710,6 +752,8 @@ Invalid certificate files, please check your:
710
752
  route += `${role}`;
711
753
  }
712
754
 
755
+ this.config.app.clients[role].route = route;
756
+
713
757
  // define template filename: `${role}.html` or `default.html`
714
758
  const {
715
759
  templatePath,
@@ -726,7 +770,7 @@ Invalid certificate files, please check your:
726
770
  try {
727
771
  const stats = fs.statSync(clientTmpl);
728
772
  template = stats.isFile() ? clientTmpl : defaultTmpl;
729
- } catch(err) {
773
+ } catch (err) {
730
774
  template = defaultTmpl;
731
775
  }
732
776
 
@@ -734,15 +778,21 @@ Invalid certificate files, please check your:
734
778
 
735
779
  try {
736
780
  tmplString = fs.readFileSync(template, 'utf8');
737
- } catch(err) {
781
+ } catch (err) {
738
782
  throw new Error(`[soundworks:Server] html template file "${template}" not found`);
739
783
  }
740
784
 
741
785
  const tmpl = templateEngine.compile(tmplString);
742
- // http request
743
- router.get(route, (req, res) => {
786
+
787
+ const soundworksClientHandler = (req, res) => {
744
788
  const data = clientConfigFunction(role, this.config, req);
745
789
 
790
+ // if the client has gone through the connection middleware (add succedeed),
791
+ // add the token to the data object
792
+ if (res.swToken) {
793
+ data.token = res.swToken;
794
+ }
795
+
746
796
  // CORS / COOP / COEP headers for `crossOriginIsolated pages,
747
797
  // enables `sharedArrayBuffers` and high precision timers
748
798
  // cf. https://web.dev/why-coop-coep/
@@ -756,7 +806,10 @@ Invalid certificate files, please check your:
756
806
 
757
807
  const appIndex = tmpl(data);
758
808
  res.end(appIndex);
759
- });
809
+ };
810
+
811
+ // http request
812
+ router.get(route, soundworksClientHandler);
760
813
 
761
814
  // return route infos for logging on server start
762
815
  return route;
@@ -766,18 +819,38 @@ Invalid certificate files, please check your:
766
819
  * Socket connection callback.
767
820
  * @private
768
821
  */
769
- _onSocketConnection(role, socket) {
822
+ _onSocketConnection(role, socket, connectionToken) {
770
823
  const client = new Client(role, socket);
771
824
  const roles = Object.keys(this.config.app.clients);
772
825
 
826
+ // this has been validated
827
+ if (this.isProtected(role) && this.isValidConnectionToken(connectionToken)) {
828
+ const { ip } = decryptData(connectionToken);
829
+ const newData = {
830
+ ip: ip,
831
+ id: client.id,
832
+ };
833
+
834
+ const newToken = encryptData(newData);
835
+
836
+ client.token = newToken;
837
+
838
+ this._pendingConnectionTokens.delete(connectionToken);
839
+ this._trustedClients.add(client);
840
+ }
841
+
773
842
  socket.addListener('close', async () => {
774
- // do nothin if client type was invalid
843
+ // do nothing if client role is invalid
775
844
  if (roles.includes(role)) {
776
845
  // decrement audit state counter
777
846
  const numClients = this._auditState.get('numClients');
778
847
  numClients[role] -= 1;
779
848
  this._auditState.set({ numClients });
780
849
 
850
+ // delete token
851
+ if (this._trustedClients.has(client)) {
852
+ this._trustedClients.delete(client);
853
+ }
781
854
  // clean context manager, await before cleaning state manager
782
855
  await this.contextManager.removeClient(client);
783
856
  // remove client from pluginManager
@@ -805,7 +878,7 @@ Invalid certificate files, please check your:
805
878
 
806
879
  try {
807
880
  this.pluginManager.checkRegisteredPlugins(registeredPlugins);
808
- } catch(err) {
881
+ } catch (err) {
809
882
  socket.send(CLIENT_HANDSHAKE_ERROR, {
810
883
  type: 'invalid-plugin-list',
811
884
  message: err.message,
@@ -830,8 +903,8 @@ Invalid certificate files, please check your:
830
903
  // add client to context manager
831
904
  await this.contextManager.addClient(client);
832
905
 
833
- const { id, uuid } = client;
834
- socket.send(CLIENT_HANDSHAKE_RESPONSE, { id, uuid });
906
+ const { id, uuid, token } = client;
907
+ socket.send(CLIENT_HANDSHAKE_RESPONSE, { id, uuid, token });
835
908
  });
836
909
  }
837
910
 
@@ -908,6 +981,27 @@ Invalid certificate files, please check your:
908
981
  * you should rely on these defaults._
909
982
  */
910
983
  useDefaultApplicationTemplate() {
984
+ const buildDirectory = path.join('.build', 'public');
985
+
986
+ const useMinifiedFile = {};
987
+ const roles = Object.keys(this.config.app.clients);
988
+
989
+ roles.forEach(role => {
990
+ if (this.config.env.type === 'production') {
991
+ // check if minified file exists
992
+ const minifiedFilePath = path.join(buildDirectory, `${role}.min.js`);
993
+
994
+ if (fs.existsSync(minifiedFilePath)) {
995
+ useMinifiedFile[role] = true;
996
+ } else {
997
+ console.log(chalk.yellow(` > Minified file not found for client "${role}", falling back to normal build file (use \`npm run build:production && npm start\` to use minified files)`));
998
+ useMinifiedFile[role] = false;
999
+ }
1000
+ } else {
1001
+ useMinifiedFile[role] = false;
1002
+ }
1003
+ });
1004
+
911
1005
  this._applicationTemplateOptions = {
912
1006
  templateEngine: { compile },
913
1007
  templatePath: path.join('.build', 'server', 'tmpl'),
@@ -922,13 +1016,14 @@ Invalid certificate files, please check your:
922
1016
  type: config.env.type,
923
1017
  websockets: config.env.websockets,
924
1018
  subpath: config.env.subpath,
1019
+ useMinifiedFile: useMinifiedFile[role],
925
1020
  },
926
1021
  };
927
1022
  },
928
1023
  };
929
1024
 
930
1025
  this.router.use(express.static('public'));
931
- this.router.use('/build', express.static(path.join('.build', 'public')));
1026
+ this.router.use('/build', express.static(buildDirectory));
932
1027
  }
933
1028
 
934
1029
  /**
@@ -964,6 +1059,81 @@ Invalid certificate files, please check your:
964
1059
 
965
1060
  return this._auditState;
966
1061
  }
1062
+
1063
+ /** @private */
1064
+ isProtected(role) {
1065
+ if (this.config.env.auth && Array.isArray(this.config.env.auth.clients)) {
1066
+ return this.config.env.auth.clients.includes(role);
1067
+ }
1068
+
1069
+ return false;
1070
+ }
1071
+
1072
+ /** @private */
1073
+ isValidConnectionToken(token) {
1074
+ // token should be in pending token list
1075
+ if (!this._pendingConnectionTokens.has(token)) {
1076
+ return false;
1077
+ }
1078
+
1079
+ // check the token is not too old
1080
+ const data = decryptData(token);
1081
+ const now = getTime();
1082
+
1083
+ // token is valid only for 30 seconds (this is arbitrary)
1084
+ if (now > data.time + TOKEN_VALID_DURATION) {
1085
+ // delete the token, is too old
1086
+ this._pendingConnectionTokens.delete(token);
1087
+ return false;
1088
+ } else {
1089
+ return true;
1090
+ }
1091
+ }
1092
+
1093
+ /**
1094
+ * Check if the given client is trusted, i.e. config.env.type == 'production'
1095
+ * and the client is protected behind a password.
1096
+ *
1097
+ * @param {server.Client} client - Client to be tested
1098
+ * @returns {Boolean}
1099
+ */
1100
+ isTrustedClient(client) {
1101
+ if (this.config.env.type !== 'production') {
1102
+ return true;
1103
+ } else {
1104
+ return this._trustedClients.has(client);
1105
+ }
1106
+ }
1107
+
1108
+ /**
1109
+ * Check if the token from a client is trusted, i.e. config.env.type == 'production'
1110
+ * and the client is protected behind a password.
1111
+ *
1112
+ * @param {Number} clientId - Id of the client
1113
+ * @param {Number} clientIp - Ip of the client
1114
+ * @param {String} token - Token to be tested
1115
+ * @returns {Boolean}
1116
+ */
1117
+ // for stateless interactions, e.g. POST files
1118
+ isTrustedToken(clientId, clientIp, token) {
1119
+ if (this.config.env.type !== 'production') {
1120
+ return true;
1121
+ } else {
1122
+ for (let client of this._trustedClients) {
1123
+ if (client.id === clientId && client.token === token) {
1124
+ // check that given token is consistent with client ip and id
1125
+ const { id, ip } = decryptData(client.token);
1126
+
1127
+ if (clientId === id && clientIp === ip) {
1128
+ return true;
1129
+ }
1130
+ }
1131
+
1132
+ }
1133
+
1134
+ return false;
1135
+ }
1136
+ }
967
1137
  }
968
1138
 
969
1139
  export default Server;
@@ -18,7 +18,6 @@ import BaseSharedState from '../common/BaseSharedState.js';
18
18
  * @callback server.SharedState~deleteOnUpdateCallback
19
19
  */
20
20
 
21
-
22
21
  /**
23
22
  * The `SharedState` is one of the most important and versatile abstraction provided
24
23
  * by `soundworks`. It represents a set of parameters that are synchronized accross
@@ -0,0 +1,36 @@
1
+ import BaseSharedStateCollection from '../common/BaseSharedStateCollection.js';
2
+
3
+ // this file exists only for documentation purposesSharedStateCollection.js
4
+
5
+ /**
6
+ * @callback server.SharedStateCollection~onUpdateCallback
7
+ * @param {server.SharedState} state - State that triggered the update.
8
+ * @param {Object} newValues - Key / value pairs of the updates that have been
9
+ * applied to the state.
10
+ * @param {Object} oldValues - Key / value pairs of the updated params before
11
+ * the updates has been applied to the state.
12
+ * @param {Mixed} [context=null] - Optionnal context object that has been passed
13
+ * with the values updates in the `set` call.
14
+ */
15
+
16
+ /**
17
+ * The `SharedStateCollection` interface represent a collection of all states
18
+ * created from a given schema name on the network, at the execption of the ones
19
+ * created by the current node.
20
+ *
21
+ * ```
22
+ * const collection = await server.stateManager.getCollection('my-schema');
23
+ * const allValues = collection.getValues();
24
+ * collection.onUpdate((state, newValues, oldValues, context) => {
25
+ * // do something
26
+ * });
27
+ * ```
28
+ * @memberof server
29
+ * @extends BaseSharedStateCollection
30
+ * @inheritdoc
31
+ * @hideconstructor
32
+ */
33
+ class SharedStateCollection extends BaseSharedStateCollection {}
34
+
35
+ export default SharedStateCollection;
36
+
@@ -2,9 +2,9 @@ import { Worker } from 'node:worker_threads';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
 
5
+ import querystring from 'querystring';
5
6
  import { WebSocketServer } from 'ws';
6
7
  import WebSocket from 'ws';
7
- import querystring from 'querystring';
8
8
 
9
9
  import Socket from './Socket.js';
10
10
 
@@ -69,13 +69,13 @@ class Sockets {
69
69
 
70
70
  // init ws server
71
71
  this._wss = new WebSocketServer({
72
- server: server.httpServer,
72
+ noServer: true,
73
73
  path: `/${config.path}`, // @note - update according to existing config files (aka cosima-apps)
74
74
  });
75
75
 
76
76
  this._wss.on('connection', (ws, req) => {
77
77
  const queryString = querystring.decode(req.url.split('?')[1]);
78
- const { role, key } = queryString;
78
+ const { role, key, token } = queryString;
79
79
  const binary = !!(parseInt(queryString.binary));
80
80
 
81
81
  if (binary) {
@@ -96,8 +96,29 @@ class Sockets {
96
96
  socket.addToRoom('*');
97
97
  socket.addToRoom(role);
98
98
 
99
- onConnectionCallback(role, socket);
99
+ onConnectionCallback(role, socket, token);
100
+ }
101
+ });
102
+
103
+ // check if client can connect
104
+ server.httpServer.on('upgrade', async (req, socket, head) => {
105
+ const queryString = querystring.decode(req.url.split('?')[1]);
106
+
107
+ const { role, token } = queryString;
108
+
109
+ if (server.isProtected(role)) {
110
+ // we don't have any ip in the upgrade request, so we just check the
111
+ // connection token is pending
112
+ const allowed = server.isValidConnectionToken(token);
113
+
114
+ if (!allowed) {
115
+ socket.destroy('not allowed');
116
+ }
100
117
  }
118
+
119
+ this._wss.handleUpgrade(req, socket, head, (ws) => {
120
+ this._wss.emit('connection', ws, req);
121
+ });
101
122
  });
102
123
  }
103
124
 
@@ -1,11 +1,11 @@
1
1
  import EventEmitter from 'node:events';
2
2
 
3
+ import { idGenerator, isString, isPlainObject } from '@ircam/sc-utils';
3
4
  import clonedeep from 'lodash.clonedeep';
4
5
 
5
6
  import BaseStateManager from '../common/BaseStateManager.js';
6
7
  import ParameterBag from '../common/ParameterBag.js';
7
8
  import SharedStatePrivate from '../common/SharedStatePrivate.js';
8
- import { isString, isPlainObject } from '../common/utils.js';
9
9
  import {
10
10
  SERVER_ID,
11
11
  CREATE_REQUEST,
@@ -22,7 +22,7 @@ import {
22
22
  DELETE_SCHEMA,
23
23
  PRIVATE_STATES,
24
24
  } from '../common/constants.js';
25
- import { idGenerator } from '../common/utils.js';
25
+
26
26
 
27
27
  const generateStateId = idGenerator();
28
28
  const generateRemoteId = idGenerator();
@@ -359,9 +359,11 @@ class StateManager extends BaseStateManager {
359
359
  client.transport.emit(CREATE_RESPONSE, reqId, stateId, remoteId, schemaName, sendedSchema, currentValues);
360
360
 
361
361
  this._observers.forEach(observer => {
362
- observer.transport.emit(OBSERVE_NOTIFICATION, [schemaName, stateId, nodeId]);
362
+ if (observer.id !== nodeId) {
363
+ observer.transport.emit(OBSERVE_NOTIFICATION, schemaName, stateId, nodeId);
364
+ }
363
365
  });
364
- } catch(err) {
366
+ } catch (err) {
365
367
  client.transport.emit(CREATE_ERROR, reqId, err.message);
366
368
  console.error(err.message);
367
369
  }
@@ -428,7 +430,7 @@ class StateManager extends BaseStateManager {
428
430
  const { schemaName, id, _creatorId } = state;
429
431
  // only track application states
430
432
  // (e.g. do not propagate infos about audit state)
431
- if (!PRIVATE_STATES.includes(schemaName)) {
433
+ if (!PRIVATE_STATES.includes(schemaName) && _creatorId !== client.id) {
432
434
  statesInfos.push([schemaName, id, _creatorId]);
433
435
  }
434
436
  });
@@ -0,0 +1,38 @@
1
+ import crypto from 'node:crypto';
2
+ import { v4 as uuidv4 } from 'uuid';
3
+
4
+ // adapted from https://dev.to/jobizil/encrypt-and-decrypt-data-in-nodejs-using-aes-256-cbc-2l6d
5
+ // generate a new key and init vector each time the server restart, we don't need
6
+ // any persistency here
7
+ const secretKey = uuidv4();
8
+ const secretIv = uuidv4();
9
+ const encryptionMethod = 'aes-256-cbc';
10
+
11
+ const key = crypto
12
+ .createHash('sha512')
13
+ .update(secretKey)
14
+ .digest('hex')
15
+ .substring(0, 32);
16
+
17
+ const encryptionIV = crypto
18
+ .createHash('sha512')
19
+ .update(secretIv)
20
+ .digest('hex')
21
+ .substring(0, 16);
22
+
23
+
24
+ export function encryptData(obj) {
25
+ const data = JSON.stringify(obj);
26
+ const cipher = crypto.createCipheriv(encryptionMethod, key, encryptionIV)
27
+ return Buffer.from(
28
+ cipher.update(data, 'utf8', 'hex') + cipher.final('hex')
29
+ ).toString('base64')
30
+ }
31
+
32
+ export function decryptData(encryptedData) {
33
+ const buff = Buffer.from(encryptedData, 'base64')
34
+ const decipher = crypto.createDecipheriv(encryptionMethod, key, encryptionIV)
35
+
36
+ const decrypted = `${decipher.update(buff.toString('utf8'), 'hex', 'utf8')}${decipher.final('utf8')}`
37
+ return JSON.parse(decrypted);
38
+ }
@@ -1,28 +0,0 @@
1
- import isPlainObj from 'is-plain-obj';
2
-
3
- // https://stackoverflow.com/questions/17575790/environment-detection-node-js-or-browser
4
- export const isBrowser = new Function('try {return this===window;}catch(e){ return false;}');
5
-
6
- export function* idGenerator() {
7
- for (let i = 0; true; i++) {
8
- if (i === Number.MAX_SAFE_INTEGER) {
9
- i = 0;
10
- }
11
-
12
- yield i;
13
- }
14
- }
15
-
16
- export function isString(val) {
17
- return (typeof val === 'string' || val instanceof String);
18
- }
19
-
20
- export function isFunction(func) {
21
- return Object.prototype.toString.call(func) == '[object Function]' ||
22
- Object.prototype.toString.call(func) == '[object AsyncFunction]';
23
- }
24
-
25
- export function isPlainObject(obj) {
26
- return isPlainObj(obj);
27
- }
28
-