peer-term 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -0
- package/bin/peer-term.js +53 -0
- package/package.json +47 -0
- package/src/check-deps.js +83 -0
- package/src/crypto.js +147 -0
- package/src/index.js +839 -0
- package/src/logger.js +102 -0
- package/src/session-viewer.js +79 -0
- package/src/ui.js +137 -0
- package/src/webrtc.js +309 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,839 @@
|
|
|
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
|
+
* --readonly Prevent client keystrokes from reaching the PTY
|
|
14
|
+
* --path <dir> Starting directory for the terminal session
|
|
15
|
+
* --relay <url> Custom relay server URL
|
|
16
|
+
* --verbose Enable debug-level logging
|
|
17
|
+
* --help Show usage information
|
|
18
|
+
* --version Print version number
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import * as dotenv from 'dotenv';
|
|
22
|
+
import os from 'os';
|
|
23
|
+
import fs from 'fs';
|
|
24
|
+
import path from 'path';
|
|
25
|
+
import net from 'net';
|
|
26
|
+
import readline from 'readline';
|
|
27
|
+
import { exec as cpExec } from 'child_process';
|
|
28
|
+
import { fileURLToPath } from 'url';
|
|
29
|
+
import WebSocket from 'ws';
|
|
30
|
+
import pty from 'node-pty';
|
|
31
|
+
import minimist from 'minimist';
|
|
32
|
+
import {
|
|
33
|
+
generateKeyPair,
|
|
34
|
+
exportPublicKey,
|
|
35
|
+
importPublicKey,
|
|
36
|
+
deriveSharedKey,
|
|
37
|
+
encrypt,
|
|
38
|
+
decrypt,
|
|
39
|
+
} from './crypto.js';
|
|
40
|
+
import { HostWebRTC } from './webrtc.js';
|
|
41
|
+
import logger, { writeToErrorLog } from './logger.js';
|
|
42
|
+
import { printBanner, printSessionBox, printHelp, printVersion } from './ui.js';
|
|
43
|
+
|
|
44
|
+
// ─── File paths ──────────────────────────────────────────────────────────────
|
|
45
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
46
|
+
const __dirname = path.dirname(__filename);
|
|
47
|
+
|
|
48
|
+
// Load .env from host/ directory (parent of src/)
|
|
49
|
+
dotenv.config({ path: path.join(__dirname, '..', '.env') });
|
|
50
|
+
|
|
51
|
+
// ─── CLI Argument Parsing ────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
const argv = minimist(process.argv.slice(2), {
|
|
54
|
+
boolean: ['readonly', 'verbose', 'help', 'version'],
|
|
55
|
+
string: ['expiry', 'relay', 'path'],
|
|
56
|
+
alias: { h: 'help', v: 'version', V: 'verbose' },
|
|
57
|
+
default: { expiry: '5m' },
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Handle --help
|
|
61
|
+
if (argv.help) {
|
|
62
|
+
printHelp();
|
|
63
|
+
process.exit(0);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Handle --version
|
|
67
|
+
if (argv.version) {
|
|
68
|
+
printVersion();
|
|
69
|
+
process.exit(0);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Enable verbose logging
|
|
73
|
+
if (argv.verbose) {
|
|
74
|
+
logger.setVerbose(true);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ─── Configuration ───────────────────────────────────────────────────────────
|
|
78
|
+
const DEFAULT_RELAYS = [
|
|
79
|
+
'wss://peer-term-relay.onrender.com',
|
|
80
|
+
'wss://peer-term-relay-production-9b7a.up.railway.app'
|
|
81
|
+
];
|
|
82
|
+
const RELAY_URLS = argv.relay
|
|
83
|
+
? argv.relay.split(',').map(s => s.trim())
|
|
84
|
+
: (process.env.RELAY_URL ? process.env.RELAY_URL.split(',').map(s => s.trim()) : DEFAULT_RELAYS);
|
|
85
|
+
const HEARTBEAT_INTERVAL_MS = 5000;
|
|
86
|
+
const MAX_MISSED_PINGS = 2;
|
|
87
|
+
|
|
88
|
+
// ─── Path Resolution ─────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
function expandTilde(inputPath) {
|
|
91
|
+
if (inputPath.startsWith('~/') || inputPath === '~') {
|
|
92
|
+
return inputPath.replace(/^~/, process.env.HOME || process.env.USERPROFILE || '.');
|
|
93
|
+
}
|
|
94
|
+
return inputPath;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function resolveStartPath(inputPath) {
|
|
98
|
+
if (!inputPath) return process.env.HOME || process.env.USERPROFILE || process.cwd();
|
|
99
|
+
|
|
100
|
+
const resolved = path.resolve(expandTilde(inputPath));
|
|
101
|
+
|
|
102
|
+
if (!fs.existsSync(resolved)) {
|
|
103
|
+
logger.error(`Path does not exist: ${resolved}`);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const stat = fs.statSync(resolved);
|
|
108
|
+
if (!stat.isDirectory()) {
|
|
109
|
+
logger.error(`Path is not a directory: ${resolved}`);
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return resolved;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ─── Duration Parsing ────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
function parseDuration(str) {
|
|
119
|
+
const match = str.match(/^(\d+)(s|m|h)$/i);
|
|
120
|
+
if (!match) return null;
|
|
121
|
+
const value = parseInt(match[1], 10);
|
|
122
|
+
const unit = match[2].toLowerCase();
|
|
123
|
+
switch (unit) {
|
|
124
|
+
case 's': return value * 1000;
|
|
125
|
+
case 'm': return value * 60 * 1000;
|
|
126
|
+
case 'h': return value * 60 * 60 * 1000;
|
|
127
|
+
default: return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function formatDuration(ms) {
|
|
132
|
+
if (ms >= 3600000) return `${Math.round(ms / 3600000)} hour(s)`;
|
|
133
|
+
if (ms >= 60000) return `${Math.round(ms / 60000)} minute(s)`;
|
|
134
|
+
return `${Math.round(ms / 1000)} second(s)`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function getExpiry() {
|
|
138
|
+
const parsed = parseDuration(argv.expiry);
|
|
139
|
+
if (!parsed) {
|
|
140
|
+
logger.error(`Invalid expiry format: "${argv.expiry}". Use 30s, 5m, or 1h.`);
|
|
141
|
+
process.exit(1);
|
|
142
|
+
}
|
|
143
|
+
return parsed;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ─── Shell Detection ─────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
function detectShell() {
|
|
149
|
+
const platform = os.platform();
|
|
150
|
+
if (process.env.SHELL) return process.env.SHELL;
|
|
151
|
+
if (platform === 'win32') return process.env.COMSPEC || 'powershell.exe';
|
|
152
|
+
try {
|
|
153
|
+
const passwd = fs.readFileSync('/etc/passwd', 'utf-8');
|
|
154
|
+
const username = os.userInfo().username;
|
|
155
|
+
const line = passwd.split('\n').find((l) => l.startsWith(username + ':'));
|
|
156
|
+
if (line) {
|
|
157
|
+
const shell = line.split(':').pop().trim();
|
|
158
|
+
if (shell) return shell;
|
|
159
|
+
}
|
|
160
|
+
} catch {}
|
|
161
|
+
return platform === 'win32' ? 'powershell.exe' : 'bash';
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ─── Session Class ───────────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
class Session {
|
|
167
|
+
constructor(shell, expiryMs, readOnly, startPath, onDestroy) {
|
|
168
|
+
this.shell = shell;
|
|
169
|
+
this.expiryMs = expiryMs;
|
|
170
|
+
this.readOnly = readOnly;
|
|
171
|
+
this.startPath = startPath;
|
|
172
|
+
this.onDestroy = onDestroy;
|
|
173
|
+
|
|
174
|
+
this.ws = null;
|
|
175
|
+
this.code = null;
|
|
176
|
+
this.keyPair = null;
|
|
177
|
+
this.sharedKey = null;
|
|
178
|
+
this.ptyProcess = null;
|
|
179
|
+
this.heartbeatInterval = null;
|
|
180
|
+
this.missedPings = 0;
|
|
181
|
+
this.isClientConnected = false;
|
|
182
|
+
this.awaitingRejoin = false;
|
|
183
|
+
this.destroyed = false;
|
|
184
|
+
this.createdAt = Date.now();
|
|
185
|
+
|
|
186
|
+
// Phase 4: WebRTC state
|
|
187
|
+
this.webrtc = null;
|
|
188
|
+
this.useDataChannel = false;
|
|
189
|
+
|
|
190
|
+
// Local TCP viewer (new terminal window)
|
|
191
|
+
this.viewerServer = null;
|
|
192
|
+
this.viewerSocket = null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
log(msg) {
|
|
196
|
+
const prefix = this.code ? `[${this.code}]` : '[???]';
|
|
197
|
+
logger.info(`${prefix} ${msg}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
logDebug(msg) {
|
|
201
|
+
const prefix = this.code ? `[${this.code}]` : '[???]';
|
|
202
|
+
logger.debug(`${prefix} ${msg}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ─── Start the session ──────────────────────────────────────────────
|
|
206
|
+
async start() {
|
|
207
|
+
for (let i = 0; i < RELAY_URLS.length; i++) {
|
|
208
|
+
const url = RELAY_URLS[i];
|
|
209
|
+
try {
|
|
210
|
+
const code = await this._tryConnect(url);
|
|
211
|
+
return code;
|
|
212
|
+
} catch (err) {
|
|
213
|
+
if (i === RELAY_URLS.length - 1) {
|
|
214
|
+
throw new Error(`All relay servers failed. Last error: ${err.message}`);
|
|
215
|
+
}
|
|
216
|
+
logger.warn(`Failed to connect to ${url}: ${err.message}. Trying next relay...`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async _tryConnect(url) {
|
|
222
|
+
return new Promise((resolve, reject) => {
|
|
223
|
+
this.ws = new WebSocket(url);
|
|
224
|
+
|
|
225
|
+
let isConnected = false;
|
|
226
|
+
|
|
227
|
+
this.ws.on('open', () => {
|
|
228
|
+
isConnected = true;
|
|
229
|
+
this.ws.send(JSON.stringify({
|
|
230
|
+
type: 'host-register',
|
|
231
|
+
expiry: this.expiryMs,
|
|
232
|
+
readonly: this.readOnly,
|
|
233
|
+
}));
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
this.ws.on('message', async (raw) => {
|
|
237
|
+
let msg;
|
|
238
|
+
try {
|
|
239
|
+
msg = JSON.parse(raw.toString());
|
|
240
|
+
} catch {
|
|
241
|
+
this.log('Invalid message from relay');
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
switch (msg.type) {
|
|
246
|
+
case 'code': {
|
|
247
|
+
this.code = msg.code;
|
|
248
|
+
printSessionBox({
|
|
249
|
+
code: this.code,
|
|
250
|
+
expiry: formatDuration(this.expiryMs),
|
|
251
|
+
mode: this.readOnly ? 'Read-Only' : 'Read-Write',
|
|
252
|
+
shell: this.shell,
|
|
253
|
+
startPath: this.startPath,
|
|
254
|
+
shareUrl: url.replace(/^wss:\/\//i, 'https://').replace(/^ws:\/\//i, 'http://')
|
|
255
|
+
});
|
|
256
|
+
resolve(this.code);
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
case 'client-connected': {
|
|
261
|
+
if (this.awaitingRejoin) {
|
|
262
|
+
this.log('Client reconnected.');
|
|
263
|
+
this.awaitingRejoin = false;
|
|
264
|
+
} else {
|
|
265
|
+
this.log('Client connected! Starting key exchange...');
|
|
266
|
+
}
|
|
267
|
+
this.isClientConnected = true;
|
|
268
|
+
this.missedPings = 0;
|
|
269
|
+
|
|
270
|
+
this.keyPair = await generateKeyPair();
|
|
271
|
+
const pubKeyBase64 = await exportPublicKey(this.keyPair.publicKey);
|
|
272
|
+
this.ws.send(JSON.stringify({ type: 'key-exchange', publicKey: pubKeyBase64 }));
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
case 'key-exchange': {
|
|
277
|
+
if (!this.keyPair) return;
|
|
278
|
+
this.logDebug('Deriving shared secret...');
|
|
279
|
+
|
|
280
|
+
const peerPublicKey = await importPublicKey(msg.publicKey);
|
|
281
|
+
this.sharedKey = await deriveSharedKey(this.keyPair.privateKey, peerPublicKey);
|
|
282
|
+
this.log('Encrypted tunnel active');
|
|
283
|
+
|
|
284
|
+
this.startHeartbeat();
|
|
285
|
+
|
|
286
|
+
if (!this.ptyProcess) {
|
|
287
|
+
this.spawnTerminal();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Phase 4: Initiate WebRTC after encryption is ready
|
|
291
|
+
this._initiateWebRTC();
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Phase 4: WebRTC signaling messages
|
|
296
|
+
case 'signal': {
|
|
297
|
+
if (this.webrtc && this.sharedKey) {
|
|
298
|
+
try {
|
|
299
|
+
const plaintext = await decrypt(this.sharedKey, msg.payload);
|
|
300
|
+
this.webrtc.handleSignal(JSON.parse(plaintext));
|
|
301
|
+
} catch (err) {
|
|
302
|
+
this.logDebug(`[WebRTC] Signal decryption failed: ${err.message}`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
case 'heartbeat': {
|
|
309
|
+
this.missedPings = 0;
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
case 'data': {
|
|
314
|
+
if (!this.sharedKey || !this.ptyProcess) return;
|
|
315
|
+
try {
|
|
316
|
+
const plaintext = await decrypt(this.sharedKey, msg.payload);
|
|
317
|
+
|
|
318
|
+
// Check if this is a resize event
|
|
319
|
+
if (msg.meta === 'resize') {
|
|
320
|
+
try {
|
|
321
|
+
const resizeData = JSON.parse(plaintext);
|
|
322
|
+
if (resizeData.type === 'resize' && resizeData.cols && resizeData.rows) {
|
|
323
|
+
this.ptyProcess.resize(resizeData.cols, resizeData.rows);
|
|
324
|
+
this.logDebug(`Terminal resized to ${resizeData.cols}x${resizeData.rows}`);
|
|
325
|
+
}
|
|
326
|
+
} catch {}
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Normal keystroke — drop if read-only
|
|
331
|
+
if (this.readOnly) return;
|
|
332
|
+
this.ptyProcess.write(plaintext);
|
|
333
|
+
} catch (err) {
|
|
334
|
+
this.log(`Decryption failed: ${err.message}`);
|
|
335
|
+
}
|
|
336
|
+
break;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
case 'peer-disconnected': {
|
|
340
|
+
this.log('Client disconnected. Rejoin window: 2 minutes.');
|
|
341
|
+
this.isClientConnected = false;
|
|
342
|
+
this.awaitingRejoin = true;
|
|
343
|
+
this.sharedKey = null;
|
|
344
|
+
this.keyPair = null;
|
|
345
|
+
this.stopHeartbeat();
|
|
346
|
+
// Phase 4: Clean up WebRTC on peer disconnect
|
|
347
|
+
this._cleanupWebRTC();
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
case 'session-expired': {
|
|
352
|
+
this.log('Rejoin window expired. Session ended.');
|
|
353
|
+
this.destroy();
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
case 'error': {
|
|
358
|
+
this.log(`Error: ${msg.msg}`);
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
this.ws.on('close', () => {
|
|
365
|
+
if (!isConnected) {
|
|
366
|
+
reject(new Error('WebSocket closed before connection was established'));
|
|
367
|
+
} else if (!this.destroyed) {
|
|
368
|
+
this.log('Connection to relay lost.');
|
|
369
|
+
this.destroy();
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
this.ws.on('error', (err) => {
|
|
374
|
+
if (!isConnected) {
|
|
375
|
+
reject(err);
|
|
376
|
+
} else {
|
|
377
|
+
this.log(`WS error: ${err.message}`);
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ─── Send encrypted resize to client ────────────────────────────────
|
|
384
|
+
async _sendResize(cols, rows) {
|
|
385
|
+
if (!this.sharedKey) return;
|
|
386
|
+
try {
|
|
387
|
+
const resizeJson = JSON.stringify({ type: 'resize', cols, rows });
|
|
388
|
+
const payload = await encrypt(this.sharedKey, resizeJson);
|
|
389
|
+
// Phase 4: Route through DataChannel if active, else relay
|
|
390
|
+
if (this.useDataChannel && this.webrtc && this.webrtc.isActive()) {
|
|
391
|
+
// Send resize as a tagged message so client can distinguish
|
|
392
|
+
this.webrtc.send(JSON.stringify({ _meta: 'resize', payload }));
|
|
393
|
+
} else if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
394
|
+
this.ws.send(JSON.stringify({ type: 'data', payload, meta: 'resize' }));
|
|
395
|
+
}
|
|
396
|
+
} catch {}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ─── Heartbeat ──────────────────────────────────────────────────────
|
|
400
|
+
startHeartbeat() {
|
|
401
|
+
this.stopHeartbeat();
|
|
402
|
+
this.missedPings = 0;
|
|
403
|
+
this.heartbeatInterval = setInterval(() => {
|
|
404
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
405
|
+
this.ws.send(JSON.stringify({ type: 'heartbeat' }));
|
|
406
|
+
}
|
|
407
|
+
this.missedPings++;
|
|
408
|
+
if (this.missedPings >= MAX_MISSED_PINGS + 1) {
|
|
409
|
+
this.log('Client heartbeat lost. Waiting for reconnect...');
|
|
410
|
+
this.isClientConnected = false;
|
|
411
|
+
this.awaitingRejoin = true;
|
|
412
|
+
this.sharedKey = null;
|
|
413
|
+
this.keyPair = null;
|
|
414
|
+
this.stopHeartbeat();
|
|
415
|
+
}
|
|
416
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
stopHeartbeat() {
|
|
420
|
+
if (this.heartbeatInterval) {
|
|
421
|
+
clearInterval(this.heartbeatInterval);
|
|
422
|
+
this.heartbeatInterval = null;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ─── Spawn PTY ──────────────────────────────────────────────────────
|
|
427
|
+
spawnTerminal() {
|
|
428
|
+
const cols = 80;
|
|
429
|
+
const rows = 24;
|
|
430
|
+
this.log(`Spawning terminal: ${this.shell} (${cols}x${rows})`);
|
|
431
|
+
|
|
432
|
+
this.ptyProcess = pty.spawn(this.shell, [], {
|
|
433
|
+
name: 'xterm-256color',
|
|
434
|
+
cols,
|
|
435
|
+
rows,
|
|
436
|
+
cwd: this.startPath,
|
|
437
|
+
env: process.env,
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
this.ptyProcess.onData(async (data) => {
|
|
441
|
+
// Mirror PTY output to local viewer terminal
|
|
442
|
+
if (this.viewerSocket) {
|
|
443
|
+
try { this.viewerSocket.write(data); } catch {}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (!this.sharedKey) return;
|
|
447
|
+
try {
|
|
448
|
+
const payload = await encrypt(this.sharedKey, data);
|
|
449
|
+
// Phase 4: Route through DataChannel if active, else relay
|
|
450
|
+
if (this.useDataChannel && this.webrtc && this.webrtc.isActive()) {
|
|
451
|
+
this.webrtc.send(payload);
|
|
452
|
+
} else if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
453
|
+
this.ws.send(JSON.stringify({ type: 'data', payload }));
|
|
454
|
+
}
|
|
455
|
+
} catch {}
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
this.ptyProcess.onExit(({ exitCode }) => {
|
|
459
|
+
this.log(`Shell exited with code ${exitCode}`);
|
|
460
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
461
|
+
this.ws.send(JSON.stringify({ type: 'session-ended' }));
|
|
462
|
+
}
|
|
463
|
+
this.destroy();
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
// Open a local viewer terminal for the host
|
|
467
|
+
this._startViewerServer();
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// ─── Local TCP viewer for host terminal ─────────────────────────────
|
|
471
|
+
_startViewerServer() {
|
|
472
|
+
this.viewerServer = net.createServer((socket) => {
|
|
473
|
+
// Close any existing viewer connection before accepting a new one
|
|
474
|
+
if (this.viewerSocket) {
|
|
475
|
+
this.logDebug('Replacing existing viewer connection.');
|
|
476
|
+
this.viewerSocket.removeAllListeners();
|
|
477
|
+
try { this.viewerSocket.destroy(); } catch {}
|
|
478
|
+
}
|
|
479
|
+
this.viewerSocket = socket;
|
|
480
|
+
this.log('Host viewer connected.');
|
|
481
|
+
|
|
482
|
+
let recvBuf = '';
|
|
483
|
+
|
|
484
|
+
socket.on('data', (data) => {
|
|
485
|
+
const chunk = data.toString();
|
|
486
|
+
|
|
487
|
+
// Control messages start with \x00{
|
|
488
|
+
if (recvBuf.length > 0 || chunk.startsWith('\x00{')) {
|
|
489
|
+
recvBuf += chunk;
|
|
490
|
+
let newlineIdx;
|
|
491
|
+
while ((newlineIdx = recvBuf.indexOf('\n')) !== -1) {
|
|
492
|
+
const line = recvBuf.slice(0, newlineIdx).trim();
|
|
493
|
+
recvBuf = recvBuf.slice(newlineIdx + 1);
|
|
494
|
+
|
|
495
|
+
if (line.startsWith('\x00')) {
|
|
496
|
+
try {
|
|
497
|
+
const msg = JSON.parse(line.slice(1));
|
|
498
|
+
if (msg.type === 'resize' &&
|
|
499
|
+
Number.isInteger(msg.cols) && msg.cols > 0 &&
|
|
500
|
+
Number.isInteger(msg.rows) && msg.rows > 0 &&
|
|
501
|
+
this.ptyProcess) {
|
|
502
|
+
this.ptyProcess.resize(msg.cols, msg.rows);
|
|
503
|
+
this._sendResize(msg.cols, msg.rows);
|
|
504
|
+
this.logDebug(`Viewer resized to ${msg.cols}x${msg.rows}`);
|
|
505
|
+
}
|
|
506
|
+
} catch {}
|
|
507
|
+
} else if (this.ptyProcess) {
|
|
508
|
+
this.ptyProcess.write(line + '\n');
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
// Flush any non-control data left in the buffer
|
|
512
|
+
if (recvBuf.length > 0 && !recvBuf.startsWith('\x00')) {
|
|
513
|
+
if (this.ptyProcess) this.ptyProcess.write(recvBuf);
|
|
514
|
+
recvBuf = '';
|
|
515
|
+
}
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Host keystrokes → PTY (host always has access)
|
|
520
|
+
if (this.ptyProcess) {
|
|
521
|
+
this.ptyProcess.write(data);
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
socket.on('close', () => {
|
|
526
|
+
this.viewerSocket = null;
|
|
527
|
+
this.logDebug('Host viewer disconnected.');
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
socket.on('error', () => {
|
|
531
|
+
this.viewerSocket = null;
|
|
532
|
+
});
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
this.viewerServer.listen(0, '127.0.0.1', () => {
|
|
536
|
+
const port = this.viewerServer.address().port;
|
|
537
|
+
this.logDebug(`Viewer server on port ${port}`);
|
|
538
|
+
this._openViewerTerminal(port);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
this.viewerServer.on('error', (err) => {
|
|
542
|
+
this.log(`Viewer server error: ${err.message}`);
|
|
543
|
+
this.viewerServer = null;
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
_openViewerTerminal(port) {
|
|
548
|
+
if (!/^[A-Za-z0-9_-]+$/.test(this.code)) {
|
|
549
|
+
this.log('Invalid session code format. Aborting viewer terminal.');
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const viewerScript = path.join(__dirname, 'session-viewer.js');
|
|
554
|
+
const platform = os.platform();
|
|
555
|
+
|
|
556
|
+
if (platform === 'win32') {
|
|
557
|
+
cpExec(`start "PeerTerm - ${this.code}" cmd /c node "${viewerScript}" ${port} ${this.code}`);
|
|
558
|
+
} else if (platform === 'darwin') {
|
|
559
|
+
cpExec(`osascript -e 'tell app "Terminal" to do script "node \"${viewerScript}\" ${port} ${this.code}"'`);
|
|
560
|
+
} else {
|
|
561
|
+
// Linux: try common terminal emulators
|
|
562
|
+
cpExec(`x-terminal-emulator -e "node '${viewerScript}' ${port} ${this.code}" 2>/dev/null || gnome-terminal -- node "${viewerScript}" ${port} ${this.code} 2>/dev/null || xterm -e "node '${viewerScript}' ${port} ${this.code}"`);
|
|
563
|
+
}
|
|
564
|
+
this.log('Opening terminal viewer...');
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// ─── Destroy ────────────────────────────────────────────────────────
|
|
568
|
+
destroy() {
|
|
569
|
+
if (this.destroyed) return;
|
|
570
|
+
this.destroyed = true;
|
|
571
|
+
this.stopHeartbeat();
|
|
572
|
+
// Clean up viewer
|
|
573
|
+
if (this.viewerSocket) {
|
|
574
|
+
try { this.viewerSocket.destroy(); } catch {}
|
|
575
|
+
this.viewerSocket = null;
|
|
576
|
+
}
|
|
577
|
+
if (this.viewerServer) {
|
|
578
|
+
try { this.viewerServer.close(); } catch {}
|
|
579
|
+
this.viewerServer = null;
|
|
580
|
+
}
|
|
581
|
+
// Phase 4: Clean up WebRTC
|
|
582
|
+
this._cleanupWebRTC();
|
|
583
|
+
if (this.ptyProcess) {
|
|
584
|
+
try { this.ptyProcess.kill(); } catch {}
|
|
585
|
+
this.ptyProcess = null;
|
|
586
|
+
}
|
|
587
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
588
|
+
this.ws.close();
|
|
589
|
+
}
|
|
590
|
+
this.log('Session ended.');
|
|
591
|
+
if (this.onDestroy) this.onDestroy(this.code);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// ─── Phase 4: WebRTC helpers ─────────────────────────────────────
|
|
595
|
+
|
|
596
|
+
_initiateWebRTC() {
|
|
597
|
+
// Clean up any previous WebRTC instance
|
|
598
|
+
this._cleanupWebRTC();
|
|
599
|
+
|
|
600
|
+
// Pass debug-level logger to WebRTC (internal messages are verbose)
|
|
601
|
+
this.webrtc = new HostWebRTC((msg) => this.logDebug(msg));
|
|
602
|
+
|
|
603
|
+
this.webrtc.onOpen(() => {
|
|
604
|
+
this.useDataChannel = true;
|
|
605
|
+
this.log('DataChannel open — relay bypassed');
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
this.webrtc.onClose(() => {
|
|
609
|
+
if (this.useDataChannel) {
|
|
610
|
+
this.log('DataChannel closed — falling back to relay');
|
|
611
|
+
this.useDataChannel = false;
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
this.webrtc.onMessage(async (data) => {
|
|
616
|
+
if (!this.sharedKey || !this.ptyProcess) return;
|
|
617
|
+
try {
|
|
618
|
+
// Check if this is a tagged message (resize)
|
|
619
|
+
let parsed;
|
|
620
|
+
try {
|
|
621
|
+
parsed = JSON.parse(data);
|
|
622
|
+
} catch {
|
|
623
|
+
parsed = null;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (parsed && parsed._meta === 'resize') {
|
|
627
|
+
const resizeData = JSON.parse(await decrypt(this.sharedKey, parsed.payload));
|
|
628
|
+
if (resizeData.type === 'resize' && resizeData.cols && resizeData.rows) {
|
|
629
|
+
this.ptyProcess.resize(resizeData.cols, resizeData.rows);
|
|
630
|
+
this.logDebug(`Terminal resized to ${resizeData.cols}x${resizeData.rows}`);
|
|
631
|
+
}
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Normal encrypted terminal data
|
|
636
|
+
const plaintext = await decrypt(this.sharedKey, data);
|
|
637
|
+
if (this.readOnly) return;
|
|
638
|
+
this.ptyProcess.write(plaintext);
|
|
639
|
+
} catch (err) {
|
|
640
|
+
this.logDebug(`[WebRTC] Decryption failed: ${err.message}`);
|
|
641
|
+
}
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
// Start the WebRTC negotiation
|
|
645
|
+
this.webrtc.initiate(async (msg) => {
|
|
646
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN && this.sharedKey) {
|
|
647
|
+
try {
|
|
648
|
+
const payloadStr = JSON.stringify(msg.payload);
|
|
649
|
+
const encryptedPayload = await encrypt(this.sharedKey, payloadStr);
|
|
650
|
+
this.ws.send(JSON.stringify({ type: 'signal', payload: encryptedPayload }));
|
|
651
|
+
} catch (e) {
|
|
652
|
+
this.logDebug(`[WebRTC] Failed to encrypt signal: ${e.message}`);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
_cleanupWebRTC() {
|
|
659
|
+
this.useDataChannel = false;
|
|
660
|
+
if (this.webrtc) {
|
|
661
|
+
this.webrtc.close();
|
|
662
|
+
this.webrtc = null;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// ─── Status string for list command ─────────────────────────────────
|
|
667
|
+
getStatus() {
|
|
668
|
+
const elapsed = Date.now() - this.createdAt;
|
|
669
|
+
const remaining = Math.max(0, this.expiryMs - elapsed);
|
|
670
|
+
let status = '';
|
|
671
|
+
if (this.isClientConnected) {
|
|
672
|
+
status = 'client connected';
|
|
673
|
+
} else if (this.awaitingRejoin) {
|
|
674
|
+
status = 'awaiting rejoin';
|
|
675
|
+
} else {
|
|
676
|
+
status = `waiting for client (expires in ${formatDuration(remaining)})`;
|
|
677
|
+
}
|
|
678
|
+
if (this.readOnly) status += ' (readonly)';
|
|
679
|
+
return status;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// ─── Session Manager ─────────────────────────────────────────────────────────
|
|
684
|
+
|
|
685
|
+
class SessionManager {
|
|
686
|
+
constructor(shell, expiryMs, readOnly, startPath) {
|
|
687
|
+
this.shell = shell;
|
|
688
|
+
this.expiryMs = expiryMs;
|
|
689
|
+
this.readOnly = readOnly;
|
|
690
|
+
this.startPath = startPath;
|
|
691
|
+
this.sessions = new Map(); // code → Session
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
async createSession() {
|
|
695
|
+
const session = new Session(this.shell, this.expiryMs, this.readOnly, this.startPath, (code) => {
|
|
696
|
+
this.sessions.delete(code);
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
try {
|
|
700
|
+
const code = await session.start();
|
|
701
|
+
this.sessions.set(code, session);
|
|
702
|
+
return code;
|
|
703
|
+
} catch (err) {
|
|
704
|
+
logger.error(`Failed to create session: ${err.message}`, err);
|
|
705
|
+
return null;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
listSessions() {
|
|
710
|
+
if (this.sessions.size === 0) {
|
|
711
|
+
logger.info('No active sessions.');
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
console.log('');
|
|
715
|
+
console.log(' Active sessions:');
|
|
716
|
+
for (const [code, session] of this.sessions) {
|
|
717
|
+
console.log(` ${code} — ${session.getStatus()}`);
|
|
718
|
+
}
|
|
719
|
+
console.log('');
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
killSession(code) {
|
|
723
|
+
const session = this.sessions.get(code);
|
|
724
|
+
if (!session) {
|
|
725
|
+
logger.warn(`Session ${code} not found.`);
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
session.destroy();
|
|
729
|
+
logger.info(`Session ${code} killed.`);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
killAll() {
|
|
733
|
+
for (const [, session] of this.sessions) {
|
|
734
|
+
session.destroy();
|
|
735
|
+
}
|
|
736
|
+
this.sessions.clear();
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// ─── Main ────────────────────────────────────────────────────────────────────
|
|
741
|
+
|
|
742
|
+
async function main() {
|
|
743
|
+
const shell = detectShell();
|
|
744
|
+
const expiryMs = getExpiry();
|
|
745
|
+
const readOnly = argv.readonly;
|
|
746
|
+
const startPath = resolveStartPath(argv.path);
|
|
747
|
+
|
|
748
|
+
// Print startup banner
|
|
749
|
+
printBanner();
|
|
750
|
+
logger.info(`Shell: ${shell}`);
|
|
751
|
+
logger.info(`Relays: ${RELAY_URLS.join(', ')}`);
|
|
752
|
+
logger.info(`Path: ${startPath}`);
|
|
753
|
+
if (readOnly) logger.info('Mode: READ-ONLY');
|
|
754
|
+
if (argv.verbose) logger.info('Verbose logging enabled');
|
|
755
|
+
console.log('');
|
|
756
|
+
|
|
757
|
+
const manager = new SessionManager(shell, expiryMs, readOnly, startPath);
|
|
758
|
+
|
|
759
|
+
// Create first session automatically
|
|
760
|
+
const firstCode = await manager.createSession();
|
|
761
|
+
if (!firstCode) {
|
|
762
|
+
logger.error('Failed to start. Is the relay server running?');
|
|
763
|
+
process.exit(1);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// ─── Interactive CLI menu ──────────────────────────────────────────
|
|
767
|
+
console.log(' ─────────────────────────────────────────');
|
|
768
|
+
console.log(' Commands:');
|
|
769
|
+
console.log(' [n] New session');
|
|
770
|
+
console.log(' [l] List sessions');
|
|
771
|
+
console.log(' [k <code>] Kill session');
|
|
772
|
+
console.log(' [q] Quit all');
|
|
773
|
+
console.log(' ─────────────────────────────────────────');
|
|
774
|
+
console.log('');
|
|
775
|
+
|
|
776
|
+
const rl = readline.createInterface({
|
|
777
|
+
input: process.stdin,
|
|
778
|
+
output: process.stdout,
|
|
779
|
+
prompt: ' > ',
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
rl.prompt();
|
|
783
|
+
|
|
784
|
+
rl.on('line', async (line) => {
|
|
785
|
+
const input = line.trim();
|
|
786
|
+
|
|
787
|
+
if (input === 'n') {
|
|
788
|
+
logger.info('Creating new session...');
|
|
789
|
+
await manager.createSession();
|
|
790
|
+
} else if (input === 'l') {
|
|
791
|
+
manager.listSessions();
|
|
792
|
+
} else if (input.startsWith('k ')) {
|
|
793
|
+
const code = input.slice(2).trim();
|
|
794
|
+
manager.killSession(code);
|
|
795
|
+
} else if (input === 'q') {
|
|
796
|
+
logger.info('Shutting down all sessions...');
|
|
797
|
+
manager.killAll();
|
|
798
|
+
rl.close();
|
|
799
|
+
process.exit(0);
|
|
800
|
+
} else if (input) {
|
|
801
|
+
console.log(' Unknown command. Use n, l, k <code>, or q.');
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
rl.prompt();
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
rl.on('close', () => {
|
|
808
|
+
manager.killAll();
|
|
809
|
+
process.exit(0);
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
process.on('SIGINT', () => {
|
|
813
|
+
console.log('');
|
|
814
|
+
logger.info('Shutting down...');
|
|
815
|
+
manager.killAll();
|
|
816
|
+
rl.close();
|
|
817
|
+
process.exit(0);
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// ─── Global Error Handling ───────────────────────────────────────────────────
|
|
822
|
+
|
|
823
|
+
process.on('uncaughtException', (err) => {
|
|
824
|
+
logger.error(`Uncaught exception: ${err.message}`, err);
|
|
825
|
+
process.exit(1);
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
process.on('unhandledRejection', (reason) => {
|
|
829
|
+
const err = reason instanceof Error ? reason : new Error(String(reason));
|
|
830
|
+
logger.error(`Unhandled rejection: ${err.message}`, err);
|
|
831
|
+
process.exit(1);
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
// ─── Run ─────────────────────────────────────────────────────────────────────
|
|
835
|
+
|
|
836
|
+
main().catch((err) => {
|
|
837
|
+
logger.error(`Fatal error: ${err.message}`, err);
|
|
838
|
+
process.exit(1);
|
|
839
|
+
});
|