peer-term 1.3.9 → 1.4.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/bin/peer-term.js +1 -1
- package/package.json +1 -1
- package/src/cli.js +128 -0
- package/src/config.js +91 -0
- package/src/file-transfer.js +230 -0
- package/src/index.js +269 -1032
- package/src/prompts.js +143 -0
- package/src/ui.js +142 -142
- package/src/utils.js +113 -0
- package/src/viewer.js +259 -0
package/src/index.js
CHANGED
|
@@ -19,18 +19,8 @@
|
|
|
19
19
|
* --version Print version number
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import * as dotenv from 'dotenv';
|
|
23
|
-
import os from 'os';
|
|
24
|
-
import fs from 'fs';
|
|
25
|
-
import crypto from 'crypto';
|
|
26
|
-
import path from 'path';
|
|
27
|
-
import net from 'net';
|
|
28
|
-
import readline from 'readline';
|
|
29
|
-
import { execFile, spawn } from 'child_process';
|
|
30
|
-
import { fileURLToPath } from 'url';
|
|
31
22
|
import WebSocket from 'ws';
|
|
32
23
|
import pty from 'node-pty';
|
|
33
|
-
import minimist from 'minimist';
|
|
34
24
|
import {
|
|
35
25
|
generateKeyPair,
|
|
36
26
|
exportPublicKey,
|
|
@@ -42,265 +32,17 @@ import {
|
|
|
42
32
|
} from './crypto.js';
|
|
43
33
|
import { HostWebRTC } from './webrtc.js';
|
|
44
34
|
import logger from './logger.js';
|
|
45
|
-
import {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
try {
|
|
57
|
-
const pkgPath = path.join(__dirname, '..', 'package.json');
|
|
58
|
-
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
59
|
-
version = pkg.version;
|
|
60
|
-
} catch {}
|
|
61
|
-
|
|
62
|
-
// ─── CLI Argument Parsing ────────────────────────────────────────────────────
|
|
63
|
-
|
|
64
|
-
const argv = minimist(process.argv.slice(2), {
|
|
65
|
-
boolean: ['readonly', 'verbose', 'help', 'version', 'secure'],
|
|
66
|
-
string: ['expiry', 'rejoin', 'relay', 'path'],
|
|
67
|
-
alias: { h: 'help', v: 'version', V: 'verbose' },
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
// Detect if --expiry or --rejoin were explicitly passed on the command line
|
|
71
|
-
const expiryFlagPassed = process.argv.some(a => a === '--expiry' || a.startsWith('--expiry='));
|
|
72
|
-
const rejoinFlagPassed = process.argv.some(a => a === '--rejoin' || a.startsWith('--rejoin='));
|
|
73
|
-
|
|
74
|
-
// Handle --help
|
|
75
|
-
if (argv.help) {
|
|
76
|
-
printHelp();
|
|
77
|
-
process.exit(0);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Handle --version
|
|
81
|
-
if (argv.version) {
|
|
82
|
-
printVersion();
|
|
83
|
-
process.exit(0);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Enable verbose logging
|
|
87
|
-
if (argv.verbose) {
|
|
88
|
-
logger.setVerbose(true);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// ─── Configuration ───────────────────────────────────────────────────────────
|
|
92
|
-
const DEFAULT_RELAYS = [
|
|
93
|
-
'wss://relay.dhananjaybalekar.in'
|
|
94
|
-
];
|
|
95
|
-
const RELAY_URLS = argv.relay
|
|
96
|
-
? argv.relay.split(',').map(s => s.trim())
|
|
97
|
-
: (process.env.RELAY_URL ? process.env.RELAY_URL.split(',').map(s => s.trim()) : DEFAULT_RELAYS);
|
|
98
|
-
const HEARTBEAT_INTERVAL_MS = 5000;
|
|
99
|
-
const MAX_MISSED_PINGS = 2;
|
|
100
|
-
|
|
101
|
-
const UPLOAD_MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MB max
|
|
102
|
-
const UPLOAD_MAX_CHUNKS_PER_FILE = Math.ceil(UPLOAD_MAX_FILE_SIZE / 16384);
|
|
103
|
-
const UPLOAD_MAX_CONCURRENT = 3;
|
|
104
|
-
|
|
105
|
-
// ─── Path Resolution ─────────────────────────────────────────────────────────
|
|
106
|
-
|
|
107
|
-
function expandTilde(inputPath) {
|
|
108
|
-
if (inputPath.startsWith('~/') || inputPath === '~') {
|
|
109
|
-
return inputPath.replace(/^~/, process.env.HOME || process.env.USERPROFILE || '.');
|
|
110
|
-
}
|
|
111
|
-
return inputPath;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function resolveStartPath(inputPath) {
|
|
115
|
-
if (!inputPath) return process.env.HOME || process.env.USERPROFILE || process.cwd();
|
|
116
|
-
|
|
117
|
-
const resolved = path.resolve(expandTilde(inputPath));
|
|
118
|
-
|
|
119
|
-
if (!fs.existsSync(resolved)) {
|
|
120
|
-
logger.error(`Path does not exist: ${resolved}`);
|
|
121
|
-
process.exit(1);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
const stat = fs.statSync(resolved);
|
|
125
|
-
if (!stat.isDirectory()) {
|
|
126
|
-
logger.error(`Path is not a directory: ${resolved}`);
|
|
127
|
-
process.exit(1);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
return resolved;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// ─── Duration Parsing ────────────────────────────────────────────────────────
|
|
134
|
-
|
|
135
|
-
function parseDuration(str) {
|
|
136
|
-
const match = str.match(/^(\d+)(s|m|h)$/i);
|
|
137
|
-
if (!match) return null;
|
|
138
|
-
const value = parseInt(match[1], 10);
|
|
139
|
-
const unit = match[2].toLowerCase();
|
|
140
|
-
switch (unit) {
|
|
141
|
-
case 's': return value * 1000;
|
|
142
|
-
case 'm': return value * 60 * 1000;
|
|
143
|
-
case 'h': return value * 60 * 60 * 1000;
|
|
144
|
-
default: return null;
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function formatDuration(ms) {
|
|
149
|
-
if (ms >= 3600000) return `${Math.round(ms / 3600000)} hour(s)`;
|
|
150
|
-
if (ms >= 60000) return `${Math.round(ms / 60000)} minute(s)`;
|
|
151
|
-
return `${Math.round(ms / 1000)} second(s)`;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
function getExpiry() {
|
|
155
|
-
const parsed = parseDuration(argv.expiry);
|
|
156
|
-
if (!parsed) {
|
|
157
|
-
logger.error(`Invalid expiry format: "${argv.expiry}". Use 30s, 5m, or 1h.`);
|
|
158
|
-
process.exit(1);
|
|
159
|
-
}
|
|
160
|
-
return parsed;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// ─── Interactive Prompts ─────────────────────────────────────────────────────
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* Prompt the user for a duration value in the terminal.
|
|
167
|
-
* Re-prompts on invalid input until a valid value is entered or Enter is pressed for default.
|
|
168
|
-
*
|
|
169
|
-
* @param {Object} opts
|
|
170
|
-
* @param {string} opts.label - Main prompt question
|
|
171
|
-
* @param {string} opts.description - Additional context shown below the question
|
|
172
|
-
* @param {number} opts.defaultMs - Default value in milliseconds
|
|
173
|
-
* @param {string} opts.defaultLabel - Human-readable default (e.g. "5 minutes")
|
|
174
|
-
* @param {number} opts.minMs - Minimum allowed value in milliseconds
|
|
175
|
-
* @param {number} opts.maxMs - Maximum allowed value in milliseconds
|
|
176
|
-
* @param {string} opts.minLabel - Human-readable minimum (e.g. "2m")
|
|
177
|
-
* @param {string} opts.maxLabel - Human-readable maximum (e.g. "24h")
|
|
178
|
-
* @returns {Promise<number>} Resolved duration in milliseconds
|
|
179
|
-
*/
|
|
180
|
-
function promptDuration({ label, description, defaultMs, defaultLabel, minMs, maxMs, minLabel, maxLabel }) {
|
|
181
|
-
return new Promise((resolve) => {
|
|
182
|
-
const rl = readline.createInterface({
|
|
183
|
-
input: process.stdin,
|
|
184
|
-
output: process.stdout,
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
const ask = () => {
|
|
188
|
-
console.log('');
|
|
189
|
-
console.log(` ${label}`);
|
|
190
|
-
if (description) console.log(` ${description}`);
|
|
191
|
-
console.log(` Enter a value between ${minLabel} and ${maxLabel} (e.g. ${minLabel}, 30m, 2h)`);
|
|
192
|
-
console.log(` Default is ${defaultLabel}. Press Enter to use default.`);
|
|
193
|
-
rl.question(' > ', (answer) => {
|
|
194
|
-
const input = answer.trim();
|
|
195
|
-
|
|
196
|
-
// Empty input — use default
|
|
197
|
-
if (!input) {
|
|
198
|
-
rl.close();
|
|
199
|
-
resolve(defaultMs);
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
const parsed = parseDuration(input);
|
|
204
|
-
if (!parsed || parsed < minMs || parsed > maxMs) {
|
|
205
|
-
console.log(` Invalid value. Please enter a time between ${minLabel} and ${maxLabel} (e.g. 5m, 1h).`);
|
|
206
|
-
ask();
|
|
207
|
-
return;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
rl.close();
|
|
211
|
-
resolve(parsed);
|
|
212
|
-
});
|
|
213
|
-
};
|
|
214
|
-
|
|
215
|
-
ask();
|
|
216
|
-
});
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* Get the session code expiry — from flag, interactive prompt, or silent default.
|
|
221
|
-
*/
|
|
222
|
-
async function getExpiryInteractive() {
|
|
223
|
-
const DEFAULT_EXPIRY = 5 * 60 * 1000; // 5 minutes
|
|
224
|
-
const MIN_EXPIRY = 2 * 60 * 1000; // 2 minutes
|
|
225
|
-
const MAX_EXPIRY = 24 * 60 * 60 * 1000; // 24 hours
|
|
226
|
-
|
|
227
|
-
// If --expiry flag was explicitly passed, use it (validate and exit on error)
|
|
228
|
-
if (expiryFlagPassed) {
|
|
229
|
-
const parsed = parseDuration(argv.expiry);
|
|
230
|
-
if (!parsed || parsed < MIN_EXPIRY || parsed > MAX_EXPIRY) {
|
|
231
|
-
logger.error(`Invalid expiry: "${argv.expiry}". Must be between 2m and 24h.`);
|
|
232
|
-
process.exit(1);
|
|
233
|
-
}
|
|
234
|
-
return parsed;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// Non-interactive — use default silently
|
|
238
|
-
if (!process.stdin.isTTY) return DEFAULT_EXPIRY;
|
|
239
|
-
|
|
240
|
-
// Interactive prompt
|
|
241
|
-
return promptDuration({
|
|
242
|
-
label: 'How long should the session code be valid?',
|
|
243
|
-
description: null,
|
|
244
|
-
defaultMs: DEFAULT_EXPIRY,
|
|
245
|
-
defaultLabel: '5 minutes',
|
|
246
|
-
minMs: MIN_EXPIRY,
|
|
247
|
-
maxMs: MAX_EXPIRY,
|
|
248
|
-
minLabel: '2m',
|
|
249
|
-
maxLabel: '24h',
|
|
250
|
-
});
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
/**
|
|
254
|
-
* Get the reconnection window — from flag, interactive prompt, or silent default.
|
|
255
|
-
*/
|
|
256
|
-
async function getRejoinInteractive() {
|
|
257
|
-
const DEFAULT_REJOIN = 5 * 60 * 1000; // 5 minutes
|
|
258
|
-
const MIN_REJOIN = 5 * 60 * 1000; // 5 minutes
|
|
259
|
-
const MAX_REJOIN = 6 * 60 * 60 * 1000; // 6 hours
|
|
260
|
-
|
|
261
|
-
// If --rejoin flag was explicitly passed, use it
|
|
262
|
-
if (rejoinFlagPassed) {
|
|
263
|
-
const parsed = parseDuration(argv.rejoin);
|
|
264
|
-
if (!parsed || parsed < MIN_REJOIN || parsed > MAX_REJOIN) {
|
|
265
|
-
logger.error(`Invalid rejoin window: "${argv.rejoin}". Must be between 5m and 6h.`);
|
|
266
|
-
process.exit(1);
|
|
267
|
-
}
|
|
268
|
-
return parsed;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
// Non-interactive — use default silently
|
|
272
|
-
if (!process.stdin.isTTY) return DEFAULT_REJOIN;
|
|
273
|
-
|
|
274
|
-
// Interactive prompt
|
|
275
|
-
return promptDuration({
|
|
276
|
-
label: 'How long should the reconnection window be?',
|
|
277
|
-
description: 'This is how long a disconnected client or host has to rejoin before the session ends.',
|
|
278
|
-
defaultMs: DEFAULT_REJOIN,
|
|
279
|
-
defaultLabel: '2 minutes',
|
|
280
|
-
minMs: MIN_REJOIN,
|
|
281
|
-
maxMs: MAX_REJOIN,
|
|
282
|
-
minLabel: '5m',
|
|
283
|
-
maxLabel: '6h',
|
|
284
|
-
});
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
// ─── Shell Detection ─────────────────────────────────────────────────────────
|
|
288
|
-
|
|
289
|
-
function detectShell() {
|
|
290
|
-
const platform = os.platform();
|
|
291
|
-
if (process.env.SHELL) return process.env.SHELL;
|
|
292
|
-
if (platform === 'win32') return process.env.COMSPEC || 'powershell.exe';
|
|
293
|
-
try {
|
|
294
|
-
const passwd = fs.readFileSync('/etc/passwd', 'utf-8');
|
|
295
|
-
const username = os.userInfo().username;
|
|
296
|
-
const line = passwd.split('\n').find((l) => l.startsWith(username + ':'));
|
|
297
|
-
if (line) {
|
|
298
|
-
const shell = line.split(':').pop().trim();
|
|
299
|
-
if (shell) return shell;
|
|
300
|
-
}
|
|
301
|
-
} catch {}
|
|
302
|
-
return platform === 'win32' ? 'powershell.exe' : 'bash';
|
|
303
|
-
}
|
|
35
|
+
import { printSessionBox } from './ui.js';
|
|
36
|
+
import {
|
|
37
|
+
argv,
|
|
38
|
+
version,
|
|
39
|
+
RELAY_URLS,
|
|
40
|
+
HEARTBEAT_INTERVAL_MS,
|
|
41
|
+
MAX_MISSED_PINGS,
|
|
42
|
+
} from './config.js';
|
|
43
|
+
import { formatDuration } from './utils.js';
|
|
44
|
+
import { FileTransferHandler } from './file-transfer.js';
|
|
45
|
+
import { ViewerServer } from './viewer.js';
|
|
304
46
|
|
|
305
47
|
// ─── Session Class ───────────────────────────────────────────────────────────
|
|
306
48
|
|
|
@@ -339,14 +81,31 @@ class Session {
|
|
|
339
81
|
this.webrtc = null;
|
|
340
82
|
this.useDataChannel = false;
|
|
341
83
|
|
|
342
|
-
// File upload
|
|
343
|
-
|
|
344
|
-
|
|
84
|
+
// File upload handler
|
|
85
|
+
this._fileTransfer = new FileTransferHandler(this._buildFileTransferContext());
|
|
86
|
+
|
|
87
|
+
// Local TCP viewer
|
|
88
|
+
this._viewer = null;
|
|
345
89
|
|
|
346
|
-
//
|
|
347
|
-
this.
|
|
348
|
-
this.
|
|
349
|
-
|
|
90
|
+
// Session duration tracking
|
|
91
|
+
this.sessionStartedAt = null; // Timestamp: when the terminal session became active
|
|
92
|
+
this.sessionDurationInterval = null; // Interval: periodic CLI status log
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Build the context object for FileTransferHandler.
|
|
97
|
+
* Called on construction and whenever shared key changes.
|
|
98
|
+
*/
|
|
99
|
+
_buildFileTransferContext() {
|
|
100
|
+
return {
|
|
101
|
+
get sharedKey() { return this.session.sharedKey; },
|
|
102
|
+
get readOnly() { return this.session.readOnly; },
|
|
103
|
+
get ptyProcess() { return this.session.ptyProcess; },
|
|
104
|
+
log: (msg) => this.log(msg),
|
|
105
|
+
logDebug: (msg) => this.logDebug(msg),
|
|
106
|
+
sendEncrypted: (payload) => this._sendEncryptedToClient(payload),
|
|
107
|
+
session: this,
|
|
108
|
+
};
|
|
350
109
|
}
|
|
351
110
|
|
|
352
111
|
log(msg) {
|
|
@@ -458,6 +217,12 @@ class Session {
|
|
|
458
217
|
this.log('Encrypted tunnel active');
|
|
459
218
|
await this._sendSessionConfig();
|
|
460
219
|
|
|
220
|
+
// Mark session start time for duration tracking
|
|
221
|
+
if (!this.sessionStartedAt) {
|
|
222
|
+
this.sessionStartedAt = Date.now();
|
|
223
|
+
this._startDurationLogging();
|
|
224
|
+
}
|
|
225
|
+
|
|
461
226
|
this.startHeartbeat();
|
|
462
227
|
|
|
463
228
|
if (!this.ptyProcess) {
|
|
@@ -532,146 +297,7 @@ class Session {
|
|
|
532
297
|
return;
|
|
533
298
|
}
|
|
534
299
|
|
|
535
|
-
|
|
536
|
-
case 'code': {
|
|
537
|
-
this.code = msg.code;
|
|
538
|
-
this.hostToken = msg.hostToken || null;
|
|
539
|
-
printSessionBox({
|
|
540
|
-
code: this.code,
|
|
541
|
-
expiry: formatDuration(this.expiryMs),
|
|
542
|
-
rejoinWindow: formatDuration(this.rejoinMs),
|
|
543
|
-
mode: (this.readOnly ? 'Read-Only' : 'Read-Write') + (this.secureMode ? ' (Secure)' : ''),
|
|
544
|
-
shell: this.shell,
|
|
545
|
-
startPath: this.startPath,
|
|
546
|
-
shareUrl: url.replace(/^wss:\/\//i, 'https://').replace(/^ws:\/\//i, 'http://')
|
|
547
|
-
});
|
|
548
|
-
resolve(this.code);
|
|
549
|
-
break;
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
case 'client-connected': {
|
|
553
|
-
if (this.awaitingRejoin) {
|
|
554
|
-
this.log('Client reconnected.');
|
|
555
|
-
this.awaitingRejoin = false;
|
|
556
|
-
} else {
|
|
557
|
-
this.log('Client connected! Starting key exchange...');
|
|
558
|
-
}
|
|
559
|
-
this.isClientConnected = true;
|
|
560
|
-
this.missedPings = 0;
|
|
561
|
-
|
|
562
|
-
if (this.useDataChannel && this.webrtc && this.webrtc.isActive()) {
|
|
563
|
-
this.startHeartbeat();
|
|
564
|
-
break;
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
await this._beginKeyExchange();
|
|
568
|
-
break;
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
case 'key-exchange': {
|
|
572
|
-
await this._completeKeyExchange(msg.publicKey);
|
|
573
|
-
break;
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
// Phase 4: WebRTC signaling messages
|
|
577
|
-
case 'signal': {
|
|
578
|
-
if (this.webrtc && this.sharedKey) {
|
|
579
|
-
try {
|
|
580
|
-
const plaintext = await decrypt(this.sharedKey, msg.payload);
|
|
581
|
-
this.webrtc.handleSignal(JSON.parse(plaintext));
|
|
582
|
-
} catch (err) {
|
|
583
|
-
this.logDebug(`[WebRTC] Signal decryption failed: ${err.message}`);
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
break;
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
case 'heartbeat': {
|
|
590
|
-
this.missedPings = 0;
|
|
591
|
-
break;
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
case 'data': {
|
|
595
|
-
if (!this.sharedKey || !this.ptyProcess) return;
|
|
596
|
-
try {
|
|
597
|
-
const plaintext = await decrypt(this.sharedKey, msg.payload);
|
|
598
|
-
|
|
599
|
-
// Check if this is a resize event
|
|
600
|
-
if (msg.meta === 'resize') {
|
|
601
|
-
try {
|
|
602
|
-
const resizeData = JSON.parse(plaintext);
|
|
603
|
-
if (resizeData.type === 'resize' && resizeData.cols && resizeData.rows) {
|
|
604
|
-
this.ptyProcess.resize(resizeData.cols, resizeData.rows);
|
|
605
|
-
this.logDebug(`Terminal resized to ${resizeData.cols}x${resizeData.rows}`);
|
|
606
|
-
}
|
|
607
|
-
} catch {}
|
|
608
|
-
return;
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
// Check if decrypted message is a file transfer message (file-start, file-chunk, file-end)
|
|
612
|
-
try {
|
|
613
|
-
const parsedMsg = JSON.parse(plaintext);
|
|
614
|
-
if (parsedMsg && typeof parsedMsg.type === 'string' && parsedMsg.type.startsWith('file-')) {
|
|
615
|
-
if (this.readOnly) {
|
|
616
|
-
this._sendFileError(parsedMsg.id, 'File uploads are disabled in read-only mode');
|
|
617
|
-
return;
|
|
618
|
-
}
|
|
619
|
-
this._handleFileMessage(parsedMsg);
|
|
620
|
-
return;
|
|
621
|
-
}
|
|
622
|
-
} catch {
|
|
623
|
-
// Not JSON — normal keystroke data, fall through
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
// Normal keystroke — drop if read-only
|
|
627
|
-
if (this.readOnly) return;
|
|
628
|
-
this.ptyProcess.write(plaintext);
|
|
629
|
-
} catch (err) {
|
|
630
|
-
this.log(`Decryption failed: ${err.message}`);
|
|
631
|
-
}
|
|
632
|
-
break;
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
case 'peer-disconnected': {
|
|
636
|
-
if (this.useDataChannel && this.webrtc && this.webrtc.isActive()) {
|
|
637
|
-
this.log('Client relay connection lost; direct DataChannel still active.');
|
|
638
|
-
this.missedPings = 0;
|
|
639
|
-
this.startHeartbeat();
|
|
640
|
-
break;
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
this.log(`Client disconnected. Rejoin window: ${formatDuration(this.rejoinMs)}.`);
|
|
644
|
-
this.isClientConnected = false;
|
|
645
|
-
this.awaitingRejoin = true;
|
|
646
|
-
this.sharedKey = null;
|
|
647
|
-
this.keyPair = null;
|
|
648
|
-
this.hostPublicKeyBase64 = null;
|
|
649
|
-
this.clientPublicKeyBase64 = null;
|
|
650
|
-
this.securityFingerprint = null;
|
|
651
|
-
this.fingerprintAuthorized = false;
|
|
652
|
-
this.stopHeartbeat();
|
|
653
|
-
// Phase 4: Clean up WebRTC on peer disconnect
|
|
654
|
-
this._cleanupWebRTC();
|
|
655
|
-
break;
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
case 'session-expired': {
|
|
659
|
-
this.log('Rejoin window expired. Session ended.');
|
|
660
|
-
this.destroy();
|
|
661
|
-
break;
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
case 'rejoined': {
|
|
665
|
-
this.log(`\u2705 Reconnected. Session restored. (code: ${msg.code})`);
|
|
666
|
-
await this._restartPeerSessionAfterHostRejoin();
|
|
667
|
-
break;
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
case 'error': {
|
|
671
|
-
this.log(`Error: ${msg.msg}`);
|
|
672
|
-
break;
|
|
673
|
-
}
|
|
674
|
-
}
|
|
300
|
+
await this._handleRelayMessage(msg, url, resolve);
|
|
675
301
|
} catch (err) {
|
|
676
302
|
this._handleMessageError(err);
|
|
677
303
|
}
|
|
@@ -703,6 +329,161 @@ class Session {
|
|
|
703
329
|
});
|
|
704
330
|
}
|
|
705
331
|
|
|
332
|
+
/**
|
|
333
|
+
* Central relay message handler — shared by _tryConnect and _attachWsHandlers.
|
|
334
|
+
* The optional `resolve` parameter is only provided during initial registration.
|
|
335
|
+
*/
|
|
336
|
+
async _handleRelayMessage(msg, url, resolve) {
|
|
337
|
+
switch (msg.type) {
|
|
338
|
+
case 'code': {
|
|
339
|
+
this.code = msg.code;
|
|
340
|
+
this.hostToken = msg.hostToken || null;
|
|
341
|
+
printSessionBox({
|
|
342
|
+
code: this.code,
|
|
343
|
+
expiry: formatDuration(this.expiryMs),
|
|
344
|
+
rejoinWindow: formatDuration(this.rejoinMs),
|
|
345
|
+
mode: (this.readOnly ? 'Read-Only' : 'Read-Write') + (this.secureMode ? ' (Secure)' : ''),
|
|
346
|
+
shell: this.shell,
|
|
347
|
+
startPath: this.startPath,
|
|
348
|
+
shareUrl: url.replace(/^wss:\/\//i, 'https://').replace(/^ws:\/\//i, 'http://')
|
|
349
|
+
});
|
|
350
|
+
if (resolve) resolve(this.code);
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
case 'client-connected': {
|
|
355
|
+
if (this.awaitingRejoin) {
|
|
356
|
+
this.log('Client reconnected.');
|
|
357
|
+
this.awaitingRejoin = false;
|
|
358
|
+
} else {
|
|
359
|
+
this.log('Client connected! Starting key exchange...');
|
|
360
|
+
}
|
|
361
|
+
this.isClientConnected = true;
|
|
362
|
+
this.missedPings = 0;
|
|
363
|
+
|
|
364
|
+
if (this.useDataChannel && this.webrtc && this.webrtc.isActive()) {
|
|
365
|
+
this.startHeartbeat();
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
await this._beginKeyExchange();
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
case 'key-exchange': {
|
|
374
|
+
await this._completeKeyExchange(msg.publicKey);
|
|
375
|
+
break;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Phase 4: WebRTC signaling messages
|
|
379
|
+
case 'signal': {
|
|
380
|
+
if (this.webrtc && this.sharedKey) {
|
|
381
|
+
try {
|
|
382
|
+
const plaintext = await decrypt(this.sharedKey, msg.payload);
|
|
383
|
+
this.webrtc.handleSignal(JSON.parse(plaintext));
|
|
384
|
+
} catch (err) {
|
|
385
|
+
this.logDebug(`[WebRTC] Signal decryption failed: ${err.message}`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
case 'heartbeat': {
|
|
392
|
+
this.missedPings = 0;
|
|
393
|
+
break;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
case 'data': {
|
|
397
|
+
if (!this.sharedKey || !this.ptyProcess) return;
|
|
398
|
+
try {
|
|
399
|
+
const plaintext = await decrypt(this.sharedKey, msg.payload);
|
|
400
|
+
|
|
401
|
+
// Check if this is a resize event
|
|
402
|
+
if (msg.meta === 'resize') {
|
|
403
|
+
try {
|
|
404
|
+
const resizeData = JSON.parse(plaintext);
|
|
405
|
+
if (resizeData.type === 'resize' && resizeData.cols && resizeData.rows) {
|
|
406
|
+
this.ptyProcess.resize(resizeData.cols, resizeData.rows);
|
|
407
|
+
this.logDebug(`Terminal resized to ${resizeData.cols}x${resizeData.rows}`);
|
|
408
|
+
}
|
|
409
|
+
} catch {}
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Check if decrypted message is a file transfer message (file-start, file-chunk, file-end)
|
|
414
|
+
try {
|
|
415
|
+
const parsedMsg = JSON.parse(plaintext);
|
|
416
|
+
if (parsedMsg && typeof parsedMsg.type === 'string' && parsedMsg.type.startsWith('file-')) {
|
|
417
|
+
if (this.readOnly) {
|
|
418
|
+
this._fileTransfer._sendError(parsedMsg.id, 'File uploads are disabled in read-only mode');
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
this._fileTransfer.handleMessage(parsedMsg);
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
} catch {
|
|
425
|
+
// Not JSON — normal keystroke data, fall through
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Normal keystroke — drop if read-only
|
|
429
|
+
if (this.readOnly) return;
|
|
430
|
+
this.ptyProcess.write(plaintext);
|
|
431
|
+
} catch (err) {
|
|
432
|
+
this.log(`Decryption failed: ${err.message}`);
|
|
433
|
+
}
|
|
434
|
+
break;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
case 'peer-disconnected': {
|
|
438
|
+
if (this.useDataChannel && this.webrtc && this.webrtc.isActive()) {
|
|
439
|
+
this.log('Client relay connection lost; direct DataChannel still active.');
|
|
440
|
+
this.missedPings = 0;
|
|
441
|
+
this.startHeartbeat();
|
|
442
|
+
break;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
this.log(`Client disconnected. Rejoin window: ${formatDuration(this.rejoinMs)}.`);
|
|
446
|
+
this.isClientConnected = false;
|
|
447
|
+
this.awaitingRejoin = true;
|
|
448
|
+
this._resetCryptoState();
|
|
449
|
+
this.stopHeartbeat();
|
|
450
|
+
// Phase 4: Clean up WebRTC on peer disconnect
|
|
451
|
+
this._cleanupWebRTC();
|
|
452
|
+
break;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
case 'session-expired': {
|
|
456
|
+
this.log('Rejoin window expired. Session ended.');
|
|
457
|
+
this.destroy();
|
|
458
|
+
break;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
case 'rejoined': {
|
|
462
|
+
this.log(`\u2705 Reconnected. Session restored. (code: ${msg.code})`);
|
|
463
|
+
await this._restartPeerSessionAfterHostRejoin();
|
|
464
|
+
break;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
case 'error': {
|
|
468
|
+
this.log(`Error: ${msg.msg}`);
|
|
469
|
+
break;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Reset all crypto state (shared key, key pair, fingerprint).
|
|
476
|
+
* Used on peer disconnect and heartbeat loss.
|
|
477
|
+
*/
|
|
478
|
+
_resetCryptoState() {
|
|
479
|
+
this.sharedKey = null;
|
|
480
|
+
this.keyPair = null;
|
|
481
|
+
this.hostPublicKeyBase64 = null;
|
|
482
|
+
this.clientPublicKeyBase64 = null;
|
|
483
|
+
this.securityFingerprint = null;
|
|
484
|
+
this.fingerprintAuthorized = false;
|
|
485
|
+
}
|
|
486
|
+
|
|
706
487
|
// ─── Send encrypted resize to client ────────────────────────────────
|
|
707
488
|
async _sendResize(cols, rows) {
|
|
708
489
|
if (!this.sharedKey) return;
|
|
@@ -735,12 +516,7 @@ class Session {
|
|
|
735
516
|
this.log('Client heartbeat lost. Waiting for reconnect...');
|
|
736
517
|
this.isClientConnected = false;
|
|
737
518
|
this.awaitingRejoin = true;
|
|
738
|
-
this.
|
|
739
|
-
this.keyPair = null;
|
|
740
|
-
this.hostPublicKeyBase64 = null;
|
|
741
|
-
this.clientPublicKeyBase64 = null;
|
|
742
|
-
this.securityFingerprint = null;
|
|
743
|
-
this.fingerprintAuthorized = false;
|
|
519
|
+
this._resetCryptoState();
|
|
744
520
|
this.stopHeartbeat();
|
|
745
521
|
}
|
|
746
522
|
}, HEARTBEAT_INTERVAL_MS);
|
|
@@ -769,8 +545,8 @@ class Session {
|
|
|
769
545
|
|
|
770
546
|
this.ptyProcess.onData((data) => {
|
|
771
547
|
// Mirror PTY output to local viewer terminal
|
|
772
|
-
if (this.
|
|
773
|
-
|
|
548
|
+
if (this._viewer) {
|
|
549
|
+
this._viewer.write(data);
|
|
774
550
|
}
|
|
775
551
|
|
|
776
552
|
if (!this.sharedKey) return;
|
|
@@ -786,10 +562,10 @@ class Session {
|
|
|
786
562
|
});
|
|
787
563
|
|
|
788
564
|
// Open a local viewer terminal for the host
|
|
789
|
-
this.
|
|
565
|
+
this._startViewer();
|
|
790
566
|
}
|
|
791
567
|
|
|
792
|
-
// ───
|
|
568
|
+
// ─── PTY Output Queue ──────────────────────────────────────────────
|
|
793
569
|
_queuePtyOutput(data, sharedKey) {
|
|
794
570
|
this.ptyOutputQueue = this.ptyOutputQueue
|
|
795
571
|
.then(() => this._sendPtyOutput(data, sharedKey))
|
|
@@ -842,364 +618,25 @@ class Session {
|
|
|
842
618
|
}
|
|
843
619
|
}
|
|
844
620
|
|
|
845
|
-
// ───
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
return;
|
|
856
|
-
}
|
|
857
|
-
|
|
858
|
-
switch (msg.type) {
|
|
859
|
-
case 'file-start': {
|
|
860
|
-
// Validate required fields
|
|
861
|
-
if (!msg.id || !msg.name || msg.size === undefined || msg.totalChunks === undefined) {
|
|
862
|
-
this._sendFileError(msg.id, 'Invalid file-start message');
|
|
863
|
-
return;
|
|
621
|
+
// ─── Viewer ─────────────────────────────────────────────────────────
|
|
622
|
+
_startViewer() {
|
|
623
|
+
this._viewer = new ViewerServer({
|
|
624
|
+
code: this.code,
|
|
625
|
+
log: (msg) => this.log(msg),
|
|
626
|
+
logDebug: (msg) => this.logDebug(msg),
|
|
627
|
+
onResize: (cols, rows) => {
|
|
628
|
+
if (this.ptyProcess) {
|
|
629
|
+
this.ptyProcess.resize(cols, rows);
|
|
630
|
+
this._sendResize(cols, rows);
|
|
864
631
|
}
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
632
|
+
},
|
|
633
|
+
onKeystroke: (data) => {
|
|
634
|
+
if (this.ptyProcess) {
|
|
635
|
+
this.ptyProcess.write(data);
|
|
869
636
|
}
|
|
870
|
-
|
|
871
|
-
if (msg.totalChunks > UPLOAD_MAX_CHUNKS_PER_FILE) {
|
|
872
|
-
this._sendFileError(msg.id, 'File requires too many chunks');
|
|
873
|
-
return;
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
if (Object.keys(this._incomingFiles).length >= UPLOAD_MAX_CONCURRENT) {
|
|
877
|
-
this._sendFileError(msg.id, 'Too many concurrent uploads');
|
|
878
|
-
return;
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
this.log(`File upload started: "${msg.name}" (${msg.size} bytes, ${msg.totalChunks} chunks)`);
|
|
882
|
-
this._incomingFiles[msg.id] = {
|
|
883
|
-
name: msg.name,
|
|
884
|
-
size: msg.size,
|
|
885
|
-
totalChunks: msg.totalChunks,
|
|
886
|
-
receivedSize: 0,
|
|
887
|
-
chunks: new Map(),
|
|
888
|
-
};
|
|
889
|
-
break;
|
|
890
|
-
}
|
|
891
|
-
case 'file-chunk': {
|
|
892
|
-
const transfer = this._incomingFiles[msg.id];
|
|
893
|
-
if (!transfer) {
|
|
894
|
-
this._sendFileError(msg.id, 'Unknown transfer ID');
|
|
895
|
-
return;
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
const chunkData = Buffer.from(msg.data, 'base64');
|
|
899
|
-
|
|
900
|
-
if (transfer.chunks.size >= transfer.totalChunks) {
|
|
901
|
-
this._sendFileError(msg.id, 'Too many chunks received');
|
|
902
|
-
delete this._incomingFiles[msg.id];
|
|
903
|
-
return;
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
if (transfer.receivedSize + chunkData.length > transfer.size) {
|
|
907
|
-
this._sendFileError(msg.id, 'Received size exceeds declared size');
|
|
908
|
-
delete this._incomingFiles[msg.id];
|
|
909
|
-
return;
|
|
910
|
-
}
|
|
911
|
-
|
|
912
|
-
transfer.chunks.set(msg.index, chunkData);
|
|
913
|
-
transfer.receivedSize += chunkData.length;
|
|
914
|
-
this.logDebug(`File chunk ${msg.index + 1}/${transfer.totalChunks} received for "${transfer.name}"`);
|
|
915
|
-
break;
|
|
916
|
-
}
|
|
917
|
-
case 'file-end': {
|
|
918
|
-
this._finalizeFile(msg.id);
|
|
919
|
-
break;
|
|
920
|
-
}
|
|
921
|
-
case 'file-cancel': {
|
|
922
|
-
if (this._incomingFiles[msg.id]) {
|
|
923
|
-
delete this._incomingFiles[msg.id];
|
|
924
|
-
this.log(`File upload cancelled by client: ${msg.id}`);
|
|
925
|
-
}
|
|
926
|
-
break;
|
|
927
|
-
}
|
|
928
|
-
}
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
/**
|
|
932
|
-
* Reassemble all chunks in order and write the completed file to disk.
|
|
933
|
-
* Saves to ~/peerterm-uploads/<filename>.
|
|
934
|
-
*/
|
|
935
|
-
async _finalizeFile(id) {
|
|
936
|
-
const transfer = this._incomingFiles[id];
|
|
937
|
-
if (!transfer) {
|
|
938
|
-
this._sendFileError(id, 'Unknown transfer ID');
|
|
939
|
-
return;
|
|
940
|
-
}
|
|
941
|
-
|
|
942
|
-
// Check all chunks are present
|
|
943
|
-
for (let i = 0; i < transfer.totalChunks; i++) {
|
|
944
|
-
if (!transfer.chunks.has(i)) {
|
|
945
|
-
this.log(`File "${transfer.name}" missing chunk ${i}`);
|
|
946
|
-
this._sendFileError(id, `Missing chunk ${i}`);
|
|
947
|
-
delete this._incomingFiles[id];
|
|
948
|
-
return;
|
|
949
|
-
}
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
// Reassemble in order using Buffer.concat
|
|
953
|
-
const ordered = [];
|
|
954
|
-
for (let i = 0; i < transfer.totalChunks; i++) {
|
|
955
|
-
ordered.push(transfer.chunks.get(i));
|
|
956
|
-
}
|
|
957
|
-
const fileData = Buffer.concat(ordered);
|
|
958
|
-
|
|
959
|
-
// Determine save path: ~/peerterm-uploads/<filename>
|
|
960
|
-
const homeDir = os.homedir();
|
|
961
|
-
const uploadDir = path.join(homeDir, 'peerterm-uploads');
|
|
962
|
-
// Sanitize filename to prevent path traversal (e.g., "../../../etc/passwd")
|
|
963
|
-
const baseName = path.basename(transfer.name);
|
|
964
|
-
if (!baseName || baseName === '.' || baseName === '..') {
|
|
965
|
-
this._sendFileError(id, 'Invalid filename');
|
|
966
|
-
delete this._incomingFiles[id];
|
|
967
|
-
return;
|
|
968
|
-
}
|
|
969
|
-
const savePath = path.join(uploadDir, baseName);
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
try {
|
|
975
|
-
await fs.promises.mkdir(uploadDir, { recursive: true });
|
|
976
|
-
await fs.promises.writeFile(savePath, fileData);
|
|
977
|
-
|
|
978
|
-
this.log(`File saved: ${savePath}`);
|
|
979
|
-
|
|
980
|
-
// Write green ANSI message into the PTY to show the save path
|
|
981
|
-
if (this.ptyProcess) {
|
|
982
|
-
this.ptyProcess.write(`\r\n\x1b[32m[PeerTerm] File saved: ${savePath}\x1b[0m\r\n`);
|
|
983
|
-
}
|
|
984
|
-
|
|
985
|
-
// Send ack to client
|
|
986
|
-
this._sendFileAck(id, transfer.name);
|
|
987
|
-
} catch (err) {
|
|
988
|
-
this.log(`Failed to save file "${transfer.name}": ${err.message}`);
|
|
989
|
-
this._sendFileError(id, `Save failed: ${err.message}`);
|
|
990
|
-
}
|
|
991
|
-
|
|
992
|
-
delete this._incomingFiles[id];
|
|
993
|
-
}
|
|
994
|
-
|
|
995
|
-
/**
|
|
996
|
-
* Send file-ack to the client confirming successful file save.
|
|
997
|
-
*/
|
|
998
|
-
async _sendFileAck(id, name) {
|
|
999
|
-
if (!this.sharedKey) return;
|
|
1000
|
-
try {
|
|
1001
|
-
const ackJson = JSON.stringify({ type: 'file-ack', id, name });
|
|
1002
|
-
const payload = await encrypt(this.sharedKey, ackJson);
|
|
1003
|
-
this._sendEncryptedToClient(payload);
|
|
1004
|
-
} catch (err) {
|
|
1005
|
-
this.log(`Failed to send file-ack: ${err.message}`);
|
|
1006
|
-
}
|
|
1007
|
-
}
|
|
1008
|
-
|
|
1009
|
-
/**
|
|
1010
|
-
* Send file-error to the client with an error message.
|
|
1011
|
-
*/
|
|
1012
|
-
async _sendFileError(id, msg) {
|
|
1013
|
-
if (!this.sharedKey) return;
|
|
1014
|
-
try {
|
|
1015
|
-
const errJson = JSON.stringify({ type: 'file-error', id, msg });
|
|
1016
|
-
const payload = await encrypt(this.sharedKey, errJson);
|
|
1017
|
-
this._sendEncryptedToClient(payload);
|
|
1018
|
-
} catch (err) {
|
|
1019
|
-
this.log(`Failed to send file-error: ${err.message}`);
|
|
1020
|
-
}
|
|
1021
|
-
}
|
|
1022
|
-
|
|
1023
|
-
_startViewerServer() {
|
|
1024
|
-
// Generate a random nonce for viewer authentication
|
|
1025
|
-
this.viewerToken = crypto.randomBytes(16).toString('hex');
|
|
1026
|
-
|
|
1027
|
-
this.viewerServer = net.createServer((socket) => {
|
|
1028
|
-
// Close any existing viewer connection before accepting a new one
|
|
1029
|
-
if (this.viewerSocket) {
|
|
1030
|
-
this.logDebug('Replacing existing viewer connection.');
|
|
1031
|
-
this.viewerSocket.removeAllListeners();
|
|
1032
|
-
try { this.viewerSocket.destroy(); } catch {}
|
|
1033
|
-
}
|
|
1034
|
-
|
|
1035
|
-
// ── Token authentication gate ──────────────────────────────────
|
|
1036
|
-
// The first data packet must be the viewer token followed by a newline.
|
|
1037
|
-
// Reject the connection if no valid token arrives within 2 seconds.
|
|
1038
|
-
let authenticated = false;
|
|
1039
|
-
let authBuf = '';
|
|
1040
|
-
|
|
1041
|
-
const authTimeout = setTimeout(() => {
|
|
1042
|
-
if (!authenticated) {
|
|
1043
|
-
this.logDebug('Viewer auth timeout — closing connection.');
|
|
1044
|
-
try { socket.destroy(); } catch {}
|
|
1045
|
-
}
|
|
1046
|
-
}, 2000);
|
|
1047
|
-
|
|
1048
|
-
const onAuthData = (data) => {
|
|
1049
|
-
authBuf += data.toString();
|
|
1050
|
-
const newlineIdx = authBuf.indexOf('\n');
|
|
1051
|
-
if (newlineIdx === -1) {
|
|
1052
|
-
// Accumulated too much data without a newline — reject
|
|
1053
|
-
if (authBuf.length > 256) {
|
|
1054
|
-
clearTimeout(authTimeout);
|
|
1055
|
-
this.logDebug('Viewer auth buffer overflow — closing connection.');
|
|
1056
|
-
try { socket.destroy(); } catch {}
|
|
1057
|
-
}
|
|
1058
|
-
return;
|
|
1059
|
-
}
|
|
1060
|
-
|
|
1061
|
-
const token = authBuf.slice(0, newlineIdx).trim();
|
|
1062
|
-
const remaining = authBuf.slice(newlineIdx + 1);
|
|
1063
|
-
clearTimeout(authTimeout);
|
|
1064
|
-
socket.removeListener('data', onAuthData);
|
|
1065
|
-
|
|
1066
|
-
if (token !== this.viewerToken) {
|
|
1067
|
-
this.logDebug('Viewer auth failed — wrong token.');
|
|
1068
|
-
try { socket.destroy(); } catch {}
|
|
1069
|
-
return;
|
|
1070
|
-
}
|
|
1071
|
-
|
|
1072
|
-
// Authenticated — promote to active viewer
|
|
1073
|
-
authenticated = true;
|
|
1074
|
-
this.viewerSocket = socket;
|
|
1075
|
-
this.log('Host viewer connected (authenticated).');
|
|
1076
|
-
|
|
1077
|
-
// Install the real data handler
|
|
1078
|
-
let recvBuf = '';
|
|
1079
|
-
|
|
1080
|
-
// Process any leftover data that arrived after the token line
|
|
1081
|
-
if (remaining.length > 0) {
|
|
1082
|
-
handleViewerData(remaining);
|
|
1083
|
-
}
|
|
1084
|
-
|
|
1085
|
-
socket.on('data', (chunk) => handleViewerData(chunk.toString()));
|
|
1086
|
-
|
|
1087
|
-
const handleViewerData = (chunk) => {
|
|
1088
|
-
// Control messages start with \x00{
|
|
1089
|
-
if (recvBuf.length > 0 || chunk.startsWith('\x00{')) {
|
|
1090
|
-
recvBuf += chunk;
|
|
1091
|
-
let nlIdx;
|
|
1092
|
-
while ((nlIdx = recvBuf.indexOf('\n')) !== -1) {
|
|
1093
|
-
const line = recvBuf.slice(0, nlIdx).trim();
|
|
1094
|
-
recvBuf = recvBuf.slice(nlIdx + 1);
|
|
1095
|
-
|
|
1096
|
-
if (line.startsWith('\x00')) {
|
|
1097
|
-
try {
|
|
1098
|
-
const msg = JSON.parse(line.slice(1));
|
|
1099
|
-
if (msg.type === 'resize' &&
|
|
1100
|
-
Number.isInteger(msg.cols) && msg.cols > 0 &&
|
|
1101
|
-
Number.isInteger(msg.rows) && msg.rows > 0 &&
|
|
1102
|
-
this.ptyProcess) {
|
|
1103
|
-
this.ptyProcess.resize(msg.cols, msg.rows);
|
|
1104
|
-
this._sendResize(msg.cols, msg.rows);
|
|
1105
|
-
this.logDebug(`Viewer resized to ${msg.cols}x${msg.rows}`);
|
|
1106
|
-
}
|
|
1107
|
-
} catch {}
|
|
1108
|
-
} else if (this.ptyProcess) {
|
|
1109
|
-
this.ptyProcess.write(line + '\n');
|
|
1110
|
-
}
|
|
1111
|
-
}
|
|
1112
|
-
// Flush any non-control data left in the buffer
|
|
1113
|
-
if (recvBuf.length > 0 && !recvBuf.startsWith('\x00')) {
|
|
1114
|
-
if (this.ptyProcess) this.ptyProcess.write(recvBuf);
|
|
1115
|
-
recvBuf = '';
|
|
1116
|
-
}
|
|
1117
|
-
return;
|
|
1118
|
-
}
|
|
1119
|
-
|
|
1120
|
-
// Host keystrokes → PTY (host always has access)
|
|
1121
|
-
if (this.ptyProcess) {
|
|
1122
|
-
this.ptyProcess.write(chunk);
|
|
1123
|
-
}
|
|
1124
|
-
};
|
|
1125
|
-
|
|
1126
|
-
socket.on('close', () => {
|
|
1127
|
-
this.viewerSocket = null;
|
|
1128
|
-
this.logDebug('Host viewer disconnected.');
|
|
1129
|
-
});
|
|
1130
|
-
|
|
1131
|
-
socket.on('error', () => {
|
|
1132
|
-
this.viewerSocket = null;
|
|
1133
|
-
});
|
|
1134
|
-
};
|
|
1135
|
-
|
|
1136
|
-
socket.on('data', onAuthData);
|
|
1137
|
-
|
|
1138
|
-
socket.on('error', () => {
|
|
1139
|
-
clearTimeout(authTimeout);
|
|
1140
|
-
});
|
|
1141
|
-
});
|
|
1142
|
-
|
|
1143
|
-
this.viewerServer.listen(0, '127.0.0.1', () => {
|
|
1144
|
-
const port = this.viewerServer.address().port;
|
|
1145
|
-
this.logDebug(`Viewer server on port ${port}`);
|
|
1146
|
-
this._openViewerTerminal(port, this.viewerToken);
|
|
1147
|
-
});
|
|
1148
|
-
|
|
1149
|
-
this.viewerServer.on('error', (err) => {
|
|
1150
|
-
this.log(`Viewer server error: ${err.message}`);
|
|
1151
|
-
this.viewerServer = null;
|
|
637
|
+
},
|
|
1152
638
|
});
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
_openViewerTerminal(port, token) {
|
|
1156
|
-
if (!/^[A-Za-z0-9_-]+$/.test(this.code)) {
|
|
1157
|
-
this.log('Invalid session code format. Aborting viewer terminal.');
|
|
1158
|
-
return;
|
|
1159
|
-
}
|
|
1160
|
-
|
|
1161
|
-
const viewerScript = path.join(__dirname, 'session-viewer.js');
|
|
1162
|
-
const platform = os.platform();
|
|
1163
|
-
const portStr = String(port);
|
|
1164
|
-
const nodeArgs = [viewerScript, portStr, this.code, token];
|
|
1165
|
-
|
|
1166
|
-
if (platform === 'win32') {
|
|
1167
|
-
// spawn with 'cmd' to open a new window; arguments are passed as an
|
|
1168
|
-
// array so the install path is never interpreted by the shell.
|
|
1169
|
-
spawn('cmd', ['/c', 'start', `PeerTerm - ${this.code}`, 'cmd', '/c', 'node', ...nodeArgs], {
|
|
1170
|
-
stdio: 'ignore',
|
|
1171
|
-
detached: true,
|
|
1172
|
-
windowsHide: false,
|
|
1173
|
-
}).unref();
|
|
1174
|
-
} else if (platform === 'darwin') {
|
|
1175
|
-
// osascript receives the AppleScript source as a single -e argument;
|
|
1176
|
-
// node args are baked into the script string, but execFile does NOT
|
|
1177
|
-
// invoke a shell so the outer path cannot break out.
|
|
1178
|
-
const script = `tell app "Terminal" to do script "node '${viewerScript.replace(/'/g, "'\\''")}' ${portStr} ${this.code} ${token}"`;
|
|
1179
|
-
execFile('osascript', ['-e', script], { stdio: 'ignore' }, () => {});
|
|
1180
|
-
} else {
|
|
1181
|
-
// Linux: try common terminal emulators in order, falling through on
|
|
1182
|
-
// failure. Each call uses execFile (no shell), so paths with
|
|
1183
|
-
// metacharacters are safe.
|
|
1184
|
-
const tryTerminals = [
|
|
1185
|
-
['x-terminal-emulator', ['-e', 'node', ...nodeArgs]],
|
|
1186
|
-
['gnome-terminal', ['--', 'node', ...nodeArgs]],
|
|
1187
|
-
['xterm', ['-e', 'node', ...nodeArgs]],
|
|
1188
|
-
];
|
|
1189
|
-
|
|
1190
|
-
const tryNext = (index) => {
|
|
1191
|
-
if (index >= tryTerminals.length) {
|
|
1192
|
-
this.log('Could not open any terminal emulator for viewer.');
|
|
1193
|
-
return;
|
|
1194
|
-
}
|
|
1195
|
-
const [cmd, args] = tryTerminals[index];
|
|
1196
|
-
execFile(cmd, args, { stdio: 'ignore' }, (err) => {
|
|
1197
|
-
if (err) tryNext(index + 1);
|
|
1198
|
-
});
|
|
1199
|
-
};
|
|
1200
|
-
tryNext(0);
|
|
1201
|
-
}
|
|
1202
|
-
this.log('Opening terminal viewer...');
|
|
639
|
+
this._viewer.start();
|
|
1203
640
|
}
|
|
1204
641
|
|
|
1205
642
|
// ─── Host Reconnect Logic ───────────────────────────────────────────
|
|
@@ -1299,7 +736,7 @@ class Session {
|
|
|
1299
736
|
|
|
1300
737
|
/**
|
|
1301
738
|
* Re-attach message/close/error handlers to a new WebSocket after rejoin.
|
|
1302
|
-
*
|
|
739
|
+
* Uses the shared _handleRelayMessage method to avoid duplicating the switch block.
|
|
1303
740
|
*/
|
|
1304
741
|
_attachWsHandlers(newWs) {
|
|
1305
742
|
newWs.on('message', async (raw) => {
|
|
@@ -1316,127 +753,7 @@ class Session {
|
|
|
1316
753
|
return;
|
|
1317
754
|
}
|
|
1318
755
|
|
|
1319
|
-
|
|
1320
|
-
case 'client-connected': {
|
|
1321
|
-
if (this.awaitingRejoin) {
|
|
1322
|
-
this.log('Client reconnected.');
|
|
1323
|
-
this.awaitingRejoin = false;
|
|
1324
|
-
} else {
|
|
1325
|
-
this.log('Client connected! Starting key exchange...');
|
|
1326
|
-
}
|
|
1327
|
-
this.isClientConnected = true;
|
|
1328
|
-
this.missedPings = 0;
|
|
1329
|
-
|
|
1330
|
-
if (this.useDataChannel && this.webrtc && this.webrtc.isActive()) {
|
|
1331
|
-
// Keep existing keys if direct session is healthy
|
|
1332
|
-
this.startHeartbeat();
|
|
1333
|
-
break;
|
|
1334
|
-
}
|
|
1335
|
-
|
|
1336
|
-
await this._beginKeyExchange();
|
|
1337
|
-
break;
|
|
1338
|
-
}
|
|
1339
|
-
|
|
1340
|
-
case 'key-exchange': {
|
|
1341
|
-
await this._completeKeyExchange(msg.publicKey);
|
|
1342
|
-
break;
|
|
1343
|
-
}
|
|
1344
|
-
|
|
1345
|
-
case 'signal': {
|
|
1346
|
-
if (this.webrtc && this.sharedKey) {
|
|
1347
|
-
try {
|
|
1348
|
-
const plaintext = await decrypt(this.sharedKey, msg.payload);
|
|
1349
|
-
this.webrtc.handleSignal(JSON.parse(plaintext));
|
|
1350
|
-
} catch (err) {
|
|
1351
|
-
this.logDebug(`[WebRTC] Signal decryption failed: ${err.message}`);
|
|
1352
|
-
}
|
|
1353
|
-
}
|
|
1354
|
-
break;
|
|
1355
|
-
}
|
|
1356
|
-
|
|
1357
|
-
case 'heartbeat': {
|
|
1358
|
-
this.missedPings = 0;
|
|
1359
|
-
break;
|
|
1360
|
-
}
|
|
1361
|
-
|
|
1362
|
-
case 'data': {
|
|
1363
|
-
if (!this.sharedKey || !this.ptyProcess) return;
|
|
1364
|
-
try {
|
|
1365
|
-
const plaintext = await decrypt(this.sharedKey, msg.payload);
|
|
1366
|
-
|
|
1367
|
-
if (msg.meta === 'resize') {
|
|
1368
|
-
try {
|
|
1369
|
-
const resizeData = JSON.parse(plaintext);
|
|
1370
|
-
if (resizeData.type === 'resize' && resizeData.cols && resizeData.rows) {
|
|
1371
|
-
this.ptyProcess.resize(resizeData.cols, resizeData.rows);
|
|
1372
|
-
this.logDebug(`Terminal resized to ${resizeData.cols}x${resizeData.rows}`);
|
|
1373
|
-
}
|
|
1374
|
-
} catch {}
|
|
1375
|
-
return;
|
|
1376
|
-
}
|
|
1377
|
-
|
|
1378
|
-
// Check if decrypted message is a file transfer message
|
|
1379
|
-
try {
|
|
1380
|
-
const parsedMsg = JSON.parse(plaintext);
|
|
1381
|
-
if (parsedMsg && typeof parsedMsg.type === 'string' && parsedMsg.type.startsWith('file-')) {
|
|
1382
|
-
if (this.readOnly) {
|
|
1383
|
-
this._sendFileError(parsedMsg.id, 'File uploads are disabled in read-only mode');
|
|
1384
|
-
return;
|
|
1385
|
-
}
|
|
1386
|
-
this._handleFileMessage(parsedMsg);
|
|
1387
|
-
return;
|
|
1388
|
-
}
|
|
1389
|
-
} catch {
|
|
1390
|
-
// Not JSON — normal keystroke data, fall through
|
|
1391
|
-
}
|
|
1392
|
-
|
|
1393
|
-
if (this.readOnly) return;
|
|
1394
|
-
this.ptyProcess.write(plaintext);
|
|
1395
|
-
} catch (err) {
|
|
1396
|
-
this.log(`Decryption failed: ${err.message}`);
|
|
1397
|
-
}
|
|
1398
|
-
break;
|
|
1399
|
-
}
|
|
1400
|
-
|
|
1401
|
-
case 'peer-disconnected': {
|
|
1402
|
-
if (this.useDataChannel && this.webrtc && this.webrtc.isActive()) {
|
|
1403
|
-
this.log('Client relay connection lost; direct DataChannel still active.');
|
|
1404
|
-
this.missedPings = 0;
|
|
1405
|
-
this.startHeartbeat();
|
|
1406
|
-
break;
|
|
1407
|
-
}
|
|
1408
|
-
|
|
1409
|
-
this.log(`Client disconnected. Rejoin window: ${formatDuration(this.rejoinMs)}.`);
|
|
1410
|
-
this.isClientConnected = false;
|
|
1411
|
-
this.awaitingRejoin = true;
|
|
1412
|
-
this.sharedKey = null;
|
|
1413
|
-
this.keyPair = null;
|
|
1414
|
-
this.hostPublicKeyBase64 = null;
|
|
1415
|
-
this.clientPublicKeyBase64 = null;
|
|
1416
|
-
this.securityFingerprint = null;
|
|
1417
|
-
this.fingerprintAuthorized = false;
|
|
1418
|
-
this.stopHeartbeat();
|
|
1419
|
-
this._cleanupWebRTC();
|
|
1420
|
-
break;
|
|
1421
|
-
}
|
|
1422
|
-
|
|
1423
|
-
case 'session-expired': {
|
|
1424
|
-
this.log('Rejoin window expired. Session ended.');
|
|
1425
|
-
this.destroy();
|
|
1426
|
-
break;
|
|
1427
|
-
}
|
|
1428
|
-
|
|
1429
|
-
case 'rejoined': {
|
|
1430
|
-
this.log(`Reconnected. Session restored. (code: ${msg.code})`);
|
|
1431
|
-
await this._restartPeerSessionAfterHostRejoin();
|
|
1432
|
-
break;
|
|
1433
|
-
}
|
|
1434
|
-
|
|
1435
|
-
case 'error': {
|
|
1436
|
-
this.log(`Error: ${msg.msg}`);
|
|
1437
|
-
break;
|
|
1438
|
-
}
|
|
1439
|
-
}
|
|
756
|
+
await this._handleRelayMessage(msg, this.relayUrl, null);
|
|
1440
757
|
} catch (err) {
|
|
1441
758
|
this._handleMessageError(err);
|
|
1442
759
|
}
|
|
@@ -1466,16 +783,13 @@ class Session {
|
|
|
1466
783
|
this.intentionalClose = true;
|
|
1467
784
|
this.stopHeartbeat();
|
|
1468
785
|
this._stopReconnecting();
|
|
786
|
+
this._stopDurationLogging();
|
|
1469
787
|
// Clean up in-progress file uploads
|
|
1470
|
-
this.
|
|
788
|
+
this._fileTransfer.cleanup();
|
|
1471
789
|
// Clean up viewer
|
|
1472
|
-
if (this.
|
|
1473
|
-
|
|
1474
|
-
this.
|
|
1475
|
-
}
|
|
1476
|
-
if (this.viewerServer) {
|
|
1477
|
-
try { this.viewerServer.close(); } catch {}
|
|
1478
|
-
this.viewerServer = null;
|
|
790
|
+
if (this._viewer) {
|
|
791
|
+
this._viewer.destroy();
|
|
792
|
+
this._viewer = null;
|
|
1479
793
|
}
|
|
1480
794
|
// Phase 4: Clean up WebRTC
|
|
1481
795
|
this._cleanupWebRTC();
|
|
@@ -1488,7 +802,13 @@ class Session {
|
|
|
1488
802
|
try { this.ws.send(JSON.stringify({ type: 'session-ended' })); } catch {}
|
|
1489
803
|
this.ws.close();
|
|
1490
804
|
}
|
|
1491
|
-
|
|
805
|
+
// Log session duration on end
|
|
806
|
+
if (this.sessionStartedAt) {
|
|
807
|
+
const duration = formatDuration(Date.now() - this.sessionStartedAt);
|
|
808
|
+
this.log(`Session ended. Duration: ${duration}`);
|
|
809
|
+
} else {
|
|
810
|
+
this.log('Session ended.');
|
|
811
|
+
}
|
|
1492
812
|
if (this.onDestroy) this.onDestroy(this.code);
|
|
1493
813
|
}
|
|
1494
814
|
|
|
@@ -1541,10 +861,10 @@ class Session {
|
|
|
1541
861
|
const msg = JSON.parse(plaintext);
|
|
1542
862
|
if (msg && typeof msg.type === 'string' && msg.type.startsWith('file-')) {
|
|
1543
863
|
if (this.readOnly) {
|
|
1544
|
-
this.
|
|
864
|
+
this._fileTransfer._sendError(msg.id, 'File uploads are disabled in read-only mode');
|
|
1545
865
|
return;
|
|
1546
866
|
}
|
|
1547
|
-
this.
|
|
867
|
+
this._fileTransfer.handleMessage(msg);
|
|
1548
868
|
return;
|
|
1549
869
|
}
|
|
1550
870
|
} catch {
|
|
@@ -1582,11 +902,7 @@ class Session {
|
|
|
1582
902
|
return;
|
|
1583
903
|
}
|
|
1584
904
|
|
|
1585
|
-
this.
|
|
1586
|
-
this.hostPublicKeyBase64 = null;
|
|
1587
|
-
this.clientPublicKeyBase64 = null;
|
|
1588
|
-
this.securityFingerprint = null;
|
|
1589
|
-
this.fingerprintAuthorized = false;
|
|
905
|
+
this._resetCryptoState();
|
|
1590
906
|
this._cleanupWebRTC();
|
|
1591
907
|
|
|
1592
908
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
@@ -1610,7 +926,10 @@ class Session {
|
|
|
1610
926
|
if (this.securityFingerprint && !this.fingerprintAuthorized) {
|
|
1611
927
|
status = `verify fingerprint ${this.securityFingerprint}`;
|
|
1612
928
|
} else if (this.isClientConnected) {
|
|
1613
|
-
|
|
929
|
+
const duration = this.sessionStartedAt
|
|
930
|
+
? ` (${formatDuration(Date.now() - this.sessionStartedAt)})`
|
|
931
|
+
: '';
|
|
932
|
+
status = `client connected${duration}`;
|
|
1614
933
|
} else if (this.awaitingRejoin) {
|
|
1615
934
|
status = 'awaiting rejoin';
|
|
1616
935
|
} else {
|
|
@@ -1619,11 +938,40 @@ class Session {
|
|
|
1619
938
|
if (this.readOnly) status += ' (readonly)';
|
|
1620
939
|
return status;
|
|
1621
940
|
}
|
|
941
|
+
|
|
942
|
+
// ─── Session Duration Logging ────────────────────────────────────────
|
|
943
|
+
|
|
944
|
+
_formatElapsedCompact(ms) {
|
|
945
|
+
const totalSeconds = Math.floor(ms / 1000);
|
|
946
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
947
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
948
|
+
const seconds = totalSeconds % 60;
|
|
949
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
950
|
+
if (hours > 0) return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
|
|
951
|
+
return `${pad(minutes)}:${pad(seconds)}`;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
_startDurationLogging() {
|
|
955
|
+
this._stopDurationLogging();
|
|
956
|
+
// Log session duration every 5 minutes
|
|
957
|
+
this.sessionDurationInterval = setInterval(() => {
|
|
958
|
+
if (!this.sessionStartedAt || this.destroyed) return;
|
|
959
|
+
const elapsed = Date.now() - this.sessionStartedAt;
|
|
960
|
+
this.log(`Session active — ${this._formatElapsedCompact(elapsed)}`);
|
|
961
|
+
}, 5 * 60 * 1000);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
_stopDurationLogging() {
|
|
965
|
+
if (this.sessionDurationInterval) {
|
|
966
|
+
clearInterval(this.sessionDurationInterval);
|
|
967
|
+
this.sessionDurationInterval = null;
|
|
968
|
+
}
|
|
969
|
+
}
|
|
1622
970
|
}
|
|
1623
971
|
|
|
1624
972
|
// ─── Session Manager ─────────────────────────────────────────────────────────
|
|
1625
973
|
|
|
1626
|
-
class SessionManager {
|
|
974
|
+
export class SessionManager {
|
|
1627
975
|
constructor(shell, expiryMs, rejoinMs, readOnly, startPath) {
|
|
1628
976
|
this.shell = shell;
|
|
1629
977
|
this.expiryMs = expiryMs;
|
|
@@ -1687,114 +1035,3 @@ class SessionManager {
|
|
|
1687
1035
|
this.sessions.clear();
|
|
1688
1036
|
}
|
|
1689
1037
|
}
|
|
1690
|
-
|
|
1691
|
-
// ─── Main ────────────────────────────────────────────────────────────────────
|
|
1692
|
-
|
|
1693
|
-
async function main() {
|
|
1694
|
-
const shell = detectShell();
|
|
1695
|
-
const readOnly = argv.readonly;
|
|
1696
|
-
const startPath = resolveStartPath(argv.path);
|
|
1697
|
-
|
|
1698
|
-
// Print startup banner
|
|
1699
|
-
printBanner();
|
|
1700
|
-
|
|
1701
|
-
// Interactive prompts — always ask unless flags are passed or non-interactive
|
|
1702
|
-
const expiryMs = await getExpiryInteractive();
|
|
1703
|
-
const rejoinMs = await getRejoinInteractive();
|
|
1704
|
-
console.log('');
|
|
1705
|
-
|
|
1706
|
-
logger.info(`Shell: ${shell}`);
|
|
1707
|
-
logger.info(`Relays: ${RELAY_URLS.join(', ')}`);
|
|
1708
|
-
logger.info(`Path: ${startPath}`);
|
|
1709
|
-
logger.info(`Expiry: ${formatDuration(expiryMs)}`);
|
|
1710
|
-
logger.info(`Rejoin Window: ${formatDuration(rejoinMs)}`);
|
|
1711
|
-
if (readOnly) logger.info('Mode: READ-ONLY');
|
|
1712
|
-
if (argv.verbose) logger.info('Verbose logging enabled');
|
|
1713
|
-
console.log('');
|
|
1714
|
-
|
|
1715
|
-
const manager = new SessionManager(shell, expiryMs, rejoinMs, readOnly, startPath);
|
|
1716
|
-
|
|
1717
|
-
// Create first session automatically
|
|
1718
|
-
const firstCode = await manager.createSession();
|
|
1719
|
-
if (!firstCode) {
|
|
1720
|
-
logger.error('Failed to start. Is the relay server running?');
|
|
1721
|
-
process.exit(1);
|
|
1722
|
-
}
|
|
1723
|
-
|
|
1724
|
-
// ─── Interactive CLI menu ──────────────────────────────────────────
|
|
1725
|
-
console.log(' ─────────────────────────────────────────');
|
|
1726
|
-
console.log(' Commands:');
|
|
1727
|
-
console.log(' [n] New session');
|
|
1728
|
-
console.log(' [l] List sessions');
|
|
1729
|
-
console.log(' [a <code>] Authorize verified fingerprint');
|
|
1730
|
-
console.log(' [k <code>] Kill session');
|
|
1731
|
-
console.log(' [q] Quit all');
|
|
1732
|
-
console.log(' ─────────────────────────────────────────');
|
|
1733
|
-
console.log('');
|
|
1734
|
-
|
|
1735
|
-
const rl = readline.createInterface({
|
|
1736
|
-
input: process.stdin,
|
|
1737
|
-
output: process.stdout,
|
|
1738
|
-
prompt: ' > ',
|
|
1739
|
-
});
|
|
1740
|
-
|
|
1741
|
-
rl.prompt();
|
|
1742
|
-
|
|
1743
|
-
rl.on('line', async (line) => {
|
|
1744
|
-
const input = line.trim();
|
|
1745
|
-
|
|
1746
|
-
if (input === 'n') {
|
|
1747
|
-
logger.info('Creating new session...');
|
|
1748
|
-
await manager.createSession();
|
|
1749
|
-
} else if (input === 'l') {
|
|
1750
|
-
manager.listSessions();
|
|
1751
|
-
} else if (input.startsWith('a ')) {
|
|
1752
|
-
const code = input.slice(2).trim();
|
|
1753
|
-
await manager.authorizeSession(code);
|
|
1754
|
-
} else if (input.startsWith('k ')) {
|
|
1755
|
-
const code = input.slice(2).trim();
|
|
1756
|
-
manager.killSession(code);
|
|
1757
|
-
} else if (input === 'q') {
|
|
1758
|
-
logger.info('Shutting down all sessions...');
|
|
1759
|
-
manager.killAll();
|
|
1760
|
-
rl.close();
|
|
1761
|
-
process.exit(0);
|
|
1762
|
-
} else if (input) {
|
|
1763
|
-
console.log(' Unknown command. Use n, l, a <code>, k <code>, or q.');
|
|
1764
|
-
}
|
|
1765
|
-
|
|
1766
|
-
rl.prompt();
|
|
1767
|
-
});
|
|
1768
|
-
|
|
1769
|
-
rl.on('close', () => {
|
|
1770
|
-
manager.killAll();
|
|
1771
|
-
process.exit(0);
|
|
1772
|
-
});
|
|
1773
|
-
|
|
1774
|
-
process.on('SIGINT', () => {
|
|
1775
|
-
console.log('');
|
|
1776
|
-
logger.info('Shutting down...');
|
|
1777
|
-
manager.killAll();
|
|
1778
|
-
rl.close();
|
|
1779
|
-
process.exit(0);
|
|
1780
|
-
});
|
|
1781
|
-
}
|
|
1782
|
-
|
|
1783
|
-
// ─── Global Error Handling ───────────────────────────────────────────────────
|
|
1784
|
-
|
|
1785
|
-
process.on('uncaughtException', (err) => {
|
|
1786
|
-
logger.error(`Uncaught exception: ${err.message}`, err);
|
|
1787
|
-
process.exit(1);
|
|
1788
|
-
});
|
|
1789
|
-
|
|
1790
|
-
process.on('unhandledRejection', (reason) => {
|
|
1791
|
-
const err = reason instanceof Error ? reason : new Error(String(reason));
|
|
1792
|
-
logger.error(`Unhandled rejection: ${err.message}`, err);
|
|
1793
|
-
});
|
|
1794
|
-
|
|
1795
|
-
// ─── Run ─────────────────────────────────────────────────────────────────────
|
|
1796
|
-
|
|
1797
|
-
main().catch((err) => {
|
|
1798
|
-
logger.error(`Fatal error: ${err.message}`, err);
|
|
1799
|
-
process.exit(1);
|
|
1800
|
-
});
|