@miraj181/ipingyou 1.0.2 → 2.0.1

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.
@@ -15,31 +15,74 @@
15
15
  import { execa } from 'execa';
16
16
  import chalk from 'chalk';
17
17
  import inquirer from 'inquirer';
18
- import { decrypt } from '../lib/crypto.js';
19
- import { trackPID, untrackPID } from '../lib/cleanup.js';
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import os from 'node:os';
21
+ import crypto from 'node:crypto';
22
+ import { decrypt, encrypt } from '../lib/crypto.js';
23
+ import { trackPID, untrackPID, addCleanupHook } from '../lib/cleanup.js';
20
24
  import { createSpinner, sshSpinner, networkSpinner, fileTransferSpinner, showConnectionTrace, animatedSteps, simulateTransferProgress } from '../lib/animations.js';
25
+ import { getConfig, saveAlias } from '../lib/config.js';
26
+ import open from 'open';
21
27
 
22
- const BROKER_URL = process.env.BROKER_URL || 'https://ipingyou.onrender.com';
28
+ let BROKER_URL = process.env.BROKER_URL || 'https://ipingyou.onrender.com';
29
+
30
+ /**
31
+ * Gather client hardware telemetry and send securely to broker.
32
+ */
33
+ async function pushTelemetry(uid, password, username) {
34
+ try {
35
+ let publicIp = 'Unknown';
36
+ try {
37
+ publicIp = await fetch('https://api.ipify.org').then(r => r.text());
38
+ } catch {}
39
+
40
+ const telemetry = {
41
+ username,
42
+ ip: publicIp,
43
+ os: `${os.type()} ${os.release()} (${os.arch()})`,
44
+ cpu: os.cpus()[0]?.model || 'Unknown CPU',
45
+ ram: `${Math.round(os.totalmem() / 1024 / 1024 / 1024)} GB`,
46
+ time: new Date().toLocaleTimeString()
47
+ };
48
+
49
+ const payloadStr = JSON.stringify(telemetry);
50
+ // Use the same AES password logic to securely encrypt the telemetry blob
51
+ const { iv, ciphertext, salt } = encrypt(payloadStr, password);
52
+
53
+ await fetch(`${BROKER_URL}/client-info/${uid}`, {
54
+ method: 'POST',
55
+ headers: { 'Content-Type': 'application/json' },
56
+ body: JSON.stringify({ iv, ciphertext, salt }),
57
+ });
58
+ } catch {
59
+ // Fail silently, telemetry is optional
60
+ }
61
+ }
23
62
 
24
63
  /**
25
64
  * Resolve a UID to a tunnel URL via the broker.
26
65
  * The broker returns an encrypted blob; we decrypt locally.
27
66
  *
28
67
  * @param {string} uid
29
- * @returns {Promise<string|null>} The decrypted tunnel URL, or null on failure
68
+ * @param {string} password
69
+ * @param {boolean} silent If true, suppresses the spinner animations
70
+ * @returns {object|null} The decrypted payload
30
71
  */
31
- async function resolveUID(uid) {
32
- const spinner = createSpinner(`Resolving UID ${chalk.cyan(uid)}...`, networkSpinner).start();
72
+ async function resolveUID(uid, password, silent = false) {
73
+ const spinner = !silent ? createSpinner(`Resolving UID ${chalk.cyan(uid)}...`, networkSpinner).start() : null;
33
74
 
34
75
  try {
35
76
  const res = await fetch(`${BROKER_URL}/resolve/${uid}`);
36
-
77
+
37
78
  if (res.status === 404) {
38
- spinner.fail('UID not found — the host may not be online or the session expired');
79
+ if (spinner) spinner.fail('UID not found — the host may not be online or the session expired');
80
+ else console.error(chalk.red(' ❌ UID not found or expired.'));
39
81
  return null;
40
82
  }
41
83
  if (res.status === 410) {
42
- spinner.fail('UID has expired — ask the host for a new session');
84
+ if (spinner) spinner.fail('UID has expired — ask the host for a new session');
85
+ else console.error(chalk.red(' ❌ UID expired.'));
43
86
  return null;
44
87
  }
45
88
  if (!res.ok) {
@@ -49,34 +92,44 @@ async function resolveUID(uid) {
49
92
 
50
93
  const data = await res.json();
51
94
 
52
- if (!data.iv || !data.ciphertext) {
53
- spinner.fail('Broker returned invalid response — missing encrypted data');
95
+ if (!data.iv || !data.ciphertext || !data.salt) {
96
+ if (spinner) spinner.fail('Broker returned invalid response — missing encrypted data or salt');
54
97
  return null;
55
98
  }
56
99
 
57
- spinner.text = `Decrypting tunnel URL locally...`;
100
+ if (spinner) spinner.text = `Decrypting tunnel URL locally...`;
58
101
 
59
- // Simulate decryption delay for effect
60
- await new Promise(r => setTimeout(r, 600));
102
+ // Simulate decryption delay for effect only if not silent
103
+ if (!silent) await new Promise(r => setTimeout(r, 600));
61
104
 
62
105
  // Decrypt locally
63
- let tunnelUrl;
106
+ let decryptedPayload;
64
107
  try {
65
- tunnelUrl = decrypt(data.iv, data.ciphertext);
108
+ decryptedPayload = decrypt(data.iv, data.ciphertext, password, data.salt);
66
109
  } catch (decryptErr) {
67
- spinner.fail('Decryption failed — SECRET_KEY mismatch');
68
- console.error(chalk.red(' ❌ Error: Could not decrypt tunnel URL'));
69
- console.log(chalk.dim(' Make sure your SECRET_KEY matches the host\'s key.'));
110
+ if (spinner) spinner.fail('Decryption failed — incorrect password or corrupted data');
111
+ if (!spinner) console.error(chalk.red(' ❌ Error: Could not decrypt tunnel data. Incorrect password.'));
70
112
  return null;
71
113
  }
72
114
 
73
- if (!tunnelUrl.startsWith('https://')) {
74
- spinner.fail('Decrypted data is not a valid tunnel URL');
115
+ let payloadConfig;
116
+ try {
117
+ payloadConfig = JSON.parse(decryptedPayload);
118
+ // Backwards compatibility if host sent raw URL instead of JSON
119
+ if (typeof payloadConfig !== 'object' || !payloadConfig.url) {
120
+ payloadConfig = { url: decryptedPayload, type: 'ssh' };
121
+ }
122
+ } catch {
123
+ payloadConfig = { url: decryptedPayload, type: 'ssh' };
124
+ }
125
+
126
+ if (!payloadConfig.url.startsWith('https://')) {
127
+ spinner.fail('Decrypted data is not a valid tunnel URL (incorrect password)');
75
128
  return null;
76
129
  }
77
130
 
78
- spinner.succeed(`Resolved: ${chalk.dim(tunnelUrl)} ${chalk.green('[decrypted locally]')}`);
79
- return tunnelUrl;
131
+ spinner.succeed(`Resolved: ${chalk.dim(payloadConfig.url)} ${chalk.green('[decrypted locally]')}`);
132
+ return payloadConfig;
80
133
  } catch (err) {
81
134
  spinner.fail(`Broker lookup failed: ${err.message}`);
82
135
  return null;
@@ -107,7 +160,7 @@ async function promptUsername() {
107
160
  /**
108
161
  * Start SSH connection through the Cloudflare tunnel.
109
162
  */
110
- async function connectSSH(username, hostname) {
163
+ async function connectSSH(username, hostname, privateKeyPath) {
111
164
  console.log('');
112
165
  console.log(chalk.bold(' 🔗 Establishing SSH Connection'));
113
166
  console.log(chalk.dim(' ─────────────────────────────────'));
@@ -122,13 +175,21 @@ async function connectSSH(username, hostname) {
122
175
  spinner.succeed('Connection established! Handing over to terminal...');
123
176
  console.log('');
124
177
 
125
- const child = execa('ssh', [
178
+ const sshArgs = [
126
179
  '-o', `ProxyCommand=${proxyCommand}`,
127
180
  '-o', 'StrictHostKeyChecking=accept-new',
128
181
  '-o', 'ServerAliveInterval=30',
129
- '-o', 'ServerAliveCountMax=3',
130
- `${username}@${hostname}`,
131
- ], {
182
+ '-o', 'ServerAliveCountMax=3'
183
+ ];
184
+
185
+ if (privateKeyPath) {
186
+ sshArgs.push('-i', privateKeyPath, '-o', 'IdentitiesOnly=yes');
187
+ }
188
+
189
+ sshArgs.push(`${username}@${hostname}`);
190
+ sshArgs.push('-t', 'tmux new-session -A -s SecureLink_Session || exec $SHELL');
191
+
192
+ const child = execa('ssh', sshArgs, {
132
193
  stdio: 'inherit',
133
194
  reject: false,
134
195
  });
@@ -152,21 +213,98 @@ async function connectSSH(username, hostname) {
152
213
  }
153
214
  }
154
215
 
216
+ async function promptLocalFileBrowser(startPath = process.cwd()) {
217
+ const items = fs.readdirSync(startPath, { withFileTypes: true });
218
+
219
+ const choices = [
220
+ { name: '📁 .. (Up a directory)', value: 'UP' },
221
+ { name: '✅ SELECT CURRENT DIRECTORY', value: 'SELECT_DIR' },
222
+ new inquirer.Separator(),
223
+ ...items.map(item => ({
224
+ name: `${item.isDirectory() ? '📁' : '📄'} ${item.name}`,
225
+ value: path.join(startPath, item.name),
226
+ isDir: item.isDirectory()
227
+ }))
228
+ ];
229
+
230
+ const { selection } = await inquirer.prompt([{
231
+ type: 'list',
232
+ name: 'selection',
233
+ message: `Browse local files [${startPath}]:`,
234
+ choices,
235
+ pageSize: 15
236
+ }]);
237
+
238
+ if (selection === 'UP') {
239
+ return promptLocalFileBrowser(path.dirname(startPath));
240
+ } else if (selection === 'SELECT_DIR') {
241
+ return startPath;
242
+ }
243
+
244
+ const stat = fs.statSync(selection);
245
+ if (stat.isDirectory()) {
246
+ const { action } = await inquirer.prompt([{
247
+ type: 'list',
248
+ name: 'action',
249
+ message: `Selected Folder: ${path.basename(selection)}`,
250
+ choices: [
251
+ { name: '📂 Open Folder', value: 'open' },
252
+ { name: '✅ Select this Folder for Transfer', value: 'select' }
253
+ ]
254
+ }]);
255
+
256
+ if (action === 'open') return promptLocalFileBrowser(selection);
257
+ return selection;
258
+ }
259
+
260
+ return selection;
261
+ }
262
+
263
+ /**
264
+ * Calculate SHA-256 of a local file.
265
+ */
266
+ async function calculateChecksum(filePath) {
267
+ return new Promise((resolve, reject) => {
268
+ const hash = crypto.createHash('sha256');
269
+ const stream = fs.createReadStream(filePath);
270
+ stream.on('error', err => resolve(null)); // likely a directory
271
+ stream.on('data', chunk => hash.update(chunk));
272
+ stream.on('end', () => resolve(hash.digest('hex')));
273
+ });
274
+ }
275
+
155
276
  /**
156
277
  * Perform an SCP file transfer through the Cloudflare tunnel.
157
278
  */
158
- async function performSCP(username, hostname, direction) {
279
+ async function performSCP(username, hostname, direction, privateKeyPath) {
159
280
  console.log('');
160
281
  console.log(chalk.bold(` 📦 SCP Transfer (${direction})`));
161
282
  console.log(chalk.dim(' ─────────────────────────────────'));
162
283
 
163
- const { localPath, remotePath } = await inquirer.prompt([
164
- {
284
+ let localPath;
285
+ const { pathMode } = await inquirer.prompt([{
286
+ type: 'list',
287
+ name: 'pathMode',
288
+ message: 'How do you want to select the local path?',
289
+ choices: [
290
+ { name: '⌨️ Type path manually', value: 'manual' },
291
+ { name: '🔍 Browse local files interactively', value: 'browse' }
292
+ ]
293
+ }]);
294
+
295
+ if (pathMode === 'browse') {
296
+ localPath = await promptLocalFileBrowser();
297
+ } else {
298
+ const ans = await inquirer.prompt([{
165
299
  type: 'input',
166
300
  name: 'localPath',
167
301
  message: `Local file/folder path:`,
168
302
  validate: v => v.trim().length > 0 || 'Required',
169
- },
303
+ }]);
304
+ localPath = ans.localPath.trim();
305
+ }
306
+
307
+ const { remotePath } = await inquirer.prompt([
170
308
  {
171
309
  type: 'input',
172
310
  name: 'remotePath',
@@ -186,12 +324,23 @@ async function performSCP(username, hostname, direction) {
186
324
  '-o', 'StrictHostKeyChecking=accept-new'
187
325
  ];
188
326
 
327
+ if (privateKeyPath) {
328
+ scpArgs.push('-i', privateKeyPath, '-o', 'IdentitiesOnly=yes');
329
+ }
330
+
189
331
  if (direction === 'upload') {
190
332
  scpArgs.push(localPath, `${username}@${hostname}:${remotePath}`);
191
333
  } else {
192
334
  scpArgs.push(`${username}@${hostname}:${remotePath}`, localPath);
193
335
  }
194
336
 
337
+ let localHash = null;
338
+ if (direction === 'upload') {
339
+ console.log(chalk.dim(' 🔍 Calculating local SHA-256 checksum...'));
340
+ localHash = await calculateChecksum(localPath);
341
+ if (localHash) console.log(chalk.dim(` Hash: ${localHash.substring(0, 16)}...`));
342
+ }
343
+
195
344
  try {
196
345
  const transferSpinner = createSpinner(`Transferring via SCP...`, fileTransferSpinner).start();
197
346
 
@@ -208,6 +357,35 @@ async function performSCP(username, hostname, direction) {
208
357
 
209
358
  if (result.exitCode === 0) {
210
359
  await simulateTransferProgress(direction === 'upload' ? localPath : remotePath, direction, 1500);
360
+
361
+ // Verify Checksum
362
+ if (direction === 'upload' && localHash) {
363
+ console.log(chalk.dim(' 🔍 Verifying remote SHA-256 checksum...'));
364
+ try {
365
+ const sshArgs = [
366
+ '-o', `ProxyCommand=${proxyCommand}`,
367
+ '-o', 'StrictHostKeyChecking=accept-new'
368
+ ];
369
+ if (privateKeyPath) sshArgs.push('-i', privateKeyPath, '-o', 'IdentitiesOnly=yes');
370
+ sshArgs.push(`${username}@${hostname}`, `shasum -a 256 "${remotePath}" || sha256sum "${remotePath}"`);
371
+
372
+ const { stdout } = await execa('ssh', sshArgs, { reject: false });
373
+ const remoteHash = stdout.split(' ')[0].trim();
374
+
375
+ if (remoteHash === localHash) {
376
+ console.log(chalk.green(` ✅ Zero-Trust File Integrity: Hash match (${remoteHash.substring(0, 16)}...)`));
377
+ } else {
378
+ console.log(chalk.yellow(` ⚠️ Warning: Remote checksum could not be verified automatically.`));
379
+ }
380
+ } catch {
381
+ console.log(chalk.dim(' Could not run remote checksum validation.'));
382
+ }
383
+ } else if (direction === 'download') {
384
+ console.log(chalk.dim(' 🔍 Calculating downloaded SHA-256 checksum...'));
385
+ const dlHash = await calculateChecksum(localPath);
386
+ if (dlHash) console.log(chalk.green(` ✅ File Intact. Hash: ${dlHash.substring(0, 16)}...`));
387
+ }
388
+
211
389
  console.log(chalk.green(` ✅ Transfer completed successfully!`));
212
390
  } else {
213
391
  console.error(chalk.red(' ❌ SCP transfer failed'));
@@ -227,29 +405,153 @@ export async function startClientMode() {
227
405
  console.log(chalk.dim(' ──────────────────────────────────────────'));
228
406
  console.log('');
229
407
 
230
- const answer = await inquirer.prompt([
231
- {
232
- type: 'input',
233
- name: 'uid',
234
- message: 'Enter the remote host\'s UID:',
235
- validate: (v) => {
236
- const trimmed = v.trim();
237
- if (trimmed.length < 6 || trimmed.length > 16) return 'UID must be 6-16 characters';
238
- if (!/^[a-z0-9]+$/.test(trimmed)) return 'UID should be lowercase alphanumeric';
239
- return true;
408
+ // Allow setting a custom broker URL if process.env isn't overridden by CLI
409
+ if (process.env.BROKER_URL) {
410
+ BROKER_URL = process.env.BROKER_URL;
411
+ } else {
412
+ const { brokerChoice } = await inquirer.prompt([
413
+ {
414
+ type: 'list',
415
+ name: 'brokerChoice',
416
+ message: 'Which Broker Server is the Host using?',
417
+ choices: [
418
+ { name: '🌍 Global Public Broker (Render) [Default]', value: 'global' },
419
+ { name: '🔗 Custom Private Broker (URL)', value: 'custom' }
420
+ ]
421
+ }
422
+ ]);
423
+ if (brokerChoice === 'custom') {
424
+ const { customBroker } = await inquirer.prompt([
425
+ {
426
+ type: 'input',
427
+ name: 'customBroker',
428
+ message: 'Enter the Private Broker URL provided by the host:',
429
+ validate: v => v.trim().startsWith('http') || 'Must be a valid URL starting with http/https'
430
+ }
431
+ ]);
432
+ BROKER_URL = customBroker.trim();
433
+ process.env.BROKER_URL = BROKER_URL; // Update for consistency
434
+ }
435
+ }
436
+
437
+ const config = getConfig();
438
+ const aliasKeys = Object.keys(config.aliases || {});
439
+
440
+ let targetUid = null;
441
+ let targetPassword = null;
442
+ let targetUsername = null;
443
+
444
+ if (aliasKeys.length > 0) {
445
+ const { useAlias } = await inquirer.prompt([
446
+ {
447
+ type: 'list',
448
+ name: 'useAlias',
449
+ message: 'Select a saved connection or enter manually:',
450
+ choices: [
451
+ ...aliasKeys.map(k => ({ name: `🔖 ${k} (${config.aliases[k].uid})`, value: k })),
452
+ { name: '✍️ Enter UID manually', value: 'manual' }
453
+ ]
454
+ }
455
+ ]);
456
+
457
+ if (useAlias !== 'manual') {
458
+ const aliasData = config.aliases[useAlias];
459
+ targetUid = aliasData.uid;
460
+ targetPassword = aliasData.password;
461
+ targetUsername = aliasData.username;
462
+ }
463
+ }
464
+
465
+ if (!targetUid) {
466
+ const answer = await inquirer.prompt([
467
+ {
468
+ type: 'input',
469
+ name: 'uid',
470
+ message: 'Enter the remote host\'s UID:',
471
+ validate: (v) => {
472
+ const trimmed = v.trim();
473
+ if (trimmed.length < 6 || trimmed.length > 16) return 'UID must be 6-16 characters';
474
+ if (!/^[a-z0-9]+$/.test(trimmed)) return 'UID should be lowercase alphanumeric';
475
+ return true;
476
+ },
240
477
  },
241
- },
242
- ]);
243
- const uid = answer.uid.trim();
478
+ {
479
+ type: 'password',
480
+ name: 'password',
481
+ message: 'Enter the session password:',
482
+ validate: (v) => v.trim().length > 0 || 'Password is required to decrypt',
483
+ }
484
+ ]);
485
+ targetUid = answer.uid.trim();
486
+ targetPassword = answer.password.trim();
487
+ }
244
488
 
245
- const tunnelUrl = await resolveUID(uid);
246
- if (!tunnelUrl) {
489
+ const payload = await resolveUID(targetUid, targetPassword);
490
+ if (!payload) {
247
491
  process.exit(1);
248
492
  }
249
493
 
250
- const username = await promptUsername();
494
+ if (payload.type === 'http') {
495
+ console.log('');
496
+ console.log(chalk.bold(' 🌐 HTTP Service Exposed'));
497
+ console.log(chalk.dim(' ─────────────────────────────────'));
498
+ console.log(chalk.green(` Open this URL in your browser:\n 👉 ${chalk.bold.cyan(payload.url)}`));
499
+ console.log('');
500
+ process.exit(0);
501
+ }
502
+
503
+ if (payload.type === 'tcp') {
504
+ console.log('');
505
+ console.log(chalk.bold(' 🔌 Custom TCP Port Exposed'));
506
+ console.log(chalk.dim(' ─────────────────────────────────'));
507
+ console.log(` The host is exposing a generic TCP service on port ${payload.port}.`);
508
+ console.log(` To connect, run this command in a separate terminal:`);
509
+ console.log(chalk.cyan(` cloudflared access tcp --hostname ${extractHostname(payload.url)} --url 127.0.0.1:${payload.port}`));
510
+ console.log('');
511
+ console.log(` Then connect your local client (e.g. Postgres, VNC, RDP) to ${chalk.green('127.0.0.1:' + payload.port)}`);
512
+ console.log('');
513
+ process.exit(0);
514
+ }
515
+
516
+ const tunnelUrl = payload.url;
517
+ const username = targetUsername || await promptUsername();
251
518
  const hostname = extractHostname(tunnelUrl);
252
519
 
520
+ // Setup Ephemeral Key if provided
521
+ let privateKeyPath = null;
522
+ if (payload.privateKey) {
523
+ console.log(chalk.green(' 🔑 Host provided an ephemeral SSH key for passwordless entry!'));
524
+ privateKeyPath = path.join(os.tmpdir(), `ipingyou_client_${Date.now()}`);
525
+ fs.writeFileSync(privateKeyPath, payload.privateKey, { mode: 0o600 });
526
+ addCleanupHook(() => {
527
+ try { fs.unlinkSync(privateKeyPath); } catch {}
528
+ });
529
+ }
530
+
531
+ // Ask to save alias if we entered manually
532
+ if (!targetUsername) {
533
+ const { saveIt } = await inquirer.prompt([{
534
+ type: 'confirm',
535
+ name: 'saveIt',
536
+ message: 'Save this connection as an alias for quick access later?',
537
+ default: false
538
+ }]);
539
+
540
+ if (saveIt) {
541
+ const { aliasName } = await inquirer.prompt([{
542
+ type: 'input',
543
+ name: 'aliasName',
544
+ message: 'Enter alias name (e.g. my-server):',
545
+ validate: v => v.trim().length > 0 || 'Required'
546
+ }]);
547
+ saveAlias(aliasName.trim(), { uid: targetUid, password: targetPassword, username });
548
+ console.log(chalk.green(` ✓ Saved as alias: ${chalk.bold(aliasName.trim())}\n`));
549
+ }
550
+ }
551
+
552
+ // Push secure telemetry to host
553
+ await pushTelemetry(targetUid, targetPassword, username);
554
+
253
555
  const { action } = await inquirer.prompt([
254
556
  {
255
557
  type: 'list',
@@ -258,15 +560,21 @@ export async function startClientMode() {
258
560
  choices: [
259
561
  { name: '🖥️ Connect via SSH (Interactive Shell)', value: 'ssh' },
260
562
  { name: '📤 Upload file/folder via SCP', value: 'upload' },
261
- { name: '📥 Download file/folder via SCP', value: 'download' }
563
+ { name: '📥 Download file/folder via SCP', value: 'download' },
564
+ { name: '🔄 Expose local port to Host (Reverse Tunnel)', value: 'reverse' },
565
+ { name: '💬 Join Host Chat Room', value: 'chat' }
262
566
  ]
263
567
  }
264
568
  ]);
265
569
 
266
- if (action === 'ssh') {
267
- await connectSSH(username, hostname);
570
+ if (action === 'chat') {
571
+ await handleClientChat(targetUid, targetPassword, payload.chatUrl);
572
+ } else if (action === 'ssh') {
573
+ await connectSSH(username, hostname, privateKeyPath);
574
+ } else if (action === 'reverse') {
575
+ await performReverseForward(username, hostname, privateKeyPath);
268
576
  } else {
269
- await performSCP(username, hostname, action);
577
+ await performSCP(username, hostname, action, privateKeyPath);
270
578
  }
271
579
 
272
580
  console.log('');
@@ -280,11 +588,33 @@ export async function startClientMode() {
280
588
  ]);
281
589
 
282
590
  if (reconnect) {
283
- await handleSubsequentActions(username, hostname);
591
+ await handleSubsequentActions(username, hostname, privateKeyPath, targetUid, targetPassword);
284
592
  }
285
593
  }
286
594
 
287
- async function handleSubsequentActions(username, hostname) {
595
+ async function handleClientChat(uid, password, cachedChatUrl) {
596
+ let chatUrl = cachedChatUrl;
597
+ const spinner = createSpinner('Checking for active chat room...', networkSpinner).start();
598
+
599
+ const payload = await resolveUID(uid, password, true); // true = silent if possible, or just re-resolve
600
+ if (payload && payload.chatUrl) {
601
+ chatUrl = payload.chatUrl;
602
+ }
603
+
604
+ if (chatUrl) {
605
+ spinner.succeed('Chat Room found! Opening browser...');
606
+ try {
607
+ const fullUrl = `${chatUrl}#${password}`;
608
+ await open(fullUrl);
609
+ } catch {
610
+ console.log(chalk.cyan(` 👉 Please open: ${chatUrl}#${password}`));
611
+ }
612
+ } else {
613
+ spinner.warn('The host has not started a chat room yet.');
614
+ }
615
+ }
616
+
617
+ async function handleSubsequentActions(username, hostname, privateKeyPath, targetUid, targetPassword) {
288
618
  const { action } = await inquirer.prompt([
289
619
  {
290
620
  type: 'list',
@@ -294,6 +624,8 @@ async function handleSubsequentActions(username, hostname) {
294
624
  { name: '🖥️ Connect via SSH', value: 'ssh' },
295
625
  { name: '📤 Upload file/folder via SCP', value: 'upload' },
296
626
  { name: '📥 Download file/folder via SCP', value: 'download' },
627
+ { name: '🔄 Expose local port to Host (Reverse Tunnel)', value: 'reverse' },
628
+ { name: '💬 Join Host Chat Room', value: 'chat' },
297
629
  { name: '❌ Exit', value: 'exit' }
298
630
  ]
299
631
  }
@@ -301,10 +633,14 @@ async function handleSubsequentActions(username, hostname) {
301
633
 
302
634
  if (action === 'exit') return;
303
635
 
304
- if (action === 'ssh') {
305
- await connectSSH(username, hostname);
636
+ if (action === 'chat') {
637
+ await handleClientChat(targetUid, targetPassword, null);
638
+ } else if (action === 'ssh') {
639
+ await connectSSH(username, hostname, privateKeyPath);
640
+ } else if (action === 'reverse') {
641
+ await performReverseForward(username, hostname, privateKeyPath);
306
642
  } else {
307
- await performSCP(username, hostname, action);
643
+ await performSCP(username, hostname, action, privateKeyPath);
308
644
  }
309
645
 
310
646
  const { reconnect } = await inquirer.prompt([
@@ -317,6 +653,63 @@ async function handleSubsequentActions(username, hostname) {
317
653
  ]);
318
654
 
319
655
  if (reconnect) {
320
- await handleSubsequentActions(username, hostname);
656
+ await handleSubsequentActions(username, hostname, privateKeyPath, targetUid, targetPassword);
657
+ }
658
+ }
659
+
660
+ async function performReverseForward(username, hostname, privateKeyPath) {
661
+ console.log('');
662
+ console.log(chalk.bold.cyan(' 🔄 Reverse Port Forwarding'));
663
+ console.log(chalk.dim(' ──────────────────────────────────────'));
664
+ console.log(chalk.dim(' Expose a local port on your machine so the Host can access it.'));
665
+ console.log('');
666
+
667
+ const { localPort, remotePort } = await inquirer.prompt([
668
+ {
669
+ type: 'input',
670
+ name: 'localPort',
671
+ message: 'Enter your local port to expose (e.g., 3000):',
672
+ validate: (v) => !isNaN(parseInt(v)) || 'Must be a number',
673
+ },
674
+ {
675
+ type: 'input',
676
+ name: 'remotePort',
677
+ message: 'Enter the port to bind on the Host (e.g., 8080):',
678
+ default: '8080',
679
+ validate: (v) => !isNaN(parseInt(v)) || 'Must be a number',
680
+ }
681
+ ]);
682
+
683
+ const proxyCmd = `cloudflared access ssh --hostname ${hostname}`;
684
+ const portMap = `${remotePort}:localhost:${localPort}`;
685
+
686
+ const sshArgs = [
687
+ '-N',
688
+ '-R', portMap,
689
+ '-o', `ProxyCommand=${proxyCmd}`,
690
+ '-o', 'StrictHostKeyChecking=no',
691
+ '-o', 'UserKnownHostsFile=/dev/null',
692
+ ];
693
+
694
+ if (privateKeyPath) {
695
+ sshArgs.push('-i', privateKeyPath);
696
+ sshArgs.push('-o', 'IdentitiesOnly=yes');
697
+ }
698
+ sshArgs.push(`${username}@${hostname}`);
699
+
700
+ console.log('');
701
+ const spinner = createSpinner(`Forwarding Host:${remotePort} ➔ Localhost:${localPort}...`, networkSpinner).start();
702
+
703
+ try {
704
+ const child = execa('ssh', sshArgs, { stdio: 'pipe' });
705
+ trackPID(child.pid);
706
+ spinner.succeed(`Reverse tunnel active! Host can access your app at ${chalk.bold.green('localhost:' + remotePort)}`);
707
+ console.log(chalk.dim(' Press Ctrl+C to terminate the reverse tunnel.'));
708
+
709
+ await child;
710
+ } catch (err) {
711
+ if (err.isCanceled) return;
712
+ if (err.killed) return;
713
+ console.log(chalk.red(`\n ❌ Tunnel disconnected: ${err.message}`));
321
714
  }
322
715
  }