diodejs 0.4.2 → 0.4.4
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/bindPort.js +241 -83
- package/package.json +1 -1
- package/publishPort.js +36 -8
- package/test/bindPort.test.js +242 -0
- package/test/publishPort.test.js +62 -1
package/bindPort.js
CHANGED
|
@@ -8,6 +8,8 @@ const DiodeRPC = require('./rpc');
|
|
|
8
8
|
const nativeCrypto = require('./nativeCrypto');
|
|
9
9
|
const logger = require('./logger');
|
|
10
10
|
|
|
11
|
+
const BIND_PORT_LISTENER_STATE = Symbol.for('diodejs.bindPort.listenerState');
|
|
12
|
+
|
|
11
13
|
// Custom Duplex stream to handle the Diode connection
|
|
12
14
|
class DiodeSocket extends Duplex {
|
|
13
15
|
constructor(ref, rpc) {
|
|
@@ -129,6 +131,104 @@ class BindPort {
|
|
|
129
131
|
return this.connection;
|
|
130
132
|
}
|
|
131
133
|
|
|
134
|
+
_connectionKey(connection) {
|
|
135
|
+
if (!connection) return '';
|
|
136
|
+
if (connection._managerHostKey) return connection._managerHostKey;
|
|
137
|
+
if (connection.host && connection.port) return `${connection.host}:${connection.port}`;
|
|
138
|
+
return '';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
_isUsableConnection(connection) {
|
|
142
|
+
return connection && connection.socket && !connection.socket.destroyed;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
_clearDeviceRelayCache(deviceIdHex) {
|
|
146
|
+
const cache = this.connection && this.connection.deviceRelayCache;
|
|
147
|
+
if (!cache || typeof cache.delete !== 'function') {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const normalized = this._stripHexPrefix(deviceIdHex || '').toLowerCase();
|
|
151
|
+
cache.delete(normalized);
|
|
152
|
+
cache.delete(`0x${normalized}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async _getApiRelayCandidates(deviceId, deviceIdHex) {
|
|
156
|
+
const candidates = [];
|
|
157
|
+
const seen = new Set();
|
|
158
|
+
const push = (connection) => {
|
|
159
|
+
if (!this._isUsableConnection(connection)) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const key = this._connectionKey(connection) || String(candidates.length);
|
|
163
|
+
if (seen.has(key)) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
seen.add(key);
|
|
167
|
+
candidates.push(connection);
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
push(await this._resolveConnectionForDevice(deviceId));
|
|
172
|
+
} catch (error) {
|
|
173
|
+
logger.warn(() => `Error resolving relay for device ${deviceIdHex}: ${error}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (typeof (this.connection && this.connection.getNearestConnection) === 'function') {
|
|
177
|
+
push(this.connection.getNearestConnection());
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (typeof (this.connection && this.connection.getConnections) === 'function') {
|
|
181
|
+
for (const connection of this.connection.getConnections()) {
|
|
182
|
+
push(connection);
|
|
183
|
+
}
|
|
184
|
+
} else {
|
|
185
|
+
push(this.connection);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return candidates;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async _openApiPortWithRelayFallback(deviceId, deviceIdHex, formattedTargetPort, flags = 'rw') {
|
|
192
|
+
let candidates = await this._getApiRelayCandidates(deviceId, deviceIdHex);
|
|
193
|
+
let clearedCache = false;
|
|
194
|
+
let lastError = null;
|
|
195
|
+
|
|
196
|
+
for (let index = 0; index < candidates.length; index += 1) {
|
|
197
|
+
const connection = candidates[index];
|
|
198
|
+
const rpc = this._getRpcFor(connection);
|
|
199
|
+
const relayKey = this._connectionKey(connection) || 'unknown relay';
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
const ref = await rpc.portOpen(deviceId, formattedTargetPort, flags);
|
|
203
|
+
if (ref) {
|
|
204
|
+
if (index > 0) {
|
|
205
|
+
logger.info(() => `Port ${formattedTargetPort} opened via fallback relay ${relayKey}`);
|
|
206
|
+
}
|
|
207
|
+
return { connection, rpc, ref };
|
|
208
|
+
}
|
|
209
|
+
lastError = new Error(`portopen returned no ref via ${relayKey}`);
|
|
210
|
+
} catch (error) {
|
|
211
|
+
lastError = error;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
logger.warn(() => `Port ${formattedTargetPort} did not open via ${relayKey}: ${lastError}`);
|
|
215
|
+
if (!clearedCache) {
|
|
216
|
+
clearedCache = true;
|
|
217
|
+
this._clearDeviceRelayCache(deviceIdHex);
|
|
218
|
+
const refreshed = await this._getApiRelayCandidates(deviceId, deviceIdHex);
|
|
219
|
+
for (const candidate of refreshed) {
|
|
220
|
+
const key = this._connectionKey(candidate) || String(candidates.length);
|
|
221
|
+
const exists = candidates.some((existing) => (this._connectionKey(existing) || '') === key);
|
|
222
|
+
if (!exists) {
|
|
223
|
+
candidates.push(candidate);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
throw lastError || new Error('No relay connection available');
|
|
230
|
+
}
|
|
231
|
+
|
|
132
232
|
async _openTlsHandshakeChannel(connection, rpc, ref) {
|
|
133
233
|
const diodeSocket = new DiodeSocket(ref, rpc);
|
|
134
234
|
const certPem = connection.getDeviceCertificate();
|
|
@@ -230,8 +330,26 @@ class BindPort {
|
|
|
230
330
|
}
|
|
231
331
|
|
|
232
332
|
_setupMessageListener() {
|
|
233
|
-
|
|
234
|
-
|
|
333
|
+
const rootConnection = this.connection;
|
|
334
|
+
if (!rootConnection || typeof rootConnection.on !== 'function') {
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
let state = rootConnection[BIND_PORT_LISTENER_STATE];
|
|
339
|
+
if (state) {
|
|
340
|
+
state.instances.add(this);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
state = {
|
|
345
|
+
instances: new Set([this]),
|
|
346
|
+
};
|
|
347
|
+
rootConnection[BIND_PORT_LISTENER_STATE] = state;
|
|
348
|
+
|
|
349
|
+
// Listen for data events from the device. This is shared per connection
|
|
350
|
+
// manager so multiple BindPort instances do not duplicate payloads into
|
|
351
|
+
// the same client socket.
|
|
352
|
+
state.onUnsolicited = (message, sourceConnection) => {
|
|
235
353
|
const connection = sourceConnection || this.connection;
|
|
236
354
|
if (!connection || typeof connection.getClientSocket !== 'function') {
|
|
237
355
|
logger.warn(() => 'Received unsolicited message without a valid connection context');
|
|
@@ -276,7 +394,11 @@ class BindPort {
|
|
|
276
394
|
if (clientSocket.diodeSocket) {
|
|
277
395
|
clientSocket.diodeSocket._destroy(null, () => {});
|
|
278
396
|
}
|
|
279
|
-
clientSocket.end
|
|
397
|
+
if (typeof clientSocket.end === 'function') {
|
|
398
|
+
clientSocket.end();
|
|
399
|
+
} else if (typeof clientSocket.close === 'function') {
|
|
400
|
+
clientSocket.close();
|
|
401
|
+
}
|
|
280
402
|
connection.deleteClientSocket(dataRef);
|
|
281
403
|
logger.info(() => `Port closed for ref: ${dataRef.toString('hex')}`);
|
|
282
404
|
}
|
|
@@ -285,19 +407,26 @@ class BindPort {
|
|
|
285
407
|
logger.warn(() => `Unknown unsolicited message type: ${messageType}`);
|
|
286
408
|
}
|
|
287
409
|
}
|
|
288
|
-
}
|
|
410
|
+
};
|
|
411
|
+
rootConnection.on('unsolicited', state.onUnsolicited);
|
|
289
412
|
|
|
290
413
|
// Handle device disconnect
|
|
291
|
-
|
|
414
|
+
state.onEnd = () => {
|
|
292
415
|
logger.info(() => 'Disconnected from Diode.io server');
|
|
293
|
-
|
|
294
|
-
|
|
416
|
+
for (const instance of state.instances) {
|
|
417
|
+
instance.closeAllServers();
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
rootConnection.on('end', state.onEnd);
|
|
295
421
|
|
|
296
422
|
// Handle connection errors
|
|
297
|
-
|
|
423
|
+
state.onError = (err) => {
|
|
298
424
|
logger.error(() => `Connection error: ${err}`);
|
|
299
|
-
|
|
300
|
-
|
|
425
|
+
for (const instance of state.instances) {
|
|
426
|
+
instance.closeAllServers();
|
|
427
|
+
}
|
|
428
|
+
};
|
|
429
|
+
rootConnection.on('error', state.onError);
|
|
301
430
|
}
|
|
302
431
|
|
|
303
432
|
addPort(localPort, targetPort, deviceIdHex, protocol = 'tls', transport = undefined) {
|
|
@@ -471,39 +600,23 @@ class BindPort {
|
|
|
471
600
|
let entry = server.clientRefs && server.clientRefs[clientKey];
|
|
472
601
|
|
|
473
602
|
if (!entry) {
|
|
474
|
-
let connection;
|
|
475
603
|
try {
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
logger.
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
}
|
|
492
|
-
logger.info(() => `Port ${formattedTargetPort} opened on device with ref: ${ref.toString('hex')} for udp client ${clientKey}`);
|
|
493
|
-
if (!server.clientRefs) server.clientRefs = {};
|
|
494
|
-
entry = { ref, connection };
|
|
495
|
-
server.clientRefs[clientKey] = entry;
|
|
496
|
-
|
|
497
|
-
// Store the client info
|
|
498
|
-
connection.addClientSocket(ref, {
|
|
499
|
-
address: rinfo.address,
|
|
500
|
-
port: rinfo.port,
|
|
501
|
-
protocol: 'udp',
|
|
502
|
-
write: (data) => {
|
|
503
|
-
server.send(data, rinfo.port, rinfo.address);
|
|
504
|
-
}
|
|
505
|
-
});
|
|
506
|
-
}
|
|
604
|
+
const opened = await this._openApiPortWithRelayFallback(deviceId, deviceIdHex, formattedTargetPort, 'rw');
|
|
605
|
+
const { ref, connection } = opened;
|
|
606
|
+
logger.info(() => `Port ${formattedTargetPort} opened on device with ref: ${ref.toString('hex')} for udp client ${clientKey}`);
|
|
607
|
+
if (!server.clientRefs) server.clientRefs = {};
|
|
608
|
+
entry = { ref, connection };
|
|
609
|
+
server.clientRefs[clientKey] = entry;
|
|
610
|
+
|
|
611
|
+
// Store the client info
|
|
612
|
+
connection.addClientSocket(ref, {
|
|
613
|
+
address: rinfo.address,
|
|
614
|
+
port: rinfo.port,
|
|
615
|
+
protocol: 'udp',
|
|
616
|
+
write: (data) => {
|
|
617
|
+
server.send(data, rinfo.port, rinfo.address);
|
|
618
|
+
}
|
|
619
|
+
});
|
|
507
620
|
} catch (error) {
|
|
508
621
|
logger.error(() => `Error opening port ${formattedTargetPort} on device: ${error}`);
|
|
509
622
|
return;
|
|
@@ -539,24 +652,69 @@ class BindPort {
|
|
|
539
652
|
const server = net.createServer(async (clientSocket) => {
|
|
540
653
|
logger.info(() => `Client connected to local server on port ${localPort}`);
|
|
541
654
|
clientSocket.setNoDelay(true);
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
try {
|
|
545
|
-
connection = await this._resolveConnectionForDevice(deviceId);
|
|
546
|
-
} catch (error) {
|
|
547
|
-
logger.error(() => `Error resolving relay for device ${deviceIdHex}: ${error}`);
|
|
548
|
-
clientSocket.destroy();
|
|
549
|
-
return;
|
|
550
|
-
}
|
|
551
|
-
if (!connection) {
|
|
552
|
-
logger.error(() => `No relay connection available for device ${deviceIdHex}`);
|
|
553
|
-
clientSocket.destroy();
|
|
554
|
-
return;
|
|
655
|
+
if (useNative) {
|
|
656
|
+
clientSocket.pause();
|
|
555
657
|
}
|
|
556
|
-
|
|
658
|
+
|
|
659
|
+
let connection = null;
|
|
660
|
+
let rpc = null;
|
|
557
661
|
|
|
558
662
|
let ref;
|
|
663
|
+
let clientClosed = false;
|
|
664
|
+
let remoteCleanupStarted = false;
|
|
665
|
+
let tlsSocketWrapper = null;
|
|
666
|
+
|
|
667
|
+
const closeRemoteRef = async () => {
|
|
668
|
+
if (remoteCleanupStarted) {
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
if (!ref || !connection || !rpc) {
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
remoteCleanupStarted = true;
|
|
676
|
+
|
|
677
|
+
const storedSocket = typeof connection.getClientSocket === 'function'
|
|
678
|
+
? connection.getClientSocket(ref)
|
|
679
|
+
: null;
|
|
680
|
+
if (storedSocket && storedSocket !== clientSocket && typeof storedSocket.end === 'function') {
|
|
681
|
+
try { storedSocket.end(); } catch {}
|
|
682
|
+
}
|
|
683
|
+
if (typeof connection.deleteClientSocket === 'function') {
|
|
684
|
+
try { connection.deleteClientSocket(ref); } catch {}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
try {
|
|
688
|
+
await rpc.portClose(ref);
|
|
689
|
+
logger.info(() => `Port closed on device for ref: ${ref.toString('hex')}`);
|
|
690
|
+
} catch (error) {
|
|
691
|
+
logger.error(() => `Error closing port on device: ${error}`);
|
|
692
|
+
}
|
|
693
|
+
};
|
|
694
|
+
|
|
695
|
+
clientSocket.once('close', () => {
|
|
696
|
+
clientClosed = true;
|
|
697
|
+
closeRemoteRef();
|
|
698
|
+
});
|
|
699
|
+
clientSocket.once('error', (err) => {
|
|
700
|
+
logger.error(() => `Client socket error: ${err}`);
|
|
701
|
+
});
|
|
702
|
+
|
|
559
703
|
if (useNative) {
|
|
704
|
+
try {
|
|
705
|
+
connection = await this._resolveConnectionForDevice(deviceId);
|
|
706
|
+
} catch (error) {
|
|
707
|
+
logger.error(() => `Error resolving relay for device ${deviceIdHex}: ${error}`);
|
|
708
|
+
clientSocket.destroy();
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
if (!connection) {
|
|
712
|
+
logger.error(() => `No relay connection available for device ${deviceIdHex}`);
|
|
713
|
+
clientSocket.destroy();
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
rpc = this._getRpcFor(connection);
|
|
717
|
+
|
|
560
718
|
// Open a new native relay port on the device for this client
|
|
561
719
|
let physicalPort;
|
|
562
720
|
try {
|
|
@@ -647,26 +805,30 @@ class BindPort {
|
|
|
647
805
|
});
|
|
648
806
|
clientSocket.on('end', cleanup);
|
|
649
807
|
relaySocket.on('end', cleanup);
|
|
808
|
+
clientSocket.resume();
|
|
650
809
|
|
|
651
810
|
return;
|
|
652
811
|
}
|
|
653
812
|
|
|
654
813
|
// Legacy API relay
|
|
655
814
|
try {
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
} else {
|
|
662
|
-
logger.info(() => `Port ${formattedTargetPort} opened on device with ref: ${ref.toString('hex')} for client`);
|
|
663
|
-
}
|
|
815
|
+
const opened = await this._openApiPortWithRelayFallback(deviceId, deviceIdHex, formattedTargetPort, 'rw');
|
|
816
|
+
connection = opened.connection;
|
|
817
|
+
rpc = opened.rpc;
|
|
818
|
+
ref = opened.ref;
|
|
819
|
+
logger.info(() => `Port ${formattedTargetPort} opened on device with ref: ${ref.toString('hex')} for client`);
|
|
664
820
|
} catch (error) {
|
|
665
821
|
logger.error(() => `Error opening port ${formattedTargetPort} on device: ${error}`);
|
|
666
822
|
clientSocket.destroy();
|
|
667
823
|
return;
|
|
668
824
|
}
|
|
669
825
|
|
|
826
|
+
if (clientClosed || clientSocket.destroyed) {
|
|
827
|
+
logger.warn(() => `Local client disconnected before port ${formattedTargetPort} opened; closing ref ${ref.toString('hex')}`);
|
|
828
|
+
await closeRemoteRef();
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
|
|
670
832
|
if (protocol === 'tls') {
|
|
671
833
|
// For tls protocol, create a proper tls connection
|
|
672
834
|
try {
|
|
@@ -713,16 +875,27 @@ class BindPort {
|
|
|
713
875
|
diodeSocket,
|
|
714
876
|
tlsSocket,
|
|
715
877
|
end: () => {
|
|
716
|
-
tlsSocket.end();
|
|
717
|
-
diodeSocket._destroy(null, () => {});
|
|
878
|
+
try { tlsSocket.end(); } catch {}
|
|
879
|
+
try { diodeSocket._destroy(null, () => {}); } catch {}
|
|
718
880
|
}
|
|
719
881
|
};
|
|
882
|
+
tlsSocketWrapper = socketWrapper;
|
|
720
883
|
|
|
721
884
|
// Store the socket wrapper
|
|
722
885
|
connection.addClientSocket(ref, socketWrapper);
|
|
886
|
+
tlsSocket.once('close', () => {
|
|
887
|
+
if (!clientSocket.destroyed) {
|
|
888
|
+
clientSocket.end();
|
|
889
|
+
}
|
|
890
|
+
closeRemoteRef();
|
|
891
|
+
});
|
|
723
892
|
|
|
724
893
|
} catch (error) {
|
|
725
894
|
logger.error(() => `Error setting up tls connection: ${error}`);
|
|
895
|
+
if (tlsSocketWrapper && typeof tlsSocketWrapper.end === 'function') {
|
|
896
|
+
tlsSocketWrapper.end();
|
|
897
|
+
}
|
|
898
|
+
await closeRemoteRef();
|
|
726
899
|
clientSocket.destroy();
|
|
727
900
|
return;
|
|
728
901
|
}
|
|
@@ -742,24 +915,9 @@ class BindPort {
|
|
|
742
915
|
}
|
|
743
916
|
|
|
744
917
|
// Handle client socket closure (common for all protocols)
|
|
745
|
-
clientSocket.
|
|
918
|
+
clientSocket.once('end', () => {
|
|
746
919
|
logger.info(() => 'Client disconnected');
|
|
747
|
-
|
|
748
|
-
try {
|
|
749
|
-
await rpc.portClose(ref);
|
|
750
|
-
logger.info(() => `Port closed on device for ref: ${ref.toString('hex')}`);
|
|
751
|
-
connection.deleteClientSocket(ref);
|
|
752
|
-
} catch (error) {
|
|
753
|
-
logger.error(() => `Error closing port on device: ${error}`);
|
|
754
|
-
}
|
|
755
|
-
} else {
|
|
756
|
-
logger.warn(() => 'Ref is invalid or no longer in clientSockets.');
|
|
757
|
-
}
|
|
758
|
-
});
|
|
759
|
-
|
|
760
|
-
// Handle client socket errors
|
|
761
|
-
clientSocket.on('error', (err) => {
|
|
762
|
-
logger.error(() => `Client socket error: ${err}`);
|
|
920
|
+
closeRemoteRef();
|
|
763
921
|
});
|
|
764
922
|
});
|
|
765
923
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "diodejs",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
4
4
|
"description": "A JavaScript client for interacting with the Diode network. It provides functionalities to bind and publish ports, send RPC commands, and handle responses.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
package/publishPort.js
CHANGED
|
@@ -399,6 +399,7 @@ class PublishPort extends EventEmitter {
|
|
|
399
399
|
if (session.localSocket && typeof session.localSocket.resume === 'function') {
|
|
400
400
|
session.localSocket.resume();
|
|
401
401
|
}
|
|
402
|
+
this._flushNativeTCPRelayPending(session);
|
|
402
403
|
|
|
403
404
|
if (session.protocol === 'udp' && session.relaySocket && session.session) {
|
|
404
405
|
const probe = nativeCrypto.createUdpPacket(session.session, Buffer.alloc(0));
|
|
@@ -502,6 +503,7 @@ class PublishPort extends EventEmitter {
|
|
|
502
503
|
relaySocket: null,
|
|
503
504
|
localSocket: null,
|
|
504
505
|
timer: null,
|
|
506
|
+
pendingRelayChunks: [],
|
|
505
507
|
};
|
|
506
508
|
session.timer = setTimeout(() => {
|
|
507
509
|
if (!session.ready) {
|
|
@@ -546,6 +548,35 @@ class PublishPort extends EventEmitter {
|
|
|
546
548
|
this.nativeSessions.delete(session.physicalPort);
|
|
547
549
|
}
|
|
548
550
|
|
|
551
|
+
_consumeNativeTCPRelayData(session, data) {
|
|
552
|
+
if (!session || !session.session || !session.localSocket) {
|
|
553
|
+
return true;
|
|
554
|
+
}
|
|
555
|
+
try {
|
|
556
|
+
const messages = nativeCrypto.consumeTcpFrames(session.session, data);
|
|
557
|
+
for (const msg of messages) {
|
|
558
|
+
session.localSocket.write(msg);
|
|
559
|
+
}
|
|
560
|
+
return true;
|
|
561
|
+
} catch (error) {
|
|
562
|
+
logger.error(() => `TCP decrypt error (${session.deviceId}): ${error}`);
|
|
563
|
+
return false;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
_flushNativeTCPRelayPending(session) {
|
|
568
|
+
if (!session || session.protocol !== 'tcp' || !Array.isArray(session.pendingRelayChunks)) {
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
const pending = session.pendingRelayChunks.splice(0, session.pendingRelayChunks.length);
|
|
572
|
+
for (const chunk of pending) {
|
|
573
|
+
if (!this._consumeNativeTCPRelayData(session, chunk)) {
|
|
574
|
+
this._cleanupNativeSession(session);
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
549
580
|
handleNativeTCPRelay(sessionId, physicalPortRef, session, connection) {
|
|
550
581
|
const rpc = this._getRpcFor(connection);
|
|
551
582
|
const { physicalPort, port, host, deviceId } = session;
|
|
@@ -611,14 +642,11 @@ class PublishPort extends EventEmitter {
|
|
|
611
642
|
localSocket.on('end', cleanup);
|
|
612
643
|
|
|
613
644
|
relaySocket.on('data', (data) => {
|
|
614
|
-
if (!session.ready || !session.session)
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
}
|
|
620
|
-
} catch (error) {
|
|
621
|
-
logger.error(() => `TCP decrypt error (${deviceId}): ${error}`);
|
|
645
|
+
if (!session.ready || !session.session) {
|
|
646
|
+
session.pendingRelayChunks.push(Buffer.from(data));
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
if (!this._consumeNativeTCPRelayData(session, data)) {
|
|
622
650
|
cleanup();
|
|
623
651
|
}
|
|
624
652
|
});
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
const test = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const EventEmitter = require('events');
|
|
4
|
+
const net = require('net');
|
|
5
|
+
const { once } = require('events');
|
|
6
|
+
|
|
7
|
+
const BindPort = require('../bindPort');
|
|
8
|
+
|
|
9
|
+
function makeRef(hex) {
|
|
10
|
+
return Buffer.from(hex.padStart(8, '0'), 'hex');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function makeRelay(hostKey, result, calls) {
|
|
14
|
+
const clientSockets = new Map();
|
|
15
|
+
const portCloseCalls = [];
|
|
16
|
+
return {
|
|
17
|
+
_managerHostKey: hostKey,
|
|
18
|
+
socket: { destroyed: false },
|
|
19
|
+
clientSockets,
|
|
20
|
+
portCloseCalls,
|
|
21
|
+
RPC: {
|
|
22
|
+
portOpen: async (_deviceId, port, flags) => {
|
|
23
|
+
calls.push({ hostKey, port, flags });
|
|
24
|
+
if (result instanceof Error) {
|
|
25
|
+
throw result;
|
|
26
|
+
}
|
|
27
|
+
return typeof result === 'function' ? result() : result;
|
|
28
|
+
},
|
|
29
|
+
portClose: async (ref) => {
|
|
30
|
+
portCloseCalls.push(ref);
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
addClientSocket(ref, socket) {
|
|
34
|
+
clientSockets.set(ref.toString('hex'), socket);
|
|
35
|
+
},
|
|
36
|
+
getClientSocket(ref) {
|
|
37
|
+
return clientSockets.get(ref.toString('hex'));
|
|
38
|
+
},
|
|
39
|
+
deleteClientSocket(ref) {
|
|
40
|
+
return clientSockets.delete(ref.toString('hex'));
|
|
41
|
+
},
|
|
42
|
+
hasClientSocket(ref) {
|
|
43
|
+
return clientSockets.has(ref.toString('hex'));
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function deferred() {
|
|
49
|
+
let resolve;
|
|
50
|
+
let reject;
|
|
51
|
+
const promise = new Promise((res, rej) => {
|
|
52
|
+
resolve = res;
|
|
53
|
+
reject = rej;
|
|
54
|
+
});
|
|
55
|
+
return { promise, resolve, reject };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function waitFor(predicate, timeoutMs = 1000) {
|
|
59
|
+
const startedAt = Date.now();
|
|
60
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
61
|
+
if (predicate()) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
65
|
+
}
|
|
66
|
+
assert.equal(predicate(), true);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
class FakeManager extends EventEmitter {
|
|
70
|
+
constructor({ relays, resolvedRelay, nearestRelay, resolveError = null }) {
|
|
71
|
+
super();
|
|
72
|
+
this.relays = relays;
|
|
73
|
+
this.resolvedRelay = resolvedRelay;
|
|
74
|
+
this.nearestRelay = nearestRelay;
|
|
75
|
+
this.resolveError = resolveError;
|
|
76
|
+
this.deviceRelayCache = new Map();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async getConnectionForDevice() {
|
|
80
|
+
if (this.resolveError) {
|
|
81
|
+
throw this.resolveError;
|
|
82
|
+
}
|
|
83
|
+
return this.resolvedRelay;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
getNearestConnection() {
|
|
87
|
+
return this.nearestRelay;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
getConnections() {
|
|
91
|
+
return this.relays.slice();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
test('API portopen falls back to another relay and clears stale device cache', async () => {
|
|
96
|
+
const calls = [];
|
|
97
|
+
const bad = makeRelay('bad.relay:41046', undefined, calls);
|
|
98
|
+
const goodRef = makeRef('01020304');
|
|
99
|
+
const good = makeRelay('good.relay:41046', goodRef, calls);
|
|
100
|
+
const manager = new FakeManager({
|
|
101
|
+
relays: [bad, good],
|
|
102
|
+
resolvedRelay: bad,
|
|
103
|
+
nearestRelay: bad,
|
|
104
|
+
});
|
|
105
|
+
manager.deviceRelayCache.set('8a72468957504d50247a260deb0218d504dd091b', {
|
|
106
|
+
hostKey: 'bad.relay:41046',
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const bind = new BindPort(manager, {});
|
|
110
|
+
const result = await bind._openApiPortWithRelayFallback(
|
|
111
|
+
Buffer.from('8a72468957504d50247a260deb0218d504dd091b', 'hex'),
|
|
112
|
+
'8a72468957504d50247a260deb0218d504dd091b',
|
|
113
|
+
'tls:8088',
|
|
114
|
+
'rw'
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
assert.equal(result.connection, good);
|
|
118
|
+
assert.equal(result.ref, goodRef);
|
|
119
|
+
assert.deepEqual(calls.map((entry) => entry.hostKey), [
|
|
120
|
+
'bad.relay:41046',
|
|
121
|
+
'good.relay:41046',
|
|
122
|
+
]);
|
|
123
|
+
assert.equal(manager.deviceRelayCache.has('8a72468957504d50247a260deb0218d504dd091b'), false);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('API portopen tries connected relays when device relay lookup fails', async () => {
|
|
127
|
+
const calls = [];
|
|
128
|
+
const ref = makeRef('05060708');
|
|
129
|
+
const fallback = makeRelay('fallback.relay:41046', ref, calls);
|
|
130
|
+
const manager = new FakeManager({
|
|
131
|
+
relays: [fallback],
|
|
132
|
+
resolvedRelay: null,
|
|
133
|
+
nearestRelay: null,
|
|
134
|
+
resolveError: new Error('ticket lookup failed'),
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const bind = new BindPort(manager, {});
|
|
138
|
+
const result = await bind._openApiPortWithRelayFallback(
|
|
139
|
+
Buffer.from('8a72468957504d50247a260deb0218d504dd091b', 'hex'),
|
|
140
|
+
'8a72468957504d50247a260deb0218d504dd091b',
|
|
141
|
+
'tls:8088',
|
|
142
|
+
'rw'
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
assert.equal(result.connection, fallback);
|
|
146
|
+
assert.equal(result.ref, ref);
|
|
147
|
+
assert.deepEqual(calls, [{
|
|
148
|
+
hostKey: 'fallback.relay:41046',
|
|
149
|
+
port: 'tls:8088',
|
|
150
|
+
flags: 'rw',
|
|
151
|
+
}]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('multiple BindPort instances share one manager listener set', () => {
|
|
155
|
+
const manager = new FakeManager({
|
|
156
|
+
relays: [],
|
|
157
|
+
resolvedRelay: null,
|
|
158
|
+
nearestRelay: null,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
new BindPort(manager, {});
|
|
162
|
+
new BindPort(manager, {});
|
|
163
|
+
|
|
164
|
+
assert.equal(manager.listenerCount('unsolicited'), 1);
|
|
165
|
+
assert.equal(manager.listenerCount('end'), 1);
|
|
166
|
+
assert.equal(manager.listenerCount('error'), 1);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('API bind closes ref when local TCP client disconnects before portopen returns', async () => {
|
|
170
|
+
const calls = [];
|
|
171
|
+
const opened = deferred();
|
|
172
|
+
const ref = makeRef('0a0b0c0d');
|
|
173
|
+
const relay = makeRelay('relay.example:41046', () => opened.promise, calls);
|
|
174
|
+
const manager = new FakeManager({
|
|
175
|
+
relays: [relay],
|
|
176
|
+
resolvedRelay: relay,
|
|
177
|
+
nearestRelay: relay,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const bind = new BindPort(manager, {
|
|
181
|
+
0: {
|
|
182
|
+
targetPort: 8088,
|
|
183
|
+
deviceIdHex: '8a72468957504d50247a260deb0218d504dd091b',
|
|
184
|
+
protocol: 'tcp',
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
bind.addPort(0, 8088, '8a72468957504d50247a260deb0218d504dd091b', 'tcp');
|
|
188
|
+
const server = bind.servers.get(0);
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
await once(server, 'listening');
|
|
192
|
+
const client = net.connect(server.address().port, '127.0.0.1');
|
|
193
|
+
await once(client, 'connect');
|
|
194
|
+
client.destroy();
|
|
195
|
+
await once(client, 'close');
|
|
196
|
+
|
|
197
|
+
opened.resolve(ref);
|
|
198
|
+
await waitFor(() => relay.portCloseCalls.length === 1);
|
|
199
|
+
|
|
200
|
+
assert.equal(relay.portCloseCalls[0].toString('hex'), ref.toString('hex'));
|
|
201
|
+
assert.equal(relay.hasClientSocket(ref), false);
|
|
202
|
+
} finally {
|
|
203
|
+
server.close();
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('API bind closes ref when local TCP client disconnects after portopen returns', async () => {
|
|
208
|
+
const calls = [];
|
|
209
|
+
const ref = makeRef('0e0f1011');
|
|
210
|
+
const relay = makeRelay('relay.example:41046', ref, calls);
|
|
211
|
+
const manager = new FakeManager({
|
|
212
|
+
relays: [relay],
|
|
213
|
+
resolvedRelay: relay,
|
|
214
|
+
nearestRelay: relay,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const bind = new BindPort(manager, {
|
|
218
|
+
0: {
|
|
219
|
+
targetPort: 8088,
|
|
220
|
+
deviceIdHex: '8a72468957504d50247a260deb0218d504dd091b',
|
|
221
|
+
protocol: 'tcp',
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
bind.addPort(0, 8088, '8a72468957504d50247a260deb0218d504dd091b', 'tcp');
|
|
225
|
+
const server = bind.servers.get(0);
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
await once(server, 'listening');
|
|
229
|
+
const client = net.connect(server.address().port, '127.0.0.1');
|
|
230
|
+
await once(client, 'connect');
|
|
231
|
+
await waitFor(() => relay.hasClientSocket(ref));
|
|
232
|
+
|
|
233
|
+
client.end();
|
|
234
|
+
await once(client, 'close');
|
|
235
|
+
await waitFor(() => relay.portCloseCalls.length === 1);
|
|
236
|
+
|
|
237
|
+
assert.equal(relay.portCloseCalls[0].toString('hex'), ref.toString('hex'));
|
|
238
|
+
assert.equal(relay.hasClientSocket(ref), false);
|
|
239
|
+
} finally {
|
|
240
|
+
server.close();
|
|
241
|
+
}
|
|
242
|
+
});
|
package/test/publishPort.test.js
CHANGED
|
@@ -6,6 +6,7 @@ const tls = require('tls');
|
|
|
6
6
|
const dgram = require('dgram');
|
|
7
7
|
|
|
8
8
|
const PublishPort = require('../publishPort');
|
|
9
|
+
const nativeCrypto = require('../nativeCrypto');
|
|
9
10
|
|
|
10
11
|
class FakeStreamSocket extends EventEmitter {
|
|
11
12
|
constructor() {
|
|
@@ -13,11 +14,14 @@ class FakeStreamSocket extends EventEmitter {
|
|
|
13
14
|
this.destroyed = false;
|
|
14
15
|
this.remoteAddress = undefined;
|
|
15
16
|
this.remotePort = undefined;
|
|
17
|
+
this.writes = [];
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
setNoDelay() {}
|
|
19
21
|
pause() {}
|
|
20
|
-
write() {
|
|
22
|
+
write(data) {
|
|
23
|
+
this.writes.push(data);
|
|
24
|
+
}
|
|
21
25
|
end() {}
|
|
22
26
|
destroy() {
|
|
23
27
|
this.destroyed = true;
|
|
@@ -307,6 +311,63 @@ test('native TCP publish connects local socket to configured host', () => {
|
|
|
307
311
|
assert.deepEqual(connectCalls[1], { port: 8089, host: '10.0.0.8' });
|
|
308
312
|
});
|
|
309
313
|
|
|
314
|
+
test('native TCP publish buffers relay data until handshake is ready', () => {
|
|
315
|
+
const connection = new FakeConnection();
|
|
316
|
+
const publishPort = new PublishPort(connection, {
|
|
317
|
+
8089: { mode: 'public', host: '10.0.0.8' },
|
|
318
|
+
});
|
|
319
|
+
const originalConnect = net.connect;
|
|
320
|
+
const originalConsumeTcpFrames = nativeCrypto.consumeTcpFrames;
|
|
321
|
+
const sockets = [];
|
|
322
|
+
|
|
323
|
+
net.connect = (options, callback) => {
|
|
324
|
+
const socket = new FakeStreamSocket();
|
|
325
|
+
socket.options = options;
|
|
326
|
+
sockets.push(socket);
|
|
327
|
+
process.nextTick(() => {
|
|
328
|
+
if (typeof callback === 'function') {
|
|
329
|
+
callback();
|
|
330
|
+
}
|
|
331
|
+
socket.emit('connect');
|
|
332
|
+
});
|
|
333
|
+
return socket;
|
|
334
|
+
};
|
|
335
|
+
nativeCrypto.consumeTcpFrames = () => [Buffer.from('plain-http')];
|
|
336
|
+
|
|
337
|
+
try {
|
|
338
|
+
const session = {
|
|
339
|
+
physicalPort: 41000,
|
|
340
|
+
port: 8089,
|
|
341
|
+
host: '10.0.0.8',
|
|
342
|
+
protocol: 'tcp',
|
|
343
|
+
deviceId: '0x' + '11'.repeat(20),
|
|
344
|
+
ready: false,
|
|
345
|
+
session: null,
|
|
346
|
+
pendingRelayChunks: [],
|
|
347
|
+
};
|
|
348
|
+
publishPort.handleNativeTCPRelay(makeSessionId('07'), 41000, session, connection);
|
|
349
|
+
|
|
350
|
+
const relaySocket = sockets[0];
|
|
351
|
+
const localSocket = sockets[1];
|
|
352
|
+
relaySocket.emit('data', Buffer.from('early-encrypted'));
|
|
353
|
+
|
|
354
|
+
assert.equal(session.pendingRelayChunks.length, 1);
|
|
355
|
+
assert.equal(localSocket.writes.length, 0);
|
|
356
|
+
|
|
357
|
+
session.ready = true;
|
|
358
|
+
session.session = {};
|
|
359
|
+
publishPort._flushNativeTCPRelayPending(session);
|
|
360
|
+
|
|
361
|
+
assert.equal(session.pendingRelayChunks.length, 0);
|
|
362
|
+
assert.equal(localSocket.writes.length, 1);
|
|
363
|
+
assert.equal(localSocket.writes[0].toString(), 'plain-http');
|
|
364
|
+
} finally {
|
|
365
|
+
nativeCrypto.consumeTcpFrames = originalConsumeTcpFrames;
|
|
366
|
+
net.connect = originalConnect;
|
|
367
|
+
publishPort.stopListening();
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
|
|
310
371
|
test('native UDP publish connects local socket to configured host', () => {
|
|
311
372
|
const connection = new FakeConnection();
|
|
312
373
|
const publishPort = new PublishPort(connection, {
|