peer-term 1.3.8 → 1.3.10
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/README.md +1 -1
- package/package.json +1 -1
- package/src/index.js +1849 -1800
- package/src/ui.js +142 -142
package/src/index.js
CHANGED
|
@@ -1,1800 +1,1849 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* PeerTerm Host Agent
|
|
3
|
-
*
|
|
4
|
-
* CLI tool that:
|
|
5
|
-
* 1. Connects to the relay server and registers sessions
|
|
6
|
-
* 2. Manages multiple simultaneous sessions
|
|
7
|
-
* 3. Performs ECDH key exchange for E2E encryption per session
|
|
8
|
-
* 4. Spawns independent PTY instances per session
|
|
9
|
-
* 5. Supports read-only mode and terminal resize
|
|
10
|
-
*
|
|
11
|
-
* CLI flags:
|
|
12
|
-
* --expiry <value> Session code expiry (e.g. 5m, 30s, 1h). Default: 5m
|
|
13
|
-
* --rejoin <value> Reconnection window (e.g. 5m, 1h, 6h). Default: 2m
|
|
14
|
-
* --readonly Prevent client keystrokes from reaching the PTY
|
|
15
|
-
* --path <dir> Starting directory for the terminal session
|
|
16
|
-
* --relay <url> Custom relay server URL
|
|
17
|
-
* --verbose Enable debug-level logging
|
|
18
|
-
* --help Show usage information
|
|
19
|
-
* --version Print version number
|
|
20
|
-
*/
|
|
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
|
-
import WebSocket from 'ws';
|
|
32
|
-
import pty from 'node-pty';
|
|
33
|
-
import minimist from 'minimist';
|
|
34
|
-
import {
|
|
35
|
-
generateKeyPair,
|
|
36
|
-
exportPublicKey,
|
|
37
|
-
importPublicKey,
|
|
38
|
-
deriveSharedKey,
|
|
39
|
-
fingerprintPublicKeys,
|
|
40
|
-
encrypt,
|
|
41
|
-
decrypt,
|
|
42
|
-
} from './crypto.js';
|
|
43
|
-
import { HostWebRTC } from './webrtc.js';
|
|
44
|
-
import logger from './logger.js';
|
|
45
|
-
import { printBanner, printSessionBox, printHelp, printVersion } from './ui.js';
|
|
46
|
-
|
|
47
|
-
// ─── File paths ──────────────────────────────────────────────────────────────
|
|
48
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
49
|
-
const __dirname = path.dirname(__filename);
|
|
50
|
-
|
|
51
|
-
// Load .env from host/ directory (parent of src/)
|
|
52
|
-
dotenv.config({ path: path.join(__dirname, '..', '.env') });
|
|
53
|
-
|
|
54
|
-
// Load package.json for version information
|
|
55
|
-
let version = 'unknown';
|
|
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
|
-
}
|
|
304
|
-
|
|
305
|
-
// ─── Session Class ───────────────────────────────────────────────────────────
|
|
306
|
-
|
|
307
|
-
class Session {
|
|
308
|
-
constructor(shell, expiryMs, rejoinMs, readOnly, startPath, onDestroy) {
|
|
309
|
-
this.shell = shell;
|
|
310
|
-
this.expiryMs = expiryMs;
|
|
311
|
-
this.rejoinMs = rejoinMs;
|
|
312
|
-
this.readOnly = readOnly;
|
|
313
|
-
this.startPath = startPath;
|
|
314
|
-
this.onDestroy = onDestroy;
|
|
315
|
-
this.secureMode = argv.secure || false;
|
|
316
|
-
|
|
317
|
-
this.ws = null;
|
|
318
|
-
this.code = null;
|
|
319
|
-
this.hostToken = null;
|
|
320
|
-
this.keyPair = null;
|
|
321
|
-
this.hostPublicKeyBase64 = null;
|
|
322
|
-
this.clientPublicKeyBase64 = null;
|
|
323
|
-
this.sharedKey = null;
|
|
324
|
-
this.securityFingerprint = null;
|
|
325
|
-
this.fingerprintAuthorized = false;
|
|
326
|
-
this.ptyProcess = null;
|
|
327
|
-
this.heartbeatInterval = null;
|
|
328
|
-
this.missedPings = 0;
|
|
329
|
-
this.isClientConnected = false;
|
|
330
|
-
this.awaitingRejoin = false;
|
|
331
|
-
this.destroyed = false;
|
|
332
|
-
this.intentionalClose = false;
|
|
333
|
-
this.ptyOutputQueue = Promise.resolve();
|
|
334
|
-
this.reconnectTimer = null;
|
|
335
|
-
this.createdAt = Date.now();
|
|
336
|
-
this.relayUrl = null; // Track which relay URL we connected to
|
|
337
|
-
|
|
338
|
-
// Phase 4: WebRTC state
|
|
339
|
-
this.webrtc = null;
|
|
340
|
-
this.useDataChannel = false;
|
|
341
|
-
|
|
342
|
-
// File upload: in-progress file transfers from client
|
|
343
|
-
// Shape: { [id]: { name, size, totalChunks, chunks: Map<index, Buffer> } }
|
|
344
|
-
this._incomingFiles = {};
|
|
345
|
-
|
|
346
|
-
// Local TCP viewer (new terminal window)
|
|
347
|
-
this.viewerServer = null;
|
|
348
|
-
this.viewerSocket = null;
|
|
349
|
-
this.viewerToken = null; // Random nonce for viewer auth
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
this.
|
|
368
|
-
this.
|
|
369
|
-
this.
|
|
370
|
-
this.
|
|
371
|
-
this.
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
this.
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
this.
|
|
415
|
-
this.
|
|
416
|
-
this.
|
|
417
|
-
this.
|
|
418
|
-
this.
|
|
419
|
-
this.
|
|
420
|
-
this.
|
|
421
|
-
this.
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
console.log(
|
|
436
|
-
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
if (this.
|
|
445
|
-
logger.
|
|
446
|
-
return
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
this.
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
const
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
this.ws.on('
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
this.
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
this.
|
|
555
|
-
this.
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
this.
|
|
638
|
-
this.
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
this.
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
this.
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
this.
|
|
660
|
-
this.
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
}
|
|
686
|
-
this.
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
this.log(
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
if (this.
|
|
735
|
-
this.
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
this.
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
this.
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
});
|
|
779
|
-
|
|
780
|
-
this.ptyProcess.
|
|
781
|
-
|
|
782
|
-
if (this.
|
|
783
|
-
this.
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
this.
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
if (msg.totalChunks
|
|
872
|
-
this._sendFileError(msg.id, '
|
|
873
|
-
return;
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
if (
|
|
877
|
-
this._sendFileError(msg.id,
|
|
878
|
-
return;
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
//
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
//
|
|
963
|
-
const
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
this.log(`
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
if (
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
//
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
tryNext(
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
this.
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
if (
|
|
1225
|
-
|
|
1226
|
-
this.
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
}
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
this.
|
|
1263
|
-
this.
|
|
1264
|
-
this._stopReconnecting();
|
|
1265
|
-
this.
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
this.
|
|
1333
|
-
|
|
1334
|
-
}
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
this.
|
|
1404
|
-
this.
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
}
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
this.
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
this.
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
this.
|
|
1425
|
-
this.
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
}
|
|
1451
|
-
this.
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
//
|
|
1481
|
-
this.
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
this.
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
try { this.
|
|
1489
|
-
this.
|
|
1490
|
-
}
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
this.
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
this.
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
this.
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
//
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
this.
|
|
1548
|
-
|
|
1549
|
-
}
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
if (
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
this.
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
if (
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
}
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
logger.
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
process.
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
});
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
})
|
|
1
|
+
/**
|
|
2
|
+
* PeerTerm Host Agent
|
|
3
|
+
*
|
|
4
|
+
* CLI tool that:
|
|
5
|
+
* 1. Connects to the relay server and registers sessions
|
|
6
|
+
* 2. Manages multiple simultaneous sessions
|
|
7
|
+
* 3. Performs ECDH key exchange for E2E encryption per session
|
|
8
|
+
* 4. Spawns independent PTY instances per session
|
|
9
|
+
* 5. Supports read-only mode and terminal resize
|
|
10
|
+
*
|
|
11
|
+
* CLI flags:
|
|
12
|
+
* --expiry <value> Session code expiry (e.g. 5m, 30s, 1h). Default: 5m
|
|
13
|
+
* --rejoin <value> Reconnection window (e.g. 5m, 1h, 6h). Default: 2m
|
|
14
|
+
* --readonly Prevent client keystrokes from reaching the PTY
|
|
15
|
+
* --path <dir> Starting directory for the terminal session
|
|
16
|
+
* --relay <url> Custom relay server URL
|
|
17
|
+
* --verbose Enable debug-level logging
|
|
18
|
+
* --help Show usage information
|
|
19
|
+
* --version Print version number
|
|
20
|
+
*/
|
|
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
|
+
import WebSocket from 'ws';
|
|
32
|
+
import pty from 'node-pty';
|
|
33
|
+
import minimist from 'minimist';
|
|
34
|
+
import {
|
|
35
|
+
generateKeyPair,
|
|
36
|
+
exportPublicKey,
|
|
37
|
+
importPublicKey,
|
|
38
|
+
deriveSharedKey,
|
|
39
|
+
fingerprintPublicKeys,
|
|
40
|
+
encrypt,
|
|
41
|
+
decrypt,
|
|
42
|
+
} from './crypto.js';
|
|
43
|
+
import { HostWebRTC } from './webrtc.js';
|
|
44
|
+
import logger from './logger.js';
|
|
45
|
+
import { printBanner, printSessionBox, printHelp, printVersion } from './ui.js';
|
|
46
|
+
|
|
47
|
+
// ─── File paths ──────────────────────────────────────────────────────────────
|
|
48
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
49
|
+
const __dirname = path.dirname(__filename);
|
|
50
|
+
|
|
51
|
+
// Load .env from host/ directory (parent of src/)
|
|
52
|
+
dotenv.config({ path: path.join(__dirname, '..', '.env') });
|
|
53
|
+
|
|
54
|
+
// Load package.json for version information
|
|
55
|
+
let version = 'unknown';
|
|
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
|
+
}
|
|
304
|
+
|
|
305
|
+
// ─── Session Class ───────────────────────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
class Session {
|
|
308
|
+
constructor(shell, expiryMs, rejoinMs, readOnly, startPath, onDestroy) {
|
|
309
|
+
this.shell = shell;
|
|
310
|
+
this.expiryMs = expiryMs;
|
|
311
|
+
this.rejoinMs = rejoinMs;
|
|
312
|
+
this.readOnly = readOnly;
|
|
313
|
+
this.startPath = startPath;
|
|
314
|
+
this.onDestroy = onDestroy;
|
|
315
|
+
this.secureMode = argv.secure || false;
|
|
316
|
+
|
|
317
|
+
this.ws = null;
|
|
318
|
+
this.code = null;
|
|
319
|
+
this.hostToken = null;
|
|
320
|
+
this.keyPair = null;
|
|
321
|
+
this.hostPublicKeyBase64 = null;
|
|
322
|
+
this.clientPublicKeyBase64 = null;
|
|
323
|
+
this.sharedKey = null;
|
|
324
|
+
this.securityFingerprint = null;
|
|
325
|
+
this.fingerprintAuthorized = false;
|
|
326
|
+
this.ptyProcess = null;
|
|
327
|
+
this.heartbeatInterval = null;
|
|
328
|
+
this.missedPings = 0;
|
|
329
|
+
this.isClientConnected = false;
|
|
330
|
+
this.awaitingRejoin = false;
|
|
331
|
+
this.destroyed = false;
|
|
332
|
+
this.intentionalClose = false;
|
|
333
|
+
this.ptyOutputQueue = Promise.resolve();
|
|
334
|
+
this.reconnectTimer = null;
|
|
335
|
+
this.createdAt = Date.now();
|
|
336
|
+
this.relayUrl = null; // Track which relay URL we connected to
|
|
337
|
+
|
|
338
|
+
// Phase 4: WebRTC state
|
|
339
|
+
this.webrtc = null;
|
|
340
|
+
this.useDataChannel = false;
|
|
341
|
+
|
|
342
|
+
// File upload: in-progress file transfers from client
|
|
343
|
+
// Shape: { [id]: { name, size, totalChunks, chunks: Map<index, Buffer> } }
|
|
344
|
+
this._incomingFiles = {};
|
|
345
|
+
|
|
346
|
+
// Local TCP viewer (new terminal window)
|
|
347
|
+
this.viewerServer = null;
|
|
348
|
+
this.viewerSocket = null;
|
|
349
|
+
this.viewerToken = null; // Random nonce for viewer auth
|
|
350
|
+
|
|
351
|
+
// Session duration tracking
|
|
352
|
+
this.sessionStartedAt = null; // Timestamp: when the terminal session became active
|
|
353
|
+
this.sessionDurationInterval = null; // Interval: periodic CLI status log
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
log(msg) {
|
|
357
|
+
const prefix = this.code ? `[${this.code}]` : '[???]';
|
|
358
|
+
logger.info(`${prefix} ${msg}`);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
logDebug(msg) {
|
|
362
|
+
const prefix = this.code ? `[${this.code}]` : '[???]';
|
|
363
|
+
logger.debug(`${prefix} ${msg}`);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async _beginKeyExchange() {
|
|
367
|
+
this.keyPair = await generateKeyPair();
|
|
368
|
+
this.hostPublicKeyBase64 = await exportPublicKey(this.keyPair.publicKey);
|
|
369
|
+
this.clientPublicKeyBase64 = null;
|
|
370
|
+
this.sharedKey = null;
|
|
371
|
+
this.securityFingerprint = null;
|
|
372
|
+
this.fingerprintAuthorized = false;
|
|
373
|
+
this.stopHeartbeat();
|
|
374
|
+
this._cleanupWebRTC();
|
|
375
|
+
this.ws.send(JSON.stringify({
|
|
376
|
+
type: 'key-exchange',
|
|
377
|
+
publicKey: this.hostPublicKeyBase64,
|
|
378
|
+
secureMode: this.secureMode
|
|
379
|
+
}));
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async _completeKeyExchange(clientPublicKeyBase64) {
|
|
383
|
+
if (!this.keyPair || !this.hostPublicKeyBase64) return;
|
|
384
|
+
if (typeof clientPublicKeyBase64 !== 'string' || clientPublicKeyBase64.length === 0) {
|
|
385
|
+
this._rejectProtocolMessage('Invalid key-exchange public key');
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
try {
|
|
390
|
+
this.logDebug('Deriving shared secret...');
|
|
391
|
+
this.clientPublicKeyBase64 = clientPublicKeyBase64;
|
|
392
|
+
|
|
393
|
+
const peerPublicKey = await importPublicKey(clientPublicKeyBase64);
|
|
394
|
+
this.sharedKey = await deriveSharedKey(this.keyPair.privateKey, peerPublicKey);
|
|
395
|
+
|
|
396
|
+
if (this.secureMode) {
|
|
397
|
+
this.securityFingerprint = await fingerprintPublicKeys(
|
|
398
|
+
this.code,
|
|
399
|
+
this.hostPublicKeyBase64,
|
|
400
|
+
this.clientPublicKeyBase64
|
|
401
|
+
);
|
|
402
|
+
this.fingerprintAuthorized = false;
|
|
403
|
+
this._printFingerprintAuthorization();
|
|
404
|
+
} else {
|
|
405
|
+
this.fingerprintAuthorized = true;
|
|
406
|
+
await this._activateSecureSession();
|
|
407
|
+
}
|
|
408
|
+
} catch (err) {
|
|
409
|
+
this._rejectProtocolMessage(`Invalid key exchange: ${err.message}`);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
_rejectProtocolMessage(reason) {
|
|
414
|
+
this.log(`Protocol error: ${reason}`);
|
|
415
|
+
this.isClientConnected = false;
|
|
416
|
+
this.awaitingRejoin = false;
|
|
417
|
+
this.sharedKey = null;
|
|
418
|
+
this.keyPair = null;
|
|
419
|
+
this.hostPublicKeyBase64 = null;
|
|
420
|
+
this.clientPublicKeyBase64 = null;
|
|
421
|
+
this.securityFingerprint = null;
|
|
422
|
+
this.fingerprintAuthorized = false;
|
|
423
|
+
this.stopHeartbeat();
|
|
424
|
+
this._cleanupWebRTC();
|
|
425
|
+
this.destroy();
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
_handleMessageError(err) {
|
|
429
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
430
|
+
this.log(`Protocol handler error: ${message}`);
|
|
431
|
+
this.destroy();
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
_printFingerprintAuthorization() {
|
|
435
|
+
console.log('');
|
|
436
|
+
this.log('Encrypted candidate established. Verify before terminal access.');
|
|
437
|
+
console.log(` Fingerprint: ${this.securityFingerprint}`);
|
|
438
|
+
console.log(' Ask the viewer to compare this exact fingerprint.');
|
|
439
|
+
console.log(` Type "a ${this.code}" after it matches, or "k ${this.code}" to kill the session.`);
|
|
440
|
+
console.log('');
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async authorizeFingerprint() {
|
|
444
|
+
if (!this.sharedKey || !this.securityFingerprint) {
|
|
445
|
+
logger.warn(`Session ${this.code} has no fingerprint awaiting authorization.`);
|
|
446
|
+
return false;
|
|
447
|
+
}
|
|
448
|
+
if (this.fingerprintAuthorized) {
|
|
449
|
+
logger.info(`Session ${this.code} is already authorized.`);
|
|
450
|
+
return true;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
this.fingerprintAuthorized = true;
|
|
454
|
+
await this._activateSecureSession();
|
|
455
|
+
return true;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async _activateSecureSession() {
|
|
459
|
+
if (!this.sharedKey || !this.fingerprintAuthorized) return;
|
|
460
|
+
|
|
461
|
+
this.log(`Fingerprint authorized: ${this.securityFingerprint}`);
|
|
462
|
+
this.log('Encrypted tunnel active');
|
|
463
|
+
await this._sendSessionConfig();
|
|
464
|
+
|
|
465
|
+
// Mark session start time for duration tracking
|
|
466
|
+
if (!this.sessionStartedAt) {
|
|
467
|
+
this.sessionStartedAt = Date.now();
|
|
468
|
+
this._startDurationLogging();
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
this.startHeartbeat();
|
|
472
|
+
|
|
473
|
+
if (!this.ptyProcess) {
|
|
474
|
+
this.spawnTerminal();
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
this._initiateWebRTC();
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async _sendSessionConfig() {
|
|
481
|
+
if (!this.sharedKey || !this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
|
482
|
+
|
|
483
|
+
const config = JSON.stringify({
|
|
484
|
+
type: 'session-config',
|
|
485
|
+
readonly: this.readOnly,
|
|
486
|
+
version: version,
|
|
487
|
+
shell: this.shell,
|
|
488
|
+
startPath: this.startPath,
|
|
489
|
+
});
|
|
490
|
+
try {
|
|
491
|
+
const payload = await encrypt(this.sharedKey, config);
|
|
492
|
+
this.ws.send(JSON.stringify({ type: 'data', payload }));
|
|
493
|
+
} catch (err) {
|
|
494
|
+
this.log(`Failed to send session config: ${err.message}`);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ─── Start the session ──────────────────────────────────────────────
|
|
499
|
+
async start() {
|
|
500
|
+
for (let i = 0; i < RELAY_URLS.length; i++) {
|
|
501
|
+
const url = RELAY_URLS[i];
|
|
502
|
+
try {
|
|
503
|
+
const code = await this._tryConnect(url);
|
|
504
|
+
return code;
|
|
505
|
+
} catch (err) {
|
|
506
|
+
if (i === RELAY_URLS.length - 1) {
|
|
507
|
+
throw new Error(`All relay servers failed. Last error: ${err.message}`);
|
|
508
|
+
}
|
|
509
|
+
logger.warn(`Failed to connect to ${url}: ${err.message}. Trying next relay...`);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async _tryConnect(url) {
|
|
515
|
+
return new Promise((resolve, reject) => {
|
|
516
|
+
this.ws = new WebSocket(url);
|
|
517
|
+
this.relayUrl = url;
|
|
518
|
+
|
|
519
|
+
let isConnected = false;
|
|
520
|
+
|
|
521
|
+
this.ws.on('open', () => {
|
|
522
|
+
isConnected = true;
|
|
523
|
+
this.ws.send(JSON.stringify({
|
|
524
|
+
type: 'host-register',
|
|
525
|
+
expiry: this.expiryMs,
|
|
526
|
+
rejoinWindow: this.rejoinMs,
|
|
527
|
+
readonly: this.readOnly,
|
|
528
|
+
}));
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
this.ws.on('message', async (raw) => {
|
|
532
|
+
try {
|
|
533
|
+
let msg;
|
|
534
|
+
try {
|
|
535
|
+
msg = JSON.parse(raw.toString());
|
|
536
|
+
} catch {
|
|
537
|
+
this.log('Invalid message from relay');
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
if (!msg || typeof msg.type !== 'string') {
|
|
541
|
+
this._rejectProtocolMessage('Malformed relay message');
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
switch (msg.type) {
|
|
546
|
+
case 'code': {
|
|
547
|
+
this.code = msg.code;
|
|
548
|
+
this.hostToken = msg.hostToken || null;
|
|
549
|
+
printSessionBox({
|
|
550
|
+
code: this.code,
|
|
551
|
+
expiry: formatDuration(this.expiryMs),
|
|
552
|
+
rejoinWindow: formatDuration(this.rejoinMs),
|
|
553
|
+
mode: (this.readOnly ? 'Read-Only' : 'Read-Write') + (this.secureMode ? ' (Secure)' : ''),
|
|
554
|
+
shell: this.shell,
|
|
555
|
+
startPath: this.startPath,
|
|
556
|
+
shareUrl: url.replace(/^wss:\/\//i, 'https://').replace(/^ws:\/\//i, 'http://')
|
|
557
|
+
});
|
|
558
|
+
resolve(this.code);
|
|
559
|
+
break;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
case 'client-connected': {
|
|
563
|
+
if (this.awaitingRejoin) {
|
|
564
|
+
this.log('Client reconnected.');
|
|
565
|
+
this.awaitingRejoin = false;
|
|
566
|
+
} else {
|
|
567
|
+
this.log('Client connected! Starting key exchange...');
|
|
568
|
+
}
|
|
569
|
+
this.isClientConnected = true;
|
|
570
|
+
this.missedPings = 0;
|
|
571
|
+
|
|
572
|
+
if (this.useDataChannel && this.webrtc && this.webrtc.isActive()) {
|
|
573
|
+
this.startHeartbeat();
|
|
574
|
+
break;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
await this._beginKeyExchange();
|
|
578
|
+
break;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
case 'key-exchange': {
|
|
582
|
+
await this._completeKeyExchange(msg.publicKey);
|
|
583
|
+
break;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Phase 4: WebRTC signaling messages
|
|
587
|
+
case 'signal': {
|
|
588
|
+
if (this.webrtc && this.sharedKey) {
|
|
589
|
+
try {
|
|
590
|
+
const plaintext = await decrypt(this.sharedKey, msg.payload);
|
|
591
|
+
this.webrtc.handleSignal(JSON.parse(plaintext));
|
|
592
|
+
} catch (err) {
|
|
593
|
+
this.logDebug(`[WebRTC] Signal decryption failed: ${err.message}`);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
break;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
case 'heartbeat': {
|
|
600
|
+
this.missedPings = 0;
|
|
601
|
+
break;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
case 'data': {
|
|
605
|
+
if (!this.sharedKey || !this.ptyProcess) return;
|
|
606
|
+
try {
|
|
607
|
+
const plaintext = await decrypt(this.sharedKey, msg.payload);
|
|
608
|
+
|
|
609
|
+
// Check if this is a resize event
|
|
610
|
+
if (msg.meta === 'resize') {
|
|
611
|
+
try {
|
|
612
|
+
const resizeData = JSON.parse(plaintext);
|
|
613
|
+
if (resizeData.type === 'resize' && resizeData.cols && resizeData.rows) {
|
|
614
|
+
this.ptyProcess.resize(resizeData.cols, resizeData.rows);
|
|
615
|
+
this.logDebug(`Terminal resized to ${resizeData.cols}x${resizeData.rows}`);
|
|
616
|
+
}
|
|
617
|
+
} catch {}
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Check if decrypted message is a file transfer message (file-start, file-chunk, file-end)
|
|
622
|
+
try {
|
|
623
|
+
const parsedMsg = JSON.parse(plaintext);
|
|
624
|
+
if (parsedMsg && typeof parsedMsg.type === 'string' && parsedMsg.type.startsWith('file-')) {
|
|
625
|
+
if (this.readOnly) {
|
|
626
|
+
this._sendFileError(parsedMsg.id, 'File uploads are disabled in read-only mode');
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
this._handleFileMessage(parsedMsg);
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
} catch {
|
|
633
|
+
// Not JSON — normal keystroke data, fall through
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Normal keystroke — drop if read-only
|
|
637
|
+
if (this.readOnly) return;
|
|
638
|
+
this.ptyProcess.write(plaintext);
|
|
639
|
+
} catch (err) {
|
|
640
|
+
this.log(`Decryption failed: ${err.message}`);
|
|
641
|
+
}
|
|
642
|
+
break;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
case 'peer-disconnected': {
|
|
646
|
+
if (this.useDataChannel && this.webrtc && this.webrtc.isActive()) {
|
|
647
|
+
this.log('Client relay connection lost; direct DataChannel still active.');
|
|
648
|
+
this.missedPings = 0;
|
|
649
|
+
this.startHeartbeat();
|
|
650
|
+
break;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
this.log(`Client disconnected. Rejoin window: ${formatDuration(this.rejoinMs)}.`);
|
|
654
|
+
this.isClientConnected = false;
|
|
655
|
+
this.awaitingRejoin = true;
|
|
656
|
+
this.sharedKey = null;
|
|
657
|
+
this.keyPair = null;
|
|
658
|
+
this.hostPublicKeyBase64 = null;
|
|
659
|
+
this.clientPublicKeyBase64 = null;
|
|
660
|
+
this.securityFingerprint = null;
|
|
661
|
+
this.fingerprintAuthorized = false;
|
|
662
|
+
this.stopHeartbeat();
|
|
663
|
+
// Phase 4: Clean up WebRTC on peer disconnect
|
|
664
|
+
this._cleanupWebRTC();
|
|
665
|
+
break;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
case 'session-expired': {
|
|
669
|
+
this.log('Rejoin window expired. Session ended.');
|
|
670
|
+
this.destroy();
|
|
671
|
+
break;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
case 'rejoined': {
|
|
675
|
+
this.log(`\u2705 Reconnected. Session restored. (code: ${msg.code})`);
|
|
676
|
+
await this._restartPeerSessionAfterHostRejoin();
|
|
677
|
+
break;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
case 'error': {
|
|
681
|
+
this.log(`Error: ${msg.msg}`);
|
|
682
|
+
break;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
} catch (err) {
|
|
686
|
+
this._handleMessageError(err);
|
|
687
|
+
}
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
this.ws.on('close', () => {
|
|
691
|
+
if (!isConnected) {
|
|
692
|
+
reject(new Error('WebSocket closed before connection was established'));
|
|
693
|
+
} else if (this.destroyed) {
|
|
694
|
+
// Already destroyed, nothing to do
|
|
695
|
+
} else if (this.intentionalClose) {
|
|
696
|
+
this.log('Connection to relay closed (intentional).');
|
|
697
|
+
this.destroy();
|
|
698
|
+
} else {
|
|
699
|
+
// Unexpected disconnect — start reconnect loop
|
|
700
|
+
this.log('Connection to relay lost. Starting reconnect...');
|
|
701
|
+
this.stopHeartbeat();
|
|
702
|
+
this._startReconnecting();
|
|
703
|
+
}
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
this.ws.on('error', (err) => {
|
|
707
|
+
if (!isConnected) {
|
|
708
|
+
reject(err);
|
|
709
|
+
} else {
|
|
710
|
+
this.log(`WS error: ${err.message}`);
|
|
711
|
+
}
|
|
712
|
+
});
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// ─── Send encrypted resize to client ────────────────────────────────
|
|
717
|
+
async _sendResize(cols, rows) {
|
|
718
|
+
if (!this.sharedKey) return;
|
|
719
|
+
try {
|
|
720
|
+
const resizeJson = JSON.stringify({ type: 'resize', cols, rows });
|
|
721
|
+
const payload = await encrypt(this.sharedKey, resizeJson);
|
|
722
|
+
this._sendEncryptedToClient(payload, {
|
|
723
|
+
directPayload: JSON.stringify({ _meta: 'resize', payload }),
|
|
724
|
+
meta: 'resize',
|
|
725
|
+
});
|
|
726
|
+
} catch {}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// ─── Heartbeat ──────────────────────────────────────────────────────
|
|
730
|
+
startHeartbeat() {
|
|
731
|
+
this.stopHeartbeat();
|
|
732
|
+
this.missedPings = 0;
|
|
733
|
+
this.heartbeatInterval = setInterval(() => {
|
|
734
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
735
|
+
this.ws.send(JSON.stringify({ type: 'heartbeat' }));
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
if (this.useDataChannel && this.webrtc && this.webrtc.isActive()) {
|
|
739
|
+
this.missedPings = 0;
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
this.missedPings++;
|
|
744
|
+
if (this.missedPings >= MAX_MISSED_PINGS + 1) {
|
|
745
|
+
this.log('Client heartbeat lost. Waiting for reconnect...');
|
|
746
|
+
this.isClientConnected = false;
|
|
747
|
+
this.awaitingRejoin = true;
|
|
748
|
+
this.sharedKey = null;
|
|
749
|
+
this.keyPair = null;
|
|
750
|
+
this.hostPublicKeyBase64 = null;
|
|
751
|
+
this.clientPublicKeyBase64 = null;
|
|
752
|
+
this.securityFingerprint = null;
|
|
753
|
+
this.fingerprintAuthorized = false;
|
|
754
|
+
this.stopHeartbeat();
|
|
755
|
+
}
|
|
756
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
stopHeartbeat() {
|
|
760
|
+
if (this.heartbeatInterval) {
|
|
761
|
+
clearInterval(this.heartbeatInterval);
|
|
762
|
+
this.heartbeatInterval = null;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// ─── Spawn PTY ──────────────────────────────────────────────────────
|
|
767
|
+
spawnTerminal() {
|
|
768
|
+
const cols = 80;
|
|
769
|
+
const rows = 24;
|
|
770
|
+
this.log(`Spawning terminal: ${this.shell} (${cols}x${rows})`);
|
|
771
|
+
|
|
772
|
+
this.ptyProcess = pty.spawn(this.shell, [], {
|
|
773
|
+
name: 'xterm-256color',
|
|
774
|
+
cols,
|
|
775
|
+
rows,
|
|
776
|
+
cwd: this.startPath,
|
|
777
|
+
env: process.env,
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
this.ptyProcess.onData((data) => {
|
|
781
|
+
// Mirror PTY output to local viewer terminal
|
|
782
|
+
if (this.viewerSocket) {
|
|
783
|
+
try { this.viewerSocket.write(data); } catch {}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
if (!this.sharedKey) return;
|
|
787
|
+
this._queuePtyOutput(data, this.sharedKey);
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
this.ptyProcess.onExit(({ exitCode }) => {
|
|
791
|
+
this.log(`Shell exited with code ${exitCode}`);
|
|
792
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
793
|
+
this.ws.send(JSON.stringify({ type: 'session-ended' }));
|
|
794
|
+
}
|
|
795
|
+
this.destroy();
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
// Open a local viewer terminal for the host
|
|
799
|
+
this._startViewerServer();
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// ─── Local TCP viewer for host terminal ─────────────────────────────
|
|
803
|
+
_queuePtyOutput(data, sharedKey) {
|
|
804
|
+
this.ptyOutputQueue = this.ptyOutputQueue
|
|
805
|
+
.then(() => this._sendPtyOutput(data, sharedKey))
|
|
806
|
+
.catch((err) => {
|
|
807
|
+
this.logDebug(`Failed to send PTY output: ${err.message}`);
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
async _sendPtyOutput(data, sharedKey) {
|
|
812
|
+
if (this.destroyed || !sharedKey || sharedKey !== this.sharedKey) return;
|
|
813
|
+
|
|
814
|
+
const payload = await encrypt(sharedKey, data);
|
|
815
|
+
if (this.destroyed || sharedKey !== this.sharedKey) return;
|
|
816
|
+
|
|
817
|
+
this._sendEncryptedToClient(payload, { enforceBackpressure: true });
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
_sendEncryptedToClient(payload, options = {}) {
|
|
821
|
+
const directPayload = options.directPayload || payload;
|
|
822
|
+
|
|
823
|
+
if (this.useDataChannel) {
|
|
824
|
+
if (this.webrtc && this.webrtc.isActive()) {
|
|
825
|
+
let sentDirect = false;
|
|
826
|
+
try {
|
|
827
|
+
sentDirect = this.webrtc.send(directPayload);
|
|
828
|
+
} catch {}
|
|
829
|
+
|
|
830
|
+
if (sentDirect) return true;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
this.useDataChannel = false;
|
|
834
|
+
this.log('DataChannel send failed — falling back to relay');
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
return this._sendViaRelay(payload, options);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
_sendViaRelay(payload, { meta, enforceBackpressure = false } = {}) {
|
|
841
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return false;
|
|
842
|
+
if (enforceBackpressure && this.ws.bufferedAmount >= 1.25 * 1024 * 1024) return false;
|
|
843
|
+
|
|
844
|
+
const msg = { type: 'data', payload };
|
|
845
|
+
if (meta) msg.meta = meta;
|
|
846
|
+
|
|
847
|
+
try {
|
|
848
|
+
this.ws.send(JSON.stringify(msg));
|
|
849
|
+
return true;
|
|
850
|
+
} catch {
|
|
851
|
+
return false;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// ─── File Upload Handling ─────────────────────────────────────────────
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* Route file transfer messages (file-start, file-chunk, file-end).
|
|
859
|
+
* All file messages arrive over the DataChannel only.
|
|
860
|
+
*/
|
|
861
|
+
_handleFileMessage(msg) {
|
|
862
|
+
// Defense-in-depth: reject all file operations in read-only sessions
|
|
863
|
+
if (this.readOnly) {
|
|
864
|
+
this._sendFileError(msg.id, 'File uploads are disabled in read-only mode');
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
switch (msg.type) {
|
|
869
|
+
case 'file-start': {
|
|
870
|
+
// Validate required fields
|
|
871
|
+
if (!msg.id || !msg.name || msg.size === undefined || msg.totalChunks === undefined) {
|
|
872
|
+
this._sendFileError(msg.id, 'Invalid file-start message');
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
if (msg.size > UPLOAD_MAX_FILE_SIZE) {
|
|
877
|
+
this._sendFileError(msg.id, `File exceeds maximum size of ${UPLOAD_MAX_FILE_SIZE / 1024 / 1024}MB`);
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
if (msg.totalChunks > UPLOAD_MAX_CHUNKS_PER_FILE) {
|
|
882
|
+
this._sendFileError(msg.id, 'File requires too many chunks');
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
if (Object.keys(this._incomingFiles).length >= UPLOAD_MAX_CONCURRENT) {
|
|
887
|
+
this._sendFileError(msg.id, 'Too many concurrent uploads');
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
this.log(`File upload started: "${msg.name}" (${msg.size} bytes, ${msg.totalChunks} chunks)`);
|
|
892
|
+
this._incomingFiles[msg.id] = {
|
|
893
|
+
name: msg.name,
|
|
894
|
+
size: msg.size,
|
|
895
|
+
totalChunks: msg.totalChunks,
|
|
896
|
+
receivedSize: 0,
|
|
897
|
+
chunks: new Map(),
|
|
898
|
+
};
|
|
899
|
+
break;
|
|
900
|
+
}
|
|
901
|
+
case 'file-chunk': {
|
|
902
|
+
const transfer = this._incomingFiles[msg.id];
|
|
903
|
+
if (!transfer) {
|
|
904
|
+
this._sendFileError(msg.id, 'Unknown transfer ID');
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
const chunkData = Buffer.from(msg.data, 'base64');
|
|
909
|
+
|
|
910
|
+
if (transfer.chunks.size >= transfer.totalChunks) {
|
|
911
|
+
this._sendFileError(msg.id, 'Too many chunks received');
|
|
912
|
+
delete this._incomingFiles[msg.id];
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
if (transfer.receivedSize + chunkData.length > transfer.size) {
|
|
917
|
+
this._sendFileError(msg.id, 'Received size exceeds declared size');
|
|
918
|
+
delete this._incomingFiles[msg.id];
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
transfer.chunks.set(msg.index, chunkData);
|
|
923
|
+
transfer.receivedSize += chunkData.length;
|
|
924
|
+
this.logDebug(`File chunk ${msg.index + 1}/${transfer.totalChunks} received for "${transfer.name}"`);
|
|
925
|
+
break;
|
|
926
|
+
}
|
|
927
|
+
case 'file-end': {
|
|
928
|
+
this._finalizeFile(msg.id);
|
|
929
|
+
break;
|
|
930
|
+
}
|
|
931
|
+
case 'file-cancel': {
|
|
932
|
+
if (this._incomingFiles[msg.id]) {
|
|
933
|
+
delete this._incomingFiles[msg.id];
|
|
934
|
+
this.log(`File upload cancelled by client: ${msg.id}`);
|
|
935
|
+
}
|
|
936
|
+
break;
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* Reassemble all chunks in order and write the completed file to disk.
|
|
943
|
+
* Saves to ~/peerterm-uploads/<filename>.
|
|
944
|
+
*/
|
|
945
|
+
async _finalizeFile(id) {
|
|
946
|
+
const transfer = this._incomingFiles[id];
|
|
947
|
+
if (!transfer) {
|
|
948
|
+
this._sendFileError(id, 'Unknown transfer ID');
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// Check all chunks are present
|
|
953
|
+
for (let i = 0; i < transfer.totalChunks; i++) {
|
|
954
|
+
if (!transfer.chunks.has(i)) {
|
|
955
|
+
this.log(`File "${transfer.name}" missing chunk ${i}`);
|
|
956
|
+
this._sendFileError(id, `Missing chunk ${i}`);
|
|
957
|
+
delete this._incomingFiles[id];
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// Reassemble in order using Buffer.concat
|
|
963
|
+
const ordered = [];
|
|
964
|
+
for (let i = 0; i < transfer.totalChunks; i++) {
|
|
965
|
+
ordered.push(transfer.chunks.get(i));
|
|
966
|
+
}
|
|
967
|
+
const fileData = Buffer.concat(ordered);
|
|
968
|
+
|
|
969
|
+
// Determine save path: ~/peerterm-uploads/<filename>
|
|
970
|
+
const homeDir = os.homedir();
|
|
971
|
+
const uploadDir = path.join(homeDir, 'peerterm-uploads');
|
|
972
|
+
// Sanitize filename to prevent path traversal (e.g., "../../../etc/passwd")
|
|
973
|
+
const baseName = path.basename(transfer.name);
|
|
974
|
+
if (!baseName || baseName === '.' || baseName === '..') {
|
|
975
|
+
this._sendFileError(id, 'Invalid filename');
|
|
976
|
+
delete this._incomingFiles[id];
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
const savePath = path.join(uploadDir, baseName);
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
try {
|
|
985
|
+
await fs.promises.mkdir(uploadDir, { recursive: true });
|
|
986
|
+
await fs.promises.writeFile(savePath, fileData);
|
|
987
|
+
|
|
988
|
+
this.log(`File saved: ${savePath}`);
|
|
989
|
+
|
|
990
|
+
// Write green ANSI message into the PTY to show the save path
|
|
991
|
+
if (this.ptyProcess) {
|
|
992
|
+
this.ptyProcess.write(`\r\n\x1b[32m[PeerTerm] File saved: ${savePath}\x1b[0m\r\n`);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// Send ack to client
|
|
996
|
+
this._sendFileAck(id, transfer.name);
|
|
997
|
+
} catch (err) {
|
|
998
|
+
this.log(`Failed to save file "${transfer.name}": ${err.message}`);
|
|
999
|
+
this._sendFileError(id, `Save failed: ${err.message}`);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
delete this._incomingFiles[id];
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* Send file-ack to the client confirming successful file save.
|
|
1007
|
+
*/
|
|
1008
|
+
async _sendFileAck(id, name) {
|
|
1009
|
+
if (!this.sharedKey) return;
|
|
1010
|
+
try {
|
|
1011
|
+
const ackJson = JSON.stringify({ type: 'file-ack', id, name });
|
|
1012
|
+
const payload = await encrypt(this.sharedKey, ackJson);
|
|
1013
|
+
this._sendEncryptedToClient(payload);
|
|
1014
|
+
} catch (err) {
|
|
1015
|
+
this.log(`Failed to send file-ack: ${err.message}`);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* Send file-error to the client with an error message.
|
|
1021
|
+
*/
|
|
1022
|
+
async _sendFileError(id, msg) {
|
|
1023
|
+
if (!this.sharedKey) return;
|
|
1024
|
+
try {
|
|
1025
|
+
const errJson = JSON.stringify({ type: 'file-error', id, msg });
|
|
1026
|
+
const payload = await encrypt(this.sharedKey, errJson);
|
|
1027
|
+
this._sendEncryptedToClient(payload);
|
|
1028
|
+
} catch (err) {
|
|
1029
|
+
this.log(`Failed to send file-error: ${err.message}`);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
_startViewerServer() {
|
|
1034
|
+
// Generate a random nonce for viewer authentication
|
|
1035
|
+
this.viewerToken = crypto.randomBytes(16).toString('hex');
|
|
1036
|
+
|
|
1037
|
+
this.viewerServer = net.createServer((socket) => {
|
|
1038
|
+
// Close any existing viewer connection before accepting a new one
|
|
1039
|
+
if (this.viewerSocket) {
|
|
1040
|
+
this.logDebug('Replacing existing viewer connection.');
|
|
1041
|
+
this.viewerSocket.removeAllListeners();
|
|
1042
|
+
try { this.viewerSocket.destroy(); } catch {}
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// ── Token authentication gate ──────────────────────────────────
|
|
1046
|
+
// The first data packet must be the viewer token followed by a newline.
|
|
1047
|
+
// Reject the connection if no valid token arrives within 2 seconds.
|
|
1048
|
+
let authenticated = false;
|
|
1049
|
+
let authBuf = '';
|
|
1050
|
+
|
|
1051
|
+
const authTimeout = setTimeout(() => {
|
|
1052
|
+
if (!authenticated) {
|
|
1053
|
+
this.logDebug('Viewer auth timeout — closing connection.');
|
|
1054
|
+
try { socket.destroy(); } catch {}
|
|
1055
|
+
}
|
|
1056
|
+
}, 2000);
|
|
1057
|
+
|
|
1058
|
+
const onAuthData = (data) => {
|
|
1059
|
+
authBuf += data.toString();
|
|
1060
|
+
const newlineIdx = authBuf.indexOf('\n');
|
|
1061
|
+
if (newlineIdx === -1) {
|
|
1062
|
+
// Accumulated too much data without a newline — reject
|
|
1063
|
+
if (authBuf.length > 256) {
|
|
1064
|
+
clearTimeout(authTimeout);
|
|
1065
|
+
this.logDebug('Viewer auth buffer overflow — closing connection.');
|
|
1066
|
+
try { socket.destroy(); } catch {}
|
|
1067
|
+
}
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
const token = authBuf.slice(0, newlineIdx).trim();
|
|
1072
|
+
const remaining = authBuf.slice(newlineIdx + 1);
|
|
1073
|
+
clearTimeout(authTimeout);
|
|
1074
|
+
socket.removeListener('data', onAuthData);
|
|
1075
|
+
|
|
1076
|
+
if (token !== this.viewerToken) {
|
|
1077
|
+
this.logDebug('Viewer auth failed — wrong token.');
|
|
1078
|
+
try { socket.destroy(); } catch {}
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// Authenticated — promote to active viewer
|
|
1083
|
+
authenticated = true;
|
|
1084
|
+
this.viewerSocket = socket;
|
|
1085
|
+
this.log('Host viewer connected (authenticated).');
|
|
1086
|
+
|
|
1087
|
+
// Install the real data handler
|
|
1088
|
+
let recvBuf = '';
|
|
1089
|
+
|
|
1090
|
+
// Process any leftover data that arrived after the token line
|
|
1091
|
+
if (remaining.length > 0) {
|
|
1092
|
+
handleViewerData(remaining);
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
socket.on('data', (chunk) => handleViewerData(chunk.toString()));
|
|
1096
|
+
|
|
1097
|
+
const handleViewerData = (chunk) => {
|
|
1098
|
+
// Control messages start with \x00{
|
|
1099
|
+
if (recvBuf.length > 0 || chunk.startsWith('\x00{')) {
|
|
1100
|
+
recvBuf += chunk;
|
|
1101
|
+
let nlIdx;
|
|
1102
|
+
while ((nlIdx = recvBuf.indexOf('\n')) !== -1) {
|
|
1103
|
+
const line = recvBuf.slice(0, nlIdx).trim();
|
|
1104
|
+
recvBuf = recvBuf.slice(nlIdx + 1);
|
|
1105
|
+
|
|
1106
|
+
if (line.startsWith('\x00')) {
|
|
1107
|
+
try {
|
|
1108
|
+
const msg = JSON.parse(line.slice(1));
|
|
1109
|
+
if (msg.type === 'resize' &&
|
|
1110
|
+
Number.isInteger(msg.cols) && msg.cols > 0 &&
|
|
1111
|
+
Number.isInteger(msg.rows) && msg.rows > 0 &&
|
|
1112
|
+
this.ptyProcess) {
|
|
1113
|
+
this.ptyProcess.resize(msg.cols, msg.rows);
|
|
1114
|
+
this._sendResize(msg.cols, msg.rows);
|
|
1115
|
+
this.logDebug(`Viewer resized to ${msg.cols}x${msg.rows}`);
|
|
1116
|
+
}
|
|
1117
|
+
} catch {}
|
|
1118
|
+
} else if (this.ptyProcess) {
|
|
1119
|
+
this.ptyProcess.write(line + '\n');
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
// Flush any non-control data left in the buffer
|
|
1123
|
+
if (recvBuf.length > 0 && !recvBuf.startsWith('\x00')) {
|
|
1124
|
+
if (this.ptyProcess) this.ptyProcess.write(recvBuf);
|
|
1125
|
+
recvBuf = '';
|
|
1126
|
+
}
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// Host keystrokes → PTY (host always has access)
|
|
1131
|
+
if (this.ptyProcess) {
|
|
1132
|
+
this.ptyProcess.write(chunk);
|
|
1133
|
+
}
|
|
1134
|
+
};
|
|
1135
|
+
|
|
1136
|
+
socket.on('close', () => {
|
|
1137
|
+
this.viewerSocket = null;
|
|
1138
|
+
this.logDebug('Host viewer disconnected.');
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
socket.on('error', () => {
|
|
1142
|
+
this.viewerSocket = null;
|
|
1143
|
+
});
|
|
1144
|
+
};
|
|
1145
|
+
|
|
1146
|
+
socket.on('data', onAuthData);
|
|
1147
|
+
|
|
1148
|
+
socket.on('error', () => {
|
|
1149
|
+
clearTimeout(authTimeout);
|
|
1150
|
+
});
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1153
|
+
this.viewerServer.listen(0, '127.0.0.1', () => {
|
|
1154
|
+
const port = this.viewerServer.address().port;
|
|
1155
|
+
this.logDebug(`Viewer server on port ${port}`);
|
|
1156
|
+
this._openViewerTerminal(port, this.viewerToken);
|
|
1157
|
+
});
|
|
1158
|
+
|
|
1159
|
+
this.viewerServer.on('error', (err) => {
|
|
1160
|
+
this.log(`Viewer server error: ${err.message}`);
|
|
1161
|
+
this.viewerServer = null;
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
_openViewerTerminal(port, token) {
|
|
1166
|
+
if (!/^[A-Za-z0-9_-]+$/.test(this.code)) {
|
|
1167
|
+
this.log('Invalid session code format. Aborting viewer terminal.');
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
const viewerScript = path.join(__dirname, 'session-viewer.js');
|
|
1172
|
+
const platform = os.platform();
|
|
1173
|
+
const portStr = String(port);
|
|
1174
|
+
const nodeArgs = [viewerScript, portStr, this.code, token];
|
|
1175
|
+
|
|
1176
|
+
if (platform === 'win32') {
|
|
1177
|
+
// spawn with 'cmd' to open a new window; arguments are passed as an
|
|
1178
|
+
// array so the install path is never interpreted by the shell.
|
|
1179
|
+
spawn('cmd', ['/c', 'start', `PeerTerm - ${this.code}`, 'cmd', '/c', 'node', ...nodeArgs], {
|
|
1180
|
+
stdio: 'ignore',
|
|
1181
|
+
detached: true,
|
|
1182
|
+
windowsHide: false,
|
|
1183
|
+
}).unref();
|
|
1184
|
+
} else if (platform === 'darwin') {
|
|
1185
|
+
// osascript receives the AppleScript source as a single -e argument;
|
|
1186
|
+
// node args are baked into the script string, but execFile does NOT
|
|
1187
|
+
// invoke a shell so the outer path cannot break out.
|
|
1188
|
+
const script = `tell app "Terminal" to do script "node '${viewerScript.replace(/'/g, "'\\''")}' ${portStr} ${this.code} ${token}"`;
|
|
1189
|
+
execFile('osascript', ['-e', script], { stdio: 'ignore' }, () => {});
|
|
1190
|
+
} else {
|
|
1191
|
+
// Linux: try common terminal emulators in order, falling through on
|
|
1192
|
+
// failure. Each call uses execFile (no shell), so paths with
|
|
1193
|
+
// metacharacters are safe.
|
|
1194
|
+
const tryTerminals = [
|
|
1195
|
+
['x-terminal-emulator', ['-e', 'node', ...nodeArgs]],
|
|
1196
|
+
['gnome-terminal', ['--', 'node', ...nodeArgs]],
|
|
1197
|
+
['xterm', ['-e', 'node', ...nodeArgs]],
|
|
1198
|
+
];
|
|
1199
|
+
|
|
1200
|
+
const tryNext = (index) => {
|
|
1201
|
+
if (index >= tryTerminals.length) {
|
|
1202
|
+
this.log('Could not open any terminal emulator for viewer.');
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1205
|
+
const [cmd, args] = tryTerminals[index];
|
|
1206
|
+
execFile(cmd, args, { stdio: 'ignore' }, (err) => {
|
|
1207
|
+
if (err) tryNext(index + 1);
|
|
1208
|
+
});
|
|
1209
|
+
};
|
|
1210
|
+
tryNext(0);
|
|
1211
|
+
}
|
|
1212
|
+
this.log('Opening terminal viewer...');
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
// ─── Host Reconnect Logic ───────────────────────────────────────────
|
|
1216
|
+
_startReconnecting() {
|
|
1217
|
+
if (this.destroyed || this.intentionalClose) return;
|
|
1218
|
+
|
|
1219
|
+
let attempts = 0;
|
|
1220
|
+
const maxAttempts = 24; // 24 × 5s = 2 minutes
|
|
1221
|
+
|
|
1222
|
+
this.reconnectTimer = setInterval(() => {
|
|
1223
|
+
attempts++;
|
|
1224
|
+
if (attempts > maxAttempts) {
|
|
1225
|
+
this.log('Could not reconnect. Session expired.');
|
|
1226
|
+
this._stopReconnecting();
|
|
1227
|
+
this.destroy();
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
this.log(`Reconnect attempt ${attempts}/${maxAttempts}...`);
|
|
1232
|
+
|
|
1233
|
+
// Close any in-flight reconnect socket from the previous tick
|
|
1234
|
+
if (this._pendingReconnectWs) {
|
|
1235
|
+
try { this._pendingReconnectWs.removeAllListeners(); this._pendingReconnectWs.close(); } catch {}
|
|
1236
|
+
this._pendingReconnectWs = null;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
try {
|
|
1240
|
+
const newWs = new WebSocket(this.relayUrl);
|
|
1241
|
+
this._pendingReconnectWs = newWs;
|
|
1242
|
+
|
|
1243
|
+
newWs.on('open', () => {
|
|
1244
|
+
newWs.send(JSON.stringify({ type: 'host-rejoin', code: this.code, hostToken: this.hostToken }));
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
newWs.on('message', async (raw) => {
|
|
1248
|
+
try {
|
|
1249
|
+
let msg;
|
|
1250
|
+
try {
|
|
1251
|
+
msg = JSON.parse(raw.toString());
|
|
1252
|
+
} catch {
|
|
1253
|
+
return;
|
|
1254
|
+
}
|
|
1255
|
+
if (!msg || typeof msg.type !== 'string') {
|
|
1256
|
+
this._rejectProtocolMessage('Malformed relay message');
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
if (msg.type === 'rejoined') {
|
|
1261
|
+
// Success — replace the old ws with this new one
|
|
1262
|
+
this._pendingReconnectWs = null;
|
|
1263
|
+
this.ws = newWs;
|
|
1264
|
+
this._stopReconnecting();
|
|
1265
|
+
this.log(`Reconnected. Session restored.`);
|
|
1266
|
+
|
|
1267
|
+
// Re-attach the full message handler by wiring up events
|
|
1268
|
+
this._attachWsHandlers(newWs);
|
|
1269
|
+
|
|
1270
|
+
await this._restartPeerSessionAfterHostRejoin();
|
|
1271
|
+
} else if (msg.type === 'error') {
|
|
1272
|
+
this.log(`Rejoin failed: ${msg.msg}`);
|
|
1273
|
+
this._pendingReconnectWs = null;
|
|
1274
|
+
this._stopReconnecting();
|
|
1275
|
+
this.destroy();
|
|
1276
|
+
try { newWs.close(); } catch {}
|
|
1277
|
+
}
|
|
1278
|
+
} catch (err) {
|
|
1279
|
+
this._handleMessageError(err);
|
|
1280
|
+
}
|
|
1281
|
+
});
|
|
1282
|
+
|
|
1283
|
+
newWs.on('error', () => {
|
|
1284
|
+
// Connection failed, next retry in 5s
|
|
1285
|
+
if (this._pendingReconnectWs === newWs) this._pendingReconnectWs = null;
|
|
1286
|
+
try { newWs.close(); } catch {}
|
|
1287
|
+
});
|
|
1288
|
+
|
|
1289
|
+
newWs.on('close', () => {
|
|
1290
|
+
// Clean up reference if this was the pending socket
|
|
1291
|
+
if (this._pendingReconnectWs === newWs) this._pendingReconnectWs = null;
|
|
1292
|
+
});
|
|
1293
|
+
} catch (e) {
|
|
1294
|
+
// Connection failed, next retry in 5s
|
|
1295
|
+
}
|
|
1296
|
+
}, 5000);
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
_stopReconnecting() {
|
|
1300
|
+
if (this.reconnectTimer) {
|
|
1301
|
+
clearInterval(this.reconnectTimer);
|
|
1302
|
+
this.reconnectTimer = null;
|
|
1303
|
+
}
|
|
1304
|
+
if (this._pendingReconnectWs) {
|
|
1305
|
+
try { this._pendingReconnectWs.removeAllListeners(); this._pendingReconnectWs.close(); } catch {}
|
|
1306
|
+
this._pendingReconnectWs = null;
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
/**
|
|
1311
|
+
* Re-attach message/close/error handlers to a new WebSocket after rejoin.
|
|
1312
|
+
* This mirrors the handlers set in _tryConnect but skips the initial registration flow.
|
|
1313
|
+
*/
|
|
1314
|
+
_attachWsHandlers(newWs) {
|
|
1315
|
+
newWs.on('message', async (raw) => {
|
|
1316
|
+
try {
|
|
1317
|
+
let msg;
|
|
1318
|
+
try {
|
|
1319
|
+
msg = JSON.parse(raw.toString());
|
|
1320
|
+
} catch {
|
|
1321
|
+
this.log('Invalid message from relay');
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1324
|
+
if (!msg || typeof msg.type !== 'string') {
|
|
1325
|
+
this._rejectProtocolMessage('Malformed relay message');
|
|
1326
|
+
return;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
switch (msg.type) {
|
|
1330
|
+
case 'client-connected': {
|
|
1331
|
+
if (this.awaitingRejoin) {
|
|
1332
|
+
this.log('Client reconnected.');
|
|
1333
|
+
this.awaitingRejoin = false;
|
|
1334
|
+
} else {
|
|
1335
|
+
this.log('Client connected! Starting key exchange...');
|
|
1336
|
+
}
|
|
1337
|
+
this.isClientConnected = true;
|
|
1338
|
+
this.missedPings = 0;
|
|
1339
|
+
|
|
1340
|
+
if (this.useDataChannel && this.webrtc && this.webrtc.isActive()) {
|
|
1341
|
+
// Keep existing keys if direct session is healthy
|
|
1342
|
+
this.startHeartbeat();
|
|
1343
|
+
break;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
await this._beginKeyExchange();
|
|
1347
|
+
break;
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
case 'key-exchange': {
|
|
1351
|
+
await this._completeKeyExchange(msg.publicKey);
|
|
1352
|
+
break;
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
case 'signal': {
|
|
1356
|
+
if (this.webrtc && this.sharedKey) {
|
|
1357
|
+
try {
|
|
1358
|
+
const plaintext = await decrypt(this.sharedKey, msg.payload);
|
|
1359
|
+
this.webrtc.handleSignal(JSON.parse(plaintext));
|
|
1360
|
+
} catch (err) {
|
|
1361
|
+
this.logDebug(`[WebRTC] Signal decryption failed: ${err.message}`);
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
break;
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
case 'heartbeat': {
|
|
1368
|
+
this.missedPings = 0;
|
|
1369
|
+
break;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
case 'data': {
|
|
1373
|
+
if (!this.sharedKey || !this.ptyProcess) return;
|
|
1374
|
+
try {
|
|
1375
|
+
const plaintext = await decrypt(this.sharedKey, msg.payload);
|
|
1376
|
+
|
|
1377
|
+
if (msg.meta === 'resize') {
|
|
1378
|
+
try {
|
|
1379
|
+
const resizeData = JSON.parse(plaintext);
|
|
1380
|
+
if (resizeData.type === 'resize' && resizeData.cols && resizeData.rows) {
|
|
1381
|
+
this.ptyProcess.resize(resizeData.cols, resizeData.rows);
|
|
1382
|
+
this.logDebug(`Terminal resized to ${resizeData.cols}x${resizeData.rows}`);
|
|
1383
|
+
}
|
|
1384
|
+
} catch {}
|
|
1385
|
+
return;
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
// Check if decrypted message is a file transfer message
|
|
1389
|
+
try {
|
|
1390
|
+
const parsedMsg = JSON.parse(plaintext);
|
|
1391
|
+
if (parsedMsg && typeof parsedMsg.type === 'string' && parsedMsg.type.startsWith('file-')) {
|
|
1392
|
+
if (this.readOnly) {
|
|
1393
|
+
this._sendFileError(parsedMsg.id, 'File uploads are disabled in read-only mode');
|
|
1394
|
+
return;
|
|
1395
|
+
}
|
|
1396
|
+
this._handleFileMessage(parsedMsg);
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
1399
|
+
} catch {
|
|
1400
|
+
// Not JSON — normal keystroke data, fall through
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
if (this.readOnly) return;
|
|
1404
|
+
this.ptyProcess.write(plaintext);
|
|
1405
|
+
} catch (err) {
|
|
1406
|
+
this.log(`Decryption failed: ${err.message}`);
|
|
1407
|
+
}
|
|
1408
|
+
break;
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
case 'peer-disconnected': {
|
|
1412
|
+
if (this.useDataChannel && this.webrtc && this.webrtc.isActive()) {
|
|
1413
|
+
this.log('Client relay connection lost; direct DataChannel still active.');
|
|
1414
|
+
this.missedPings = 0;
|
|
1415
|
+
this.startHeartbeat();
|
|
1416
|
+
break;
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
this.log(`Client disconnected. Rejoin window: ${formatDuration(this.rejoinMs)}.`);
|
|
1420
|
+
this.isClientConnected = false;
|
|
1421
|
+
this.awaitingRejoin = true;
|
|
1422
|
+
this.sharedKey = null;
|
|
1423
|
+
this.keyPair = null;
|
|
1424
|
+
this.hostPublicKeyBase64 = null;
|
|
1425
|
+
this.clientPublicKeyBase64 = null;
|
|
1426
|
+
this.securityFingerprint = null;
|
|
1427
|
+
this.fingerprintAuthorized = false;
|
|
1428
|
+
this.stopHeartbeat();
|
|
1429
|
+
this._cleanupWebRTC();
|
|
1430
|
+
break;
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
case 'session-expired': {
|
|
1434
|
+
this.log('Rejoin window expired. Session ended.');
|
|
1435
|
+
this.destroy();
|
|
1436
|
+
break;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
case 'rejoined': {
|
|
1440
|
+
this.log(`Reconnected. Session restored. (code: ${msg.code})`);
|
|
1441
|
+
await this._restartPeerSessionAfterHostRejoin();
|
|
1442
|
+
break;
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
case 'error': {
|
|
1446
|
+
this.log(`Error: ${msg.msg}`);
|
|
1447
|
+
break;
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
} catch (err) {
|
|
1451
|
+
this._handleMessageError(err);
|
|
1452
|
+
}
|
|
1453
|
+
});
|
|
1454
|
+
|
|
1455
|
+
newWs.on('close', () => {
|
|
1456
|
+
if (this.destroyed) return;
|
|
1457
|
+
if (this.intentionalClose) {
|
|
1458
|
+
this.log('Connection to relay closed (intentional).');
|
|
1459
|
+
this.destroy();
|
|
1460
|
+
} else {
|
|
1461
|
+
this.log('Connection to relay lost. Starting reconnect...');
|
|
1462
|
+
this.stopHeartbeat();
|
|
1463
|
+
this._startReconnecting();
|
|
1464
|
+
}
|
|
1465
|
+
});
|
|
1466
|
+
|
|
1467
|
+
newWs.on('error', (err) => {
|
|
1468
|
+
this.log(`WS error: ${err.message}`);
|
|
1469
|
+
});
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
// ─── Destroy ────────────────────────────────────────────────────────
|
|
1473
|
+
destroy() {
|
|
1474
|
+
if (this.destroyed) return;
|
|
1475
|
+
this.destroyed = true;
|
|
1476
|
+
this.intentionalClose = true;
|
|
1477
|
+
this.stopHeartbeat();
|
|
1478
|
+
this._stopReconnecting();
|
|
1479
|
+
this._stopDurationLogging();
|
|
1480
|
+
// Clean up in-progress file uploads
|
|
1481
|
+
this._incomingFiles = {};
|
|
1482
|
+
// Clean up viewer
|
|
1483
|
+
if (this.viewerSocket) {
|
|
1484
|
+
try { this.viewerSocket.destroy(); } catch {}
|
|
1485
|
+
this.viewerSocket = null;
|
|
1486
|
+
}
|
|
1487
|
+
if (this.viewerServer) {
|
|
1488
|
+
try { this.viewerServer.close(); } catch {}
|
|
1489
|
+
this.viewerServer = null;
|
|
1490
|
+
}
|
|
1491
|
+
// Phase 4: Clean up WebRTC
|
|
1492
|
+
this._cleanupWebRTC();
|
|
1493
|
+
if (this.ptyProcess) {
|
|
1494
|
+
try { this.ptyProcess.kill(); } catch {}
|
|
1495
|
+
this.ptyProcess = null;
|
|
1496
|
+
}
|
|
1497
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
1498
|
+
// Send session-ended so relay knows this is intentional and destroys session immediately
|
|
1499
|
+
try { this.ws.send(JSON.stringify({ type: 'session-ended' })); } catch {}
|
|
1500
|
+
this.ws.close();
|
|
1501
|
+
}
|
|
1502
|
+
// Log session duration on end
|
|
1503
|
+
if (this.sessionStartedAt) {
|
|
1504
|
+
const duration = formatDuration(Date.now() - this.sessionStartedAt);
|
|
1505
|
+
this.log(`Session ended. Duration: ${duration}`);
|
|
1506
|
+
} else {
|
|
1507
|
+
this.log('Session ended.');
|
|
1508
|
+
}
|
|
1509
|
+
if (this.onDestroy) this.onDestroy(this.code);
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
// ─── Phase 4: WebRTC helpers ─────────────────────────────────────
|
|
1513
|
+
|
|
1514
|
+
_initiateWebRTC() {
|
|
1515
|
+
// Clean up any previous WebRTC instance
|
|
1516
|
+
this._cleanupWebRTC();
|
|
1517
|
+
|
|
1518
|
+
// Pass debug-level logger to WebRTC (internal messages are verbose)
|
|
1519
|
+
this.webrtc = new HostWebRTC((msg) => this.logDebug(msg));
|
|
1520
|
+
|
|
1521
|
+
this.webrtc.onOpen(() => {
|
|
1522
|
+
this.useDataChannel = true;
|
|
1523
|
+
this.log('DataChannel open — relay bypassed');
|
|
1524
|
+
});
|
|
1525
|
+
|
|
1526
|
+
this.webrtc.onClose(() => {
|
|
1527
|
+
if (this.useDataChannel) {
|
|
1528
|
+
this.log('DataChannel closed — falling back to relay');
|
|
1529
|
+
this.useDataChannel = false;
|
|
1530
|
+
}
|
|
1531
|
+
});
|
|
1532
|
+
|
|
1533
|
+
this.webrtc.onMessage(async (data) => {
|
|
1534
|
+
if (!this.sharedKey) return;
|
|
1535
|
+
try {
|
|
1536
|
+
// Check if this is a tagged message (resize)
|
|
1537
|
+
let parsed;
|
|
1538
|
+
try {
|
|
1539
|
+
parsed = JSON.parse(data);
|
|
1540
|
+
} catch {
|
|
1541
|
+
parsed = null;
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
if (parsed && parsed._meta === 'resize') {
|
|
1545
|
+
const resizeData = JSON.parse(await decrypt(this.sharedKey, parsed.payload));
|
|
1546
|
+
if (resizeData.type === 'resize' && resizeData.cols && resizeData.rows) {
|
|
1547
|
+
this.ptyProcess.resize(resizeData.cols, resizeData.rows);
|
|
1548
|
+
this.logDebug(`Terminal resized to ${resizeData.cols}x${resizeData.rows}`);
|
|
1549
|
+
}
|
|
1550
|
+
return;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
// Normal encrypted data — decrypt first
|
|
1554
|
+
const plaintext = await decrypt(this.sharedKey, data);
|
|
1555
|
+
|
|
1556
|
+
// Check if decrypted message is a file transfer message (file-start, file-chunk, file-end)
|
|
1557
|
+
try {
|
|
1558
|
+
const msg = JSON.parse(plaintext);
|
|
1559
|
+
if (msg && typeof msg.type === 'string' && msg.type.startsWith('file-')) {
|
|
1560
|
+
if (this.readOnly) {
|
|
1561
|
+
this._sendFileError(msg.id, 'File uploads are disabled in read-only mode');
|
|
1562
|
+
return;
|
|
1563
|
+
}
|
|
1564
|
+
this._handleFileMessage(msg);
|
|
1565
|
+
return;
|
|
1566
|
+
}
|
|
1567
|
+
} catch {
|
|
1568
|
+
// Not JSON — normal keystroke data, fall through
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
// Normal keystroke — drop if read-only or no PTY
|
|
1572
|
+
if (!this.ptyProcess) return;
|
|
1573
|
+
if (this.readOnly) return;
|
|
1574
|
+
this.ptyProcess.write(plaintext);
|
|
1575
|
+
} catch (err) {
|
|
1576
|
+
this.logDebug(`[WebRTC] Decryption failed: ${err.message}`);
|
|
1577
|
+
}
|
|
1578
|
+
});
|
|
1579
|
+
|
|
1580
|
+
// Start the WebRTC negotiation
|
|
1581
|
+
this.webrtc.initiate(async (msg) => {
|
|
1582
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN && this.sharedKey) {
|
|
1583
|
+
try {
|
|
1584
|
+
const payloadStr = JSON.stringify(msg.payload);
|
|
1585
|
+
const encryptedPayload = await encrypt(this.sharedKey, payloadStr);
|
|
1586
|
+
this.ws.send(JSON.stringify({ type: 'signal', payload: encryptedPayload }));
|
|
1587
|
+
} catch (e) {
|
|
1588
|
+
this.logDebug(`[WebRTC] Failed to encrypt signal: ${e.message}`);
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
});
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
async _restartPeerSessionAfterHostRejoin() {
|
|
1595
|
+
this.missedPings = 0;
|
|
1596
|
+
|
|
1597
|
+
if (this.useDataChannel && this.webrtc && this.webrtc.isActive()) {
|
|
1598
|
+
this.startHeartbeat();
|
|
1599
|
+
return;
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
this.sharedKey = null;
|
|
1603
|
+
this.hostPublicKeyBase64 = null;
|
|
1604
|
+
this.clientPublicKeyBase64 = null;
|
|
1605
|
+
this.securityFingerprint = null;
|
|
1606
|
+
this.fingerprintAuthorized = false;
|
|
1607
|
+
this._cleanupWebRTC();
|
|
1608
|
+
|
|
1609
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
1610
|
+
await this._beginKeyExchange();
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
_cleanupWebRTC() {
|
|
1615
|
+
this.useDataChannel = false;
|
|
1616
|
+
if (this.webrtc) {
|
|
1617
|
+
this.webrtc.close();
|
|
1618
|
+
this.webrtc = null;
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
// ─── Status string for list command ─────────────────────────────────
|
|
1623
|
+
getStatus() {
|
|
1624
|
+
const elapsed = Date.now() - this.createdAt;
|
|
1625
|
+
const remaining = Math.max(0, this.expiryMs - elapsed);
|
|
1626
|
+
let status = '';
|
|
1627
|
+
if (this.securityFingerprint && !this.fingerprintAuthorized) {
|
|
1628
|
+
status = `verify fingerprint ${this.securityFingerprint}`;
|
|
1629
|
+
} else if (this.isClientConnected) {
|
|
1630
|
+
const duration = this.sessionStartedAt
|
|
1631
|
+
? ` (${formatDuration(Date.now() - this.sessionStartedAt)})`
|
|
1632
|
+
: '';
|
|
1633
|
+
status = `client connected${duration}`;
|
|
1634
|
+
} else if (this.awaitingRejoin) {
|
|
1635
|
+
status = 'awaiting rejoin';
|
|
1636
|
+
} else {
|
|
1637
|
+
status = `waiting for client (expires in ${formatDuration(remaining)})`;
|
|
1638
|
+
}
|
|
1639
|
+
if (this.readOnly) status += ' (readonly)';
|
|
1640
|
+
return status;
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
// ─── Session Duration Logging ────────────────────────────────────────
|
|
1644
|
+
|
|
1645
|
+
_formatElapsedCompact(ms) {
|
|
1646
|
+
const totalSeconds = Math.floor(ms / 1000);
|
|
1647
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
1648
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
1649
|
+
const seconds = totalSeconds % 60;
|
|
1650
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
1651
|
+
if (hours > 0) return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
|
|
1652
|
+
return `${pad(minutes)}:${pad(seconds)}`;
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
_startDurationLogging() {
|
|
1656
|
+
this._stopDurationLogging();
|
|
1657
|
+
// Log session duration every 5 minutes
|
|
1658
|
+
this.sessionDurationInterval = setInterval(() => {
|
|
1659
|
+
if (!this.sessionStartedAt || this.destroyed) return;
|
|
1660
|
+
const elapsed = Date.now() - this.sessionStartedAt;
|
|
1661
|
+
this.log(`Session active — ${this._formatElapsedCompact(elapsed)}`);
|
|
1662
|
+
}, 5 * 60 * 1000);
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
_stopDurationLogging() {
|
|
1666
|
+
if (this.sessionDurationInterval) {
|
|
1667
|
+
clearInterval(this.sessionDurationInterval);
|
|
1668
|
+
this.sessionDurationInterval = null;
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
// ─── Session Manager ─────────────────────────────────────────────────────────
|
|
1674
|
+
|
|
1675
|
+
class SessionManager {
|
|
1676
|
+
constructor(shell, expiryMs, rejoinMs, readOnly, startPath) {
|
|
1677
|
+
this.shell = shell;
|
|
1678
|
+
this.expiryMs = expiryMs;
|
|
1679
|
+
this.rejoinMs = rejoinMs;
|
|
1680
|
+
this.readOnly = readOnly;
|
|
1681
|
+
this.startPath = startPath;
|
|
1682
|
+
this.sessions = new Map(); // code → Session
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
async createSession() {
|
|
1686
|
+
const session = new Session(this.shell, this.expiryMs, this.rejoinMs, this.readOnly, this.startPath, (code) => {
|
|
1687
|
+
this.sessions.delete(code);
|
|
1688
|
+
});
|
|
1689
|
+
|
|
1690
|
+
try {
|
|
1691
|
+
const code = await session.start();
|
|
1692
|
+
this.sessions.set(code, session);
|
|
1693
|
+
return code;
|
|
1694
|
+
} catch (err) {
|
|
1695
|
+
logger.error(`Failed to create session: ${err.message}`, err);
|
|
1696
|
+
return null;
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
listSessions() {
|
|
1701
|
+
if (this.sessions.size === 0) {
|
|
1702
|
+
logger.info('No active sessions.');
|
|
1703
|
+
return;
|
|
1704
|
+
}
|
|
1705
|
+
console.log('');
|
|
1706
|
+
console.log(' Active sessions:');
|
|
1707
|
+
for (const [code, session] of this.sessions) {
|
|
1708
|
+
console.log(` ${code} — ${session.getStatus()}`);
|
|
1709
|
+
}
|
|
1710
|
+
console.log('');
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
killSession(code) {
|
|
1714
|
+
const session = this.sessions.get(code);
|
|
1715
|
+
if (!session) {
|
|
1716
|
+
logger.warn(`Session ${code} not found.`);
|
|
1717
|
+
return;
|
|
1718
|
+
}
|
|
1719
|
+
session.destroy();
|
|
1720
|
+
logger.info(`Session ${code} killed.`);
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
async authorizeSession(code) {
|
|
1724
|
+
const session = this.sessions.get(code);
|
|
1725
|
+
if (!session) {
|
|
1726
|
+
logger.warn(`Session ${code} not found.`);
|
|
1727
|
+
return;
|
|
1728
|
+
}
|
|
1729
|
+
await session.authorizeFingerprint();
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
killAll() {
|
|
1733
|
+
for (const [, session] of this.sessions) {
|
|
1734
|
+
session.destroy();
|
|
1735
|
+
}
|
|
1736
|
+
this.sessions.clear();
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
// ─── Main ────────────────────────────────────────────────────────────────────
|
|
1741
|
+
|
|
1742
|
+
async function main() {
|
|
1743
|
+
const shell = detectShell();
|
|
1744
|
+
const readOnly = argv.readonly;
|
|
1745
|
+
const startPath = resolveStartPath(argv.path);
|
|
1746
|
+
|
|
1747
|
+
// Print startup banner
|
|
1748
|
+
printBanner();
|
|
1749
|
+
|
|
1750
|
+
// Interactive prompts — always ask unless flags are passed or non-interactive
|
|
1751
|
+
const expiryMs = await getExpiryInteractive();
|
|
1752
|
+
const rejoinMs = await getRejoinInteractive();
|
|
1753
|
+
console.log('');
|
|
1754
|
+
|
|
1755
|
+
logger.info(`Shell: ${shell}`);
|
|
1756
|
+
logger.info(`Relays: ${RELAY_URLS.join(', ')}`);
|
|
1757
|
+
logger.info(`Path: ${startPath}`);
|
|
1758
|
+
logger.info(`Expiry: ${formatDuration(expiryMs)}`);
|
|
1759
|
+
logger.info(`Rejoin Window: ${formatDuration(rejoinMs)}`);
|
|
1760
|
+
if (readOnly) logger.info('Mode: READ-ONLY');
|
|
1761
|
+
if (argv.verbose) logger.info('Verbose logging enabled');
|
|
1762
|
+
console.log('');
|
|
1763
|
+
|
|
1764
|
+
const manager = new SessionManager(shell, expiryMs, rejoinMs, readOnly, startPath);
|
|
1765
|
+
|
|
1766
|
+
// Create first session automatically
|
|
1767
|
+
const firstCode = await manager.createSession();
|
|
1768
|
+
if (!firstCode) {
|
|
1769
|
+
logger.error('Failed to start. Is the relay server running?');
|
|
1770
|
+
process.exit(1);
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
// ─── Interactive CLI menu ──────────────────────────────────────────
|
|
1774
|
+
console.log(' ─────────────────────────────────────────');
|
|
1775
|
+
console.log(' Commands:');
|
|
1776
|
+
console.log(' [n] New session');
|
|
1777
|
+
console.log(' [l] List sessions');
|
|
1778
|
+
console.log(' [a <code>] Authorize verified fingerprint');
|
|
1779
|
+
console.log(' [k <code>] Kill session');
|
|
1780
|
+
console.log(' [q] Quit all');
|
|
1781
|
+
console.log(' ─────────────────────────────────────────');
|
|
1782
|
+
console.log('');
|
|
1783
|
+
|
|
1784
|
+
const rl = readline.createInterface({
|
|
1785
|
+
input: process.stdin,
|
|
1786
|
+
output: process.stdout,
|
|
1787
|
+
prompt: ' > ',
|
|
1788
|
+
});
|
|
1789
|
+
|
|
1790
|
+
rl.prompt();
|
|
1791
|
+
|
|
1792
|
+
rl.on('line', async (line) => {
|
|
1793
|
+
const input = line.trim();
|
|
1794
|
+
|
|
1795
|
+
if (input === 'n') {
|
|
1796
|
+
logger.info('Creating new session...');
|
|
1797
|
+
await manager.createSession();
|
|
1798
|
+
} else if (input === 'l') {
|
|
1799
|
+
manager.listSessions();
|
|
1800
|
+
} else if (input.startsWith('a ')) {
|
|
1801
|
+
const code = input.slice(2).trim();
|
|
1802
|
+
await manager.authorizeSession(code);
|
|
1803
|
+
} else if (input.startsWith('k ')) {
|
|
1804
|
+
const code = input.slice(2).trim();
|
|
1805
|
+
manager.killSession(code);
|
|
1806
|
+
} else if (input === 'q') {
|
|
1807
|
+
logger.info('Shutting down all sessions...');
|
|
1808
|
+
manager.killAll();
|
|
1809
|
+
rl.close();
|
|
1810
|
+
process.exit(0);
|
|
1811
|
+
} else if (input) {
|
|
1812
|
+
console.log(' Unknown command. Use n, l, a <code>, k <code>, or q.');
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
rl.prompt();
|
|
1816
|
+
});
|
|
1817
|
+
|
|
1818
|
+
rl.on('close', () => {
|
|
1819
|
+
manager.killAll();
|
|
1820
|
+
process.exit(0);
|
|
1821
|
+
});
|
|
1822
|
+
|
|
1823
|
+
process.on('SIGINT', () => {
|
|
1824
|
+
console.log('');
|
|
1825
|
+
logger.info('Shutting down...');
|
|
1826
|
+
manager.killAll();
|
|
1827
|
+
rl.close();
|
|
1828
|
+
process.exit(0);
|
|
1829
|
+
});
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
// ─── Global Error Handling ───────────────────────────────────────────────────
|
|
1833
|
+
|
|
1834
|
+
process.on('uncaughtException', (err) => {
|
|
1835
|
+
logger.error(`Uncaught exception: ${err.message}`, err);
|
|
1836
|
+
process.exit(1);
|
|
1837
|
+
});
|
|
1838
|
+
|
|
1839
|
+
process.on('unhandledRejection', (reason) => {
|
|
1840
|
+
const err = reason instanceof Error ? reason : new Error(String(reason));
|
|
1841
|
+
logger.error(`Unhandled rejection: ${err.message}`, err);
|
|
1842
|
+
});
|
|
1843
|
+
|
|
1844
|
+
// ─── Run ─────────────────────────────────────────────────────────────────────
|
|
1845
|
+
|
|
1846
|
+
main().catch((err) => {
|
|
1847
|
+
logger.error(`Fatal error: ${err.message}`, err);
|
|
1848
|
+
process.exit(1);
|
|
1849
|
+
});
|