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