peer-term 1.2.11 → 1.2.13
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/package.json +1 -1
- package/src/index.js +177 -67
- package/src/session-viewer.js +15 -2
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
import * as dotenv from 'dotenv';
|
|
23
23
|
import os from 'os';
|
|
24
24
|
import fs from 'fs';
|
|
25
|
+
import crypto from 'crypto';
|
|
25
26
|
import path from 'path';
|
|
26
27
|
import net from 'net';
|
|
27
28
|
import readline from 'readline';
|
|
@@ -338,6 +339,7 @@ class Session {
|
|
|
338
339
|
// Local TCP viewer (new terminal window)
|
|
339
340
|
this.viewerServer = null;
|
|
340
341
|
this.viewerSocket = null;
|
|
342
|
+
this.viewerToken = null; // Random nonce for viewer auth
|
|
341
343
|
}
|
|
342
344
|
|
|
343
345
|
log(msg) {
|
|
@@ -368,25 +370,54 @@ class Session {
|
|
|
368
370
|
|
|
369
371
|
async _completeKeyExchange(clientPublicKeyBase64) {
|
|
370
372
|
if (!this.keyPair || !this.hostPublicKeyBase64) return;
|
|
373
|
+
if (typeof clientPublicKeyBase64 !== 'string' || clientPublicKeyBase64.length === 0) {
|
|
374
|
+
this._rejectProtocolMessage('Invalid key-exchange public key');
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
371
377
|
|
|
372
|
-
|
|
373
|
-
|
|
378
|
+
try {
|
|
379
|
+
this.logDebug('Deriving shared secret...');
|
|
380
|
+
this.clientPublicKeyBase64 = clientPublicKeyBase64;
|
|
381
|
+
|
|
382
|
+
const peerPublicKey = await importPublicKey(clientPublicKeyBase64);
|
|
383
|
+
this.sharedKey = await deriveSharedKey(this.keyPair.privateKey, peerPublicKey);
|
|
384
|
+
|
|
385
|
+
if (this.secureMode) {
|
|
386
|
+
this.securityFingerprint = await fingerprintPublicKeys(
|
|
387
|
+
this.code,
|
|
388
|
+
this.hostPublicKeyBase64,
|
|
389
|
+
this.clientPublicKeyBase64
|
|
390
|
+
);
|
|
391
|
+
this.fingerprintAuthorized = false;
|
|
392
|
+
this._printFingerprintAuthorization();
|
|
393
|
+
} else {
|
|
394
|
+
this.fingerprintAuthorized = true;
|
|
395
|
+
await this._activateSecureSession();
|
|
396
|
+
}
|
|
397
|
+
} catch (err) {
|
|
398
|
+
this._rejectProtocolMessage(`Invalid key exchange: ${err.message}`);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
374
401
|
|
|
375
|
-
|
|
376
|
-
this.
|
|
402
|
+
_rejectProtocolMessage(reason) {
|
|
403
|
+
this.log(`Protocol error: ${reason}`);
|
|
404
|
+
this.isClientConnected = false;
|
|
405
|
+
this.awaitingRejoin = false;
|
|
406
|
+
this.sharedKey = null;
|
|
407
|
+
this.keyPair = null;
|
|
408
|
+
this.hostPublicKeyBase64 = null;
|
|
409
|
+
this.clientPublicKeyBase64 = null;
|
|
410
|
+
this.securityFingerprint = null;
|
|
411
|
+
this.fingerprintAuthorized = false;
|
|
412
|
+
this.stopHeartbeat();
|
|
413
|
+
this._cleanupWebRTC();
|
|
414
|
+
this.destroy();
|
|
415
|
+
}
|
|
377
416
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
this.clientPublicKeyBase64
|
|
383
|
-
);
|
|
384
|
-
this.fingerprintAuthorized = false;
|
|
385
|
-
this._printFingerprintAuthorization();
|
|
386
|
-
} else {
|
|
387
|
-
this.fingerprintAuthorized = true;
|
|
388
|
-
await this._activateSecureSession();
|
|
389
|
-
}
|
|
417
|
+
_handleMessageError(err) {
|
|
418
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
419
|
+
this.log(`Protocol handler error: ${message}`);
|
|
420
|
+
this.destroy();
|
|
390
421
|
}
|
|
391
422
|
|
|
392
423
|
_printFingerprintAuthorization() {
|
|
@@ -481,6 +512,7 @@ class Session {
|
|
|
481
512
|
});
|
|
482
513
|
|
|
483
514
|
this.ws.on('message', async (raw) => {
|
|
515
|
+
try {
|
|
484
516
|
let msg;
|
|
485
517
|
try {
|
|
486
518
|
msg = JSON.parse(raw.toString());
|
|
@@ -488,6 +520,10 @@ class Session {
|
|
|
488
520
|
this.log('Invalid message from relay');
|
|
489
521
|
return;
|
|
490
522
|
}
|
|
523
|
+
if (!msg || typeof msg.type !== 'string') {
|
|
524
|
+
this._rejectProtocolMessage('Malformed relay message');
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
491
527
|
|
|
492
528
|
switch (msg.type) {
|
|
493
529
|
case 'code': {
|
|
@@ -614,6 +650,9 @@ class Session {
|
|
|
614
650
|
break;
|
|
615
651
|
}
|
|
616
652
|
}
|
|
653
|
+
} catch (err) {
|
|
654
|
+
this._handleMessageError(err);
|
|
655
|
+
}
|
|
617
656
|
});
|
|
618
657
|
|
|
619
658
|
this.ws.on('close', () => {
|
|
@@ -782,6 +821,9 @@ class Session {
|
|
|
782
821
|
}
|
|
783
822
|
|
|
784
823
|
_startViewerServer() {
|
|
824
|
+
// Generate a random nonce for viewer authentication
|
|
825
|
+
this.viewerToken = crypto.randomBytes(16).toString('hex');
|
|
826
|
+
|
|
785
827
|
this.viewerServer = net.createServer((socket) => {
|
|
786
828
|
// Close any existing viewer connection before accepting a new one
|
|
787
829
|
if (this.viewerSocket) {
|
|
@@ -789,66 +831,119 @@ class Session {
|
|
|
789
831
|
this.viewerSocket.removeAllListeners();
|
|
790
832
|
try { this.viewerSocket.destroy(); } catch {}
|
|
791
833
|
}
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
this.ptyProcess.resize(msg.cols, msg.rows);
|
|
816
|
-
this._sendResize(msg.cols, msg.rows);
|
|
817
|
-
this.logDebug(`Viewer resized to ${msg.cols}x${msg.rows}`);
|
|
818
|
-
}
|
|
819
|
-
} catch {}
|
|
820
|
-
} else if (this.ptyProcess) {
|
|
821
|
-
this.ptyProcess.write(line + '\n');
|
|
822
|
-
}
|
|
823
|
-
}
|
|
824
|
-
// Flush any non-control data left in the buffer
|
|
825
|
-
if (recvBuf.length > 0 && !recvBuf.startsWith('\x00')) {
|
|
826
|
-
if (this.ptyProcess) this.ptyProcess.write(recvBuf);
|
|
827
|
-
recvBuf = '';
|
|
834
|
+
|
|
835
|
+
// ── Token authentication gate ──────────────────────────────────
|
|
836
|
+
// The first data packet must be the viewer token followed by a newline.
|
|
837
|
+
// Reject the connection if no valid token arrives within 2 seconds.
|
|
838
|
+
let authenticated = false;
|
|
839
|
+
let authBuf = '';
|
|
840
|
+
|
|
841
|
+
const authTimeout = setTimeout(() => {
|
|
842
|
+
if (!authenticated) {
|
|
843
|
+
this.logDebug('Viewer auth timeout — closing connection.');
|
|
844
|
+
try { socket.destroy(); } catch {}
|
|
845
|
+
}
|
|
846
|
+
}, 2000);
|
|
847
|
+
|
|
848
|
+
const onAuthData = (data) => {
|
|
849
|
+
authBuf += data.toString();
|
|
850
|
+
const newlineIdx = authBuf.indexOf('\n');
|
|
851
|
+
if (newlineIdx === -1) {
|
|
852
|
+
// Accumulated too much data without a newline — reject
|
|
853
|
+
if (authBuf.length > 256) {
|
|
854
|
+
clearTimeout(authTimeout);
|
|
855
|
+
this.logDebug('Viewer auth buffer overflow — closing connection.');
|
|
856
|
+
try { socket.destroy(); } catch {}
|
|
828
857
|
}
|
|
829
858
|
return;
|
|
830
859
|
}
|
|
831
860
|
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
861
|
+
const token = authBuf.slice(0, newlineIdx).trim();
|
|
862
|
+
const remaining = authBuf.slice(newlineIdx + 1);
|
|
863
|
+
clearTimeout(authTimeout);
|
|
864
|
+
socket.removeListener('data', onAuthData);
|
|
865
|
+
|
|
866
|
+
if (token !== this.viewerToken) {
|
|
867
|
+
this.logDebug('Viewer auth failed — wrong token.');
|
|
868
|
+
try { socket.destroy(); } catch {}
|
|
869
|
+
return;
|
|
835
870
|
}
|
|
836
|
-
});
|
|
837
871
|
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
this.
|
|
841
|
-
|
|
872
|
+
// Authenticated — promote to active viewer
|
|
873
|
+
authenticated = true;
|
|
874
|
+
this.viewerSocket = socket;
|
|
875
|
+
this.log('Host viewer connected (authenticated).');
|
|
876
|
+
|
|
877
|
+
// Install the real data handler
|
|
878
|
+
let recvBuf = '';
|
|
879
|
+
|
|
880
|
+
// Process any leftover data that arrived after the token line
|
|
881
|
+
if (remaining.length > 0) {
|
|
882
|
+
handleViewerData(remaining);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
socket.on('data', (chunk) => handleViewerData(chunk.toString()));
|
|
886
|
+
|
|
887
|
+
const handleViewerData = (chunk) => {
|
|
888
|
+
// Control messages start with \x00{
|
|
889
|
+
if (recvBuf.length > 0 || chunk.startsWith('\x00{')) {
|
|
890
|
+
recvBuf += chunk;
|
|
891
|
+
let nlIdx;
|
|
892
|
+
while ((nlIdx = recvBuf.indexOf('\n')) !== -1) {
|
|
893
|
+
const line = recvBuf.slice(0, nlIdx).trim();
|
|
894
|
+
recvBuf = recvBuf.slice(nlIdx + 1);
|
|
895
|
+
|
|
896
|
+
if (line.startsWith('\x00')) {
|
|
897
|
+
try {
|
|
898
|
+
const msg = JSON.parse(line.slice(1));
|
|
899
|
+
if (msg.type === 'resize' &&
|
|
900
|
+
Number.isInteger(msg.cols) && msg.cols > 0 &&
|
|
901
|
+
Number.isInteger(msg.rows) && msg.rows > 0 &&
|
|
902
|
+
this.ptyProcess) {
|
|
903
|
+
this.ptyProcess.resize(msg.cols, msg.rows);
|
|
904
|
+
this._sendResize(msg.cols, msg.rows);
|
|
905
|
+
this.logDebug(`Viewer resized to ${msg.cols}x${msg.rows}`);
|
|
906
|
+
}
|
|
907
|
+
} catch {}
|
|
908
|
+
} else if (this.ptyProcess) {
|
|
909
|
+
this.ptyProcess.write(line + '\n');
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
// Flush any non-control data left in the buffer
|
|
913
|
+
if (recvBuf.length > 0 && !recvBuf.startsWith('\x00')) {
|
|
914
|
+
if (this.ptyProcess) this.ptyProcess.write(recvBuf);
|
|
915
|
+
recvBuf = '';
|
|
916
|
+
}
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// Host keystrokes → PTY (host always has access)
|
|
921
|
+
if (this.ptyProcess) {
|
|
922
|
+
this.ptyProcess.write(chunk);
|
|
923
|
+
}
|
|
924
|
+
};
|
|
925
|
+
|
|
926
|
+
socket.on('close', () => {
|
|
927
|
+
this.viewerSocket = null;
|
|
928
|
+
this.logDebug('Host viewer disconnected.');
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
socket.on('error', () => {
|
|
932
|
+
this.viewerSocket = null;
|
|
933
|
+
});
|
|
934
|
+
};
|
|
935
|
+
|
|
936
|
+
socket.on('data', onAuthData);
|
|
842
937
|
|
|
843
938
|
socket.on('error', () => {
|
|
844
|
-
|
|
939
|
+
clearTimeout(authTimeout);
|
|
845
940
|
});
|
|
846
941
|
});
|
|
847
942
|
|
|
848
943
|
this.viewerServer.listen(0, '127.0.0.1', () => {
|
|
849
944
|
const port = this.viewerServer.address().port;
|
|
850
945
|
this.logDebug(`Viewer server on port ${port}`);
|
|
851
|
-
this._openViewerTerminal(port);
|
|
946
|
+
this._openViewerTerminal(port, this.viewerToken);
|
|
852
947
|
});
|
|
853
948
|
|
|
854
949
|
this.viewerServer.on('error', (err) => {
|
|
@@ -857,7 +952,7 @@ class Session {
|
|
|
857
952
|
});
|
|
858
953
|
}
|
|
859
954
|
|
|
860
|
-
_openViewerTerminal(port) {
|
|
955
|
+
_openViewerTerminal(port, token) {
|
|
861
956
|
if (!/^[A-Za-z0-9_-]+$/.test(this.code)) {
|
|
862
957
|
this.log('Invalid session code format. Aborting viewer terminal.');
|
|
863
958
|
return;
|
|
@@ -867,12 +962,12 @@ class Session {
|
|
|
867
962
|
const platform = os.platform();
|
|
868
963
|
|
|
869
964
|
if (platform === 'win32') {
|
|
870
|
-
cpExec(`start "PeerTerm - ${this.code}" cmd /c node "${viewerScript}" ${port} ${this.code}`);
|
|
965
|
+
cpExec(`start "PeerTerm - ${this.code}" cmd /c node "${viewerScript}" ${port} ${this.code} ${token}`);
|
|
871
966
|
} else if (platform === 'darwin') {
|
|
872
|
-
cpExec(`osascript -e 'tell app "Terminal" to do script "node \"${viewerScript}\" ${port} ${this.code}"'`);
|
|
967
|
+
cpExec(`osascript -e 'tell app "Terminal" to do script "node \"${viewerScript}\" ${port} ${this.code} ${token}"'`);
|
|
873
968
|
} else {
|
|
874
969
|
// Linux: try common terminal emulators
|
|
875
|
-
cpExec(`x-terminal-emulator -e "node '${viewerScript}' ${port} ${this.code}" 2>/dev/null || gnome-terminal -- node "${viewerScript}" ${port} ${this.code} 2>/dev/null || xterm -e "node '${viewerScript}' ${port} ${this.code}"`);
|
|
970
|
+
cpExec(`x-terminal-emulator -e "node '${viewerScript}' ${port} ${this.code} ${token}" 2>/dev/null || gnome-terminal -- node "${viewerScript}" ${port} ${this.code} ${token} 2>/dev/null || xterm -e "node '${viewerScript}' ${port} ${this.code} ${token}"`);
|
|
876
971
|
}
|
|
877
972
|
this.log('Opening terminal viewer...');
|
|
878
973
|
}
|
|
@@ -910,12 +1005,17 @@ class Session {
|
|
|
910
1005
|
});
|
|
911
1006
|
|
|
912
1007
|
newWs.on('message', async (raw) => {
|
|
1008
|
+
try {
|
|
913
1009
|
let msg;
|
|
914
1010
|
try {
|
|
915
1011
|
msg = JSON.parse(raw.toString());
|
|
916
1012
|
} catch {
|
|
917
1013
|
return;
|
|
918
1014
|
}
|
|
1015
|
+
if (!msg || typeof msg.type !== 'string') {
|
|
1016
|
+
this._rejectProtocolMessage('Malformed relay message');
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
919
1019
|
|
|
920
1020
|
if (msg.type === 'rejoined') {
|
|
921
1021
|
// Success — replace the old ws with this new one
|
|
@@ -935,6 +1035,9 @@ class Session {
|
|
|
935
1035
|
this.destroy();
|
|
936
1036
|
try { newWs.close(); } catch {}
|
|
937
1037
|
}
|
|
1038
|
+
} catch (err) {
|
|
1039
|
+
this._handleMessageError(err);
|
|
1040
|
+
}
|
|
938
1041
|
});
|
|
939
1042
|
|
|
940
1043
|
newWs.on('error', () => {
|
|
@@ -970,6 +1073,7 @@ class Session {
|
|
|
970
1073
|
*/
|
|
971
1074
|
_attachWsHandlers(newWs) {
|
|
972
1075
|
newWs.on('message', async (raw) => {
|
|
1076
|
+
try {
|
|
973
1077
|
let msg;
|
|
974
1078
|
try {
|
|
975
1079
|
msg = JSON.parse(raw.toString());
|
|
@@ -977,6 +1081,10 @@ class Session {
|
|
|
977
1081
|
this.log('Invalid message from relay');
|
|
978
1082
|
return;
|
|
979
1083
|
}
|
|
1084
|
+
if (!msg || typeof msg.type !== 'string') {
|
|
1085
|
+
this._rejectProtocolMessage('Malformed relay message');
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
980
1088
|
|
|
981
1089
|
switch (msg.type) {
|
|
982
1090
|
case 'client-connected': {
|
|
@@ -1084,6 +1192,9 @@ class Session {
|
|
|
1084
1192
|
break;
|
|
1085
1193
|
}
|
|
1086
1194
|
}
|
|
1195
|
+
} catch (err) {
|
|
1196
|
+
this._handleMessageError(err);
|
|
1197
|
+
}
|
|
1087
1198
|
});
|
|
1088
1199
|
|
|
1089
1200
|
newWs.on('close', () => {
|
|
@@ -1414,7 +1525,6 @@ process.on('uncaughtException', (err) => {
|
|
|
1414
1525
|
process.on('unhandledRejection', (reason) => {
|
|
1415
1526
|
const err = reason instanceof Error ? reason : new Error(String(reason));
|
|
1416
1527
|
logger.error(`Unhandled rejection: ${err.message}`, err);
|
|
1417
|
-
process.exit(1);
|
|
1418
1528
|
});
|
|
1419
1529
|
|
|
1420
1530
|
// ─── Run ─────────────────────────────────────────────────────────────────────
|
package/src/session-viewer.js
CHANGED
|
@@ -4,14 +4,23 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Opens in a new terminal window and connects to the host's PTY
|
|
6
6
|
* via a local TCP socket. Gives the host a direct terminal view.
|
|
7
|
+
*
|
|
8
|
+
* Usage: session-viewer.js <port> <code> <token>
|
|
9
|
+
* The token is a random nonce generated by the host for authentication.
|
|
7
10
|
*/
|
|
8
11
|
|
|
9
12
|
import net from 'net';
|
|
10
13
|
|
|
11
|
-
const port
|
|
14
|
+
const port = parseInt(process.argv[2], 10);
|
|
15
|
+
const token = process.argv[4] || '';
|
|
12
16
|
|
|
13
17
|
if (!port || isNaN(port) || port < 1 || port > 65535) {
|
|
14
|
-
console.error('Usage: session-viewer.js <port>\nPort must be a number between 1 and 65535.');
|
|
18
|
+
console.error('Usage: session-viewer.js <port> <code> <token>\nPort must be a number between 1 and 65535.');
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (!token) {
|
|
23
|
+
console.error('Missing viewer token. Cannot authenticate.');
|
|
15
24
|
process.exit(1);
|
|
16
25
|
}
|
|
17
26
|
|
|
@@ -33,6 +42,10 @@ function formatResizeMessage(cols, rows) {
|
|
|
33
42
|
|
|
34
43
|
const client = net.connect({ port, host: '127.0.0.1' }, () => {
|
|
35
44
|
client.setTimeout(0); // Clear timeout once connected
|
|
45
|
+
|
|
46
|
+
// Send the authentication token as the very first message
|
|
47
|
+
client.write(token + '\n');
|
|
48
|
+
|
|
36
49
|
// Send initial terminal size
|
|
37
50
|
const cols = process.stdout.isTTY ? (process.stdout.columns || 80) : 80;
|
|
38
51
|
const rows = process.stdout.isTTY ? (process.stdout.rows || 24) : 24;
|